From 2a6ac9357703dbb7889c4ddf33488d1b4f756e86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Wed, 1 Apr 2026 10:44:19 -0700 Subject: [PATCH 01/11] docs: add SSH key management provider design spec Co-Authored-By: Claude --- .../2026-02-15-feat-ssh-key-management.md | 42 ----- ...4-01-ssh-key-management-provider-design.md | 160 ++++++++++++++++++ 2 files changed, 160 insertions(+), 42 deletions(-) delete mode 100644 docs/docs/sidebar/development/tasks/backlog/2026-02-15-feat-ssh-key-management.md create mode 100644 docs/plans/2026-04-01-ssh-key-management-provider-design.md diff --git a/docs/docs/sidebar/development/tasks/backlog/2026-02-15-feat-ssh-key-management.md b/docs/docs/sidebar/development/tasks/backlog/2026-02-15-feat-ssh-key-management.md deleted file mode 100644 index 028e23395..000000000 --- a/docs/docs/sidebar/development/tasks/backlog/2026-02-15-feat-ssh-key-management.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -title: SSH key and access management -status: backlog -created: 2026-02-15 -updated: 2026-02-15 ---- - -## Objective - -Add SSH authorized key management. Appliances typically use SSH for emergency -access, and managing keys programmatically avoids manual file editing. - -## API Endpoints - -``` -GET /ssh/key/{user} - List authorized keys for user -POST /ssh/key/{user} - Add authorized key -DELETE /ssh/key/{user}/{fingerprint} - Remove authorized key - -GET /ssh/config - Get SSH server config summary -PUT /ssh/config - Update SSH server settings -``` - -## Operations - -- `ssh.key.list.get` (query) -- `ssh.key.add.execute`, `ssh.key.remove.execute` (modify) -- `ssh.config.get` (query) -- `ssh.config.update` (modify) - -## Provider - -- `internal/provider/security/ssh/` -- Manage `~/.ssh/authorized_keys` files -- Parse and manage `/etc/ssh/sshd_config` (port, auth methods, etc.) - -## Notes - -- SSH key changes are security-sensitive -- Scopes: `ssh:read`, `ssh:write` -- sshd_config changes require service restart — coordinate with service - management feature diff --git a/docs/plans/2026-04-01-ssh-key-management-provider-design.md b/docs/plans/2026-04-01-ssh-key-management-provider-design.md new file mode 100644 index 000000000..8b511d9d5 --- /dev/null +++ b/docs/plans/2026-04-01-ssh-key-management-provider-design.md @@ -0,0 +1,160 @@ +# SSH Key Management Provider Design + +## Overview + +Add SSH authorized key management to OSAPI. List, add, and remove +SSH public keys in a user's `~/.ssh/authorized_keys` file. Extends +the existing user provider — no new provider package or +permissions. Manages any key regardless of who added it. + +## Architecture + +Extends the existing user provider at +`internal/provider/node/user/`. + +- **Category**: `node` +- **Path prefix**: `/node/{hostname}/user/{name}/ssh-key` +- **Permissions**: `user:read` (list), `user:write` (add, remove) +- **Provider type**: direct-write (avfs.VFS) + +No state tracking, no file.Deployer. The provider reads and +writes `authorized_keys` directly. The orchestrator is +responsible for desired-state management. + +## Provider Interface Additions + +Added to the existing `user.Provider` interface: + +```go +ListKeys(ctx context.Context, username string) ([]SSHKey, error) +AddKey(ctx context.Context, username string, key SSHKey) (*SSHKeyResult, error) +RemoveKey(ctx context.Context, username string, fingerprint string) (*SSHKeyResult, error) +``` + +## Data Types + +```go +type SSHKey struct { + Type string `json:"type"` + Fingerprint string `json:"fingerprint"` + Comment string `json:"comment,omitempty"` +} + +type SSHKeyResult struct { + Changed bool `json:"changed"` +} +``` + +## Debian Implementation + +The provider resolves the user's home directory from +`/etc/passwd` (already parsed by the user provider), then +operates on `~/.ssh/authorized_keys`. + +- **ListKeys**: Read `authorized_keys`, parse each non-empty, + non-comment line into type + base64 key + comment. Compute + SHA256 fingerprint from decoded key bytes. Return all entries. +- **AddKey**: Check if key already exists by fingerprint. If + present, return `changed: false`. Otherwise append the raw + public key line. Create `~/.ssh/` (mode `0700`) and + `authorized_keys` (mode `0600`) if they don't exist. Set + ownership to the target user via `exec.Manager` + (`chown user:user`). +- **RemoveKey**: Read file, filter out the line matching the + fingerprint, rewrite file. Return `changed: false` if + fingerprint not found. Return error if file doesn't exist. + +## Platform Implementations + +| Platform | Implementation | +| -------- | ---------------------- | +| Debian | Direct file read/write | +| Darwin | ErrUnsupported | +| Linux | ErrUnsupported | + +## Container Behavior + +No container check — SSH key management works in containers. + +## API Endpoints + +| Method | Path | Permission | Description | +| -------- | ---------------------------------------------------- | ------------ | -------------------- | +| `GET` | `/node/{hostname}/user/{name}/ssh-key` | `user:read` | List authorized keys | +| `POST` | `/node/{hostname}/user/{name}/ssh-key` | `user:write` | Add a key | +| `DELETE` | `/node/{hostname}/user/{name}/ssh-key/{fingerprint}` | `user:write` | Remove a key | + +All endpoints support broadcast targeting. + +### POST Request Body + +```json +{ + "key": "ssh-ed25519 AAAA... user@host" +} +``` + +The full public key line as it would appear in +`authorized_keys`. + +### Response Shape (List) + +```json +{ + "job_id": "...", + "results": [{ + "hostname": "web-01", + "status": "ok", + "keys": [ + { + "type": "ssh-ed25519", + "fingerprint": "SHA256:abc123...", + "comment": "john@laptop" + } + ] + }] +} +``` + +### Response Shape (Add/Remove) + +```json +{ + "job_id": "...", + "results": [{ + "hostname": "web-01", + "status": "ok", + "changed": true + }] +} +``` + +## SDK + +```go +client.User.ListKeys(ctx, host, username) +client.User.AddKey(ctx, host, username, opts) +client.User.RemoveKey(ctx, host, username, fingerprint) +``` + +`SSHKeyAddOpts` struct with `Key` field (the full public key +string). + +## CLI + +```bash +osapi client node user ssh-key list --target web-01 --name john +osapi client node user ssh-key add --target web-01 --name john \ + --key "ssh-ed25519 AAAA... john@laptop" +osapi client node user ssh-key remove --target web-01 --name john \ + --fingerprint "SHA256:abc123..." +``` + +## Permissions + +Reuses existing permissions — no new permissions needed. + +- `user:read` — list keys +- `user:write` — add and remove keys + +These are already in all built-in roles. From e436ffd243f9129b17f809006416aff5d5942dad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Wed, 1 Apr 2026 10:55:52 -0700 Subject: [PATCH 02/11] docs: add SSH key management implementation plan Co-Authored-By: Claude --- .../2026-04-01-ssh-key-management-provider.md | 1235 +++++++++++++++++ 1 file changed, 1235 insertions(+) create mode 100644 docs/plans/2026-04-01-ssh-key-management-provider.md diff --git a/docs/plans/2026-04-01-ssh-key-management-provider.md b/docs/plans/2026-04-01-ssh-key-management-provider.md new file mode 100644 index 000000000..cb6a87140 --- /dev/null +++ b/docs/plans/2026-04-01-ssh-key-management-provider.md @@ -0,0 +1,1235 @@ +# SSH Key Management Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use +> superpowers:subagent-driven-development (recommended) or +> superpowers:executing-plans to implement this plan task-by-task. +> Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add SSH authorized key management to OSAPI — list, add, +and remove SSH public keys in a user's `~/.ssh/authorized_keys` +file by extending the existing user provider. + +**Architecture:** Extends `internal/provider/node/user/` with three +new methods (ListKeys, AddKey, RemoveKey). New SSH key endpoints +added to the existing user OpenAPI spec. Operations dispatched via +a new `sshKey` case in the node processor. Reuses existing +`user:read`/`user:write` permissions. No new provider package, +agent category, or permissions needed. + +**Tech Stack:** Go, avfs.VFS, crypto/sha256 for fingerprints, +encoding/base64 for key decoding, oapi-codegen strict-server + +--- + +## File Structure + +### Provider Layer + +- Modify: `internal/provider/node/user/types.go` — add SSHKey, + SSHKeyResult types + 3 methods to Provider interface +- Create: `internal/provider/node/user/debian_ssh_key.go` — Debian + implementation (list/add/remove authorized_keys) +- Modify: `internal/provider/node/user/darwin.go` — add 3 stub + methods +- Modify: `internal/provider/node/user/linux.go` — add 3 stub + methods +- Test: `internal/provider/node/user/debian_ssh_key_public_test.go` +- Modify: `internal/provider/node/user/darwin_public_test.go` — add + stub tests +- Modify: `internal/provider/node/user/linux_public_test.go` — add + stub tests + +### Agent Layer + +- Create: `internal/agent/processor_ssh_key.go` — SSH key operation + dispatcher +- Modify: `internal/agent/processor.go` — add `sshKey` case to + NewNodeProcessor +- Test: `internal/agent/processor_ssh_key_public_test.go` + +### Operations + +- Modify: `pkg/sdk/client/operations.go` — add SSH key operation + constants +- Modify: `internal/job/types.go` — add SSH key operation aliases + +### API Layer + +- Modify: `internal/controller/api/node/user/gen/api.yaml` — add + 3 ssh-key endpoints + schemas +- Create: + `internal/controller/api/node/user/ssh_key_list_get.go` — list + handler +- Create: + `internal/controller/api/node/user/ssh_key_add_post.go` — add + handler +- Create: + `internal/controller/api/node/user/ssh_key_remove_delete.go` — + remove handler +- Test: + `internal/controller/api/node/user/ssh_key_list_get_public_test.go` +- Test: + `internal/controller/api/node/user/ssh_key_add_post_public_test.go` +- Test: + `internal/controller/api/node/user/ssh_key_remove_delete_public_test.go` + +### SDK Layer + +- Modify: `pkg/sdk/client/user.go` — add ListKeys, AddKey, + RemoveKey methods +- Modify: `pkg/sdk/client/user_types.go` — add SSHKey result + types + conversions +- Modify: `pkg/sdk/client/user_public_test.go` — add tests +- Modify: `pkg/sdk/client/user_types_public_test.go` — add + conversion tests + +### CLI Layer + +- Create: `cmd/client_node_user_ssh_key.go` — parent command +- Create: `cmd/client_node_user_ssh_key_list.go` — list + subcommand +- Create: `cmd/client_node_user_ssh_key_add.go` — add subcommand +- Create: `cmd/client_node_user_ssh_key_remove.go` — remove + subcommand + +### Documentation + +- Modify: `docs/docs/sidebar/features/user-management.md` — add + SSH key section +- Create: + `docs/docs/sidebar/usage/cli/client/node/user/ssh-key.md` — CLI + landing +- Create: + `docs/docs/sidebar/usage/cli/client/node/user/ssh-key-list.md` +- Create: + `docs/docs/sidebar/usage/cli/client/node/user/ssh-key-add.md` +- Create: + `docs/docs/sidebar/usage/cli/client/node/user/ssh-key-remove.md` +- Modify: `docs/docs/sidebar/sdk/client/management/user.md` — add + SSH key methods +- Modify: `examples/sdk/client/user.go` — add SSH key demo +- Modify: `docs/docs/sidebar/architecture/api-guidelines.md` — add + endpoints + +--- + +### Task 1: Provider Types and Stubs + +**Files:** +- Modify: `internal/provider/node/user/types.go` +- Modify: `internal/provider/node/user/darwin.go` +- Modify: `internal/provider/node/user/linux.go` +- Modify: `internal/provider/node/user/darwin_public_test.go` +- Modify: `internal/provider/node/user/linux_public_test.go` + +- [ ] **Step 1: Add types to types.go** + +Add to `internal/provider/node/user/types.go`: + +```go +// SSHKey represents an SSH authorized key entry. +type SSHKey struct { + Type string `json:"type"` + Fingerprint string `json:"fingerprint"` + Comment string `json:"comment,omitempty"` +} + +// SSHKeyResult represents the result of an SSH key mutation. +type SSHKeyResult struct { + Changed bool `json:"changed"` +} +``` + +Add 3 methods to the Provider interface: + +```go + // ListKeys returns SSH authorized keys for a user. + ListKeys(ctx context.Context, username string) ([]SSHKey, error) + // AddKey adds an SSH authorized key for a user. + AddKey(ctx context.Context, username string, key SSHKey) (*SSHKeyResult, error) + // RemoveKey removes an SSH authorized key by fingerprint. + RemoveKey(ctx context.Context, username string, fingerprint string) (*SSHKeyResult, error) +``` + +- [ ] **Step 2: Add stub methods to darwin.go and linux.go** + +Add to both `darwin.go` and `linux.go`: + +```go +// ListKeys returns ErrUnsupported on Darwin/Linux. +func (d *Darwin) ListKeys( + _ context.Context, + _ string, +) ([]SSHKey, error) { + return nil, fmt.Errorf("user: %w", provider.ErrUnsupported) +} + +// AddKey returns ErrUnsupported on Darwin/Linux. +func (d *Darwin) AddKey( + _ context.Context, + _ string, + _ SSHKey, +) (*SSHKeyResult, error) { + return nil, fmt.Errorf("user: %w", provider.ErrUnsupported) +} + +// RemoveKey returns ErrUnsupported on Darwin/Linux. +func (d *Darwin) RemoveKey( + _ context.Context, + _ string, + _ string, +) (*SSHKeyResult, error) { + return nil, fmt.Errorf("user: %w", provider.ErrUnsupported) +} +``` + +(Same for Linux struct.) + +- [ ] **Step 3: Add stub tests** + +Add test cases to the existing test tables in +`darwin_public_test.go` and `linux_public_test.go` for +ListKeys, AddKey, and RemoveKey all returning +ErrUnsupported. + +- [ ] **Step 4: Regenerate mocks** + +Run: `go generate ./internal/provider/node/user/mocks/...` + +- [ ] **Step 5: Run tests** + +Run: `go test -v ./internal/provider/node/user/...` +Expected: all pass, new stub tests included + +- [ ] **Step 6: Commit** + +```bash +git add internal/provider/node/user/ +git commit -m "feat(user): add SSH key types and platform stubs" +``` + +--- + +### Task 2: Debian SSH Key Implementation + +**Files:** +- Create: `internal/provider/node/user/debian_ssh_key.go` +- Test: `internal/provider/node/user/debian_ssh_key_public_test.go` + +- [ ] **Step 1: Write tests** + +Create `debian_ssh_key_public_test.go` with testify/suite. +Use `memfs.New()` for filesystem and gomock for exec.Manager. + +Set up a memfs with `/etc/passwd` containing: +``` +root:x:0:0:root:/root:/bin/bash +john:x:1000:1000:John:/home/john:/bin/bash +``` + +**TestListKeys** — table-driven: +- success (authorized_keys with 2 keys, verify type + fingerprint + + comment) +- user not found in /etc/passwd → error +- no authorized_keys file → empty list, no error +- empty authorized_keys → empty list +- lines with comments and blank lines skipped +- malformed key line skipped (logged as debug) + +**TestAddKey** — table-driven: +- success (creates .ssh dir + file, appends key) +- key already exists (same fingerprint) → changed: false +- user not found → error +- creates .ssh dir with 0700 if missing +- creates authorized_keys with 0600 if missing +- appends to existing file + +**TestRemoveKey** — table-driven: +- success (rewrites file without matching key) +- fingerprint not found → changed: false +- user not found → error +- no authorized_keys file → changed: false +- file becomes empty after removal (still valid) + +- [ ] **Step 2: Implement debian_ssh_key.go** + +```go +package user + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "fmt" + "log/slog" + "strings" +) + +// ListKeys returns SSH authorized keys for a user. +func (d *Debian) ListKeys( + _ context.Context, + username string, +) ([]SSHKey, error) { + d.logger.Debug("executing user.ListKeys", + slog.String("username", username), + ) + + home, err := d.userHomeDir(username) + if err != nil { + return nil, fmt.Errorf("ssh key: list: %w", err) + } + + authKeysPath := home + "/.ssh/authorized_keys" + + content, err := d.fs.ReadFile(authKeysPath) + if err != nil { + // No file = no keys, not an error. + return []SSHKey{}, nil + } + + return parseAuthorizedKeys(string(content), d.logger), nil +} + +// AddKey adds an SSH authorized key for a user. +func (d *Debian) AddKey( + _ context.Context, + username string, + key SSHKey, +) (*SSHKeyResult, error) { + d.logger.Debug("executing user.AddKey", + slog.String("username", username), + ) + + home, err := d.userHomeDir(username) + if err != nil { + return nil, fmt.Errorf("ssh key: add: %w", err) + } + + sshDir := home + "/.ssh" + authKeysPath := sshDir + "/authorized_keys" + + // Ensure .ssh directory exists. + if err := d.fs.MkdirAll(sshDir, 0o700); err != nil { + return nil, fmt.Errorf( + "ssh key: create .ssh dir: %w", err) + } + + // Read existing keys to check for duplicates. + existing, _ := d.fs.ReadFile(authKeysPath) + existingKeys := parseAuthorizedKeys( + string(existing), d.logger) + + for _, ek := range existingKeys { + if ek.Fingerprint == key.Fingerprint { + return &SSHKeyResult{Changed: false}, nil + } + } + + // Build the key line from the SSHKey fields. + keyLine := key.Type + " " + + base64.StdEncoding.EncodeToString(/* raw key bytes */) + // Actually, the API receives the full key line in a + // dedicated field. See the AddKey handler — it passes + // the raw key line. The provider should store the raw + // public key line. + + // Append key to file. + f, err := d.fs.OpenFile( + authKeysPath, + os.O_APPEND|os.O_CREATE|os.O_WRONLY, + 0o600, + ) + if err != nil { + return nil, fmt.Errorf( + "ssh key: open authorized_keys: %w", err) + } + defer f.Close() + + if _, err := f.Write( + []byte(key.RawLine + "\n"), + ); err != nil { + return nil, fmt.Errorf( + "ssh key: write key: %w", err) + } + + // Set ownership. + if _, err := d.execManager.RunCmd( + "chown", + []string{"-R", username + ":" + username, sshDir}, + ); err != nil { + d.logger.Warn("failed to set .ssh ownership", + slog.String("error", err.Error()), + ) + } + + return &SSHKeyResult{Changed: true}, nil +} + +// RemoveKey removes an SSH authorized key by fingerprint. +func (d *Debian) RemoveKey( + _ context.Context, + username string, + fingerprint string, +) (*SSHKeyResult, error) { + d.logger.Debug("executing user.RemoveKey", + slog.String("username", username), + slog.String("fingerprint", fingerprint), + ) + + home, err := d.userHomeDir(username) + if err != nil { + return nil, fmt.Errorf("ssh key: remove: %w", err) + } + + authKeysPath := home + "/.ssh/authorized_keys" + + content, err := d.fs.ReadFile(authKeysPath) + if err != nil { + return &SSHKeyResult{Changed: false}, nil + } + + lines := strings.Split(string(content), "\n") + var newLines []string + found := false + + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + newLines = append(newLines, line) + continue + } + + fp := fingerprintFromLine(trimmed) + if fp == fingerprint { + found = true + continue // skip this line + } + newLines = append(newLines, line) + } + + if !found { + return &SSHKeyResult{Changed: false}, nil + } + + newContent := strings.Join(newLines, "\n") + if err := d.fs.WriteFile( + authKeysPath, []byte(newContent), 0o600, + ); err != nil { + return nil, fmt.Errorf( + "ssh key: write authorized_keys: %w", err) + } + + return &SSHKeyResult{Changed: true}, nil +} + +// userHomeDir resolves a user's home directory from +// /etc/passwd. +func (d *Debian) userHomeDir( + username string, +) (string, error) { + content, err := d.fs.ReadFile("/etc/passwd") + if err != nil { + return "", fmt.Errorf( + "read /etc/passwd: %w", err) + } + + for _, line := range strings.Split( + string(content), "\n") { + fields := strings.Split(line, ":") + if len(fields) >= 6 && fields[0] == username { + return fields[5], nil + } + } + + return "", fmt.Errorf("user %q not found", username) +} + +// parseAuthorizedKeys parses an authorized_keys file content +// into SSHKey entries. +func parseAuthorizedKeys( + content string, + logger *slog.Logger, +) []SSHKey { + var keys []SSHKey + + for _, line := range strings.Split(content, "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + parts := strings.Fields(line) + if len(parts) < 2 { + logger.Debug("skipping malformed key line", + slog.String("line", line), + ) + continue + } + + keyType := parts[0] + keyData := parts[1] + comment := "" + if len(parts) >= 3 { + comment = strings.Join(parts[2:], " ") + } + + fp := computeFingerprint(keyData) + if fp == "" { + logger.Debug("skipping key with invalid base64", + slog.String("line", line), + ) + continue + } + + keys = append(keys, SSHKey{ + Type: keyType, + Fingerprint: fp, + Comment: comment, + }) + } + + return keys +} + +// computeFingerprint computes SHA256 fingerprint from base64- +// encoded key data. +func computeFingerprint( + keyData string, +) string { + decoded, err := base64.StdEncoding.DecodeString(keyData) + if err != nil { + return "" + } + + hash := sha256.Sum256(decoded) + + return "SHA256:" + + base64.RawStdEncoding.EncodeToString(hash[:]) +} + +// fingerprintFromLine extracts fingerprint from a key line. +func fingerprintFromLine( + line string, +) string { + parts := strings.Fields(line) + if len(parts) < 2 { + return "" + } + + return computeFingerprint(parts[1]) +} +``` + +**IMPORTANT**: The SSHKey type needs a `RawLine` field to store +the full public key string for AddKey. Update the types: + +```go +type SSHKey struct { + Type string `json:"type"` + Fingerprint string `json:"fingerprint"` + Comment string `json:"comment,omitempty"` + RawLine string `json:"raw_line,omitempty"` +} +``` + +The API handler populates `RawLine` from the POST body's `key` +field. The provider uses `RawLine` to append to +`authorized_keys`. ListKeys does NOT populate `RawLine` (we +don't expose raw key data in list responses — just type, +fingerprint, comment). + +- [ ] **Step 3: Run tests** + +Run: `go test -v ./internal/provider/node/user/...` +Expected: all pass + +- [ ] **Step 4: Verify 100% coverage on new file** + +```bash +go test -coverprofile=/tmp/ssh.cov \ + ./internal/provider/node/user/... && \ + go tool cover -func=/tmp/ssh.cov | \ + grep "debian_ssh_key" +``` + +All functions must be 100%. + +- [ ] **Step 5: Commit** + +```bash +git add internal/provider/node/user/ +git commit -m "feat(user): add SSH key management to debian provider" +``` + +--- + +### Task 3: Operations and Agent Processor + +**Files:** +- Modify: `pkg/sdk/client/operations.go` +- Modify: `internal/job/types.go` +- Create: `internal/agent/processor_ssh_key.go` +- Modify: `internal/agent/processor.go` — add `sshKey` case +- Test: `internal/agent/processor_ssh_key_public_test.go` + +- [ ] **Step 1: Add operation constants** + +In `pkg/sdk/client/operations.go`, add after User operations: + +```go +// SSH Key operations. +const ( + OpSSHKeyList JobOperation = "node.sshKey.list" + OpSSHKeyAdd JobOperation = "node.sshKey.add" + OpSSHKeyRemove JobOperation = "node.sshKey.remove" +) +``` + +In `internal/job/types.go`, add corresponding aliases: + +```go +// SSH Key operations. +const ( + OperationSSHKeyList = client.OpSSHKeyList + OperationSSHKeyAdd = client.OpSSHKeyAdd + OperationSSHKeyRemove = client.OpSSHKeyRemove +) +``` + +- [ ] **Step 2: Write processor tests** + +Create `internal/agent/processor_ssh_key_public_test.go`. +The processor dispatches to the existing `userProvider` (same +as user/group operations). Test via `NewNodeProcessor`. + +**TestProcessSSHKeyOperation** — dispatch-level table: +- nil user provider → error +- invalid operation format +- unsupported sub-operation + +**TestProcessSSHKeyList** — table-driven: +- success (returns keys) +- unmarshal error (invalid JSON) +- provider error + +**TestProcessSSHKeyAdd** — table-driven: +- success +- unmarshal error +- provider error + +**TestProcessSSHKeyRemove** — table-driven: +- success +- unmarshal error +- provider error + +One suite method per function, ALL scenarios as table rows. + +- [ ] **Step 3: Implement processor_ssh_key.go** + +```go +func processSshKeyOperation( + userProvider user.Provider, + logger *slog.Logger, + jobRequest job.Request, +) (json.RawMessage, error) { + if userProvider == nil { + return nil, fmt.Errorf( + "user provider not available") + } + + parts := strings.Split(jobRequest.Operation, ".") + if len(parts) < 2 { + return nil, fmt.Errorf( + "invalid sshKey operation: %s", + jobRequest.Operation) + } + subOp := parts[1] + + ctx := context.Background() + + switch subOp { + case "list": + return processSshKeyList( + ctx, userProvider, logger, jobRequest) + case "add": + return processSshKeyAdd( + ctx, userProvider, logger, jobRequest) + case "remove": + return processSshKeyRemove( + ctx, userProvider, logger, jobRequest) + default: + return nil, fmt.Errorf( + "unsupported sshKey operation: %s", + jobRequest.Operation) + } +} +``` + +Each sub-handler unmarshals username (and key data for add, +fingerprint for remove) from `jobRequest.Data`, calls the +provider, and marshals the result. + +- [ ] **Step 4: Wire into node processor** + +In `internal/agent/processor.go`, add case to the +`NewNodeProcessor` switch: + +```go + case "sshKey": + return processSshKeyOperation( + userProvider, logger, req) +``` + +- [ ] **Step 5: Run tests and verify coverage** + +```bash +go test -v ./internal/agent/... +go build ./... +go test -coverprofile=/tmp/ssh_proc.cov \ + ./internal/agent/... && \ + go tool cover -func=/tmp/ssh_proc.cov | \ + grep "processor_ssh_key" +``` + +- [ ] **Step 6: Commit** + +```bash +git add pkg/sdk/client/operations.go \ + internal/job/types.go \ + internal/agent/processor_ssh_key.go \ + internal/agent/processor_ssh_key_public_test.go \ + internal/agent/processor.go +git commit -m "feat(user): add SSH key operations and agent processor" +``` + +--- + +### Task 4: OpenAPI Spec Update and Code Generation + +**Files:** +- Modify: `internal/controller/api/node/user/gen/api.yaml` + +- [ ] **Step 1: Add endpoints to existing user OpenAPI spec** + +Add to `internal/controller/api/node/user/gen/api.yaml` after +the password endpoint section: + +```yaml + # -- SSH Key management ------------------------------------------------ + + /node/{hostname}/user/{name}/ssh-key: + get: + summary: List SSH authorized keys + description: > + List SSH authorized keys for a user on the target node. + tags: + - user_operations + operationId: GetNodeUserSshKey + security: + - BearerAuth: + - user:read + parameters: + - $ref: '#/components/parameters/Hostname' + - $ref: '#/components/parameters/UserName' + responses: + '200': + description: List of SSH authorized keys. + content: + application/json: + schema: + $ref: '#/components/schemas/SSHKeyCollectionResponse' + '401': ... + '403': ... + '500': ... + + post: + summary: Add SSH authorized key + description: > + Add an SSH authorized key for a user on the target node. + tags: + - user_operations + operationId: PostNodeUserSshKey + security: + - BearerAuth: + - user:write + parameters: + - $ref: '#/components/parameters/Hostname' + - $ref: '#/components/parameters/UserName' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SSHKeyAddRequest' + responses: + '200': + description: Key added. + content: + application/json: + schema: + $ref: '#/components/schemas/SSHKeyMutationResponse' + '400': ... + '401': ... + '403': ... + '500': ... + + /node/{hostname}/user/{name}/ssh-key/{fingerprint}: + delete: + summary: Remove SSH authorized key + description: > + Remove an SSH authorized key by fingerprint. + tags: + - user_operations + operationId: DeleteNodeUserSshKey + security: + - BearerAuth: + - user:write + parameters: + - $ref: '#/components/parameters/Hostname' + - $ref: '#/components/parameters/UserName' + - $ref: '#/components/parameters/SSHKeyFingerprint' + responses: + '200': + description: Key removed. + content: + application/json: + schema: + $ref: '#/components/schemas/SSHKeyMutationResponse' + '401': ... + '403': ... + '500': ... +``` + +Add schemas: + +```yaml + SSHKeyAddRequest: + type: object + required: + - key + properties: + key: + type: string + description: > + Full SSH public key line (e.g., + "ssh-ed25519 AAAA... user@host"). + x-oapi-codegen-extra-tags: + validate: required,min=1 + + SSHKeyInfo: + type: object + properties: + type: + type: string + example: "ssh-ed25519" + fingerprint: + type: string + example: "SHA256:abc123..." + comment: + type: string + example: "john@laptop" + + SSHKeyEntry: + type: object + properties: + hostname: + type: string + status: + type: string + enum: [ok, failed, skipped] + keys: + type: array + items: + $ref: '#/components/schemas/SSHKeyInfo' + error: + type: string + required: + - hostname + - status + + SSHKeyMutationEntry: + type: object + properties: + hostname: + type: string + status: + type: string + enum: [ok, failed, skipped] + changed: + type: boolean + error: + type: string + required: + - hostname + - status + + SSHKeyCollectionResponse: + type: object + properties: + job_id: + type: string + format: uuid + results: + type: array + items: + $ref: '#/components/schemas/SSHKeyEntry' + required: + - results + + SSHKeyMutationResponse: + type: object + properties: + job_id: + type: string + format: uuid + results: + type: array + items: + $ref: '#/components/schemas/SSHKeyMutationEntry' + required: + - results +``` + +Add parameter: + +```yaml + SSHKeyFingerprint: + name: fingerprint + in: path + required: true + description: SSH key SHA256 fingerprint. + x-oapi-codegen-extra-tags: + validate: required,min=1 + schema: + type: string + minLength: 1 +``` + +- [ ] **Step 2: Generate code and rebuild** + +```bash +go generate ./internal/controller/api/node/user/gen/... +just generate +go build ./... +``` + +- [ ] **Step 3: Commit** + +```bash +git add internal/controller/api/node/user/gen/ \ + internal/controller/api/gen/ \ + pkg/sdk/client/gen/ +git commit -m "feat(user): add SSH key endpoints to OpenAPI spec" +``` + +--- + +### Task 5: API Handler Implementation + +**Files:** +- Create: `internal/controller/api/node/user/ssh_key_list_get.go` +- Create: `internal/controller/api/node/user/ssh_key_add_post.go` +- Create: `internal/controller/api/node/user/ssh_key_remove_delete.go` +- Test: all 3 `*_public_test.go` files + +- [ ] **Step 1: Implement list handler** + +`GetNodeUserSshKey` method on the existing `User` handler +struct: +- Validate hostname +- username from `request.Name` +- Query with category `"node"`, operation + `job.OperationSSHKeyList`, data `{"username": username}` +- Parse response: unmarshal `[]userProv.SSHKey`, convert to + `[]gen.SSHKeyInfo` +- Broadcast support + +- [ ] **Step 2: Implement add handler** + +`PostNodeUserSshKey`: +- Validate hostname, body (`key` field) +- Parse the raw key line to extract type, fingerprint, comment +- Build `userProv.SSHKey{Type, Fingerprint, Comment, RawLine}` +- Modify with `job.OperationSSHKeyAdd`, data includes + `username` + the SSHKey struct +- Parse mutation response + +- [ ] **Step 3: Implement remove handler** + +`DeleteNodeUserSshKey`: +- Validate hostname +- fingerprint from `request.Fingerprint` +- Modify with `job.OperationSSHKeyRemove`, data + `{"username": username, "fingerprint": fingerprint}` +- Parse mutation response + +- [ ] **Step 4: Write tests** + +Each handler test file needs: success, skipped, broadcast, +validation error, job error, HTTP wiring, RBAC (401/403/200). +One suite method per handler, all scenarios as table rows. + +- [ ] **Step 5: Run tests and verify coverage** + +```bash +go test -v ./internal/controller/api/node/user/... +go test -coverprofile=/tmp/ssh_h.cov \ + ./internal/controller/api/node/user/... && \ + go tool cover -func=/tmp/ssh_h.cov | \ + grep "ssh_key" | grep -v "100.0%" +``` + +- [ ] **Step 6: Commit** + +```bash +git add internal/controller/api/node/user/ +git commit -m "feat(user): add SSH key API handlers with broadcast support" +``` + +--- + +### Task 6: SDK Service Extension + +**Files:** +- Modify: `pkg/sdk/client/user.go` +- Modify: `pkg/sdk/client/user_types.go` +- Modify: `pkg/sdk/client/user_public_test.go` +- Modify: `pkg/sdk/client/user_types_public_test.go` + +- [ ] **Step 1: Add types** + +In `user_types.go`, add: + +```go +type SSHKeyInfoResult struct { + Hostname string `json:"hostname"` + Status string `json:"status"` + Keys []SSHKeyInfo `json:"keys,omitempty"` + Error string `json:"error,omitempty"` +} + +type SSHKeyInfo struct { + Type string `json:"type,omitempty"` + Fingerprint string `json:"fingerprint,omitempty"` + Comment string `json:"comment,omitempty"` +} + +type SSHKeyMutationResult struct { + Hostname string `json:"hostname"` + Status string `json:"status"` + Changed bool `json:"changed"` + Error string `json:"error,omitempty"` +} + +type SSHKeyAddOpts struct { + Key string +} +``` + +Add conversion functions. + +- [ ] **Step 2: Add methods to UserService** + +In `user.go`, add: + +```go +func (s *UserService) ListKeys( + ctx context.Context, + hostname string, + username string, +) (*Response[Collection[SSHKeyInfoResult]], error) + +func (s *UserService) AddKey( + ctx context.Context, + hostname string, + username string, + opts SSHKeyAddOpts, +) (*Response[Collection[SSHKeyMutationResult]], error) + +func (s *UserService) RemoveKey( + ctx context.Context, + hostname string, + username string, + fingerprint string, +) (*Response[Collection[SSHKeyMutationResult]], error) +``` + +- [ ] **Step 3: Regenerate SDK client** + +```bash +go generate ./pkg/sdk/client/gen/... +``` + +- [ ] **Step 4: Write tests** + +Add tests to existing test files (or create new +`user_ssh_key_public_test.go` / `user_ssh_key_types_public_test.go` +if the existing files are already large). Follow existing +patterns with httptest.Server. + +- [ ] **Step 5: Verify 100% coverage** + +```bash +go test -coverprofile=/tmp/ssh_sdk.cov \ + ./pkg/sdk/client/... && \ + go tool cover -func=/tmp/ssh_sdk.cov | \ + grep "user" | grep -v "100.0%" +``` + +- [ ] **Step 6: Commit** + +```bash +git add pkg/sdk/client/ +git commit -m "feat(user): add SSH key SDK methods with tests" +``` + +--- + +### Task 7: CLI Commands + +**Files:** +- Create: `cmd/client_node_user_ssh_key.go` +- Create: `cmd/client_node_user_ssh_key_list.go` +- Create: `cmd/client_node_user_ssh_key_add.go` +- Create: `cmd/client_node_user_ssh_key_remove.go` + +- [ ] **Step 1: Create parent command** + +```go +var clientNodeUserSshKeyCmd = &cobra.Command{ + Use: "ssh-key", + Short: "Manage SSH authorized keys", +} + +func init() { + clientNodeUserCmd.AddCommand(clientNodeUserSshKeyCmd) +} +``` + +Wait — check whether `clientNodeUserCmd` exists. Look at +`cmd/client_node_user.go` for the parent. + +- [ ] **Step 2: Create list subcommand** + +Flags: `--name` (username, required) +- Calls `sdkClient.User.ListKeys(ctx, host, name)` +- Table headers: `TYPE`, `FINGERPRINT`, `COMMENT` +- Uses `BuildBroadcastTable` + +- [ ] **Step 3: Create add subcommand** + +Flags: `--name` (required), `--key` (required, full public +key line) +- Calls `sdkClient.User.AddKey(ctx, host, name, opts)` +- Uses `BuildMutationTable` with headers `CHANGED` + +- [ ] **Step 4: Create remove subcommand** + +Flags: `--name` (required), `--fingerprint` (required) +- Calls `sdkClient.User.RemoveKey(ctx, host, name, fp)` +- Uses `BuildMutationTable` + +- [ ] **Step 5: Verify build** + +```bash +go build ./... +``` + +- [ ] **Step 6: Commit** + +```bash +git add cmd/client_node_user_ssh_key*.go +git commit -m "feat(user): add SSH key CLI commands" +``` + +--- + +### Task 8: Documentation + +**Files:** +- Modify: `docs/docs/sidebar/features/user-management.md` +- Create: CLI doc pages for ssh-key commands +- Modify: `docs/docs/sidebar/sdk/client/management/user.md` +- Modify: `examples/sdk/client/user.go` +- Modify: `docs/docs/sidebar/architecture/api-guidelines.md` + +- [ ] **Step 1: Update feature page** + +Add SSH Key Management section to +`docs/docs/sidebar/features/user-management.md`: +- How It Works (list, add, remove) +- Add to Operations table +- Add CLI examples for ssh-key subcommands +- Note: uses existing `user:read`/`user:write` permissions + +- [ ] **Step 2: Create CLI doc pages** + +Create landing page + list.md, add.md, remove.md under +`docs/docs/sidebar/usage/cli/client/node/user/`. + +- [ ] **Step 3: Update SDK doc** + +Add ListKeys, AddKey, RemoveKey to the user SDK doc page +with code examples and result type tables. + +- [ ] **Step 4: Update SDK example** + +Add SSH key demo to `examples/sdk/client/user.go`. + +- [ ] **Step 5: Update api-guidelines** + +Add endpoint rows: +``` +| `/node/{hostname}/user/{name}/ssh-key` | User | +| `/node/{hostname}/user/{name}/ssh-key/{fingerprint}` | User | +``` + +- [ ] **Step 6: Commit** + +```bash +git add docs/ examples/ +git commit -m "docs: add SSH key management to user docs and SDK example" +``` + +--- + +### Task 9: Integration Test and Final Verification + +**Files:** +- Modify or create: `test/integration/user_test.go` (add SSH + key tests) + +- [ ] **Step 1: Add integration test** + +Add SSH key list test to the existing user integration test +file (or create new if it doesn't exist). Test: +- `osapi client node user ssh-key list --target _any --name root --json` + +- [ ] **Step 2: Run full suite** + +```bash +just generate +go build ./... +just go::unit +just go::vet +``` + +- [ ] **Step 3: Verify coverage** + +```bash +go test -coverprofile=/tmp/ssh_all.cov \ + ./internal/provider/node/user/... \ + ./internal/agent/... \ + ./internal/controller/api/node/user/... \ + ./pkg/sdk/client/... +go tool cover -func=/tmp/ssh_all.cov | \ + grep "ssh_key\|ssh_key" | grep -v "100.0%" | \ + grep -v "mocks\|gen/" +``` + +- [ ] **Step 4: Commit any fixes** + +```bash +git add -A +git commit -m "chore(user): fix formatting and lint" +``` From 6c33b9dc2e6be873e80818427fb96c40277eb48d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Wed, 1 Apr 2026 11:36:08 -0700 Subject: [PATCH 03/11] feat(user): add SSH key management to user provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add ListKeys, AddKey, and RemoveKey methods to the user Provider interface for managing SSH authorized_keys files. Includes Debian implementation with fingerprint-based idempotency, Darwin/Linux ErrUnsupported stubs, regenerated mocks, and 100% test coverage. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- internal/provider/node/user/darwin.go | 26 + .../provider/node/user/darwin_public_test.go | 62 ++ internal/provider/node/user/debian_ssh_key.go | 341 +++++++ .../node/user/debian_ssh_key_public_test.go | 915 ++++++++++++++++++ internal/provider/node/user/linux.go | 26 + .../provider/node/user/linux_public_test.go | 62 ++ .../provider/node/user/mocks/provider.gen.go | 45 + internal/provider/node/user/types.go | 16 + 8 files changed, 1493 insertions(+) create mode 100644 internal/provider/node/user/debian_ssh_key.go create mode 100644 internal/provider/node/user/debian_ssh_key_public_test.go diff --git a/internal/provider/node/user/darwin.go b/internal/provider/node/user/darwin.go index 85240a4ef..f094a3847 100644 --- a/internal/provider/node/user/darwin.go +++ b/internal/provider/node/user/darwin.go @@ -125,3 +125,29 @@ func (d *Darwin) DeleteGroup( ) (*GroupResult, error) { return nil, fmt.Errorf("user: %w", provider.ErrUnsupported) } + +// ListKeys returns ErrUnsupported on Darwin. +func (d *Darwin) ListKeys( + _ context.Context, + _ string, +) ([]SSHKey, error) { + return nil, fmt.Errorf("user: %w", provider.ErrUnsupported) +} + +// AddKey returns ErrUnsupported on Darwin. +func (d *Darwin) AddKey( + _ context.Context, + _ string, + _ SSHKey, +) (*SSHKeyResult, error) { + return nil, fmt.Errorf("user: %w", provider.ErrUnsupported) +} + +// RemoveKey returns ErrUnsupported on Darwin. +func (d *Darwin) RemoveKey( + _ context.Context, + _ string, + _ string, +) (*SSHKeyResult, error) { + return nil, fmt.Errorf("user: %w", provider.ErrUnsupported) +} diff --git a/internal/provider/node/user/darwin_public_test.go b/internal/provider/node/user/darwin_public_test.go index e17fb70d1..6bf3317dd 100644 --- a/internal/provider/node/user/darwin_public_test.go +++ b/internal/provider/node/user/darwin_public_test.go @@ -270,6 +270,68 @@ func (suite *DarwinPublicTestSuite) TestDeleteGroup() { } } +func (suite *DarwinPublicTestSuite) TestListKeys() { + tests := []struct { + name string + }{ + { + name: "returns ErrUnsupported", + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + result, err := suite.provider.ListKeys(suite.ctx, "testuser") + + suite.Error(err) + suite.Nil(result) + suite.ErrorIs(err, provider.ErrUnsupported) + }) + } +} + +func (suite *DarwinPublicTestSuite) TestAddKey() { + tests := []struct { + name string + }{ + { + name: "returns ErrUnsupported", + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + result, err := suite.provider.AddKey(suite.ctx, "testuser", user.SSHKey{ + RawLine: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI test@example", + }) + + suite.Error(err) + suite.Nil(result) + suite.ErrorIs(err, provider.ErrUnsupported) + }) + } +} + +func (suite *DarwinPublicTestSuite) TestRemoveKey() { + tests := []struct { + name string + }{ + { + name: "returns ErrUnsupported", + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + result, err := suite.provider.RemoveKey(suite.ctx, "testuser", "SHA256:abc123") + + suite.Error(err) + suite.Nil(result) + suite.ErrorIs(err, provider.ErrUnsupported) + }) + } +} + func TestDarwinPublicTestSuite(t *testing.T) { suite.Run(t, new(DarwinPublicTestSuite)) } diff --git a/internal/provider/node/user/debian_ssh_key.go b/internal/provider/node/user/debian_ssh_key.go new file mode 100644 index 000000000..4185cc48f --- /dev/null +++ b/internal/provider/node/user/debian_ssh_key.go @@ -0,0 +1,341 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package user + +import ( + "bufio" + "context" + "crypto/sha256" + "encoding/base64" + "errors" + "fmt" + "io/fs" + "log/slog" + "os" + "path/filepath" + "strings" +) + +const ( + authorizedKeysFile = "authorized_keys" + sshDirName = ".ssh" + sshDirMode = 0o700 + authorizedKeysMode = 0o600 + minKeyFields = 2 +) + +// ListKeys returns the SSH authorized keys for the given user. +func (d *Debian) ListKeys( + ctx context.Context, + username string, +) ([]SSHKey, error) { + _ = ctx + + d.logger.Debug("executing user.ListKeys", + slog.String("username", username), + ) + + homeDir, err := d.userHomeDir(username) + if err != nil { + return nil, fmt.Errorf("ssh key: list: %w", err) + } + + authKeysPath := filepath.Join(homeDir, sshDirName, authorizedKeysFile) + + content, err := d.fs.ReadFile(authKeysPath) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return []SSHKey{}, nil + } + + return nil, fmt.Errorf("ssh key: list: read %s: %w", authKeysPath, err) + } + + keys := d.parseAuthorizedKeys(string(content)) + + return keys, nil +} + +// AddKey adds an SSH public key to the user's authorized_keys file. +func (d *Debian) AddKey( + ctx context.Context, + username string, + key SSHKey, +) (*SSHKeyResult, error) { + _ = ctx + + d.logger.Debug("executing user.AddKey", + slog.String("username", username), + ) + + homeDir, err := d.userHomeDir(username) + if err != nil { + return nil, fmt.Errorf("ssh key: add: %w", err) + } + + sshDir := filepath.Join(homeDir, sshDirName) + authKeysPath := filepath.Join(sshDir, authorizedKeysFile) + + // Check if key already exists by fingerprint. + content, err := d.fs.ReadFile(authKeysPath) + if err != nil && !errors.Is(err, fs.ErrNotExist) { + return nil, fmt.Errorf("ssh key: add: read %s: %w", authKeysPath, err) + } + + if err == nil { + newFingerprint := fingerprintFromLine(key.RawLine) + if newFingerprint != "" { + existing := d.parseAuthorizedKeys(string(content)) + for _, k := range existing { + if k.Fingerprint == newFingerprint { + return &SSHKeyResult{Changed: false}, nil + } + } + } + } + + // Create .ssh directory if missing. + if err := d.fs.MkdirAll(sshDir, sshDirMode); err != nil { + return nil, fmt.Errorf("ssh key: add: mkdir %s: %w", sshDir, err) + } + + // Append key to authorized_keys. + f, err := d.fs.OpenFile( + authKeysPath, + os.O_APPEND|os.O_CREATE|os.O_WRONLY, + authorizedKeysMode, + ) + if err != nil { + return nil, fmt.Errorf("ssh key: add: open %s: %w", authKeysPath, err) + } + + _, writeErr := f.Write([]byte(key.RawLine + "\n")) + + if closeErr := f.Close(); closeErr != nil && writeErr == nil { + writeErr = closeErr + } + + if writeErr != nil { + return nil, fmt.Errorf("ssh key: add: write %s: %w", authKeysPath, writeErr) + } + + // Best-effort chown. + _, chownErr := d.execManager.RunCmd("chown", []string{ + "-R", + username + ":" + username, + sshDir, + }) + if chownErr != nil { + d.logger.Warn("chown failed for ssh directory", + slog.String("username", username), + slog.String("path", sshDir), + slog.String("error", chownErr.Error()), + ) + } + + d.logger.Info("ssh key added", + slog.String("username", username), + ) + + return &SSHKeyResult{Changed: true}, nil +} + +// RemoveKey removes an SSH public key by fingerprint from the user's +// authorized_keys file. +func (d *Debian) RemoveKey( + ctx context.Context, + username string, + fingerprint string, +) (*SSHKeyResult, error) { + _ = ctx + + d.logger.Debug("executing user.RemoveKey", + slog.String("username", username), + slog.String("fingerprint", fingerprint), + ) + + homeDir, err := d.userHomeDir(username) + if err != nil { + return nil, fmt.Errorf("ssh key: remove: %w", err) + } + + authKeysPath := filepath.Join(homeDir, sshDirName, authorizedKeysFile) + + content, err := d.fs.ReadFile(authKeysPath) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return &SSHKeyResult{Changed: false}, nil + } + + return nil, fmt.Errorf("ssh key: remove: read %s: %w", authKeysPath, err) + } + + lines := strings.Split(string(content), "\n") + var remaining []string + + found := false + + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + remaining = append(remaining, line) + + continue + } + + fp := fingerprintFromLine(trimmed) + if fp == fingerprint { + found = true + + continue + } + + remaining = append(remaining, line) + } + + if !found { + return &SSHKeyResult{Changed: false}, nil + } + + output := strings.Join(remaining, "\n") + + if err := d.fs.WriteFile(authKeysPath, []byte(output), authorizedKeysMode); err != nil { + return nil, fmt.Errorf("ssh key: remove: write %s: %w", authKeysPath, err) + } + + d.logger.Info("ssh key removed", + slog.String("username", username), + slog.String("fingerprint", fingerprint), + ) + + return &SSHKeyResult{Changed: true}, nil +} + +// userHomeDir resolves a user's home directory from /etc/passwd. +func (d *Debian) userHomeDir( + username string, +) (string, error) { + f, err := d.fs.Open(passwdFile) + if err != nil { + return "", fmt.Errorf("open %s: %w", passwdFile, err) + } + defer func() { _ = f.Close() }() + + scanner := bufio.NewScanner(f) + + for scanner.Scan() { + line := scanner.Text() + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + fields := strings.Split(line, ":") + if len(fields) < passwdFields { + continue + } + + if fields[0] == username { + return fields[5], nil + } + } + + if err := scanner.Err(); err != nil { + return "", fmt.Errorf("read %s: %w", passwdFile, err) + } + + return "", fmt.Errorf("user %q not found", username) +} + +// parseAuthorizedKeys parses the content of an authorized_keys file into +// SSHKey entries. Lines that are empty, comments, or malformed are skipped. +func (d *Debian) parseAuthorizedKeys( + content string, +) []SSHKey { + var keys []SSHKey + + for _, line := range strings.Split(content, "\n") { + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + continue + } + + fields := strings.Fields(trimmed) + if len(fields) < minKeyFields { + d.logger.Debug("skipping malformed authorized_keys line", + slog.String("line", trimmed), + ) + + continue + } + + fp := computeFingerprint(fields[1]) + if fp == "" { + d.logger.Debug("skipping line with invalid base64 key data", + slog.String("line", trimmed), + ) + + continue + } + + key := SSHKey{ + Type: fields[0], + Fingerprint: fp, + } + + if len(fields) > minKeyFields { + key.Comment = strings.Join(fields[minKeyFields:], " ") + } + + keys = append(keys, key) + } + + return keys +} + +// computeFingerprint computes the SHA256 fingerprint of base64-encoded key +// data, returning the OpenSSH format "SHA256:". +// Returns empty string if the key data is not valid base64. +func computeFingerprint( + keyData string, +) string { + decoded, err := base64.StdEncoding.DecodeString(keyData) + if err != nil { + return "" + } + + hash := sha256.Sum256(decoded) + + return "SHA256:" + base64.RawStdEncoding.EncodeToString(hash[:]) +} + +// fingerprintFromLine extracts and computes the fingerprint from a full +// authorized_keys line. Returns empty string if the line is malformed or +// contains invalid key data. +func fingerprintFromLine( + line string, +) string { + fields := strings.Fields(strings.TrimSpace(line)) + if len(fields) < minKeyFields { + return "" + } + + return computeFingerprint(fields[1]) +} diff --git a/internal/provider/node/user/debian_ssh_key_public_test.go b/internal/provider/node/user/debian_ssh_key_public_test.go new file mode 100644 index 000000000..3701dfa8a --- /dev/null +++ b/internal/provider/node/user/debian_ssh_key_public_test.go @@ -0,0 +1,915 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package user_test + +import ( + "context" + "errors" + "fmt" + "log/slog" + "os" + "testing" + + "github.com/avfs/avfs" + "github.com/avfs/avfs/vfs/failfs" + "github.com/avfs/avfs/vfs/memfs" + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/suite" + + execmocks "github.com/retr0h/osapi/internal/exec/mocks" + "github.com/retr0h/osapi/internal/provider/node/user" +) + +const ( + // Valid ed25519 key line for testing. + testKey1Line = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHRlc3RrZXkxZGF0YQ== user@host" + testKey1FP = "SHA256:fs7cRe+Lieb9g9TQ7a4HbYTDyVWnO8tXg6D9H2cAWIY" + + // Valid RSA key line for testing. + testKey2Line = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQC7 admin@server" + testKey2FP = "SHA256:BgjHH5Pzls0x0ceexhHl0tFm6EBSFKWukOczrQrdl9Y" + + testPasswdSSH = `root:x:0:0:root:/root:/bin/bash +testuser:x:1000:1000:Test:/home/testuser:/bin/bash +` +) + +type DebianSSHKeyPublicTestSuite struct { + suite.Suite + + ctrl *gomock.Controller + ctx context.Context + logger *slog.Logger + memFs avfs.VFS + mockExec *execmocks.MockManager + provider *user.Debian +} + +func (suite *DebianSSHKeyPublicTestSuite) SetupTest() { + suite.ctrl = gomock.NewController(suite.T()) + suite.ctx = context.Background() + suite.logger = slog.New(slog.NewTextHandler(os.Stdout, nil)) + suite.memFs = memfs.New() + suite.mockExec = execmocks.NewMockManager(suite.ctrl) + + suite.provider = user.NewDebianProvider( + suite.logger, + suite.memFs, + suite.mockExec, + ) +} + +func (suite *DebianSSHKeyPublicTestSuite) SetupSubTest() { + suite.SetupTest() +} + +func (suite *DebianSSHKeyPublicTestSuite) TearDownTest() { + suite.ctrl.Finish() +} + +func (suite *DebianSSHKeyPublicTestSuite) writePasswd(content string) { + _ = suite.memFs.MkdirAll("/etc", 0o755) + + f, err := suite.memFs.Create("/etc/passwd") + suite.Require().NoError(err) + + _, err = f.Write([]byte(content)) + suite.Require().NoError(err) + suite.Require().NoError(f.Close()) +} + +func (suite *DebianSSHKeyPublicTestSuite) writeAuthorizedKeys( + username string, + homeDir string, + content string, +) { + sshDir := homeDir + "/.ssh" + _ = suite.memFs.MkdirAll(sshDir, 0o700) + + f, err := suite.memFs.Create(sshDir + "/authorized_keys") + suite.Require().NoError(err) + + _, err = f.Write([]byte(content)) + suite.Require().NoError(err) + suite.Require().NoError(f.Close()) +} + +func (suite *DebianSSHKeyPublicTestSuite) readFile(path string) string { + content, err := suite.memFs.ReadFile(path) + suite.Require().NoError(err) + + return string(content) +} + +// newFailFSProvider creates a provider backed by failfs wrapping memfs. +// The caller must write /etc/passwd and any other files to baseFs before +// setting the fail function. +func (suite *DebianSSHKeyPublicTestSuite) newFailFSProvider( + baseFs avfs.VFS, + ff failfs.FailFunc, +) *user.Debian { + ffs := failfs.New(baseFs) + _ = ffs.SetFailFunc(ff) + + return user.NewDebianProvider( + suite.logger, + ffs, + suite.mockExec, + ) +} + +func (suite *DebianSSHKeyPublicTestSuite) TestListKeys() { + tests := []struct { + name string + username string + passwd string + skipPasswd bool + setupFS func() + validateFunc func([]user.SSHKey, error) + }{ + { + name: "when successful with two keys", + username: "testuser", + passwd: testPasswdSSH, + setupFS: func() { + suite.writeAuthorizedKeys( + "testuser", + "/home/testuser", + testKey1Line+"\n"+testKey2Line+"\n", + ) + }, + validateFunc: func(keys []user.SSHKey, err error) { + suite.NoError(err) + suite.Require().Len(keys, 2) + + suite.Equal("ssh-ed25519", keys[0].Type) + suite.Equal(testKey1FP, keys[0].Fingerprint) + suite.Equal("user@host", keys[0].Comment) + suite.Empty(keys[0].RawLine) + + suite.Equal("ssh-rsa", keys[1].Type) + suite.Equal(testKey2FP, keys[1].Fingerprint) + suite.Equal("admin@server", keys[1].Comment) + suite.Empty(keys[1].RawLine) + }, + }, + { + name: "when user not found in passwd", + username: "nonexistent", + passwd: testPasswdSSH, + setupFS: func() {}, + validateFunc: func(keys []user.SSHKey, err error) { + suite.Error(err) + suite.Nil(keys) + suite.Contains(err.Error(), "not found") + }, + }, + { + name: "when passwd file missing", + username: "testuser", + skipPasswd: true, + setupFS: func() {}, + validateFunc: func(keys []user.SSHKey, err error) { + suite.Error(err) + suite.Nil(keys) + suite.Contains(err.Error(), "ssh key: list") + }, + }, + { + name: "when no authorized_keys file", + username: "testuser", + passwd: testPasswdSSH, + setupFS: func() {}, + validateFunc: func(keys []user.SSHKey, err error) { + suite.NoError(err) + suite.Empty(keys) + }, + }, + { + name: "when authorized_keys is empty", + username: "testuser", + passwd: testPasswdSSH, + setupFS: func() { + suite.writeAuthorizedKeys("testuser", "/home/testuser", "") + }, + validateFunc: func(keys []user.SSHKey, err error) { + suite.NoError(err) + suite.Empty(keys) + }, + }, + { + name: "when comment lines and blank lines are skipped", + username: "testuser", + passwd: testPasswdSSH, + setupFS: func() { + content := "# This is a comment\n\n" + testKey1Line + "\n\n# Another comment\n" + suite.writeAuthorizedKeys("testuser", "/home/testuser", content) + }, + validateFunc: func(keys []user.SSHKey, err error) { + suite.NoError(err) + suite.Require().Len(keys, 1) + suite.Equal(testKey1FP, keys[0].Fingerprint) + }, + }, + { + name: "when malformed line with only one field is skipped", + username: "testuser", + passwd: testPasswdSSH, + setupFS: func() { + content := "onlyonefield\n" + testKey1Line + "\n" + suite.writeAuthorizedKeys("testuser", "/home/testuser", content) + }, + validateFunc: func(keys []user.SSHKey, err error) { + suite.NoError(err) + suite.Require().Len(keys, 1) + suite.Equal(testKey1FP, keys[0].Fingerprint) + }, + }, + { + name: "when invalid base64 in key data is skipped", + username: "testuser", + passwd: testPasswdSSH, + setupFS: func() { + content := "ssh-rsa !!!invalid-base64!!! bad@key\n" + testKey1Line + "\n" + suite.writeAuthorizedKeys("testuser", "/home/testuser", content) + }, + validateFunc: func(keys []user.SSHKey, err error) { + suite.NoError(err) + suite.Require().Len(keys, 1) + suite.Equal(testKey1FP, keys[0].Fingerprint) + }, + }, + { + name: "when read file fails with non-NotExist error", + username: "testuser", + passwd: testPasswdSSH, + skipPasswd: true, + setupFS: func() { + baseFs := memfs.New() + _ = baseFs.MkdirAll("/etc", 0o755) + f, _ := baseFs.Create("/etc/passwd") + _, _ = f.Write([]byte(testPasswdSSH)) + _ = f.Close() + sshDir := "/home/testuser/.ssh" + _ = baseFs.MkdirAll(sshDir, 0o700) + af, _ := baseFs.Create(sshDir + "/authorized_keys") + _, _ = af.Write([]byte(testKey1Line + "\n")) + _ = af.Close() + + suite.provider = suite.newFailFSProvider(baseFs, + func(_ avfs.VFSBase, fn avfs.FnVFS, _ *failfs.FailParam) error { + if fn == avfs.FnReadFile { + return fmt.Errorf("injected I/O error") + } + + return nil + }) + }, + validateFunc: func(keys []user.SSHKey, err error) { + suite.Error(err) + suite.Nil(keys) + suite.Contains(err.Error(), "ssh key: list: read") + }, + }, + { + name: "when passwd read fails mid-scan", + username: "testuser", + skipPasswd: true, + setupFS: func() { + baseFs := memfs.New() + _ = baseFs.MkdirAll("/etc", 0o755) + f, _ := baseFs.Create("/etc/passwd") + _, _ = f.Write([]byte("other:x:1000:1000:Other:/home/other:/bin/bash\n")) + _ = f.Close() + + suite.provider = suite.newFailFSProvider(baseFs, + func(_ avfs.VFSBase, fn avfs.FnVFS, _ *failfs.FailParam) error { + if fn == avfs.FnFileRead { + return fmt.Errorf("injected read error") + } + + return nil + }) + }, + validateFunc: func(keys []user.SSHKey, err error) { + suite.Error(err) + suite.Nil(keys) + suite.Contains(err.Error(), "ssh key: list") + }, + }, + { + name: "when passwd has comments and malformed lines", + username: "testuser", + passwd: "# comment\n\nshort:line\ntestuser:x:1000:1000:Test:/home/testuser:/bin/bash\n", + setupFS: func() { + suite.writeAuthorizedKeys( + "testuser", + "/home/testuser", + testKey1Line+"\n", + ) + }, + validateFunc: func(keys []user.SSHKey, err error) { + suite.NoError(err) + suite.Require().Len(keys, 1) + suite.Equal(testKey1FP, keys[0].Fingerprint) + }, + }, + { + name: "when key has no comment", + username: "testuser", + passwd: testPasswdSSH, + setupFS: func() { + content := "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHRlc3RrZXkxZGF0YQ==\n" + suite.writeAuthorizedKeys("testuser", "/home/testuser", content) + }, + validateFunc: func(keys []user.SSHKey, err error) { + suite.NoError(err) + suite.Require().Len(keys, 1) + suite.Equal("ssh-ed25519", keys[0].Type) + suite.Equal(testKey1FP, keys[0].Fingerprint) + suite.Empty(keys[0].Comment) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + if !tc.skipPasswd { + suite.writePasswd(tc.passwd) + } + tc.setupFS() + + result, err := suite.provider.ListKeys(suite.ctx, tc.username) + + tc.validateFunc(result, err) + }) + } +} + +func (suite *DebianSSHKeyPublicTestSuite) TestAddKey() { + tests := []struct { + name string + username string + passwd string + skipPasswd bool + key user.SSHKey + setupFS func() + setupMock func() + validateFunc func(*user.SSHKeyResult, error) + }{ + { + name: "when successful appends key and runs chown", + username: "testuser", + passwd: testPasswdSSH, + key: user.SSHKey{ + RawLine: testKey1Line, + }, + setupFS: func() {}, + setupMock: func() { + suite.mockExec.EXPECT(). + RunCmd("chown", []string{"-R", "testuser:testuser", "/home/testuser/.ssh"}). + Return("", nil) + }, + validateFunc: func(result *user.SSHKeyResult, err error) { + suite.NoError(err) + suite.Require().NotNil(result) + suite.True(result.Changed) + + content := suite.readFile("/home/testuser/.ssh/authorized_keys") + suite.Contains(content, testKey1Line) + }, + }, + { + name: "when key already exists returns changed false", + username: "testuser", + passwd: testPasswdSSH, + key: user.SSHKey{ + RawLine: testKey1Line, + }, + setupFS: func() { + suite.writeAuthorizedKeys("testuser", "/home/testuser", testKey1Line+"\n") + }, + setupMock: func() {}, + validateFunc: func(result *user.SSHKeyResult, err error) { + suite.NoError(err) + suite.Require().NotNil(result) + suite.False(result.Changed) + }, + }, + { + name: "when user not found returns error", + username: "nonexistent", + passwd: testPasswdSSH, + key: user.SSHKey{ + RawLine: testKey1Line, + }, + setupFS: func() {}, + setupMock: func() {}, + validateFunc: func(result *user.SSHKeyResult, err error) { + suite.Error(err) + suite.Nil(result) + suite.Contains(err.Error(), "not found") + }, + }, + { + name: "when ssh dir and file are missing creates them", + username: "testuser", + passwd: testPasswdSSH, + key: user.SSHKey{ + RawLine: testKey2Line, + }, + setupFS: func() { + _ = suite.memFs.MkdirAll("/home/testuser", 0o755) + }, + setupMock: func() { + suite.mockExec.EXPECT(). + RunCmd("chown", []string{"-R", "testuser:testuser", "/home/testuser/.ssh"}). + Return("", nil) + }, + validateFunc: func(result *user.SSHKeyResult, err error) { + suite.NoError(err) + suite.Require().NotNil(result) + suite.True(result.Changed) + + content := suite.readFile("/home/testuser/.ssh/authorized_keys") + suite.Contains(content, testKey2Line) + }, + }, + { + name: "when passwd file missing returns error", + username: "testuser", + skipPasswd: true, + key: user.SSHKey{ + RawLine: testKey1Line, + }, + setupFS: func() {}, + setupMock: func() {}, + validateFunc: func(result *user.SSHKeyResult, err error) { + suite.Error(err) + suite.Nil(result) + suite.Contains(err.Error(), "ssh key: add") + }, + }, + { + name: "when raw line is malformed skips duplicate check", + username: "testuser", + passwd: testPasswdSSH, + key: user.SSHKey{ + RawLine: "malformed-single-field", + }, + setupFS: func() { + suite.writeAuthorizedKeys("testuser", "/home/testuser", testKey1Line+"\n") + }, + setupMock: func() { + suite.mockExec.EXPECT(). + RunCmd("chown", []string{"-R", "testuser:testuser", "/home/testuser/.ssh"}). + Return("", nil) + }, + validateFunc: func(result *user.SSHKeyResult, err error) { + suite.NoError(err) + suite.Require().NotNil(result) + suite.True(result.Changed) + }, + }, + { + name: "when read file fails with non-NotExist error", + username: "testuser", + skipPasswd: true, + key: user.SSHKey{ + RawLine: testKey1Line, + }, + setupFS: func() { + baseFs := memfs.New() + _ = baseFs.MkdirAll("/etc", 0o755) + f, _ := baseFs.Create("/etc/passwd") + _, _ = f.Write([]byte(testPasswdSSH)) + _ = f.Close() + sshDir := "/home/testuser/.ssh" + _ = baseFs.MkdirAll(sshDir, 0o700) + af, _ := baseFs.Create(sshDir + "/authorized_keys") + _, _ = af.Write([]byte(testKey2Line + "\n")) + _ = af.Close() + + suite.provider = suite.newFailFSProvider(baseFs, + func(_ avfs.VFSBase, fn avfs.FnVFS, _ *failfs.FailParam) error { + if fn == avfs.FnReadFile { + return fmt.Errorf("injected I/O error") + } + + return nil + }) + }, + setupMock: func() {}, + validateFunc: func(result *user.SSHKeyResult, err error) { + suite.Error(err) + suite.Nil(result) + suite.Contains(err.Error(), "ssh key: add: read") + }, + }, + { + name: "when mkdir fails", + username: "testuser", + skipPasswd: true, + key: user.SSHKey{ + RawLine: testKey1Line, + }, + setupFS: func() { + baseFs := memfs.New() + _ = baseFs.MkdirAll("/etc", 0o755) + f, _ := baseFs.Create("/etc/passwd") + _, _ = f.Write([]byte(testPasswdSSH)) + _ = f.Close() + + suite.provider = suite.newFailFSProvider(baseFs, + func(_ avfs.VFSBase, fn avfs.FnVFS, _ *failfs.FailParam) error { + if fn == avfs.FnMkdirAll { + return fmt.Errorf("injected mkdir error") + } + + return nil + }) + }, + setupMock: func() {}, + validateFunc: func(result *user.SSHKeyResult, err error) { + suite.Error(err) + suite.Nil(result) + suite.Contains(err.Error(), "ssh key: add: mkdir") + }, + }, + { + name: "when open file fails for append", + username: "testuser", + skipPasswd: true, + key: user.SSHKey{ + RawLine: testKey1Line, + }, + setupFS: func() { + baseFs := memfs.New() + _ = baseFs.MkdirAll("/etc", 0o755) + f, _ := baseFs.Create("/etc/passwd") + _, _ = f.Write([]byte(testPasswdSSH)) + _ = f.Close() + + openFileCalls := 0 + + suite.provider = suite.newFailFSProvider(baseFs, + func(_ avfs.VFSBase, fn avfs.FnVFS, _ *failfs.FailParam) error { + if fn == avfs.FnOpenFile { + openFileCalls++ + // 1st: userHomeDir Open("/etc/passwd") + // 2nd: ReadFile's internal OpenFile (not-exist) + // 3rd: OpenFile for append + if openFileCalls > 2 { + return fmt.Errorf("injected open error") + } + } + + return nil + }) + }, + setupMock: func() {}, + validateFunc: func(result *user.SSHKeyResult, err error) { + suite.Error(err) + suite.Nil(result) + suite.Contains(err.Error(), "ssh key: add: open") + }, + }, + { + name: "when write fails", + username: "testuser", + skipPasswd: true, + key: user.SSHKey{ + RawLine: testKey1Line, + }, + setupFS: func() { + baseFs := memfs.New() + _ = baseFs.MkdirAll("/etc", 0o755) + f, _ := baseFs.Create("/etc/passwd") + _, _ = f.Write([]byte(testPasswdSSH)) + _ = f.Close() + + suite.provider = suite.newFailFSProvider(baseFs, + func(_ avfs.VFSBase, fn avfs.FnVFS, _ *failfs.FailParam) error { + if fn == avfs.FnFileWrite { + return fmt.Errorf("injected write error") + } + + return nil + }) + }, + setupMock: func() {}, + validateFunc: func(result *user.SSHKeyResult, err error) { + suite.Error(err) + suite.Nil(result) + suite.Contains(err.Error(), "ssh key: add: write") + }, + }, + { + name: "when file close fails after write", + username: "testuser", + skipPasswd: true, + key: user.SSHKey{ + RawLine: testKey1Line, + }, + setupFS: func() { + baseFs := memfs.New() + _ = baseFs.MkdirAll("/etc", 0o755) + f, _ := baseFs.Create("/etc/passwd") + _, _ = f.Write([]byte(testPasswdSSH)) + _ = f.Close() + + suite.provider = suite.newFailFSProvider(baseFs, + func(_ avfs.VFSBase, fn avfs.FnVFS, _ *failfs.FailParam) error { + if fn == avfs.FnFileClose { + return fmt.Errorf("injected close error") + } + + return nil + }) + }, + setupMock: func() {}, + validateFunc: func(result *user.SSHKeyResult, err error) { + suite.Error(err) + suite.Nil(result) + suite.Contains(err.Error(), "ssh key: add") + }, + }, + { + name: "when chown fails still returns changed true", + username: "testuser", + passwd: testPasswdSSH, + key: user.SSHKey{ + RawLine: testKey1Line, + }, + setupFS: func() {}, + setupMock: func() { + suite.mockExec.EXPECT(). + RunCmd("chown", []string{"-R", "testuser:testuser", "/home/testuser/.ssh"}). + Return("", errors.New("permission denied")) + }, + validateFunc: func(result *user.SSHKeyResult, err error) { + suite.NoError(err) + suite.Require().NotNil(result) + suite.True(result.Changed) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + if !tc.skipPasswd { + suite.writePasswd(tc.passwd) + } + tc.setupFS() + tc.setupMock() + + result, err := suite.provider.AddKey(suite.ctx, tc.username, tc.key) + + tc.validateFunc(result, err) + }) + } +} + +func (suite *DebianSSHKeyPublicTestSuite) TestRemoveKey() { + tests := []struct { + name string + username string + passwd string + skipPasswd bool + fingerprint string + setupFS func() + validateFunc func(*user.SSHKeyResult, error) + }{ + { + name: "when successful removes matching key", + username: "testuser", + passwd: testPasswdSSH, + fingerprint: testKey1FP, + setupFS: func() { + suite.writeAuthorizedKeys( + "testuser", + "/home/testuser", + testKey1Line+"\n"+testKey2Line+"\n", + ) + }, + validateFunc: func(result *user.SSHKeyResult, err error) { + suite.NoError(err) + suite.Require().NotNil(result) + suite.True(result.Changed) + + content := suite.readFile("/home/testuser/.ssh/authorized_keys") + suite.NotContains(content, testKey1Line) + suite.Contains(content, testKey2Line) + }, + }, + { + name: "when fingerprint not found returns changed false", + username: "testuser", + passwd: testPasswdSSH, + fingerprint: "SHA256:nonexistent", + setupFS: func() { + suite.writeAuthorizedKeys( + "testuser", + "/home/testuser", + testKey1Line+"\n", + ) + }, + validateFunc: func(result *user.SSHKeyResult, err error) { + suite.NoError(err) + suite.Require().NotNil(result) + suite.False(result.Changed) + }, + }, + { + name: "when user not found returns error", + username: "nonexistent", + passwd: testPasswdSSH, + fingerprint: testKey1FP, + setupFS: func() {}, + validateFunc: func(result *user.SSHKeyResult, err error) { + suite.Error(err) + suite.Nil(result) + suite.Contains(err.Error(), "not found") + }, + }, + { + name: "when no authorized_keys file returns changed false", + username: "testuser", + passwd: testPasswdSSH, + fingerprint: testKey1FP, + setupFS: func() {}, + validateFunc: func(result *user.SSHKeyResult, err error) { + suite.NoError(err) + suite.Require().NotNil(result) + suite.False(result.Changed) + }, + }, + { + name: "when passwd file missing returns error", + username: "testuser", + skipPasswd: true, + fingerprint: testKey1FP, + setupFS: func() {}, + validateFunc: func(result *user.SSHKeyResult, err error) { + suite.Error(err) + suite.Nil(result) + suite.Contains(err.Error(), "ssh key: remove") + }, + }, + { + name: "when read file fails with non-NotExist error", + username: "testuser", + skipPasswd: true, + fingerprint: testKey1FP, + setupFS: func() { + baseFs := memfs.New() + _ = baseFs.MkdirAll("/etc", 0o755) + f, _ := baseFs.Create("/etc/passwd") + _, _ = f.Write([]byte(testPasswdSSH)) + _ = f.Close() + sshDir := "/home/testuser/.ssh" + _ = baseFs.MkdirAll(sshDir, 0o700) + af, _ := baseFs.Create(sshDir + "/authorized_keys") + _, _ = af.Write([]byte(testKey1Line + "\n")) + _ = af.Close() + + suite.provider = suite.newFailFSProvider(baseFs, + func(_ avfs.VFSBase, fn avfs.FnVFS, _ *failfs.FailParam) error { + if fn == avfs.FnReadFile { + return fmt.Errorf("injected I/O error") + } + + return nil + }) + }, + validateFunc: func(result *user.SSHKeyResult, err error) { + suite.Error(err) + suite.Nil(result) + suite.Contains(err.Error(), "ssh key: remove: read") + }, + }, + { + name: "when write file fails on rewrite", + username: "testuser", + skipPasswd: true, + fingerprint: testKey1FP, + setupFS: func() { + baseFs := memfs.New() + _ = baseFs.MkdirAll("/etc", 0o755) + f, _ := baseFs.Create("/etc/passwd") + _, _ = f.Write([]byte(testPasswdSSH)) + _ = f.Close() + sshDir := "/home/testuser/.ssh" + _ = baseFs.MkdirAll(sshDir, 0o700) + af, _ := baseFs.Create(sshDir + "/authorized_keys") + _, _ = af.Write([]byte(testKey1Line + "\n")) + _ = af.Close() + + openFileCalls := 0 + + suite.provider = suite.newFailFSProvider(baseFs, + func(_ avfs.VFSBase, fn avfs.FnVFS, fp *failfs.FailParam) error { + // WriteFile uses OpenFile internally. + // The first OpenFile is from ReadFile, the second is + // from WriteFile (the rewrite). Fail the second. + if fn == avfs.FnOpenFile { + openFileCalls++ + // First OpenFile: userHomeDir Open("/etc/passwd") + // Second OpenFile: ReadFile's internal OpenFile + // Third OpenFile: WriteFile's internal OpenFile + if openFileCalls > 2 { + return fmt.Errorf("injected write error") + } + } + + return nil + }) + }, + validateFunc: func(result *user.SSHKeyResult, err error) { + suite.Error(err) + suite.Nil(result) + suite.Contains(err.Error(), "ssh key: remove: write") + }, + }, + { + name: "when preserves comment lines and blank lines", + username: "testuser", + passwd: testPasswdSSH, + fingerprint: testKey1FP, + setupFS: func() { + content := "# Header comment\n\n" + testKey1Line + "\n" + testKey2Line + "\n" + suite.writeAuthorizedKeys("testuser", "/home/testuser", content) + }, + validateFunc: func(result *user.SSHKeyResult, err error) { + suite.NoError(err) + suite.Require().NotNil(result) + suite.True(result.Changed) + + content := suite.readFile("/home/testuser/.ssh/authorized_keys") + suite.Contains(content, "# Header comment") + suite.Contains(content, testKey2Line) + suite.NotContains(content, testKey1Line) + }, + }, + { + name: "when file has single key after removal", + username: "testuser", + passwd: testPasswdSSH, + fingerprint: testKey1FP, + setupFS: func() { + suite.writeAuthorizedKeys( + "testuser", + "/home/testuser", + testKey1Line+"\n", + ) + }, + validateFunc: func(result *user.SSHKeyResult, err error) { + suite.NoError(err) + suite.Require().NotNil(result) + suite.True(result.Changed) + + content := suite.readFile("/home/testuser/.ssh/authorized_keys") + suite.NotContains(content, testKey1Line) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + if !tc.skipPasswd { + suite.writePasswd(tc.passwd) + } + tc.setupFS() + + result, err := suite.provider.RemoveKey( + suite.ctx, + tc.username, + tc.fingerprint, + ) + + tc.validateFunc(result, err) + }) + } +} + +func TestDebianSSHKeyPublicTestSuite(t *testing.T) { + suite.Run(t, new(DebianSSHKeyPublicTestSuite)) +} diff --git a/internal/provider/node/user/linux.go b/internal/provider/node/user/linux.go index 52e92311d..076312645 100644 --- a/internal/provider/node/user/linux.go +++ b/internal/provider/node/user/linux.go @@ -125,3 +125,29 @@ func (l *Linux) DeleteGroup( ) (*GroupResult, error) { return nil, fmt.Errorf("user: %w", provider.ErrUnsupported) } + +// ListKeys returns ErrUnsupported on generic Linux. +func (l *Linux) ListKeys( + _ context.Context, + _ string, +) ([]SSHKey, error) { + return nil, fmt.Errorf("user: %w", provider.ErrUnsupported) +} + +// AddKey returns ErrUnsupported on generic Linux. +func (l *Linux) AddKey( + _ context.Context, + _ string, + _ SSHKey, +) (*SSHKeyResult, error) { + return nil, fmt.Errorf("user: %w", provider.ErrUnsupported) +} + +// RemoveKey returns ErrUnsupported on generic Linux. +func (l *Linux) RemoveKey( + _ context.Context, + _ string, + _ string, +) (*SSHKeyResult, error) { + return nil, fmt.Errorf("user: %w", provider.ErrUnsupported) +} diff --git a/internal/provider/node/user/linux_public_test.go b/internal/provider/node/user/linux_public_test.go index 8e66ae930..fa1a15347 100644 --- a/internal/provider/node/user/linux_public_test.go +++ b/internal/provider/node/user/linux_public_test.go @@ -270,6 +270,68 @@ func (suite *LinuxPublicTestSuite) TestDeleteGroup() { } } +func (suite *LinuxPublicTestSuite) TestListKeys() { + tests := []struct { + name string + }{ + { + name: "returns ErrUnsupported", + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + result, err := suite.provider.ListKeys(suite.ctx, "testuser") + + suite.Error(err) + suite.Nil(result) + suite.ErrorIs(err, provider.ErrUnsupported) + }) + } +} + +func (suite *LinuxPublicTestSuite) TestAddKey() { + tests := []struct { + name string + }{ + { + name: "returns ErrUnsupported", + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + result, err := suite.provider.AddKey(suite.ctx, "testuser", user.SSHKey{ + RawLine: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI test@example", + }) + + suite.Error(err) + suite.Nil(result) + suite.ErrorIs(err, provider.ErrUnsupported) + }) + } +} + +func (suite *LinuxPublicTestSuite) TestRemoveKey() { + tests := []struct { + name string + }{ + { + name: "returns ErrUnsupported", + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + result, err := suite.provider.RemoveKey(suite.ctx, "testuser", "SHA256:abc123") + + suite.Error(err) + suite.Nil(result) + suite.ErrorIs(err, provider.ErrUnsupported) + }) + } +} + func TestLinuxPublicTestSuite(t *testing.T) { suite.Run(t, new(LinuxPublicTestSuite)) } diff --git a/internal/provider/node/user/mocks/provider.gen.go b/internal/provider/node/user/mocks/provider.gen.go index 11aa8ca11..0a22b024a 100644 --- a/internal/provider/node/user/mocks/provider.gen.go +++ b/internal/provider/node/user/mocks/provider.gen.go @@ -35,6 +35,21 @@ func (m *MockProvider) EXPECT() *MockProviderMockRecorder { return m.recorder } +// AddKey mocks base method. +func (m *MockProvider) AddKey(ctx context.Context, username string, key user.SSHKey) (*user.SSHKeyResult, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AddKey", ctx, username, key) + ret0, _ := ret[0].(*user.SSHKeyResult) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// AddKey indicates an expected call of AddKey. +func (mr *MockProviderMockRecorder) AddKey(ctx, username, key interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddKey", reflect.TypeOf((*MockProvider)(nil).AddKey), ctx, username, key) +} + // ChangePassword mocks base method. func (m *MockProvider) ChangePassword(ctx context.Context, name, password string) (*user.Result, error) { m.ctrl.T.Helper() @@ -155,6 +170,21 @@ func (mr *MockProviderMockRecorder) ListGroups(ctx interface{}) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListGroups", reflect.TypeOf((*MockProvider)(nil).ListGroups), ctx) } +// ListKeys mocks base method. +func (m *MockProvider) ListKeys(ctx context.Context, username string) ([]user.SSHKey, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListKeys", ctx, username) + ret0, _ := ret[0].([]user.SSHKey) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListKeys indicates an expected call of ListKeys. +func (mr *MockProviderMockRecorder) ListKeys(ctx, username interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListKeys", reflect.TypeOf((*MockProvider)(nil).ListKeys), ctx, username) +} + // ListUsers mocks base method. func (m *MockProvider) ListUsers(ctx context.Context) ([]user.User, error) { m.ctrl.T.Helper() @@ -170,6 +200,21 @@ func (mr *MockProviderMockRecorder) ListUsers(ctx interface{}) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListUsers", reflect.TypeOf((*MockProvider)(nil).ListUsers), ctx) } +// RemoveKey mocks base method. +func (m *MockProvider) RemoveKey(ctx context.Context, username, fingerprint string) (*user.SSHKeyResult, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RemoveKey", ctx, username, fingerprint) + ret0, _ := ret[0].(*user.SSHKeyResult) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// RemoveKey indicates an expected call of RemoveKey. +func (mr *MockProviderMockRecorder) RemoveKey(ctx, username, fingerprint interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RemoveKey", reflect.TypeOf((*MockProvider)(nil).RemoveKey), ctx, username, fingerprint) +} + // UpdateGroup mocks base method. func (m *MockProvider) UpdateGroup(ctx context.Context, name string, opts user.UpdateGroupOpts) (*user.GroupResult, error) { m.ctrl.T.Helper() diff --git a/internal/provider/node/user/types.go b/internal/provider/node/user/types.go index d2202e32c..8770e86fe 100644 --- a/internal/provider/node/user/types.go +++ b/internal/provider/node/user/types.go @@ -36,6 +36,9 @@ type Provider interface { CreateGroup(ctx context.Context, opts CreateGroupOpts) (*GroupResult, error) UpdateGroup(ctx context.Context, name string, opts UpdateGroupOpts) (*GroupResult, error) DeleteGroup(ctx context.Context, name string) (*GroupResult, error) + ListKeys(ctx context.Context, username string) ([]SSHKey, error) + AddKey(ctx context.Context, username string, key SSHKey) (*SSHKeyResult, error) + RemoveKey(ctx context.Context, username string, fingerprint string) (*SSHKeyResult, error) } // User represents a system user account. @@ -101,3 +104,16 @@ type GroupResult struct { Changed bool `json:"changed"` Error string `json:"error,omitempty"` } + +// SSHKey represents an SSH public key from authorized_keys. +type SSHKey struct { + Type string `json:"type"` + Fingerprint string `json:"fingerprint"` + Comment string `json:"comment,omitempty"` + RawLine string `json:"raw_line,omitempty"` +} + +// SSHKeyResult represents the result of an SSH key mutation operation. +type SSHKeyResult struct { + Changed bool `json:"changed"` +} From b6e55417fab793b67ede2e6080da8523f82207c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Wed, 1 Apr 2026 11:39:29 -0700 Subject: [PATCH 04/11] feat(user): add SSH key operations and agent processor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add SSH key operation constants (list, add, remove) to the SDK and job packages, create the agent processor to dispatch SSH key sub-operations via the existing user provider, and wire the new "sshKey" case into the node processor. All functions have 100% test coverage. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- internal/agent/processor.go | 2 + internal/agent/processor_ssh_key.go | 144 ++++++ .../agent/processor_ssh_key_public_test.go | 416 ++++++++++++++++++ internal/job/types.go | 7 + pkg/sdk/client/operations.go | 7 + 5 files changed, 576 insertions(+) create mode 100644 internal/agent/processor_ssh_key.go create mode 100644 internal/agent/processor_ssh_key_public_test.go diff --git a/internal/agent/processor.go b/internal/agent/processor.go index 66e9ca749..4af5f5721 100644 --- a/internal/agent/processor.go +++ b/internal/agent/processor.go @@ -107,6 +107,8 @@ func NewNodeProcessor( return processUserOperation(userProvider, logger, req) case "group": return processGroupOperation(userProvider, logger, req) + case "sshKey": + return processSshKeyOperation(userProvider, logger, req) case "package": return processPackageOperation(packageProvider, logger, req) case "log": diff --git a/internal/agent/processor_ssh_key.go b/internal/agent/processor_ssh_key.go new file mode 100644 index 000000000..2d2b9ec92 --- /dev/null +++ b/internal/agent/processor_ssh_key.go @@ -0,0 +1,144 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package agent + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "strings" + + "github.com/retr0h/osapi/internal/job" + "github.com/retr0h/osapi/internal/provider/node/user" +) + +// processSshKeyOperation dispatches SSH key sub-operations. +func processSshKeyOperation( + userProvider user.Provider, + logger *slog.Logger, + jobRequest job.Request, +) (json.RawMessage, error) { + if userProvider == nil { + return nil, fmt.Errorf("user provider not available") + } + + // Extract sub-operation: "sshKey.list" -> "list" + parts := strings.Split(jobRequest.Operation, ".") + if len(parts) < 2 { + return nil, fmt.Errorf("invalid sshKey operation: %s", jobRequest.Operation) + } + subOp := parts[1] + + ctx := context.Background() + + switch subOp { + case "list": + return processSshKeyList(ctx, userProvider, logger, jobRequest) + case "add": + return processSshKeyAdd(ctx, userProvider, logger, jobRequest) + case "remove": + return processSshKeyRemove(ctx, userProvider, logger, jobRequest) + default: + return nil, fmt.Errorf("unsupported sshKey operation: %s", jobRequest.Operation) + } +} + +// processSshKeyList lists SSH keys for a user. +func processSshKeyList( + ctx context.Context, + userProvider user.Provider, + logger *slog.Logger, + jobRequest job.Request, +) (json.RawMessage, error) { + var data struct { + Username string `json:"username"` + } + if err := json.Unmarshal(jobRequest.Data, &data); err != nil { + return nil, fmt.Errorf("unmarshal sshKey list data: %w", err) + } + + logger.Debug("executing sshKey.List", + slog.String("username", data.Username), + ) + + keys, err := userProvider.ListKeys(ctx, data.Username) + if err != nil { + return nil, err + } + + return json.Marshal(keys) +} + +// processSshKeyAdd adds an SSH key for a user. +func processSshKeyAdd( + ctx context.Context, + userProvider user.Provider, + logger *slog.Logger, + jobRequest job.Request, +) (json.RawMessage, error) { + var data struct { + Username string `json:"username"` + Key user.SSHKey `json:"key"` + } + if err := json.Unmarshal(jobRequest.Data, &data); err != nil { + return nil, fmt.Errorf("unmarshal sshKey add data: %w", err) + } + + logger.Debug("executing sshKey.Add", + slog.String("username", data.Username), + ) + + result, err := userProvider.AddKey(ctx, data.Username, data.Key) + if err != nil { + return nil, err + } + + return json.Marshal(result) +} + +// processSshKeyRemove removes an SSH key for a user. +func processSshKeyRemove( + ctx context.Context, + userProvider user.Provider, + logger *slog.Logger, + jobRequest job.Request, +) (json.RawMessage, error) { + var data struct { + Username string `json:"username"` + Fingerprint string `json:"fingerprint"` + } + if err := json.Unmarshal(jobRequest.Data, &data); err != nil { + return nil, fmt.Errorf("unmarshal sshKey remove data: %w", err) + } + + logger.Debug("executing sshKey.Remove", + slog.String("username", data.Username), + slog.String("fingerprint", data.Fingerprint), + ) + + result, err := userProvider.RemoveKey(ctx, data.Username, data.Fingerprint) + if err != nil { + return nil, err + } + + return json.Marshal(result) +} diff --git a/internal/agent/processor_ssh_key_public_test.go b/internal/agent/processor_ssh_key_public_test.go new file mode 100644 index 000000000..7e48ba936 --- /dev/null +++ b/internal/agent/processor_ssh_key_public_test.go @@ -0,0 +1,416 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package agent_test + +import ( + "encoding/json" + "errors" + "log/slog" + "testing" + + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/suite" + + "github.com/retr0h/osapi/internal/agent" + "github.com/retr0h/osapi/internal/config" + "github.com/retr0h/osapi/internal/job" + "github.com/retr0h/osapi/internal/provider/node/user" + userMocks "github.com/retr0h/osapi/internal/provider/node/user/mocks" +) + +type ProcessorSshKeyPublicTestSuite struct { + suite.Suite + + mockCtrl *gomock.Controller +} + +func (s *ProcessorSshKeyPublicTestSuite) SetupTest() { + s.mockCtrl = gomock.NewController(s.T()) +} + +func (s *ProcessorSshKeyPublicTestSuite) TearDownTest() { + s.mockCtrl.Finish() +} + +func (s *ProcessorSshKeyPublicTestSuite) newProcessor( + userProvider user.Provider, +) agent.ProcessorFunc { + return agent.NewNodeProcessor( + nil, nil, nil, nil, + nil, nil, nil, nil, + nil, + userProvider, + nil, + nil, + config.Config{}, + slog.Default(), + ) +} + +func (s *ProcessorSshKeyPublicTestSuite) TestProcessSshKeyOperation() { + tests := []struct { + name string + jobRequest job.Request + setupMock func() user.Provider + expectError bool + errorMsg string + }{ + { + name: "nil provider returns error", + jobRequest: job.Request{ + Type: job.TypeQuery, + Category: "node", + Operation: "sshKey.list", + Data: json.RawMessage(`{"username":"john"}`), + }, + setupMock: nil, + expectError: true, + errorMsg: "user provider not available", + }, + { + name: "invalid sshKey operation missing sub-operation", + jobRequest: job.Request{ + Type: job.TypeQuery, + Category: "node", + Operation: "sshKey", + Data: json.RawMessage(`{}`), + }, + setupMock: func() user.Provider { + return userMocks.NewMockProvider(s.mockCtrl) + }, + expectError: true, + errorMsg: "invalid sshKey operation: sshKey", + }, + { + name: "unsupported sshKey sub-operation", + jobRequest: job.Request{ + Type: job.TypeQuery, + Category: "node", + Operation: "sshKey.invalid", + Data: json.RawMessage(`{}`), + }, + setupMock: func() user.Provider { + return userMocks.NewMockProvider(s.mockCtrl) + }, + expectError: true, + errorMsg: "unsupported sshKey operation: sshKey.invalid", + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + var userProvider user.Provider + if tt.setupMock != nil { + userProvider = tt.setupMock() + } + + processor := s.newProcessor(userProvider) + result, err := processor(tt.jobRequest) + + if tt.expectError { + s.Error(err) + s.Contains(err.Error(), tt.errorMsg) + s.Nil(result) + } else { + s.NoError(err) + s.NotNil(result) + } + }) + } +} + +func (s *ProcessorSshKeyPublicTestSuite) TestProcessSshKeyList() { + tests := []struct { + name string + jobRequest job.Request + setupMock func() user.Provider + expectError bool + errorMsg string + validate func(json.RawMessage) + }{ + { + name: "successful ssh key list", + jobRequest: job.Request{ + Type: job.TypeQuery, + Category: "node", + Operation: "sshKey.list", + Data: json.RawMessage(`{"username":"john"}`), + }, + setupMock: func() user.Provider { + m := userMocks.NewMockProvider(s.mockCtrl) + m.EXPECT().ListKeys(gomock.Any(), "john").Return([]user.SSHKey{ + { + Type: "ssh-ed25519", + Fingerprint: "SHA256:abc123", + Comment: "john@laptop", + }, + { + Type: "ssh-rsa", + Fingerprint: "SHA256:def456", + Comment: "john@desktop", + }, + }, nil) + return m + }, + validate: func(result json.RawMessage) { + var keys []user.SSHKey + err := json.Unmarshal(result, &keys) + s.NoError(err) + s.Len(keys, 2) + s.Equal("ssh-ed25519", keys[0].Type) + s.Equal("SHA256:abc123", keys[0].Fingerprint) + s.Equal("ssh-rsa", keys[1].Type) + }, + }, + { + name: "ssh key list with invalid JSON data", + jobRequest: job.Request{ + Type: job.TypeQuery, + Category: "node", + Operation: "sshKey.list", + Data: json.RawMessage(`invalid json`), + }, + setupMock: func() user.Provider { + return userMocks.NewMockProvider(s.mockCtrl) + }, + expectError: true, + errorMsg: "unmarshal sshKey list data", + }, + { + name: "ssh key list provider error", + jobRequest: job.Request{ + Type: job.TypeQuery, + Category: "node", + Operation: "sshKey.list", + Data: json.RawMessage(`{"username":"missing"}`), + }, + setupMock: func() user.Provider { + m := userMocks.NewMockProvider(s.mockCtrl) + m.EXPECT().ListKeys(gomock.Any(), "missing").Return(nil, errors.New("user not found")) + return m + }, + expectError: true, + errorMsg: "user not found", + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + processor := s.newProcessor(tt.setupMock()) + result, err := processor(tt.jobRequest) + + if tt.expectError { + s.Error(err) + s.Contains(err.Error(), tt.errorMsg) + s.Nil(result) + } else { + s.NoError(err) + s.NotNil(result) + if tt.validate != nil { + tt.validate(result) + } + } + }) + } +} + +func (s *ProcessorSshKeyPublicTestSuite) TestProcessSshKeyAdd() { + tests := []struct { + name string + jobRequest job.Request + setupMock func() user.Provider + expectError bool + errorMsg string + validate func(json.RawMessage) + }{ + { + name: "successful ssh key add", + jobRequest: job.Request{ + Type: job.TypeModify, + Category: "node", + Operation: "sshKey.add", + Data: json.RawMessage( + `{"username":"john","key":{"type":"ssh-ed25519","fingerprint":"SHA256:abc123","comment":"john@laptop","raw_line":"ssh-ed25519 AAAA... john@laptop"}}`, + ), + }, + setupMock: func() user.Provider { + m := userMocks.NewMockProvider(s.mockCtrl) + m.EXPECT().AddKey(gomock.Any(), "john", user.SSHKey{ + Type: "ssh-ed25519", + Fingerprint: "SHA256:abc123", + Comment: "john@laptop", + RawLine: "ssh-ed25519 AAAA... john@laptop", + }).Return(&user.SSHKeyResult{ + Changed: true, + }, nil) + return m + }, + validate: func(result json.RawMessage) { + var r user.SSHKeyResult + err := json.Unmarshal(result, &r) + s.NoError(err) + s.True(r.Changed) + }, + }, + { + name: "ssh key add with invalid JSON data", + jobRequest: job.Request{ + Type: job.TypeModify, + Category: "node", + Operation: "sshKey.add", + Data: json.RawMessage(`invalid json`), + }, + setupMock: func() user.Provider { + return userMocks.NewMockProvider(s.mockCtrl) + }, + expectError: true, + errorMsg: "unmarshal sshKey add data", + }, + { + name: "ssh key add provider error", + jobRequest: job.Request{ + Type: job.TypeModify, + Category: "node", + Operation: "sshKey.add", + Data: json.RawMessage( + `{"username":"john","key":{"type":"ssh-ed25519","fingerprint":"SHA256:abc123","raw_line":"ssh-ed25519 AAAA..."}}`, + ), + }, + setupMock: func() user.Provider { + m := userMocks.NewMockProvider(s.mockCtrl) + m.EXPECT(). + AddKey(gomock.Any(), gomock.Any(), gomock.Any()). + Return(nil, errors.New("permission denied")) + return m + }, + expectError: true, + errorMsg: "permission denied", + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + processor := s.newProcessor(tt.setupMock()) + result, err := processor(tt.jobRequest) + + if tt.expectError { + s.Error(err) + s.Contains(err.Error(), tt.errorMsg) + s.Nil(result) + } else { + s.NoError(err) + s.NotNil(result) + if tt.validate != nil { + tt.validate(result) + } + } + }) + } +} + +func (s *ProcessorSshKeyPublicTestSuite) TestProcessSshKeyRemove() { + tests := []struct { + name string + jobRequest job.Request + setupMock func() user.Provider + expectError bool + errorMsg string + validate func(json.RawMessage) + }{ + { + name: "successful ssh key remove", + jobRequest: job.Request{ + Type: job.TypeModify, + Category: "node", + Operation: "sshKey.remove", + Data: json.RawMessage(`{"username":"john","fingerprint":"SHA256:abc123"}`), + }, + setupMock: func() user.Provider { + m := userMocks.NewMockProvider(s.mockCtrl) + m.EXPECT().RemoveKey(gomock.Any(), "john", "SHA256:abc123").Return(&user.SSHKeyResult{ + Changed: true, + }, nil) + return m + }, + validate: func(result json.RawMessage) { + var r user.SSHKeyResult + err := json.Unmarshal(result, &r) + s.NoError(err) + s.True(r.Changed) + }, + }, + { + name: "ssh key remove with invalid JSON data", + jobRequest: job.Request{ + Type: job.TypeModify, + Category: "node", + Operation: "sshKey.remove", + Data: json.RawMessage(`invalid json`), + }, + setupMock: func() user.Provider { + return userMocks.NewMockProvider(s.mockCtrl) + }, + expectError: true, + errorMsg: "unmarshal sshKey remove data", + }, + { + name: "ssh key remove provider error", + jobRequest: job.Request{ + Type: job.TypeModify, + Category: "node", + Operation: "sshKey.remove", + Data: json.RawMessage(`{"username":"john","fingerprint":"SHA256:missing"}`), + }, + setupMock: func() user.Provider { + m := userMocks.NewMockProvider(s.mockCtrl) + m.EXPECT(). + RemoveKey(gomock.Any(), "john", "SHA256:missing"). + Return(nil, errors.New("key not found")) + return m + }, + expectError: true, + errorMsg: "key not found", + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + processor := s.newProcessor(tt.setupMock()) + result, err := processor(tt.jobRequest) + + if tt.expectError { + s.Error(err) + s.Contains(err.Error(), tt.errorMsg) + s.Nil(result) + } else { + s.NoError(err) + s.NotNil(result) + if tt.validate != nil { + tt.validate(result) + } + } + }) + } +} + +func TestProcessorSshKeyPublicTestSuite(t *testing.T) { + suite.Run(t, new(ProcessorSshKeyPublicTestSuite)) +} diff --git a/internal/job/types.go b/internal/job/types.go index 7e7d4036c..3786180b1 100644 --- a/internal/job/types.go +++ b/internal/job/types.go @@ -209,6 +209,13 @@ const ( OperationGroupDelete = client.OpGroupDelete ) +// SSH Key operations. +const ( + OperationSSHKeyList = client.OpSSHKeyList + OperationSSHKeyAdd = client.OpSSHKeyAdd + OperationSSHKeyRemove = client.OpSSHKeyRemove +) + // Package operations. const ( OperationPackageList = client.OpPackageList diff --git a/pkg/sdk/client/operations.go b/pkg/sdk/client/operations.go index d766286d1..639d23768 100644 --- a/pkg/sdk/client/operations.go +++ b/pkg/sdk/client/operations.go @@ -145,6 +145,13 @@ const ( OpGroupDelete JobOperation = "node.group.delete" ) +// SSH Key operations. +const ( + OpSSHKeyList JobOperation = "node.sshKey.list" + OpSSHKeyAdd JobOperation = "node.sshKey.add" + OpSSHKeyRemove JobOperation = "node.sshKey.remove" +) + // Package operations. const ( OpPackageList JobOperation = "node.package.list" From b05f31616e01378feed5d598d6a96ce748cdb5ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Wed, 1 Apr 2026 11:43:09 -0700 Subject: [PATCH 05/11] feat(user): add SSH key endpoints to OpenAPI spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add GET/POST /node/{hostname}/user/{name}/ssh-key and DELETE /node/{hostname}/user/{name}/ssh-key/{fingerprint} endpoints to the user OpenAPI spec. Regenerate server code, combined spec, SDK client, and docs. Include stub handler methods so the project compiles (implementation in Task 5). 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .../gen/api/delete-node-user-ssh-key.api.mdx | 530 ++++++++++++++ .../gen/api/get-node-user-ssh-key.api.mdx | 593 ++++++++++++++++ .../gen/api/post-node-user-ssh-key.api.mdx | 670 ++++++++++++++++++ docs/docs/gen/api/sidebar.ts | 18 + internal/controller/api/gen/api.yaml | 247 +++++++ .../controller/api/node/user/gen/api.yaml | 257 +++++++ .../controller/api/node/user/gen/user.gen.go | 426 +++++++++++ .../api/node/user/ssh_key_create.go | 36 + .../api/node/user/ssh_key_delete.go | 37 + .../api/node/user/ssh_key_list_get.go | 36 + pkg/sdk/client/gen/client.gen.go | 560 +++++++++++++++ 11 files changed, 3410 insertions(+) create mode 100644 docs/docs/gen/api/delete-node-user-ssh-key.api.mdx create mode 100644 docs/docs/gen/api/get-node-user-ssh-key.api.mdx create mode 100644 docs/docs/gen/api/post-node-user-ssh-key.api.mdx create mode 100644 internal/controller/api/node/user/ssh_key_create.go create mode 100644 internal/controller/api/node/user/ssh_key_delete.go create mode 100644 internal/controller/api/node/user/ssh_key_list_get.go diff --git a/docs/docs/gen/api/delete-node-user-ssh-key.api.mdx b/docs/docs/gen/api/delete-node-user-ssh-key.api.mdx new file mode 100644 index 000000000..535680678 --- /dev/null +++ b/docs/docs/gen/api/delete-node-user-ssh-key.api.mdx @@ -0,0 +1,530 @@ +--- +id: delete-node-user-ssh-key +title: "Remove SSH authorized key" +description: "Remove an SSH authorized key by fingerprint for a user on the target node." +sidebar_label: "Remove SSH authorized key" +hide_title: true +hide_table_of_contents: true +api: eJztV0tv20YQ/iuLOTkA9XIktyXQg4o4jZukCCwHObiCsOKOxLXIXWZ3qFgV+N+LWVIyLSlo4yRADzmJpHbn8X3z3IJCnzhdkLYGYrjG3K5RSCMmk1dClpRap/9GJVa4EfONWGizRFc4bUgsrBNSlB6dsEZQioKkWyIJYxV2/zIQAcmlh/gW3nt0M2nU7Hdny2L2Vhq5xBwNzcbvrmYsYmYLdJKt8DCNYP92pSCGF5gh4Z9WIQua+PQ1biACj0npNG0gvt3CbygdunFJKStkkfEnpwlhWk0jKKSTORI6Hw4bmSPEkFpP4TECzd4XklKIwOHHUjtUEJMrMTqA6KZ2Ui7RkNhJiIRDj26NSjhbkjZLsZZZieJsJs0mEjOZZc8iYZ3I5Bwz4THDhKwTZyvcxOHosxqy+46Vhe4kVuESTQfvyclOjeMW1jLTShLbvjMyyrX5dRCFf2Y1AVBF4JMUc8l3aFPweU9OmyVEkGvzBs2ScRpUVbQH44uBYC6ETBJbGhJ8+2sceJrNrXD8ItM5uDmiJ6/G56OLR1F9ht1lN2r+ieU8GZw/73a7X8XOlzg3Zdt9YY3HIPS83+efx/a/xo1wIVVVFyJIrCE0xOdkUWQ6CanTu/N8eHus2s7vMGHECseJRrpWdWfnM61OmbiwLpcEMZSlVnCUESmKOzsXVy+4GihBVhTOJui9oFR7wWCgJ7YU72VeZCx7NOrjz8N+v4Pnv8w7w4EaduRPg4vOcHhxMRoNh/1+v8+4OfRlRr5llXROcvprwtyf8uo013lJARZRS2zKl9dmmWGdzd0jRPYF4gQmxxjsTgu7COWwEcrkk6TSn5KCpsy5YNkVwyx1hoyvX+miQMWV8FhNLWynZF8pg0MBb7YjqE1SaZbYZnRubYbSHJn/IUVK0R1IzK3SC41K+I0nzINmDJLROev+HZVLPiZy9F4uUegWLKL2tQtV1U7W23ZNblCbRkCaQtBMJq9e4+Ztw+SlIbeB6lDCLmI+e++6yS++WkUw7A+OM+y9aTW/jhi/uwpBtNfzzZLuPyI5Fq33HffhrqBUkrBJUjpXl4OHJHsZQOaEdEhO43oXPYFEhSR1djIsD5QrpflRZqK5I+TclvRgxEm1qkRWbZA+WbcSpHO0ZROZVrWTShvCJbqTWVU7yRceKRn1+0zejuEQaUfEPj8m9qV1c60UGtERV8aXi4VONAdkgS7X3ocJ5Ae7/392R6caY11wQmvkGayp/d+wR/4g9DsRWkWQI6WW530V5n3Gnoe5GHq8UfS2u9ZQ9Xi8723rZ+/Tzgo3vW1riqvCduDW9bw/fVgVJkxzzWR7Ydh7lhIV0AxroV+GQxA1Dy93c9AfH25C59JmYcP1xq9x6G0P+w03DoiADakhGnT73TDXFNZTLkPsNcNss3sdL16HOG8fgvmbL2w1DIT31CsyqQ2bWrqMldZk3AKfhgjiVqdmmfxp17hrSvhLe0KfRqG7s4ztdi49vndZVfHnjyW6Tc3UWjot5wzm7RaU9vysIF7IzB+O8W0czq6bzvxMfOcF7SREu9HUsNPhNMQAETAKrT2TV5cn+fSZXesJtnydHU9enJ5gaTt0qmkVQYpSoQuBUZ8YJwkW1Lp7VNl5ndqXlReXby5vLiEC+TjpD5I8KDhp2nZbn7ixKzRVtbeU+J1trKp/AGuF9S4= +sidebar_class_name: "delete api-method" +info_path: gen/api/agent-management-api +custom_edit_url: null +--- + +import ApiTabs from "@theme/ApiTabs"; +import DiscriminatorTabs from "@theme/DiscriminatorTabs"; +import MethodEndpoint from "@theme/ApiExplorer/MethodEndpoint"; +import SecuritySchemes from "@theme/ApiExplorer/SecuritySchemes"; +import MimeTabs from "@theme/MimeTabs"; +import ParamsItem from "@theme/ParamsItem"; +import ResponseSamples from "@theme/ResponseSamples"; +import SchemaItem from "@theme/SchemaItem"; +import SchemaTabs from "@theme/SchemaTabs"; +import Heading from "@theme/Heading"; +import OperationTabs from "@theme/OperationTabs"; +import TabItem from "@theme/TabItem"; + + + + + + + + + + +Remove an SSH authorized key by fingerprint for a user on the target node. + + + + + +
+ +

+ Path Parameters +

+
+
    + + + + + + + +
+
+
+
+ + +
+ + + Key removed. + + +
+ + + + +
+ + + Schema + +
+ +
    + + + +
    + + + + results + + object[] + + + + required + + +
    +
  • +
    + Array [ +
    +
  • + + + + + + + +
  • +
    + ] +
    +
  • +
    +
    +
    +
+
+
+ + + + +
+
+
+
+
+
+ + + Unauthorized - API key required + + +
+ + + + +
+ + + Schema + +
+ +
    + + + + + + + +
+
+
+ + + + +
+
+
+
+
+
+ + + Forbidden - Insufficient permissions + + +
+ + + + +
+ + + Schema + +
+ +
    + + + + + + + +
+
+
+ + + + +
+
+
+
+
+
+ + + Error removing SSH key. + + +
+ + + + +
+ + + Schema + +
+ +
    + + + + + + + +
+
+
+ + + + +
+
+
+
+
+
+
+
+ \ No newline at end of file diff --git a/docs/docs/gen/api/get-node-user-ssh-key.api.mdx b/docs/docs/gen/api/get-node-user-ssh-key.api.mdx new file mode 100644 index 000000000..c28336867 --- /dev/null +++ b/docs/docs/gen/api/get-node-user-ssh-key.api.mdx @@ -0,0 +1,593 @@ +--- +id: get-node-user-ssh-key +title: "List SSH authorized keys" +description: "List SSH authorized keys for a user on the target node." +sidebar_label: "List SSH authorized keys" +hide_title: true +hide_table_of_contents: true +api: eJztV21v2zYQ/ivEfUoA2rFTO1sNDKi3pW3WbiiaFPuQGQYtnS3GEqmSpyyaof8+HCU7iq2u6RuwD/1kSb7X5x7e8TYQo4+czklbAxN4rT2Jy8uXQhWUWKf/wVissfRiaZ1QovDohDWCEhSk3ApJGBtj/y8DEkitPEyu4Z1HN1cmnr9wtsjnvyujVpihofn0zcWcTcxtjk6xSw8zCbu3ixgm8ALpDxsjW7n0ySssQYLHqHCaSphcb+BnVA7dtKCEvbG9iUMVw6yaSciVUxkSOh9kjcoQJpBYT+FRguY0c0UJSHD4vtAOY5iQK1DuYXFVJ6hWaEhsLUjh0KO7xVg4W5A2K3Gr0gLF0VyZUoq5StNjKawTqVpgKjymGJF14miN5SSIHtdw3fWsynUvsjGu0PTwjpzq1Rhu4FalOlbEsW+DlJk2Pw1l+Gdegw+VBB8lmCnWoTJneU9OmxVIyLR5jWbFMA2rSu7A+GQguBRCRZEtDAnW/pIEPiXmGUfmc2s8BqOngwH/dFDWLrtY2wcJkTWEhlhP5Xmqo8C0kxvPypvDUOziBiMCCbljXpKuXd/YxVzHXSEvrcsUwQSKQsdwQKIExY1diItf+fDEgqzInY3Qe0GJ9oLBQU8cKd6pLE/Z9ng8wB9Hg0EPT58ueqNhPOqpH4ZnvdHo7Gw8Ho0Gg8GAcXToi5R8KyrlnOIDowkz35XVw+gYszWWImUMa2vNSffarFKsyd8/QGN3njrwOMx/K81V4s7RGGUikKLCd1lBU2R8vO2aIVY6RcbWr3WeY8xN49BNbWzrZNdUQkIBa44juGVudIB2iM1+FwytT/t7WB6J89R00FOgIVceglub+hiwr7AULCGOsL/qS+F90nNe1Q8Yn47Hw6fHD2nV+odRWGqzQpc7XZ+O/3Z3+XJ6Oj4TLZ0t0GssH7qpRSdqEQ1Pn/T7AfHIZhk+xg+n1QiLIypzHak0LcPgecb120vpxibmWapysjlUlQTSVMdw+fIVlhdmacNndM66jzs/ZzGRofdqhUK32CpqCvaDtftued2eLA2ZZ/tRnHOZodrX3B7eA/lfbMozQ1vztml+rFxJGA2Gh+3vnWmxqiemby4Ct3aevloHfCSGU9F633Ik6ApKFAkbRYVzDGW7js8DvNwdHZLTeLs9zoE9MZLSaWef2HMex5ofVSoaHaEWtqD7IDrdxgWya4P0t3VrQTpDW1BD3Lh9GLUhXKHrbHN1kqzwwMl4MGgzM3DsoLBPDgv73LqFjmM0oicujC+WSx1ppmKOLtPeh9vT9+r+/6s77rq11K2G5y5fIZs5/DUvLN8L+o0KWknIkBLLuwrfwWV9iZ7ACa9CJ5vtRKhOeGidbOpnnr3rZpdxt/V6MrtfbC65qnXh2uvNLpGEKIfm4szviyAEsnl4vr2D/vbnVRhRmicfqzdpTMMQu1/FeE6ABA6kRmTYH/TDnTK3njIVqNbsCx/aCfdR3dxT98v2yDplwjs6yVOlDYdVuJQ91FBfA0uDhElr/LJN/rSdxg3gMxlmNCttNgvl8Z1Lq4o/vy/QlXUZbpXTasFIXW8g1p6fY5gsVer3t6F2lkdvmyl7LL7xstiJyfb6aphWQRomAOF62955efX7rJw+sPd9Rix1HLNKQoIqRhdwrv+aRhHm1FI6aHq8Bu5O3IvzK5CgHp6OvdMQrHcGtNnUEld2jaaqdvERv3OAVfUvgBbeIQ== +sidebar_class_name: "get api-method" +info_path: gen/api/agent-management-api +custom_edit_url: null +--- + +import ApiTabs from "@theme/ApiTabs"; +import DiscriminatorTabs from "@theme/DiscriminatorTabs"; +import MethodEndpoint from "@theme/ApiExplorer/MethodEndpoint"; +import SecuritySchemes from "@theme/ApiExplorer/SecuritySchemes"; +import MimeTabs from "@theme/MimeTabs"; +import ParamsItem from "@theme/ParamsItem"; +import ResponseSamples from "@theme/ResponseSamples"; +import SchemaItem from "@theme/SchemaItem"; +import SchemaTabs from "@theme/SchemaTabs"; +import Heading from "@theme/Heading"; +import OperationTabs from "@theme/OperationTabs"; +import TabItem from "@theme/TabItem"; + + + + + + + + + + +List SSH authorized keys for a user on the target node. + + + + + +
+ +

+ Path Parameters +

+
+
    + + + + + +
+
+
+
+ + +
+ + + List of SSH authorized keys. + + +
+ + + + +
+ + + Schema + +
+ +
    + + + +
    + + + + results + + object[] + + + + required + + +
    +
  • +
    + Array [ +
    +
  • + + + + +
    + + + + keys + + object[] + + +
    +
    + + + SSH authorized keys on this agent. + + +
  • +
    + Array [ +
    +
  • + + + + + +
  • +
    + ] +
    +
  • +
    +
    +
    + +
  • +
    + ] +
    +
  • +
    +
    +
    +
+
+
+ + + + +
+
+
+
+
+
+ + + Unauthorized - API key required + + +
+ + + + +
+ + + Schema + +
+ +
    + + + + + + + +
+
+
+ + + + +
+
+
+
+
+
+ + + Forbidden - Insufficient permissions + + +
+ + + + +
+ + + Schema + +
+ +
    + + + + + + + +
+
+
+ + + + +
+
+
+
+
+
+ + + Error listing SSH keys. + + +
+ + + + +
+ + + Schema + +
+ +
    + + + + + + + +
+
+
+ + + + +
+
+
+
+
+
+
+
+ \ No newline at end of file diff --git a/docs/docs/gen/api/post-node-user-ssh-key.api.mdx b/docs/docs/gen/api/post-node-user-ssh-key.api.mdx new file mode 100644 index 000000000..53716b9d5 --- /dev/null +++ b/docs/docs/gen/api/post-node-user-ssh-key.api.mdx @@ -0,0 +1,670 @@ +--- +id: post-node-user-ssh-key +title: "Add SSH authorized key" +description: "Add an SSH authorized key for a user on the target node." +sidebar_label: "Add SSH authorized key" +hide_title: true +hide_table_of_contents: true +api: eJztWG1v2zYQ/isEP6WA7Did3a0CBszdmi3LugVJin5IDYMWzxJjiVTJUxJN0H8fjpRsx3bXtd2ADug3vRzv5bnnTndquASXWFWiMprHfColE5pdXf3CRIWZsepPkGwFNVsaywSrHFhmNMMMGAqbAjJtJAzfah5xFKnj8Q1/7cDOhZbzn62pyvkroUUKBWicTy/O5qRibkqwgmw6Pov4+u5M8phfGIe/Gwmk5spl51DziDtIKquw5vFNw1+AsGCnFWZkjhTG91Yh8Fk7i3gprCgAwTovrEUBPOaZcegvI64o0lJgxiNu4V2lLEgeo60g2oHjOoQoUtDIeg0Rs+DA3oFk1lSodMruRF4BO5oLXUdsLvL8ScSMZblYQM4c5JCgsexoBXXsRZ8EwB4GRpRqkBgJKegBPKAVg4Biw+9ErqRA8r13MiqU/v4k8m/mAX7eRtwlGRSCzmBdkrxDq3TKI14o/RvolHA6adtoDcZHA0G5YCJJTKWR0enPCeBjfJ4Fz8DhCyNrkn/sGDG1rBa5SjxL0TAh5fBAPInRCBpJgyjLXCWecMe3jtQ0+/6YxS0k+EjRDV9BTXwtLTEWFfg46eGBOB77eVrlOdtxNlca2BEM02HE3nLnsgHIp5PJyXM2nU6nw+HQl9sPxLu3/LMow9u2jTgqzCFgdg71VMrLgCy9bSlQVxrtQlBPR6N9sM+hJnjBA/zJgD5G79Ys5koeAnBpbCGQx7yqlNwD9DoDdmsW7OwnQklS5ktrEnCOYaYc61hDnsKDKEof+WQygu/Go9EAnj5fDMYncjwQ3548G4zHz55NJuPxaDQa8QBFlaPb8kpYK6gPKYTCHYpqn5aU4qJCDwsLGrsu6pROcwhtZbiHyLpTfZBUhEEvzczSd+VOKZUYCqzcIS2gq4LobFYEs1A5EL5upcoSJBF830xQ1htZN2wfkMeb/PBmk0zoFLYzujAmB6H33H+TAWZgdzQWRqqlAslc7RAKbxm8ZrDW2A+j8pLEWAHOiRSY2oKFhViHPNB9U9dbH4cOtdluubzqMvlSo615u6uhZ8x7z1121dUV2/hQfZ1pX8E9d1kp6tyIf7Pa/iGEU7Z13yfdn2WYCWQmSSprQx/YVNepR5cq0QJaBXc9bXz2JKBQ+UE+7s0gii5FzrozTCxMhRsnDpqVFZBpDXhv7IqhKsBUHSWN3K4mpRFSsAfLKQRJBx4ZmYxG2x3UU2wvoyf7GX2tt8aoAZtenPm2sGbO18T+HxL7zX5iT41dKClBswE7065aLlWiqMWUYAvlnB9tv2b3y8/u5FAjDp8QISUN9923/GsX/vLT2Ua8AMwMrZGlcR55Wm9ifkxr6nHTf+jbYxqtj5twTaP3qlsz7V1YHGebnfOK0hoyt715riPJEEverTR+3vFCPOouTvs59tc3137yILpcbpaal3143SrRZ6SlBW1pvKEu4qmfYjYLNX1QeMTJ5QDeyXA09BMsRV8Iz8pu56PVfn+v34W/2XD8M38GBGwQHvC4zIXS5FVlczIRcnLDSZpHPN4av0gnPeqnsS4zs8jPaHSoaRbCwWubty09fleBrUO+7oRVYkFA3TRcKkfXksdLkbvdhXY7zKPL7mv8hP3H+/5BTPoFQ1MuvDSPOY86Lqyhoe39k2J6z+r+Cb4EP2ZtxDMQEqzHObz6MVgeXJOCzdG9JklRhBPTJIES/1Z2tlXNF39cXVNFdT8BCt9LuBX3tKGL++CmKcMvpbgJzxqeC51WIiXZoJPqTzwu351y9VEdBKJpgsS1WYFu2zUuSPcETNv+BVUoqXI= +sidebar_class_name: "post api-method" +info_path: gen/api/agent-management-api +custom_edit_url: null +--- + +import ApiTabs from "@theme/ApiTabs"; +import DiscriminatorTabs from "@theme/DiscriminatorTabs"; +import MethodEndpoint from "@theme/ApiExplorer/MethodEndpoint"; +import SecuritySchemes from "@theme/ApiExplorer/SecuritySchemes"; +import MimeTabs from "@theme/MimeTabs"; +import ParamsItem from "@theme/ParamsItem"; +import ResponseSamples from "@theme/ResponseSamples"; +import SchemaItem from "@theme/SchemaItem"; +import SchemaTabs from "@theme/SchemaTabs"; +import Heading from "@theme/Heading"; +import OperationTabs from "@theme/OperationTabs"; +import TabItem from "@theme/TabItem"; + + + + + + + + + + +Add an SSH authorized key for a user on the target node. + + + + + +
+ +

+ Path Parameters +

+
+
    + + + + + +
+
+
+ +
+ +

+ Body +

+ required + +
+
+ + + SSH public key to add. + + +
+
    + + + +
+
+
+
+
+ + +
+ + + Key added. + + +
+ + + + +
+ + + Schema + +
+ +
    + + + +
    + + + + results + + object[] + + + + required + + +
    +
  • +
    + Array [ +
    +
  • + + + + + + + +
  • +
    + ] +
    +
  • +
    +
    +
    +
+
+
+ + + + +
+
+
+
+
+
+ + + Invalid request payload. + + +
+ + + + +
+ + + Schema + +
+ +
    + + + + + + + +
+
+
+ + + + +
+
+
+
+
+
+ + + Unauthorized - API key required + + +
+ + + + +
+ + + Schema + +
+ +
    + + + + + + + +
+
+
+ + + + +
+
+
+
+
+
+ + + Forbidden - Insufficient permissions + + +
+ + + + +
+ + + Schema + +
+ +
    + + + + + + + +
+
+
+ + + + +
+
+
+
+
+
+ + + Error adding SSH key. + + +
+ + + + +
+ + + Schema + +
+ +
    + + + + + + + +
+
+
+ + + + +
+
+
+
+
+
+
+
+ \ No newline at end of file diff --git a/docs/docs/gen/api/sidebar.ts b/docs/docs/gen/api/sidebar.ts index 8ce332416..f2a2052b7 100644 --- a/docs/docs/gen/api/sidebar.ts +++ b/docs/docs/gen/api/sidebar.ts @@ -784,6 +784,24 @@ const sidebar: SidebarsConfig = { label: "Change user password", className: "api-method post", }, + { + type: "doc", + id: "gen/api/get-node-user-ssh-key", + label: "List SSH authorized keys", + className: "api-method get", + }, + { + type: "doc", + id: "gen/api/post-node-user-ssh-key", + label: "Add SSH authorized key", + className: "api-method post", + }, + { + type: "doc", + id: "gen/api/delete-node-user-ssh-key", + label: "Remove SSH authorized key", + className: "api-method delete", + }, ], }, { diff --git a/internal/controller/api/gen/api.yaml b/internal/controller/api/gen/api.yaml index 2cb82a506..a486fe03c 100644 --- a/internal/controller/api/gen/api.yaml +++ b/internal/controller/api/gen/api.yaml @@ -4292,6 +4292,139 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorResponse' + /node/{hostname}/user/{name}/ssh-key: + servers: [] + get: + summary: List SSH authorized keys + description: | + List SSH authorized keys for a user on the target node. + tags: + - User_and_Group_Management_API_user_operations + operationId: GetNodeUserSshKey + security: + - BearerAuth: + - user:read + parameters: + - $ref: '#/components/parameters/Hostname' + - $ref: '#/components/parameters/UserName' + responses: + '200': + description: List of SSH authorized keys. + content: + application/json: + schema: + $ref: '#/components/schemas/SSHKeyCollectionResponse' + '401': + description: Unauthorized - API key required + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Forbidden - Insufficient permissions + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Error listing SSH keys. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + post: + summary: Add SSH authorized key + description: | + Add an SSH authorized key for a user on the target node. + tags: + - User_and_Group_Management_API_user_operations + operationId: PostNodeUserSshKey + security: + - BearerAuth: + - user:write + parameters: + - $ref: '#/components/parameters/Hostname' + - $ref: '#/components/parameters/UserName' + requestBody: + description: SSH public key to add. + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SSHKeyAddRequest' + responses: + '200': + description: Key added. + content: + application/json: + schema: + $ref: '#/components/schemas/SSHKeyMutationResponse' + '400': + description: Invalid request payload. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized - API key required + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Forbidden - Insufficient permissions + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Error adding SSH key. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /node/{hostname}/user/{name}/ssh-key/{fingerprint}: + servers: [] + delete: + summary: Remove SSH authorized key + description: > + Remove an SSH authorized key by fingerprint for a user on the target + node. + tags: + - User_and_Group_Management_API_user_operations + operationId: DeleteNodeUserSshKey + security: + - BearerAuth: + - user:write + parameters: + - $ref: '#/components/parameters/Hostname' + - $ref: '#/components/parameters/UserName' + - $ref: '#/components/parameters/SSHKeyFingerprint' + responses: + '200': + description: Key removed. + content: + application/json: + schema: + $ref: '#/components/schemas/SSHKeyMutationResponse' + '401': + description: Unauthorized - API key required + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Forbidden - Insufficient permissions + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Error removing SSH key. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /node/{hostname}/group: servers: [] get: @@ -8198,6 +8331,109 @@ components: $ref: '#/components/schemas/UserMutationResult' required: - results + SSHKeyAddRequest: + type: object + required: + - key + properties: + key: + type: string + description: | + Full SSH public key line (e.g., "ssh-ed25519 AAAA... user@host"). + x-oapi-codegen-extra-tags: + validate: required,min=1 + SSHKeyInfo: + type: object + description: An SSH authorized key entry. + properties: + type: + type: string + description: Key type (e.g., ssh-rsa, ssh-ed25519). + example: ssh-ed25519 + fingerprint: + type: string + description: SHA256 fingerprint of the key. + example: SHA256:abc123... + comment: + type: string + description: Key comment (typically user@host). + example: john@laptop + SSHKeyEntry: + type: object + description: SSH key list result for a single agent. + properties: + hostname: + type: string + description: The hostname of the agent. + status: + type: string + enum: + - ok + - failed + - skipped + description: The status of the operation for this host. + keys: + type: array + description: SSH authorized keys on this agent. + items: + $ref: '#/components/schemas/SSHKeyInfo' + error: + type: string + description: Error message if the agent failed. + required: + - hostname + - status + SSHKeyMutationEntry: + type: object + description: SSH key mutation result for a single agent. + properties: + hostname: + type: string + description: The hostname of the agent. + status: + type: string + enum: + - ok + - failed + - skipped + description: The status of the operation for this host. + changed: + type: boolean + description: Whether the operation modified system state. + error: + type: string + description: Error message if the agent failed. + required: + - hostname + - status + SSHKeyCollectionResponse: + type: object + properties: + job_id: + type: string + format: uuid + description: The job ID used to process this request. + example: 550e8400-e29b-41d4-a716-446655440000 + results: + type: array + items: + $ref: '#/components/schemas/SSHKeyEntry' + required: + - results + SSHKeyMutationResponse: + type: object + properties: + job_id: + type: string + format: uuid + description: The job ID used to process this request. + example: 550e8400-e29b-41d4-a716-446655440000 + results: + type: array + items: + $ref: '#/components/schemas/SSHKeyMutationEntry' + required: + - results GroupInfo: type: object description: A group on the target node. @@ -8424,6 +8660,17 @@ components: schema: type: string minLength: 1 + SSHKeyFingerprint: + name: fingerprint + in: path + required: true + description: | + SSH key SHA256 fingerprint (e.g., SHA256:abc123...). + x-oapi-codegen-extra-tags: + validate: required,min=1 + schema: + type: string + minLength: 1 x-tagGroups: - name: Agent Management API tags: diff --git a/internal/controller/api/node/user/gen/api.yaml b/internal/controller/api/node/user/gen/api.yaml index c3e3d3fe1..87f684137 100644 --- a/internal/controller/api/node/user/gen/api.yaml +++ b/internal/controller/api/node/user/gen/api.yaml @@ -336,6 +336,142 @@ paths: schema: $ref: '../../../common/gen/api.yaml#/components/schemas/ErrorResponse' + # -- SSH Key management ------------------------------------------------ + + /node/{hostname}/user/{name}/ssh-key: + get: + summary: List SSH authorized keys + description: > + List SSH authorized keys for a user on the target node. + tags: + - user_operations + operationId: GetNodeUserSshKey + security: + - BearerAuth: + - user:read + parameters: + - $ref: '#/components/parameters/Hostname' + - $ref: '#/components/parameters/UserName' + responses: + '200': + description: List of SSH authorized keys. + content: + application/json: + schema: + $ref: '#/components/schemas/SSHKeyCollectionResponse' + '401': + description: Unauthorized - API key required + content: + application/json: + schema: + $ref: '../../../common/gen/api.yaml#/components/schemas/ErrorResponse' + '403': + description: Forbidden - Insufficient permissions + content: + application/json: + schema: + $ref: '../../../common/gen/api.yaml#/components/schemas/ErrorResponse' + '500': + description: Error listing SSH keys. + content: + application/json: + schema: + $ref: '../../../common/gen/api.yaml#/components/schemas/ErrorResponse' + + post: + summary: Add SSH authorized key + description: > + Add an SSH authorized key for a user on the target node. + tags: + - user_operations + operationId: PostNodeUserSshKey + security: + - BearerAuth: + - user:write + parameters: + - $ref: '#/components/parameters/Hostname' + - $ref: '#/components/parameters/UserName' + requestBody: + description: SSH public key to add. + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SSHKeyAddRequest' + responses: + '200': + description: Key added. + content: + application/json: + schema: + $ref: '#/components/schemas/SSHKeyMutationResponse' + '400': + description: Invalid request payload. + content: + application/json: + schema: + $ref: '../../../common/gen/api.yaml#/components/schemas/ErrorResponse' + '401': + description: Unauthorized - API key required + content: + application/json: + schema: + $ref: '../../../common/gen/api.yaml#/components/schemas/ErrorResponse' + '403': + description: Forbidden - Insufficient permissions + content: + application/json: + schema: + $ref: '../../../common/gen/api.yaml#/components/schemas/ErrorResponse' + '500': + description: Error adding SSH key. + content: + application/json: + schema: + $ref: '../../../common/gen/api.yaml#/components/schemas/ErrorResponse' + + /node/{hostname}/user/{name}/ssh-key/{fingerprint}: + delete: + summary: Remove SSH authorized key + description: > + Remove an SSH authorized key by fingerprint for a user on the + target node. + tags: + - user_operations + operationId: DeleteNodeUserSshKey + security: + - BearerAuth: + - user:write + parameters: + - $ref: '#/components/parameters/Hostname' + - $ref: '#/components/parameters/UserName' + - $ref: '#/components/parameters/SSHKeyFingerprint' + responses: + '200': + description: Key removed. + content: + application/json: + schema: + $ref: '#/components/schemas/SSHKeyMutationResponse' + '401': + description: Unauthorized - API key required + content: + application/json: + schema: + $ref: '../../../common/gen/api.yaml#/components/schemas/ErrorResponse' + '403': + description: Forbidden - Insufficient permissions + content: + application/json: + schema: + $ref: '../../../common/gen/api.yaml#/components/schemas/ErrorResponse' + '500': + description: Error removing SSH key. + content: + application/json: + schema: + $ref: '../../../common/gen/api.yaml#/components/schemas/ErrorResponse' + # -- Group collection ---------------------------------------------------- /node/{hostname}/group: @@ -629,6 +765,21 @@ components: type: string minLength: 1 + SSHKeyFingerprint: + name: fingerprint + in: path + required: true + description: > + SSH key SHA256 fingerprint (e.g., SHA256:abc123...). + # NOTE: x-oapi-codegen-extra-tags on path params do not generate + # validate tags in strict-server mode. Validation is handled + # manually in the handler. + x-oapi-codegen-extra-tags: + validate: required,min=1 + schema: + type: string + minLength: 1 + securitySchemes: BearerAuth: type: http @@ -837,6 +988,112 @@ components: required: - results + # -- SSH Key schemas -- + + SSHKeyAddRequest: + type: object + required: + - key + properties: + key: + type: string + description: > + Full SSH public key line (e.g., + "ssh-ed25519 AAAA... user@host"). + x-oapi-codegen-extra-tags: + validate: required,min=1 + + SSHKeyInfo: + type: object + description: An SSH authorized key entry. + properties: + type: + type: string + description: Key type (e.g., ssh-rsa, ssh-ed25519). + example: "ssh-ed25519" + fingerprint: + type: string + description: SHA256 fingerprint of the key. + example: "SHA256:abc123..." + comment: + type: string + description: Key comment (typically user@host). + example: "john@laptop" + + SSHKeyEntry: + type: object + description: SSH key list result for a single agent. + properties: + hostname: + type: string + description: The hostname of the agent. + status: + type: string + enum: [ok, failed, skipped] + description: The status of the operation for this host. + keys: + type: array + description: SSH authorized keys on this agent. + items: + $ref: '#/components/schemas/SSHKeyInfo' + error: + type: string + description: Error message if the agent failed. + required: + - hostname + - status + + SSHKeyMutationEntry: + type: object + description: SSH key mutation result for a single agent. + properties: + hostname: + type: string + description: The hostname of the agent. + status: + type: string + enum: [ok, failed, skipped] + description: The status of the operation for this host. + changed: + type: boolean + description: Whether the operation modified system state. + error: + type: string + description: Error message if the agent failed. + required: + - hostname + - status + + SSHKeyCollectionResponse: + type: object + properties: + job_id: + type: string + format: uuid + description: The job ID used to process this request. + example: "550e8400-e29b-41d4-a716-446655440000" + results: + type: array + items: + $ref: '#/components/schemas/SSHKeyEntry' + required: + - results + + SSHKeyMutationResponse: + type: object + properties: + job_id: + type: string + format: uuid + description: The job ID used to process this request. + example: "550e8400-e29b-41d4-a716-446655440000" + results: + type: array + items: + $ref: '#/components/schemas/SSHKeyMutationEntry' + required: + - results + GroupInfo: type: object description: A group on the target node. diff --git a/internal/controller/api/node/user/gen/user.gen.go b/internal/controller/api/node/user/gen/user.gen.go index 78af53950..47378c2d3 100644 --- a/internal/controller/api/node/user/gen/user.gen.go +++ b/internal/controller/api/node/user/gen/user.gen.go @@ -34,6 +34,20 @@ const ( GroupMutationResultStatusSkipped GroupMutationResultStatus = "skipped" ) +// Defines values for SSHKeyEntryStatus. +const ( + SSHKeyEntryStatusFailed SSHKeyEntryStatus = "failed" + SSHKeyEntryStatusOk SSHKeyEntryStatus = "ok" + SSHKeyEntryStatusSkipped SSHKeyEntryStatus = "skipped" +) + +// Defines values for SSHKeyMutationEntryStatus. +const ( + SSHKeyMutationEntryStatusFailed SSHKeyMutationEntryStatus = "failed" + SSHKeyMutationEntryStatusOk SSHKeyMutationEntryStatus = "ok" + SSHKeyMutationEntryStatusSkipped SSHKeyMutationEntryStatus = "skipped" +) + // Defines values for UserEntryStatus. const ( UserEntryStatusFailed UserEntryStatus = "failed" @@ -134,6 +148,74 @@ type GroupUpdateRequest struct { Members *[]string `json:"members,omitempty"` } +// SSHKeyAddRequest defines model for SSHKeyAddRequest. +type SSHKeyAddRequest struct { + // Key Full SSH public key line (e.g., "ssh-ed25519 AAAA... user@host"). + Key string `json:"key" validate:"required,min=1"` +} + +// SSHKeyCollectionResponse defines model for SSHKeyCollectionResponse. +type SSHKeyCollectionResponse struct { + // JobId The job ID used to process this request. + JobId *openapi_types.UUID `json:"job_id,omitempty"` + Results []SSHKeyEntry `json:"results"` +} + +// SSHKeyEntry SSH key list result for a single agent. +type SSHKeyEntry struct { + // Error Error message if the agent failed. + Error *string `json:"error,omitempty"` + + // Hostname The hostname of the agent. + Hostname string `json:"hostname"` + + // Keys SSH authorized keys on this agent. + Keys *[]SSHKeyInfo `json:"keys,omitempty"` + + // Status The status of the operation for this host. + Status SSHKeyEntryStatus `json:"status"` +} + +// SSHKeyEntryStatus The status of the operation for this host. +type SSHKeyEntryStatus string + +// SSHKeyInfo An SSH authorized key entry. +type SSHKeyInfo struct { + // Comment Key comment (typically user@host). + Comment *string `json:"comment,omitempty"` + + // Fingerprint SHA256 fingerprint of the key. + Fingerprint *string `json:"fingerprint,omitempty"` + + // Type Key type (e.g., ssh-rsa, ssh-ed25519). + Type *string `json:"type,omitempty"` +} + +// SSHKeyMutationEntry SSH key mutation result for a single agent. +type SSHKeyMutationEntry struct { + // Changed Whether the operation modified system state. + Changed *bool `json:"changed,omitempty"` + + // Error Error message if the agent failed. + Error *string `json:"error,omitempty"` + + // Hostname The hostname of the agent. + Hostname string `json:"hostname"` + + // Status The status of the operation for this host. + Status SSHKeyMutationEntryStatus `json:"status"` +} + +// SSHKeyMutationEntryStatus The status of the operation for this host. +type SSHKeyMutationEntryStatus string + +// SSHKeyMutationResponse defines model for SSHKeyMutationResponse. +type SSHKeyMutationResponse struct { + // JobId The job ID used to process this request. + JobId *openapi_types.UUID `json:"job_id,omitempty"` + Results []SSHKeyMutationEntry `json:"results"` +} + // UserCollectionResponse defines model for UserCollectionResponse. type UserCollectionResponse struct { // JobId The job ID used to process this request. @@ -265,6 +347,9 @@ type GroupName = string // Hostname defines model for Hostname. type Hostname = string +// SSHKeyFingerprint defines model for SSHKeyFingerprint. +type SSHKeyFingerprint = string + // UserName defines model for UserName. type UserName = string @@ -283,6 +368,9 @@ type PutNodeUserJSONRequestBody = UserUpdateRequest // PostNodeUserPasswordJSONRequestBody defines body for PostNodeUserPassword for application/json ContentType. type PostNodeUserPasswordJSONRequestBody = UserPasswordRequest +// PostNodeUserSshKeyJSONRequestBody defines body for PostNodeUserSshKey for application/json ContentType. +type PostNodeUserSshKeyJSONRequestBody = SSHKeyAddRequest + // ServerInterface represents all server handlers. type ServerInterface interface { // List all groups @@ -318,6 +406,15 @@ type ServerInterface interface { // Change user password // (POST /node/{hostname}/user/{name}/password) PostNodeUserPassword(ctx echo.Context, hostname Hostname, name UserName) error + // List SSH authorized keys + // (GET /node/{hostname}/user/{name}/ssh-key) + GetNodeUserSshKey(ctx echo.Context, hostname Hostname, name UserName) error + // Add SSH authorized key + // (POST /node/{hostname}/user/{name}/ssh-key) + PostNodeUserSshKey(ctx echo.Context, hostname Hostname, name UserName) error + // Remove SSH authorized key + // (DELETE /node/{hostname}/user/{name}/ssh-key/{fingerprint}) + DeleteNodeUserSshKey(ctx echo.Context, hostname Hostname, name UserName, fingerprint SSHKeyFingerprint) error } // ServerInterfaceWrapper converts echo contexts to parameters. @@ -579,6 +676,92 @@ func (w *ServerInterfaceWrapper) PostNodeUserPassword(ctx echo.Context) error { return err } +// GetNodeUserSshKey converts echo context to params. +func (w *ServerInterfaceWrapper) GetNodeUserSshKey(ctx echo.Context) error { + var err error + // ------------- Path parameter "hostname" ------------- + var hostname Hostname + + err = runtime.BindStyledParameterWithOptions("simple", "hostname", ctx.Param("hostname"), &hostname, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter hostname: %s", err)) + } + + // ------------- Path parameter "name" ------------- + var name UserName + + err = runtime.BindStyledParameterWithOptions("simple", "name", ctx.Param("name"), &name, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter name: %s", err)) + } + + ctx.Set(BearerAuthScopes, []string{"user:read"}) + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.GetNodeUserSshKey(ctx, hostname, name) + return err +} + +// PostNodeUserSshKey converts echo context to params. +func (w *ServerInterfaceWrapper) PostNodeUserSshKey(ctx echo.Context) error { + var err error + // ------------- Path parameter "hostname" ------------- + var hostname Hostname + + err = runtime.BindStyledParameterWithOptions("simple", "hostname", ctx.Param("hostname"), &hostname, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter hostname: %s", err)) + } + + // ------------- Path parameter "name" ------------- + var name UserName + + err = runtime.BindStyledParameterWithOptions("simple", "name", ctx.Param("name"), &name, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter name: %s", err)) + } + + ctx.Set(BearerAuthScopes, []string{"user:write"}) + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.PostNodeUserSshKey(ctx, hostname, name) + return err +} + +// DeleteNodeUserSshKey converts echo context to params. +func (w *ServerInterfaceWrapper) DeleteNodeUserSshKey(ctx echo.Context) error { + var err error + // ------------- Path parameter "hostname" ------------- + var hostname Hostname + + err = runtime.BindStyledParameterWithOptions("simple", "hostname", ctx.Param("hostname"), &hostname, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter hostname: %s", err)) + } + + // ------------- Path parameter "name" ------------- + var name UserName + + err = runtime.BindStyledParameterWithOptions("simple", "name", ctx.Param("name"), &name, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter name: %s", err)) + } + + // ------------- Path parameter "fingerprint" ------------- + var fingerprint SSHKeyFingerprint + + err = runtime.BindStyledParameterWithOptions("simple", "fingerprint", ctx.Param("fingerprint"), &fingerprint, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter fingerprint: %s", err)) + } + + ctx.Set(BearerAuthScopes, []string{"user:write"}) + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.DeleteNodeUserSshKey(ctx, hostname, name, fingerprint) + return err +} + // This is a simple interface which specifies echo.Route addition functions which // are present on both echo.Echo and echo.Group, since we want to allow using // either of them for path registration @@ -618,6 +801,9 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL router.GET(baseURL+"/node/:hostname/user/:name", wrapper.GetNodeUserByName) router.PUT(baseURL+"/node/:hostname/user/:name", wrapper.PutNodeUser) router.POST(baseURL+"/node/:hostname/user/:name/password", wrapper.PostNodeUserPassword) + router.GET(baseURL+"/node/:hostname/user/:name/ssh-key", wrapper.GetNodeUserSshKey) + router.POST(baseURL+"/node/:hostname/user/:name/ssh-key", wrapper.PostNodeUserSshKey) + router.DELETE(baseURL+"/node/:hostname/user/:name/ssh-key/:fingerprint", wrapper.DeleteNodeUserSshKey) } @@ -1225,6 +1411,152 @@ func (response PostNodeUserPassword500JSONResponse) VisitPostNodeUserPasswordRes return json.NewEncoder(w).Encode(response) } +type GetNodeUserSshKeyRequestObject struct { + Hostname Hostname `json:"hostname"` + Name UserName `json:"name"` +} + +type GetNodeUserSshKeyResponseObject interface { + VisitGetNodeUserSshKeyResponse(w http.ResponseWriter) error +} + +type GetNodeUserSshKey200JSONResponse SSHKeyCollectionResponse + +func (response GetNodeUserSshKey200JSONResponse) VisitGetNodeUserSshKeyResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type GetNodeUserSshKey401JSONResponse externalRef0.ErrorResponse + +func (response GetNodeUserSshKey401JSONResponse) VisitGetNodeUserSshKeyResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(401) + + return json.NewEncoder(w).Encode(response) +} + +type GetNodeUserSshKey403JSONResponse externalRef0.ErrorResponse + +func (response GetNodeUserSshKey403JSONResponse) VisitGetNodeUserSshKeyResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(403) + + return json.NewEncoder(w).Encode(response) +} + +type GetNodeUserSshKey500JSONResponse externalRef0.ErrorResponse + +func (response GetNodeUserSshKey500JSONResponse) VisitGetNodeUserSshKeyResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(500) + + return json.NewEncoder(w).Encode(response) +} + +type PostNodeUserSshKeyRequestObject struct { + Hostname Hostname `json:"hostname"` + Name UserName `json:"name"` + Body *PostNodeUserSshKeyJSONRequestBody +} + +type PostNodeUserSshKeyResponseObject interface { + VisitPostNodeUserSshKeyResponse(w http.ResponseWriter) error +} + +type PostNodeUserSshKey200JSONResponse SSHKeyMutationResponse + +func (response PostNodeUserSshKey200JSONResponse) VisitPostNodeUserSshKeyResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type PostNodeUserSshKey400JSONResponse externalRef0.ErrorResponse + +func (response PostNodeUserSshKey400JSONResponse) VisitPostNodeUserSshKeyResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(400) + + return json.NewEncoder(w).Encode(response) +} + +type PostNodeUserSshKey401JSONResponse externalRef0.ErrorResponse + +func (response PostNodeUserSshKey401JSONResponse) VisitPostNodeUserSshKeyResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(401) + + return json.NewEncoder(w).Encode(response) +} + +type PostNodeUserSshKey403JSONResponse externalRef0.ErrorResponse + +func (response PostNodeUserSshKey403JSONResponse) VisitPostNodeUserSshKeyResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(403) + + return json.NewEncoder(w).Encode(response) +} + +type PostNodeUserSshKey500JSONResponse externalRef0.ErrorResponse + +func (response PostNodeUserSshKey500JSONResponse) VisitPostNodeUserSshKeyResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(500) + + return json.NewEncoder(w).Encode(response) +} + +type DeleteNodeUserSshKeyRequestObject struct { + Hostname Hostname `json:"hostname"` + Name UserName `json:"name"` + Fingerprint SSHKeyFingerprint `json:"fingerprint"` +} + +type DeleteNodeUserSshKeyResponseObject interface { + VisitDeleteNodeUserSshKeyResponse(w http.ResponseWriter) error +} + +type DeleteNodeUserSshKey200JSONResponse SSHKeyMutationResponse + +func (response DeleteNodeUserSshKey200JSONResponse) VisitDeleteNodeUserSshKeyResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type DeleteNodeUserSshKey401JSONResponse externalRef0.ErrorResponse + +func (response DeleteNodeUserSshKey401JSONResponse) VisitDeleteNodeUserSshKeyResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(401) + + return json.NewEncoder(w).Encode(response) +} + +type DeleteNodeUserSshKey403JSONResponse externalRef0.ErrorResponse + +func (response DeleteNodeUserSshKey403JSONResponse) VisitDeleteNodeUserSshKeyResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(403) + + return json.NewEncoder(w).Encode(response) +} + +type DeleteNodeUserSshKey500JSONResponse externalRef0.ErrorResponse + +func (response DeleteNodeUserSshKey500JSONResponse) VisitDeleteNodeUserSshKeyResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(500) + + return json.NewEncoder(w).Encode(response) +} + // StrictServerInterface represents all server handlers. type StrictServerInterface interface { // List all groups @@ -1260,6 +1592,15 @@ type StrictServerInterface interface { // Change user password // (POST /node/{hostname}/user/{name}/password) PostNodeUserPassword(ctx context.Context, request PostNodeUserPasswordRequestObject) (PostNodeUserPasswordResponseObject, error) + // List SSH authorized keys + // (GET /node/{hostname}/user/{name}/ssh-key) + GetNodeUserSshKey(ctx context.Context, request GetNodeUserSshKeyRequestObject) (GetNodeUserSshKeyResponseObject, error) + // Add SSH authorized key + // (POST /node/{hostname}/user/{name}/ssh-key) + PostNodeUserSshKey(ctx context.Context, request PostNodeUserSshKeyRequestObject) (PostNodeUserSshKeyResponseObject, error) + // Remove SSH authorized key + // (DELETE /node/{hostname}/user/{name}/ssh-key/{fingerprint}) + DeleteNodeUserSshKey(ctx context.Context, request DeleteNodeUserSshKeyRequestObject) (DeleteNodeUserSshKeyResponseObject, error) } type StrictHandlerFunc = strictecho.StrictEchoHandlerFunc @@ -1585,3 +1926,88 @@ func (sh *strictHandler) PostNodeUserPassword(ctx echo.Context, hostname Hostnam } return nil } + +// GetNodeUserSshKey operation middleware +func (sh *strictHandler) GetNodeUserSshKey(ctx echo.Context, hostname Hostname, name UserName) error { + var request GetNodeUserSshKeyRequestObject + + request.Hostname = hostname + request.Name = name + + handler := func(ctx echo.Context, request interface{}) (interface{}, error) { + return sh.ssi.GetNodeUserSshKey(ctx.Request().Context(), request.(GetNodeUserSshKeyRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetNodeUserSshKey") + } + + response, err := handler(ctx, request) + + if err != nil { + return err + } else if validResponse, ok := response.(GetNodeUserSshKeyResponseObject); ok { + return validResponse.VisitGetNodeUserSshKeyResponse(ctx.Response()) + } else if response != nil { + return fmt.Errorf("unexpected response type: %T", response) + } + return nil +} + +// PostNodeUserSshKey operation middleware +func (sh *strictHandler) PostNodeUserSshKey(ctx echo.Context, hostname Hostname, name UserName) error { + var request PostNodeUserSshKeyRequestObject + + request.Hostname = hostname + request.Name = name + + var body PostNodeUserSshKeyJSONRequestBody + if err := ctx.Bind(&body); err != nil { + return err + } + request.Body = &body + + handler := func(ctx echo.Context, request interface{}) (interface{}, error) { + return sh.ssi.PostNodeUserSshKey(ctx.Request().Context(), request.(PostNodeUserSshKeyRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "PostNodeUserSshKey") + } + + response, err := handler(ctx, request) + + if err != nil { + return err + } else if validResponse, ok := response.(PostNodeUserSshKeyResponseObject); ok { + return validResponse.VisitPostNodeUserSshKeyResponse(ctx.Response()) + } else if response != nil { + return fmt.Errorf("unexpected response type: %T", response) + } + return nil +} + +// DeleteNodeUserSshKey operation middleware +func (sh *strictHandler) DeleteNodeUserSshKey(ctx echo.Context, hostname Hostname, name UserName, fingerprint SSHKeyFingerprint) error { + var request DeleteNodeUserSshKeyRequestObject + + request.Hostname = hostname + request.Name = name + request.Fingerprint = fingerprint + + handler := func(ctx echo.Context, request interface{}) (interface{}, error) { + return sh.ssi.DeleteNodeUserSshKey(ctx.Request().Context(), request.(DeleteNodeUserSshKeyRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "DeleteNodeUserSshKey") + } + + response, err := handler(ctx, request) + + if err != nil { + return err + } else if validResponse, ok := response.(DeleteNodeUserSshKeyResponseObject); ok { + return validResponse.VisitDeleteNodeUserSshKeyResponse(ctx.Response()) + } else if response != nil { + return fmt.Errorf("unexpected response type: %T", response) + } + return nil +} diff --git a/internal/controller/api/node/user/ssh_key_create.go b/internal/controller/api/node/user/ssh_key_create.go new file mode 100644 index 000000000..3136248a6 --- /dev/null +++ b/internal/controller/api/node/user/ssh_key_create.go @@ -0,0 +1,36 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package user + +import ( + "context" + + "github.com/retr0h/osapi/internal/controller/api/node/user/gen" +) + +// PostNodeUserSshKey adds an SSH authorized key for a user on a target node. +func (u *User) PostNodeUserSshKey( + _ context.Context, + _ gen.PostNodeUserSshKeyRequestObject, +) (gen.PostNodeUserSshKeyResponseObject, error) { + // TODO(Task 5): implement handler + panic("not implemented") +} diff --git a/internal/controller/api/node/user/ssh_key_delete.go b/internal/controller/api/node/user/ssh_key_delete.go new file mode 100644 index 000000000..bf1f5bba8 --- /dev/null +++ b/internal/controller/api/node/user/ssh_key_delete.go @@ -0,0 +1,37 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package user + +import ( + "context" + + "github.com/retr0h/osapi/internal/controller/api/node/user/gen" +) + +// DeleteNodeUserSshKey removes an SSH authorized key by fingerprint for a user +// on a target node. +func (u *User) DeleteNodeUserSshKey( + _ context.Context, + _ gen.DeleteNodeUserSshKeyRequestObject, +) (gen.DeleteNodeUserSshKeyResponseObject, error) { + // TODO(Task 5): implement handler + panic("not implemented") +} diff --git a/internal/controller/api/node/user/ssh_key_list_get.go b/internal/controller/api/node/user/ssh_key_list_get.go new file mode 100644 index 000000000..a65619227 --- /dev/null +++ b/internal/controller/api/node/user/ssh_key_list_get.go @@ -0,0 +1,36 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package user + +import ( + "context" + + "github.com/retr0h/osapi/internal/controller/api/node/user/gen" +) + +// GetNodeUserSshKey lists SSH authorized keys for a user on a target node. +func (u *User) GetNodeUserSshKey( + _ context.Context, + _ gen.GetNodeUserSshKeyRequestObject, +) (gen.GetNodeUserSshKeyResponseObject, error) { + // TODO(Task 5): implement handler + panic("not implemented") +} diff --git a/pkg/sdk/client/gen/client.gen.go b/pkg/sdk/client/gen/client.gen.go index 557f84907..4c3479d1a 100644 --- a/pkg/sdk/client/gen/client.gen.go +++ b/pkg/sdk/client/gen/client.gen.go @@ -344,6 +344,20 @@ const ( ProcessSignalResultStatusSkipped ProcessSignalResultStatus = "skipped" ) +// Defines values for SSHKeyEntryStatus. +const ( + SSHKeyEntryStatusFailed SSHKeyEntryStatus = "failed" + SSHKeyEntryStatusOk SSHKeyEntryStatus = "ok" + SSHKeyEntryStatusSkipped SSHKeyEntryStatus = "skipped" +) + +// Defines values for SSHKeyMutationEntryStatus. +const ( + SSHKeyMutationEntryStatusFailed SSHKeyMutationEntryStatus = "failed" + SSHKeyMutationEntryStatusOk SSHKeyMutationEntryStatus = "ok" + SSHKeyMutationEntryStatusSkipped SSHKeyMutationEntryStatus = "skipped" +) + // Defines values for SysctlEntryStatus. const ( SysctlEntryStatusFailed SysctlEntryStatus = "failed" @@ -2486,6 +2500,74 @@ type RouteResponse struct { Metric *int `json:"metric,omitempty"` } +// SSHKeyAddRequest defines model for SSHKeyAddRequest. +type SSHKeyAddRequest struct { + // Key Full SSH public key line (e.g., "ssh-ed25519 AAAA... user@host"). + Key string `json:"key" validate:"required,min=1"` +} + +// SSHKeyCollectionResponse defines model for SSHKeyCollectionResponse. +type SSHKeyCollectionResponse struct { + // JobId The job ID used to process this request. + JobId *openapi_types.UUID `json:"job_id,omitempty"` + Results []SSHKeyEntry `json:"results"` +} + +// SSHKeyEntry SSH key list result for a single agent. +type SSHKeyEntry struct { + // Error Error message if the agent failed. + Error *string `json:"error,omitempty"` + + // Hostname The hostname of the agent. + Hostname string `json:"hostname"` + + // Keys SSH authorized keys on this agent. + Keys *[]SSHKeyInfo `json:"keys,omitempty"` + + // Status The status of the operation for this host. + Status SSHKeyEntryStatus `json:"status"` +} + +// SSHKeyEntryStatus The status of the operation for this host. +type SSHKeyEntryStatus string + +// SSHKeyInfo An SSH authorized key entry. +type SSHKeyInfo struct { + // Comment Key comment (typically user@host). + Comment *string `json:"comment,omitempty"` + + // Fingerprint SHA256 fingerprint of the key. + Fingerprint *string `json:"fingerprint,omitempty"` + + // Type Key type (e.g., ssh-rsa, ssh-ed25519). + Type *string `json:"type,omitempty"` +} + +// SSHKeyMutationEntry SSH key mutation result for a single agent. +type SSHKeyMutationEntry struct { + // Changed Whether the operation modified system state. + Changed *bool `json:"changed,omitempty"` + + // Error Error message if the agent failed. + Error *string `json:"error,omitempty"` + + // Hostname The hostname of the agent. + Hostname string `json:"hostname"` + + // Status The status of the operation for this host. + Status SSHKeyMutationEntryStatus `json:"status"` +} + +// SSHKeyMutationEntryStatus The status of the operation for this host. +type SSHKeyMutationEntryStatus string + +// SSHKeyMutationResponse defines model for SSHKeyMutationResponse. +type SSHKeyMutationResponse struct { + // JobId The job ID used to process this request. + JobId *openapi_types.UUID `json:"job_id,omitempty"` + Results []SSHKeyMutationEntry `json:"results"` +} + // StatusResponse defines model for StatusResponse. type StatusResponse struct { Agents *AgentStats `json:"agents,omitempty"` @@ -2910,6 +2992,9 @@ type PackageName = string // Pid defines model for Pid. type Pid = int +// SSHKeyFingerprint defines model for SSHKeyFingerprint. +type SSHKeyFingerprint = string + // SysctlKey defines model for SysctlKey. type SysctlKey = string @@ -3113,6 +3198,9 @@ type PutNodeUserJSONRequestBody = UserUpdateRequest // PostNodeUserPasswordJSONRequestBody defines body for PostNodeUserPassword for application/json ContentType. type PostNodeUserPasswordJSONRequestBody = UserPasswordRequest +// PostNodeUserSshKeyJSONRequestBody defines body for PostNodeUserSshKey for application/json ContentType. +type PostNodeUserSshKeyJSONRequestBody = SSHKeyAddRequest + // RequestEditorFn is the function signature for the RequestEditor callback function type RequestEditorFn func(ctx context.Context, req *http.Request) error @@ -3515,6 +3603,17 @@ type ClientInterface interface { PostNodeUserPassword(ctx context.Context, hostname Hostname, name UserName, body PostNodeUserPasswordJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + // GetNodeUserSshKey request + GetNodeUserSshKey(ctx context.Context, hostname Hostname, name UserName, reqEditors ...RequestEditorFn) (*http.Response, error) + + // PostNodeUserSshKeyWithBody request with any body + PostNodeUserSshKeyWithBody(ctx context.Context, hostname Hostname, name UserName, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + PostNodeUserSshKey(ctx context.Context, hostname Hostname, name UserName, body PostNodeUserSshKeyJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + + // DeleteNodeUserSshKey request + DeleteNodeUserSshKey(ctx context.Context, hostname Hostname, name UserName, fingerprint SSHKeyFingerprint, reqEditors ...RequestEditorFn) (*http.Response, error) + // GetVersion request GetVersion(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) } @@ -4959,6 +5058,54 @@ func (c *Client) PostNodeUserPassword(ctx context.Context, hostname Hostname, na return c.Client.Do(req) } +func (c *Client) GetNodeUserSshKey(ctx context.Context, hostname Hostname, name UserName, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetNodeUserSshKeyRequest(c.Server, hostname, name) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) PostNodeUserSshKeyWithBody(ctx context.Context, hostname Hostname, name UserName, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewPostNodeUserSshKeyRequestWithBody(c.Server, hostname, name, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) PostNodeUserSshKey(ctx context.Context, hostname Hostname, name UserName, body PostNodeUserSshKeyJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewPostNodeUserSshKeyRequest(c.Server, hostname, name, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) DeleteNodeUserSshKey(ctx context.Context, hostname Hostname, name UserName, fingerprint SSHKeyFingerprint, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewDeleteNodeUserSshKeyRequest(c.Server, hostname, name, fingerprint) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + func (c *Client) GetVersion(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) { req, err := NewGetVersionRequest(c.Server) if err != nil { @@ -8825,6 +8972,149 @@ func NewPostNodeUserPasswordRequestWithBody(server string, hostname Hostname, na return req, nil } +// NewGetNodeUserSshKeyRequest generates requests for GetNodeUserSshKey +func NewGetNodeUserSshKeyRequest(server string, hostname Hostname, name UserName) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithLocation("simple", false, "hostname", runtime.ParamLocationPath, hostname) + if err != nil { + return nil, err + } + + var pathParam1 string + + pathParam1, err = runtime.StyleParamWithLocation("simple", false, "name", runtime.ParamLocationPath, name) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/node/%s/user/%s/ssh-key", pathParam0, pathParam1) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("GET", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewPostNodeUserSshKeyRequest calls the generic PostNodeUserSshKey builder with application/json body +func NewPostNodeUserSshKeyRequest(server string, hostname Hostname, name UserName, body PostNodeUserSshKeyJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewPostNodeUserSshKeyRequestWithBody(server, hostname, name, "application/json", bodyReader) +} + +// NewPostNodeUserSshKeyRequestWithBody generates requests for PostNodeUserSshKey with any type of body +func NewPostNodeUserSshKeyRequestWithBody(server string, hostname Hostname, name UserName, contentType string, body io.Reader) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithLocation("simple", false, "hostname", runtime.ParamLocationPath, hostname) + if err != nil { + return nil, err + } + + var pathParam1 string + + pathParam1, err = runtime.StyleParamWithLocation("simple", false, "name", runtime.ParamLocationPath, name) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/node/%s/user/%s/ssh-key", pathParam0, pathParam1) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// NewDeleteNodeUserSshKeyRequest generates requests for DeleteNodeUserSshKey +func NewDeleteNodeUserSshKeyRequest(server string, hostname Hostname, name UserName, fingerprint SSHKeyFingerprint) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithLocation("simple", false, "hostname", runtime.ParamLocationPath, hostname) + if err != nil { + return nil, err + } + + var pathParam1 string + + pathParam1, err = runtime.StyleParamWithLocation("simple", false, "name", runtime.ParamLocationPath, name) + if err != nil { + return nil, err + } + + var pathParam2 string + + pathParam2, err = runtime.StyleParamWithLocation("simple", false, "fingerprint", runtime.ParamLocationPath, fingerprint) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/node/%s/user/%s/ssh-key/%s", pathParam0, pathParam1, pathParam2) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("DELETE", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + // NewGetVersionRequest generates requests for GetVersion func NewGetVersionRequest(server string) (*http.Request, error) { var err error @@ -9224,6 +9514,17 @@ type ClientWithResponsesInterface interface { PostNodeUserPasswordWithResponse(ctx context.Context, hostname Hostname, name UserName, body PostNodeUserPasswordJSONRequestBody, reqEditors ...RequestEditorFn) (*PostNodeUserPasswordResponse, error) + // GetNodeUserSshKeyWithResponse request + GetNodeUserSshKeyWithResponse(ctx context.Context, hostname Hostname, name UserName, reqEditors ...RequestEditorFn) (*GetNodeUserSshKeyResponse, error) + + // PostNodeUserSshKeyWithBodyWithResponse request with any body + PostNodeUserSshKeyWithBodyWithResponse(ctx context.Context, hostname Hostname, name UserName, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PostNodeUserSshKeyResponse, error) + + PostNodeUserSshKeyWithResponse(ctx context.Context, hostname Hostname, name UserName, body PostNodeUserSshKeyJSONRequestBody, reqEditors ...RequestEditorFn) (*PostNodeUserSshKeyResponse, error) + + // DeleteNodeUserSshKeyWithResponse request + DeleteNodeUserSshKeyWithResponse(ctx context.Context, hostname Hostname, name UserName, fingerprint SSHKeyFingerprint, reqEditors ...RequestEditorFn) (*DeleteNodeUserSshKeyResponse, error) + // GetVersionWithResponse request GetVersionWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetVersionResponse, error) } @@ -11540,6 +11841,82 @@ func (r PostNodeUserPasswordResponse) StatusCode() int { return 0 } +type GetNodeUserSshKeyResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *SSHKeyCollectionResponse + JSON401 *ErrorResponse + JSON403 *ErrorResponse + JSON500 *ErrorResponse +} + +// Status returns HTTPResponse.Status +func (r GetNodeUserSshKeyResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GetNodeUserSshKeyResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type PostNodeUserSshKeyResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *SSHKeyMutationResponse + JSON400 *ErrorResponse + JSON401 *ErrorResponse + JSON403 *ErrorResponse + JSON500 *ErrorResponse +} + +// Status returns HTTPResponse.Status +func (r PostNodeUserSshKeyResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r PostNodeUserSshKeyResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type DeleteNodeUserSshKeyResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *SSHKeyMutationResponse + JSON401 *ErrorResponse + JSON403 *ErrorResponse + JSON500 *ErrorResponse +} + +// Status returns HTTPResponse.Status +func (r DeleteNodeUserSshKeyResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r DeleteNodeUserSshKeyResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + type GetVersionResponse struct { Body []byte HTTPResponse *http.Response @@ -12611,6 +12988,41 @@ func (c *ClientWithResponses) PostNodeUserPasswordWithResponse(ctx context.Conte return ParsePostNodeUserPasswordResponse(rsp) } +// GetNodeUserSshKeyWithResponse request returning *GetNodeUserSshKeyResponse +func (c *ClientWithResponses) GetNodeUserSshKeyWithResponse(ctx context.Context, hostname Hostname, name UserName, reqEditors ...RequestEditorFn) (*GetNodeUserSshKeyResponse, error) { + rsp, err := c.GetNodeUserSshKey(ctx, hostname, name, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetNodeUserSshKeyResponse(rsp) +} + +// PostNodeUserSshKeyWithBodyWithResponse request with arbitrary body returning *PostNodeUserSshKeyResponse +func (c *ClientWithResponses) PostNodeUserSshKeyWithBodyWithResponse(ctx context.Context, hostname Hostname, name UserName, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PostNodeUserSshKeyResponse, error) { + rsp, err := c.PostNodeUserSshKeyWithBody(ctx, hostname, name, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParsePostNodeUserSshKeyResponse(rsp) +} + +func (c *ClientWithResponses) PostNodeUserSshKeyWithResponse(ctx context.Context, hostname Hostname, name UserName, body PostNodeUserSshKeyJSONRequestBody, reqEditors ...RequestEditorFn) (*PostNodeUserSshKeyResponse, error) { + rsp, err := c.PostNodeUserSshKey(ctx, hostname, name, body, reqEditors...) + if err != nil { + return nil, err + } + return ParsePostNodeUserSshKeyResponse(rsp) +} + +// DeleteNodeUserSshKeyWithResponse request returning *DeleteNodeUserSshKeyResponse +func (c *ClientWithResponses) DeleteNodeUserSshKeyWithResponse(ctx context.Context, hostname Hostname, name UserName, fingerprint SSHKeyFingerprint, reqEditors ...RequestEditorFn) (*DeleteNodeUserSshKeyResponse, error) { + rsp, err := c.DeleteNodeUserSshKey(ctx, hostname, name, fingerprint, reqEditors...) + if err != nil { + return nil, err + } + return ParseDeleteNodeUserSshKeyResponse(rsp) +} + // GetVersionWithResponse request returning *GetVersionResponse func (c *ClientWithResponses) GetVersionWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetVersionResponse, error) { rsp, err := c.GetVersion(ctx, reqEditors...) @@ -17388,6 +17800,154 @@ func ParsePostNodeUserPasswordResponse(rsp *http.Response) (*PostNodeUserPasswor return response, nil } +// ParseGetNodeUserSshKeyResponse parses an HTTP response from a GetNodeUserSshKeyWithResponse call +func ParseGetNodeUserSshKeyResponse(rsp *http.Response) (*GetNodeUserSshKeyResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetNodeUserSshKeyResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest SSHKeyCollectionResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + +// ParsePostNodeUserSshKeyResponse parses an HTTP response from a PostNodeUserSshKeyWithResponse call +func ParsePostNodeUserSshKeyResponse(rsp *http.Response) (*PostNodeUserSshKeyResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &PostNodeUserSshKeyResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest SSHKeyMutationResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + +// ParseDeleteNodeUserSshKeyResponse parses an HTTP response from a DeleteNodeUserSshKeyWithResponse call +func ParseDeleteNodeUserSshKeyResponse(rsp *http.Response) (*DeleteNodeUserSshKeyResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &DeleteNodeUserSshKeyResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest SSHKeyMutationResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + // ParseGetVersionResponse parses an HTTP response from a GetVersionWithResponse call func ParseGetVersionResponse(rsp *http.Response) (*GetVersionResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) From ec61858977a3eae0e3dd3276bb418ebb33e3c0a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Wed, 1 Apr 2026 11:49:03 -0700 Subject: [PATCH 06/11] feat(user): add SSH key API handlers with broadcast support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace stub SSH key handlers with full implementations for list, add, and remove operations. Each handler supports single-target and broadcast routing with proper validation, skipped/failed status handling, and comprehensive test coverage (unit, HTTP wiring, RBAC). 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .../api/node/user/ssh_key_create.go | 132 ++++- .../node/user/ssh_key_create_public_test.go | 493 ++++++++++++++++ .../api/node/user/ssh_key_delete.go | 122 +++- .../node/user/ssh_key_delete_public_test.go | 473 +++++++++++++++ .../api/node/user/ssh_key_list_get.go | 160 +++++- .../node/user/ssh_key_list_get_public_test.go | 541 ++++++++++++++++++ 6 files changed, 1909 insertions(+), 12 deletions(-) create mode 100644 internal/controller/api/node/user/ssh_key_create_public_test.go create mode 100644 internal/controller/api/node/user/ssh_key_delete_public_test.go create mode 100644 internal/controller/api/node/user/ssh_key_list_get_public_test.go diff --git a/internal/controller/api/node/user/ssh_key_create.go b/internal/controller/api/node/user/ssh_key_create.go index 3136248a6..7701f49ac 100644 --- a/internal/controller/api/node/user/ssh_key_create.go +++ b/internal/controller/api/node/user/ssh_key_create.go @@ -22,15 +22,139 @@ package user import ( "context" + "encoding/json" + "log/slog" + + "github.com/google/uuid" "github.com/retr0h/osapi/internal/controller/api/node/user/gen" + "github.com/retr0h/osapi/internal/job" + userProv "github.com/retr0h/osapi/internal/provider/node/user" + "github.com/retr0h/osapi/internal/validation" ) // PostNodeUserSshKey adds an SSH authorized key for a user on a target node. func (u *User) PostNodeUserSshKey( - _ context.Context, - _ gen.PostNodeUserSshKeyRequestObject, + ctx context.Context, + request gen.PostNodeUserSshKeyRequestObject, ) (gen.PostNodeUserSshKeyResponseObject, error) { - // TODO(Task 5): implement handler - panic("not implemented") + if errMsg, ok := validateHostname(request.Hostname); !ok { + return gen.PostNodeUserSshKey400JSONResponse{Error: &errMsg}, nil + } + + if errMsg, ok := validation.Struct(request.Body); !ok { + return gen.PostNodeUserSshKey400JSONResponse{Error: &errMsg}, nil + } + + hostname := request.Hostname + username := request.Name + + u.logger.Debug("ssh key add", + slog.String("target", hostname), + slog.String("username", username), + slog.Bool("broadcast", job.IsBroadcastTarget(hostname)), + ) + + data := map[string]string{ + "username": username, + "raw_line": request.Body.Key, + } + + if job.IsBroadcastTarget(hostname) { + return u.postNodeUserSshKeyBroadcast(ctx, hostname, data) + } + + jobID, resp, err := u.JobClient.Modify( + ctx, + hostname, + "user", + job.OperationSSHKeyAdd, + data, + ) + if err != nil { + errMsg := err.Error() + return gen.PostNodeUserSshKey500JSONResponse{Error: &errMsg}, nil + } + + if resp.Status == job.StatusSkipped { + jobUUID := uuid.MustParse(jobID) + e := resp.Error + return gen.PostNodeUserSshKey200JSONResponse{ + JobId: &jobUUID, + Results: []gen.SSHKeyMutationEntry{ + { + Hostname: resp.Hostname, + Status: gen.SSHKeyMutationEntryStatusSkipped, + Error: &e, + }, + }, + }, nil + } + + var result userProv.SSHKeyResult + if resp.Data != nil { + _ = json.Unmarshal(resp.Data, &result) + } + + jobUUID := uuid.MustParse(jobID) + changed := resp.Changed + agentHostname := resp.Hostname + + return gen.PostNodeUserSshKey200JSONResponse{ + JobId: &jobUUID, + Results: []gen.SSHKeyMutationEntry{ + { + Hostname: agentHostname, + Status: gen.SSHKeyMutationEntryStatusOk, + Changed: changed, + }, + }, + }, nil +} + +// postNodeUserSshKeyBroadcast handles broadcast targets for SSH key add. +func (u *User) postNodeUserSshKeyBroadcast( + ctx context.Context, + target string, + data map[string]string, +) (gen.PostNodeUserSshKeyResponseObject, error) { + jobID, responses, err := u.JobClient.ModifyBroadcast( + ctx, + target, + "user", + job.OperationSSHKeyAdd, + data, + ) + if err != nil { + errMsg := err.Error() + return gen.PostNodeUserSshKey500JSONResponse{Error: &errMsg}, nil + } + + var apiResponses []gen.SSHKeyMutationEntry + for host, resp := range responses { + item := gen.SSHKeyMutationEntry{ + Hostname: host, + } + switch resp.Status { + case job.StatusFailed: + item.Status = gen.SSHKeyMutationEntryStatusFailed + e := resp.Error + item.Error = &e + case job.StatusSkipped: + item.Status = gen.SSHKeyMutationEntryStatusSkipped + e := resp.Error + item.Error = &e + default: + item.Status = gen.SSHKeyMutationEntryStatusOk + item.Changed = resp.Changed + } + apiResponses = append(apiResponses, item) + } + + jobUUID := uuid.MustParse(jobID) + + return gen.PostNodeUserSshKey200JSONResponse{ + JobId: &jobUUID, + Results: apiResponses, + }, nil } diff --git a/internal/controller/api/node/user/ssh_key_create_public_test.go b/internal/controller/api/node/user/ssh_key_create_public_test.go new file mode 100644 index 000000000..ad76a510b --- /dev/null +++ b/internal/controller/api/node/user/ssh_key_create_public_test.go @@ -0,0 +1,493 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package user_test + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" + + "github.com/retr0h/osapi/internal/authtoken" + "github.com/retr0h/osapi/internal/config" + "github.com/retr0h/osapi/internal/controller/api" + apiuser "github.com/retr0h/osapi/internal/controller/api/node/user" + "github.com/retr0h/osapi/internal/controller/api/node/user/gen" + "github.com/retr0h/osapi/internal/job" + jobmocks "github.com/retr0h/osapi/internal/job/mocks" + "github.com/retr0h/osapi/internal/validation" +) + +type SSHKeyCreatePublicTestSuite struct { + suite.Suite + + mockCtrl *gomock.Controller + mockJobClient *jobmocks.MockJobClient + handler *apiuser.User + ctx context.Context + appConfig config.Config + logger *slog.Logger +} + +func (s *SSHKeyCreatePublicTestSuite) SetupSuite() { + validation.RegisterTargetValidator(func(_ context.Context) ([]validation.AgentTarget, error) { + return []validation.AgentTarget{ + {Hostname: "server1", Labels: map[string]string{"group": "web"}}, + {Hostname: "server2"}, + }, nil + }) +} + +func (s *SSHKeyCreatePublicTestSuite) SetupTest() { + s.mockCtrl = gomock.NewController(s.T()) + s.mockJobClient = jobmocks.NewMockJobClient(s.mockCtrl) + s.handler = apiuser.New(slog.Default(), s.mockJobClient) + s.ctx = context.Background() + s.appConfig = config.Config{} + s.logger = slog.New(slog.NewTextHandler(os.Stdout, nil)) +} + +func (s *SSHKeyCreatePublicTestSuite) TearDownTest() { + s.mockCtrl.Finish() +} + +func (s *SSHKeyCreatePublicTestSuite) TestPostNodeUserSshKey() { + tests := []struct { + name string + request gen.PostNodeUserSshKeyRequestObject + setupMock func() + validateFunc func(resp gen.PostNodeUserSshKeyResponseObject) + }{ + { + name: "success", + request: gen.PostNodeUserSshKeyRequestObject{ + Hostname: "server1", + Name: "testuser", + Body: &gen.SSHKeyAddRequest{ + Key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITest user@host", + }, + }, + setupMock: func() { + s.mockJobClient.EXPECT(). + Modify( + gomock.Any(), + "server1", + "user", + job.OperationSSHKeyAdd, + map[string]string{ + "username": "testuser", + "raw_line": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITest user@host", + }, + ). + Return("550e8400-e29b-41d4-a716-446655440000", &job.Response{ + Hostname: "agent1", + Changed: boolPtr(true), + Data: json.RawMessage(`{"changed":true}`), + }, nil) + }, + validateFunc: func(resp gen.PostNodeUserSshKeyResponseObject) { + r, ok := resp.(gen.PostNodeUserSshKey200JSONResponse) + s.True(ok) + s.Require().Len(r.Results, 1) + s.Equal(gen.SSHKeyMutationEntryStatusOk, r.Results[0].Status) + s.Require().NotNil(r.Results[0].Changed) + s.True(*r.Results[0].Changed) + }, + }, + { + name: "validation error empty key", + request: gen.PostNodeUserSshKeyRequestObject{ + Hostname: "server1", + Name: "testuser", + Body: &gen.SSHKeyAddRequest{ + Key: "", + }, + }, + setupMock: func() {}, + validateFunc: func(resp gen.PostNodeUserSshKeyResponseObject) { + _, ok := resp.(gen.PostNodeUserSshKey400JSONResponse) + s.True(ok) + }, + }, + { + name: "validation error empty hostname", + request: gen.PostNodeUserSshKeyRequestObject{ + Hostname: "", + Name: "testuser", + Body: &gen.SSHKeyAddRequest{ + Key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITest user@host", + }, + }, + setupMock: func() {}, + validateFunc: func(resp gen.PostNodeUserSshKeyResponseObject) { + _, ok := resp.(gen.PostNodeUserSshKey400JSONResponse) + s.True(ok) + }, + }, + { + name: "when job skipped", + request: gen.PostNodeUserSshKeyRequestObject{ + Hostname: "server1", + Name: "testuser", + Body: &gen.SSHKeyAddRequest{ + Key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITest user@host", + }, + }, + setupMock: func() { + s.mockJobClient.EXPECT(). + Modify( + gomock.Any(), + "server1", + "user", + job.OperationSSHKeyAdd, + gomock.Any(), + ). + Return("550e8400-e29b-41d4-a716-446655440000", &job.Response{ + Status: job.StatusSkipped, + Hostname: "server1", + Error: "unsupported", + }, nil) + }, + validateFunc: func(resp gen.PostNodeUserSshKeyResponseObject) { + r, ok := resp.(gen.PostNodeUserSshKey200JSONResponse) + s.True(ok) + s.Equal(gen.SSHKeyMutationEntryStatusSkipped, r.Results[0].Status) + }, + }, + { + name: "job client error", + request: gen.PostNodeUserSshKeyRequestObject{ + Hostname: "server1", + Name: "testuser", + Body: &gen.SSHKeyAddRequest{ + Key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITest user@host", + }, + }, + setupMock: func() { + s.mockJobClient.EXPECT(). + Modify( + gomock.Any(), + "server1", + "user", + job.OperationSSHKeyAdd, + gomock.Any(), + ). + Return("", nil, assert.AnError) + }, + validateFunc: func(resp gen.PostNodeUserSshKeyResponseObject) { + _, ok := resp.(gen.PostNodeUserSshKey500JSONResponse) + s.True(ok) + }, + }, + { + name: "broadcast target _all", + request: gen.PostNodeUserSshKeyRequestObject{ + Hostname: "_all", + Name: "testuser", + Body: &gen.SSHKeyAddRequest{ + Key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITest user@host", + }, + }, + setupMock: func() { + s.mockJobClient.EXPECT(). + ModifyBroadcast( + gomock.Any(), + "_all", + "user", + job.OperationSSHKeyAdd, + gomock.Any(), + ). + Return("550e8400-e29b-41d4-a716-446655440000", + map[string]*job.Response{ + "server1": { + Hostname: "server1", + Status: job.StatusCompleted, + Changed: boolPtr(true), + Data: json.RawMessage(`{"changed":true}`), + }, + }, nil) + }, + validateFunc: func(resp gen.PostNodeUserSshKeyResponseObject) { + r, ok := resp.(gen.PostNodeUserSshKey200JSONResponse) + s.True(ok) + s.Len(r.Results, 1) + }, + }, + { + name: "broadcast with failed and skipped agents", + request: gen.PostNodeUserSshKeyRequestObject{ + Hostname: "_all", + Name: "testuser", + Body: &gen.SSHKeyAddRequest{ + Key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITest user@host", + }, + }, + setupMock: func() { + s.mockJobClient.EXPECT(). + ModifyBroadcast( + gomock.Any(), + "_all", + "user", + job.OperationSSHKeyAdd, + gomock.Any(), + ). + Return("550e8400-e29b-41d4-a716-446655440000", + map[string]*job.Response{ + "server1": { + Hostname: "server1", + Status: job.StatusCompleted, + Changed: boolPtr(true), + Data: json.RawMessage(`{"changed":true}`), + }, + "server2": { + Hostname: "server2", + Status: job.StatusFailed, + Error: "connection timeout", + }, + "server3": { + Hostname: "server3", + Status: job.StatusSkipped, + Error: "unsupported", + }, + }, nil) + }, + validateFunc: func(resp gen.PostNodeUserSshKeyResponseObject) { + r, ok := resp.(gen.PostNodeUserSshKey200JSONResponse) + s.True(ok) + s.Len(r.Results, 3) + + byHost := make(map[string]gen.SSHKeyMutationEntry) + for _, res := range r.Results { + byHost[res.Hostname] = res + } + + s.Equal(gen.SSHKeyMutationEntryStatusOk, byHost["server1"].Status) + s.Equal(gen.SSHKeyMutationEntryStatusFailed, byHost["server2"].Status) + s.Contains(*byHost["server2"].Error, "connection timeout") + s.Equal(gen.SSHKeyMutationEntryStatusSkipped, byHost["server3"].Status) + }, + }, + { + name: "broadcast job client error", + request: gen.PostNodeUserSshKeyRequestObject{ + Hostname: "_all", + Name: "testuser", + Body: &gen.SSHKeyAddRequest{ + Key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITest user@host", + }, + }, + setupMock: func() { + s.mockJobClient.EXPECT(). + ModifyBroadcast( + gomock.Any(), + "_all", + "user", + job.OperationSSHKeyAdd, + gomock.Any(), + ). + Return("", nil, assert.AnError) + }, + validateFunc: func(resp gen.PostNodeUserSshKeyResponseObject) { + _, ok := resp.(gen.PostNodeUserSshKey500JSONResponse) + s.True(ok) + }, + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + tt.setupMock() + resp, err := s.handler.PostNodeUserSshKey(s.ctx, tt.request) + s.NoError(err) + tt.validateFunc(resp) + }) + } +} + +func (s *SSHKeyCreatePublicTestSuite) TestPostNodeUserSshKeyValidationHTTP() { + tests := []struct { + name string + body string + wantCode int + }{ + { + name: "when valid request", + body: `{"key":"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITest user@host"}`, + wantCode: http.StatusOK, + }, + { + name: "when missing key", + body: `{}`, + wantCode: http.StatusBadRequest, + }, + } + + for _, tc := range tests { + s.Run(tc.name, func() { + jobMock := jobmocks.NewMockJobClient(s.mockCtrl) + if tc.wantCode == http.StatusOK { + jobMock.EXPECT(). + Modify( + gomock.Any(), + "server1", + "user", + job.OperationSSHKeyAdd, + gomock.Any(), + ). + Return("550e8400-e29b-41d4-a716-446655440000", &job.Response{ + Hostname: "agent1", + Changed: boolPtr(true), + Data: json.RawMessage(`{"changed":true}`), + }, nil) + } + + userHandler := apiuser.New(s.logger, jobMock) + strictHandler := gen.NewStrictHandler(userHandler, nil) + a := api.New(s.appConfig, s.logger) + gen.RegisterHandlers(a.Echo, strictHandler) + + req := httptest.NewRequest( + http.MethodPost, + "/node/server1/user/testuser/ssh-key", + strings.NewReader(tc.body), + ) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + a.Echo.ServeHTTP(rec, req) + + s.Equal(tc.wantCode, rec.Code) + }) + } +} + +const rbacSSHKeyCreateTestSigningKey = "test-signing-key-for-rbac-ssh-key-create" + +func (s *SSHKeyCreatePublicTestSuite) TestPostNodeUserSshKeyRBACHTTP() { + tokenManager := authtoken.New(s.logger) + + tests := []struct { + name string + setupAuth func(req *http.Request) + setupJobMock func() *jobmocks.MockJobClient + wantCode int + }{ + { + name: "when no token returns 401", + setupAuth: func(_ *http.Request) {}, + setupJobMock: func() *jobmocks.MockJobClient { + return jobmocks.NewMockJobClient(s.mockCtrl) + }, + wantCode: http.StatusUnauthorized, + }, + { + name: "when insufficient permissions returns 403", + setupAuth: func(req *http.Request) { + token, _ := tokenManager.Generate( + rbacSSHKeyCreateTestSigningKey, + []string{"read"}, + "test-user", + []string{"user:read"}, + ) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + }, + setupJobMock: func() *jobmocks.MockJobClient { + return jobmocks.NewMockJobClient(s.mockCtrl) + }, + wantCode: http.StatusForbidden, + }, + { + name: "when valid admin token returns 200", + setupAuth: func(req *http.Request) { + token, _ := tokenManager.Generate( + rbacSSHKeyCreateTestSigningKey, + []string{"admin"}, + "test-user", + nil, + ) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + }, + setupJobMock: func() *jobmocks.MockJobClient { + mock := jobmocks.NewMockJobClient(s.mockCtrl) + mock.EXPECT(). + Modify( + gomock.Any(), + "server1", + "user", + job.OperationSSHKeyAdd, + gomock.Any(), + ). + Return("550e8400-e29b-41d4-a716-446655440000", &job.Response{ + Hostname: "agent1", + Changed: boolPtr(true), + Data: json.RawMessage(`{"changed":true}`), + }, nil) + return mock + }, + wantCode: http.StatusOK, + }, + } + + for _, tc := range tests { + s.Run(tc.name, func() { + jobMock := tc.setupJobMock() + appConfig := config.Config{ + Controller: config.Controller{ + API: config.APIServer{ + Security: config.ServerSecurity{SigningKey: rbacSSHKeyCreateTestSigningKey}, + }, + }, + } + server := api.New(appConfig, s.logger) + handlers := apiuser.Handler( + s.logger, + jobMock, + appConfig.Controller.API.Security.SigningKey, + nil, + ) + server.RegisterHandlers(handlers) + + req := httptest.NewRequest( + http.MethodPost, + "/node/server1/user/testuser/ssh-key", + strings.NewReader(`{"key":"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITest user@host"}`), + ) + req.Header.Set("Content-Type", "application/json") + tc.setupAuth(req) + rec := httptest.NewRecorder() + server.Echo.ServeHTTP(rec, req) + + s.Equal(tc.wantCode, rec.Code) + }) + } +} + +func TestSSHKeyCreatePublicTestSuite(t *testing.T) { + suite.Run(t, new(SSHKeyCreatePublicTestSuite)) +} diff --git a/internal/controller/api/node/user/ssh_key_delete.go b/internal/controller/api/node/user/ssh_key_delete.go index bf1f5bba8..3bed27962 100644 --- a/internal/controller/api/node/user/ssh_key_delete.go +++ b/internal/controller/api/node/user/ssh_key_delete.go @@ -22,16 +22,130 @@ package user import ( "context" + "log/slog" + + "github.com/google/uuid" "github.com/retr0h/osapi/internal/controller/api/node/user/gen" + "github.com/retr0h/osapi/internal/job" ) // DeleteNodeUserSshKey removes an SSH authorized key by fingerprint for a user // on a target node. func (u *User) DeleteNodeUserSshKey( - _ context.Context, - _ gen.DeleteNodeUserSshKeyRequestObject, + ctx context.Context, + request gen.DeleteNodeUserSshKeyRequestObject, ) (gen.DeleteNodeUserSshKeyResponseObject, error) { - // TODO(Task 5): implement handler - panic("not implemented") + if errMsg, ok := validateHostname(request.Hostname); !ok { + return gen.DeleteNodeUserSshKey500JSONResponse{Error: &errMsg}, nil + } + + hostname := request.Hostname + username := request.Name + fingerprint := request.Fingerprint + + u.logger.Debug("ssh key remove", + slog.String("target", hostname), + slog.String("username", username), + slog.String("fingerprint", fingerprint), + slog.Bool("broadcast", job.IsBroadcastTarget(hostname)), + ) + + data := map[string]string{ + "username": username, + "fingerprint": fingerprint, + } + + if job.IsBroadcastTarget(hostname) { + return u.deleteNodeUserSshKeyBroadcast(ctx, hostname, data) + } + + jobID, resp, err := u.JobClient.Modify( + ctx, + hostname, + "user", + job.OperationSSHKeyRemove, + data, + ) + if err != nil { + errMsg := err.Error() + return gen.DeleteNodeUserSshKey500JSONResponse{Error: &errMsg}, nil + } + + if resp.Status == job.StatusSkipped { + jobUUID := uuid.MustParse(jobID) + e := resp.Error + return gen.DeleteNodeUserSshKey200JSONResponse{ + JobId: &jobUUID, + Results: []gen.SSHKeyMutationEntry{ + { + Hostname: resp.Hostname, + Status: gen.SSHKeyMutationEntryStatusSkipped, + Error: &e, + }, + }, + }, nil + } + + jobUUID := uuid.MustParse(jobID) + changed := resp.Changed + agentHostname := resp.Hostname + + return gen.DeleteNodeUserSshKey200JSONResponse{ + JobId: &jobUUID, + Results: []gen.SSHKeyMutationEntry{ + { + Hostname: agentHostname, + Status: gen.SSHKeyMutationEntryStatusOk, + Changed: changed, + }, + }, + }, nil +} + +// deleteNodeUserSshKeyBroadcast handles broadcast targets for SSH key remove. +func (u *User) deleteNodeUserSshKeyBroadcast( + ctx context.Context, + target string, + data map[string]string, +) (gen.DeleteNodeUserSshKeyResponseObject, error) { + jobID, responses, err := u.JobClient.ModifyBroadcast( + ctx, + target, + "user", + job.OperationSSHKeyRemove, + data, + ) + if err != nil { + errMsg := err.Error() + return gen.DeleteNodeUserSshKey500JSONResponse{Error: &errMsg}, nil + } + + var apiResponses []gen.SSHKeyMutationEntry + for host, resp := range responses { + item := gen.SSHKeyMutationEntry{ + Hostname: host, + } + switch resp.Status { + case job.StatusFailed: + item.Status = gen.SSHKeyMutationEntryStatusFailed + e := resp.Error + item.Error = &e + case job.StatusSkipped: + item.Status = gen.SSHKeyMutationEntryStatusSkipped + e := resp.Error + item.Error = &e + default: + item.Status = gen.SSHKeyMutationEntryStatusOk + item.Changed = resp.Changed + } + apiResponses = append(apiResponses, item) + } + + jobUUID := uuid.MustParse(jobID) + + return gen.DeleteNodeUserSshKey200JSONResponse{ + JobId: &jobUUID, + Results: apiResponses, + }, nil } diff --git a/internal/controller/api/node/user/ssh_key_delete_public_test.go b/internal/controller/api/node/user/ssh_key_delete_public_test.go new file mode 100644 index 000000000..9e4c826c5 --- /dev/null +++ b/internal/controller/api/node/user/ssh_key_delete_public_test.go @@ -0,0 +1,473 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package user_test + +import ( + "context" + "fmt" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" + + "github.com/retr0h/osapi/internal/authtoken" + "github.com/retr0h/osapi/internal/config" + "github.com/retr0h/osapi/internal/controller/api" + apiuser "github.com/retr0h/osapi/internal/controller/api/node/user" + "github.com/retr0h/osapi/internal/controller/api/node/user/gen" + "github.com/retr0h/osapi/internal/job" + jobmocks "github.com/retr0h/osapi/internal/job/mocks" + "github.com/retr0h/osapi/internal/validation" +) + +type SSHKeyDeletePublicTestSuite struct { + suite.Suite + + mockCtrl *gomock.Controller + mockJobClient *jobmocks.MockJobClient + handler *apiuser.User + ctx context.Context + appConfig config.Config + logger *slog.Logger +} + +func (s *SSHKeyDeletePublicTestSuite) SetupSuite() { + validation.RegisterTargetValidator(func(_ context.Context) ([]validation.AgentTarget, error) { + return []validation.AgentTarget{ + {Hostname: "server1", Labels: map[string]string{"group": "web"}}, + {Hostname: "server2"}, + }, nil + }) +} + +func (s *SSHKeyDeletePublicTestSuite) SetupTest() { + s.mockCtrl = gomock.NewController(s.T()) + s.mockJobClient = jobmocks.NewMockJobClient(s.mockCtrl) + s.handler = apiuser.New(slog.Default(), s.mockJobClient) + s.ctx = context.Background() + s.appConfig = config.Config{} + s.logger = slog.New(slog.NewTextHandler(os.Stdout, nil)) +} + +func (s *SSHKeyDeletePublicTestSuite) TearDownTest() { + s.mockCtrl.Finish() +} + +func (s *SSHKeyDeletePublicTestSuite) TestDeleteNodeUserSshKey() { + tests := []struct { + name string + request gen.DeleteNodeUserSshKeyRequestObject + setupMock func() + validateFunc func(resp gen.DeleteNodeUserSshKeyResponseObject) + }{ + { + name: "success", + request: gen.DeleteNodeUserSshKeyRequestObject{ + Hostname: "server1", + Name: "testuser", + Fingerprint: "SHA256:abc123", + }, + setupMock: func() { + s.mockJobClient.EXPECT(). + Modify( + gomock.Any(), + "server1", + "user", + job.OperationSSHKeyRemove, + map[string]string{ + "username": "testuser", + "fingerprint": "SHA256:abc123", + }, + ). + Return("550e8400-e29b-41d4-a716-446655440000", &job.Response{ + Hostname: "agent1", + Changed: boolPtr(true), + }, nil) + }, + validateFunc: func(resp gen.DeleteNodeUserSshKeyResponseObject) { + r, ok := resp.(gen.DeleteNodeUserSshKey200JSONResponse) + s.True(ok) + s.Require().Len(r.Results, 1) + s.Equal(gen.SSHKeyMutationEntryStatusOk, r.Results[0].Status) + s.Require().NotNil(r.Results[0].Changed) + s.True(*r.Results[0].Changed) + }, + }, + { + name: "when job skipped", + request: gen.DeleteNodeUserSshKeyRequestObject{ + Hostname: "server1", + Name: "testuser", + Fingerprint: "SHA256:abc123", + }, + setupMock: func() { + s.mockJobClient.EXPECT(). + Modify( + gomock.Any(), + "server1", + "user", + job.OperationSSHKeyRemove, + gomock.Any(), + ). + Return("550e8400-e29b-41d4-a716-446655440000", &job.Response{ + Status: job.StatusSkipped, + Hostname: "server1", + Error: "unsupported", + }, nil) + }, + validateFunc: func(resp gen.DeleteNodeUserSshKeyResponseObject) { + r, ok := resp.(gen.DeleteNodeUserSshKey200JSONResponse) + s.True(ok) + s.Equal(gen.SSHKeyMutationEntryStatusSkipped, r.Results[0].Status) + }, + }, + { + name: "job client error", + request: gen.DeleteNodeUserSshKeyRequestObject{ + Hostname: "server1", + Name: "testuser", + Fingerprint: "SHA256:abc123", + }, + setupMock: func() { + s.mockJobClient.EXPECT(). + Modify( + gomock.Any(), + "server1", + "user", + job.OperationSSHKeyRemove, + gomock.Any(), + ). + Return("", nil, assert.AnError) + }, + validateFunc: func(resp gen.DeleteNodeUserSshKeyResponseObject) { + _, ok := resp.(gen.DeleteNodeUserSshKey500JSONResponse) + s.True(ok) + }, + }, + { + name: "validation error empty hostname", + request: gen.DeleteNodeUserSshKeyRequestObject{ + Hostname: "", + Name: "testuser", + Fingerprint: "SHA256:abc123", + }, + setupMock: func() {}, + validateFunc: func(resp gen.DeleteNodeUserSshKeyResponseObject) { + r, ok := resp.(gen.DeleteNodeUserSshKey500JSONResponse) + s.True(ok) + s.Require().NotNil(r.Error) + s.Contains(*r.Error, "required") + }, + }, + { + name: "broadcast target _all", + request: gen.DeleteNodeUserSshKeyRequestObject{ + Hostname: "_all", + Name: "testuser", + Fingerprint: "SHA256:abc123", + }, + setupMock: func() { + s.mockJobClient.EXPECT(). + ModifyBroadcast( + gomock.Any(), + "_all", + "user", + job.OperationSSHKeyRemove, + gomock.Any(), + ). + Return("550e8400-e29b-41d4-a716-446655440000", + map[string]*job.Response{ + "server1": { + Hostname: "server1", + Status: job.StatusCompleted, + Changed: boolPtr(true), + }, + }, nil) + }, + validateFunc: func(resp gen.DeleteNodeUserSshKeyResponseObject) { + r, ok := resp.(gen.DeleteNodeUserSshKey200JSONResponse) + s.True(ok) + s.Len(r.Results, 1) + }, + }, + { + name: "broadcast with failed and skipped agents", + request: gen.DeleteNodeUserSshKeyRequestObject{ + Hostname: "_all", + Name: "testuser", + Fingerprint: "SHA256:abc123", + }, + setupMock: func() { + s.mockJobClient.EXPECT(). + ModifyBroadcast( + gomock.Any(), + "_all", + "user", + job.OperationSSHKeyRemove, + gomock.Any(), + ). + Return("550e8400-e29b-41d4-a716-446655440000", + map[string]*job.Response{ + "server1": { + Hostname: "server1", + Status: job.StatusCompleted, + Changed: boolPtr(true), + }, + "server2": { + Hostname: "server2", + Status: job.StatusFailed, + Error: "connection timeout", + }, + "server3": { + Hostname: "server3", + Status: job.StatusSkipped, + Error: "unsupported", + }, + }, nil) + }, + validateFunc: func(resp gen.DeleteNodeUserSshKeyResponseObject) { + r, ok := resp.(gen.DeleteNodeUserSshKey200JSONResponse) + s.True(ok) + s.Len(r.Results, 3) + + byHost := make(map[string]gen.SSHKeyMutationEntry) + for _, res := range r.Results { + byHost[res.Hostname] = res + } + + s.Equal(gen.SSHKeyMutationEntryStatusOk, byHost["server1"].Status) + s.Equal(gen.SSHKeyMutationEntryStatusFailed, byHost["server2"].Status) + s.Contains(*byHost["server2"].Error, "connection timeout") + s.Equal(gen.SSHKeyMutationEntryStatusSkipped, byHost["server3"].Status) + }, + }, + { + name: "broadcast job client error", + request: gen.DeleteNodeUserSshKeyRequestObject{ + Hostname: "_all", + Name: "testuser", + Fingerprint: "SHA256:abc123", + }, + setupMock: func() { + s.mockJobClient.EXPECT(). + ModifyBroadcast( + gomock.Any(), + "_all", + "user", + job.OperationSSHKeyRemove, + gomock.Any(), + ). + Return("", nil, assert.AnError) + }, + validateFunc: func(resp gen.DeleteNodeUserSshKeyResponseObject) { + _, ok := resp.(gen.DeleteNodeUserSshKey500JSONResponse) + s.True(ok) + }, + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + tt.setupMock() + resp, err := s.handler.DeleteNodeUserSshKey(s.ctx, tt.request) + s.NoError(err) + tt.validateFunc(resp) + }) + } +} + +func (s *SSHKeyDeletePublicTestSuite) TestDeleteNodeUserSshKeyValidationHTTP() { + tests := []struct { + name string + path string + setupJobMock func() *jobmocks.MockJobClient + wantCode int + wantContains []string + }{ + { + name: "when valid request", + path: "/node/server1/user/testuser/ssh-key/SHA256:abc123", + setupJobMock: func() *jobmocks.MockJobClient { + mock := jobmocks.NewMockJobClient(s.mockCtrl) + mock.EXPECT(). + Modify( + gomock.Any(), + "server1", + "user", + job.OperationSSHKeyRemove, + map[string]string{ + "username": "testuser", + "fingerprint": "SHA256:abc123", + }, + ). + Return("550e8400-e29b-41d4-a716-446655440000", &job.Response{ + Hostname: "agent1", + Changed: boolPtr(true), + }, nil) + return mock + }, + wantCode: http.StatusOK, + wantContains: []string{`"job_id"`, `"results"`}, + }, + { + name: "when target agent not found", + path: "/node/nonexistent/user/testuser/ssh-key/SHA256:abc123", + setupJobMock: func() *jobmocks.MockJobClient { + return jobmocks.NewMockJobClient(s.mockCtrl) + }, + wantCode: http.StatusInternalServerError, + wantContains: []string{`"error"`, "valid_target"}, + }, + } + + for _, tc := range tests { + s.Run(tc.name, func() { + jobMock := tc.setupJobMock() + + userHandler := apiuser.New(s.logger, jobMock) + strictHandler := gen.NewStrictHandler(userHandler, nil) + + a := api.New(s.appConfig, s.logger) + gen.RegisterHandlers(a.Echo, strictHandler) + + req := httptest.NewRequest(http.MethodDelete, tc.path, nil) + rec := httptest.NewRecorder() + + a.Echo.ServeHTTP(rec, req) + + s.Equal(tc.wantCode, rec.Code) + for _, str := range tc.wantContains { + s.Contains(rec.Body.String(), str) + } + }) + } +} + +const rbacSSHKeyDeleteTestSigningKey = "test-signing-key-for-rbac-ssh-key-delete" + +func (s *SSHKeyDeletePublicTestSuite) TestDeleteNodeUserSshKeyRBACHTTP() { + tokenManager := authtoken.New(s.logger) + + tests := []struct { + name string + setupAuth func(req *http.Request) + setupJobMock func() *jobmocks.MockJobClient + wantCode int + }{ + { + name: "when no token returns 401", + setupAuth: func(_ *http.Request) {}, + setupJobMock: func() *jobmocks.MockJobClient { + return jobmocks.NewMockJobClient(s.mockCtrl) + }, + wantCode: http.StatusUnauthorized, + }, + { + name: "when insufficient permissions returns 403", + setupAuth: func(req *http.Request) { + token, _ := tokenManager.Generate( + rbacSSHKeyDeleteTestSigningKey, + []string{"read"}, + "test-user", + []string{"user:read"}, + ) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + }, + setupJobMock: func() *jobmocks.MockJobClient { + return jobmocks.NewMockJobClient(s.mockCtrl) + }, + wantCode: http.StatusForbidden, + }, + { + name: "when valid admin token returns 200", + setupAuth: func(req *http.Request) { + token, _ := tokenManager.Generate( + rbacSSHKeyDeleteTestSigningKey, + []string{"admin"}, + "test-user", + nil, + ) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + }, + setupJobMock: func() *jobmocks.MockJobClient { + mock := jobmocks.NewMockJobClient(s.mockCtrl) + mock.EXPECT(). + Modify( + gomock.Any(), + "server1", + "user", + job.OperationSSHKeyRemove, + map[string]string{ + "username": "testuser", + "fingerprint": "SHA256:abc123", + }, + ). + Return("550e8400-e29b-41d4-a716-446655440000", &job.Response{ + Hostname: "agent1", + Changed: boolPtr(true), + }, nil) + return mock + }, + wantCode: http.StatusOK, + }, + } + + for _, tc := range tests { + s.Run(tc.name, func() { + jobMock := tc.setupJobMock() + appConfig := config.Config{ + Controller: config.Controller{ + API: config.APIServer{ + Security: config.ServerSecurity{SigningKey: rbacSSHKeyDeleteTestSigningKey}, + }, + }, + } + server := api.New(appConfig, s.logger) + handlers := apiuser.Handler( + s.logger, + jobMock, + appConfig.Controller.API.Security.SigningKey, + nil, + ) + server.RegisterHandlers(handlers) + + req := httptest.NewRequest( + http.MethodDelete, + "/node/server1/user/testuser/ssh-key/SHA256:abc123", + nil, + ) + tc.setupAuth(req) + rec := httptest.NewRecorder() + server.Echo.ServeHTTP(rec, req) + + s.Equal(tc.wantCode, rec.Code) + }) + } +} + +func TestSSHKeyDeletePublicTestSuite(t *testing.T) { + suite.Run(t, new(SSHKeyDeletePublicTestSuite)) +} diff --git a/internal/controller/api/node/user/ssh_key_list_get.go b/internal/controller/api/node/user/ssh_key_list_get.go index a65619227..bf3b25891 100644 --- a/internal/controller/api/node/user/ssh_key_list_get.go +++ b/internal/controller/api/node/user/ssh_key_list_get.go @@ -22,15 +22,167 @@ package user import ( "context" + "encoding/json" + "log/slog" + + "github.com/google/uuid" "github.com/retr0h/osapi/internal/controller/api/node/user/gen" + "github.com/retr0h/osapi/internal/job" + userProv "github.com/retr0h/osapi/internal/provider/node/user" ) // GetNodeUserSshKey lists SSH authorized keys for a user on a target node. func (u *User) GetNodeUserSshKey( - _ context.Context, - _ gen.GetNodeUserSshKeyRequestObject, + ctx context.Context, + request gen.GetNodeUserSshKeyRequestObject, ) (gen.GetNodeUserSshKeyResponseObject, error) { - // TODO(Task 5): implement handler - panic("not implemented") + if errMsg, ok := validateHostname(request.Hostname); !ok { + return gen.GetNodeUserSshKey500JSONResponse{Error: &errMsg}, nil + } + + hostname := request.Hostname + username := request.Name + + u.logger.Debug("ssh key list", + slog.String("target", hostname), + slog.String("username", username), + slog.Bool("broadcast", job.IsBroadcastTarget(hostname)), + ) + + data := map[string]string{ + "username": username, + } + + if job.IsBroadcastTarget(hostname) { + return u.getNodeUserSshKeyBroadcast(ctx, hostname, data) + } + + jobID, resp, err := u.JobClient.Query( + ctx, + hostname, + "user", + job.OperationSSHKeyList, + data, + ) + if err != nil { + errMsg := err.Error() + return gen.GetNodeUserSshKey500JSONResponse{Error: &errMsg}, nil + } + + if resp.Status == job.StatusSkipped { + e := resp.Error + jobUUID := uuid.MustParse(jobID) + return gen.GetNodeUserSshKey200JSONResponse{ + JobId: &jobUUID, + Results: []gen.SSHKeyEntry{ + { + Hostname: resp.Hostname, + Status: gen.SSHKeyEntryStatusSkipped, + Error: &e, + }, + }, + }, nil + } + + results := sshKeyInfoListFromResponse(resp) + jobUUID := uuid.MustParse(jobID) + + return gen.GetNodeUserSshKey200JSONResponse{ + JobId: &jobUUID, + Results: results, + }, nil +} + +// getNodeUserSshKeyBroadcast handles broadcast targets for SSH key list. +func (u *User) getNodeUserSshKeyBroadcast( + ctx context.Context, + target string, + data map[string]string, +) (gen.GetNodeUserSshKeyResponseObject, error) { + jobID, responses, err := u.JobClient.QueryBroadcast( + ctx, + target, + "user", + job.OperationSSHKeyList, + data, + ) + if err != nil { + errMsg := err.Error() + return gen.GetNodeUserSshKey500JSONResponse{Error: &errMsg}, nil + } + + allResults := make([]gen.SSHKeyEntry, 0) + for host, resp := range responses { + switch resp.Status { + case job.StatusFailed: + e := resp.Error + allResults = append(allResults, gen.SSHKeyEntry{ + Hostname: host, + Status: gen.SSHKeyEntryStatusFailed, + Error: &e, + }) + case job.StatusSkipped: + e := resp.Error + allResults = append(allResults, gen.SSHKeyEntry{ + Hostname: host, + Status: gen.SSHKeyEntryStatusSkipped, + Error: &e, + }) + default: + allResults = append(allResults, sshKeyInfoListFromResponse(resp)...) + } + } + + jobUUID := uuid.MustParse(jobID) + + return gen.GetNodeUserSshKey200JSONResponse{ + JobId: &jobUUID, + Results: allResults, + }, nil +} + +// sshKeyInfoListFromResponse converts a job response to gen SSHKeyEntry slice. +func sshKeyInfoListFromResponse( + resp *job.Response, +) []gen.SSHKeyEntry { + var keys []userProv.SSHKey + if resp.Data != nil { + _ = json.Unmarshal(resp.Data, &keys) + } + + hostname := resp.Hostname + + keyInfos := make([]gen.SSHKeyInfo, 0, len(keys)) + for _, k := range keys { + keyInfos = append(keyInfos, sshKeyInfoToGen(k)) + } + + return []gen.SSHKeyEntry{ + { + Hostname: hostname, + Status: gen.SSHKeyEntryStatusOk, + Keys: &keyInfos, + }, + } +} + +// sshKeyInfoToGen converts a provider SSHKey to a gen SSHKeyInfo. +func sshKeyInfoToGen( + k userProv.SSHKey, +) gen.SSHKeyInfo { + keyType := k.Type + fingerprint := k.Fingerprint + + info := gen.SSHKeyInfo{ + Type: &keyType, + Fingerprint: &fingerprint, + } + + if k.Comment != "" { + comment := k.Comment + info.Comment = &comment + } + + return info } diff --git a/internal/controller/api/node/user/ssh_key_list_get_public_test.go b/internal/controller/api/node/user/ssh_key_list_get_public_test.go new file mode 100644 index 000000000..1a9fa44b7 --- /dev/null +++ b/internal/controller/api/node/user/ssh_key_list_get_public_test.go @@ -0,0 +1,541 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package user_test + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" + + "github.com/retr0h/osapi/internal/authtoken" + "github.com/retr0h/osapi/internal/config" + "github.com/retr0h/osapi/internal/controller/api" + apiuser "github.com/retr0h/osapi/internal/controller/api/node/user" + "github.com/retr0h/osapi/internal/controller/api/node/user/gen" + "github.com/retr0h/osapi/internal/job" + jobmocks "github.com/retr0h/osapi/internal/job/mocks" + "github.com/retr0h/osapi/internal/validation" +) + +type SSHKeyListGetPublicTestSuite struct { + suite.Suite + + mockCtrl *gomock.Controller + mockJobClient *jobmocks.MockJobClient + handler *apiuser.User + ctx context.Context + appConfig config.Config + logger *slog.Logger +} + +func (s *SSHKeyListGetPublicTestSuite) SetupSuite() { + validation.RegisterTargetValidator(func(_ context.Context) ([]validation.AgentTarget, error) { + return []validation.AgentTarget{ + {Hostname: "server1", Labels: map[string]string{"group": "web"}}, + {Hostname: "server2"}, + }, nil + }) +} + +func (s *SSHKeyListGetPublicTestSuite) SetupTest() { + s.mockCtrl = gomock.NewController(s.T()) + s.mockJobClient = jobmocks.NewMockJobClient(s.mockCtrl) + s.handler = apiuser.New(slog.Default(), s.mockJobClient) + s.ctx = context.Background() + s.appConfig = config.Config{} + s.logger = slog.New(slog.NewTextHandler(os.Stdout, nil)) +} + +func (s *SSHKeyListGetPublicTestSuite) TearDownTest() { + s.mockCtrl.Finish() +} + +func (s *SSHKeyListGetPublicTestSuite) TestGetNodeUserSshKey() { + tests := []struct { + name string + request gen.GetNodeUserSshKeyRequestObject + setupMock func() + validateFunc func(resp gen.GetNodeUserSshKeyResponseObject) + }{ + { + name: "success with keys", + request: gen.GetNodeUserSshKeyRequestObject{ + Hostname: "server1", + Name: "testuser", + }, + setupMock: func() { + s.mockJobClient.EXPECT(). + Query( + gomock.Any(), + "server1", + "user", + job.OperationSSHKeyList, + map[string]string{"username": "testuser"}, + ). + Return("550e8400-e29b-41d4-a716-446655440000", &job.Response{ + Hostname: "agent1", + Data: json.RawMessage( + `[{"type":"ssh-ed25519","fingerprint":"SHA256:abc123","comment":"user@host"}]`, + ), + }, nil) + }, + validateFunc: func(resp gen.GetNodeUserSshKeyResponseObject) { + r, ok := resp.(gen.GetNodeUserSshKey200JSONResponse) + s.True(ok) + s.Require().NotNil(r.JobId) + s.Require().Len(r.Results, 1) + s.Require().NotNil(r.Results[0].Keys) + s.Len(*r.Results[0].Keys, 1) + s.Equal("ssh-ed25519", *(*r.Results[0].Keys)[0].Type) + s.Equal("SHA256:abc123", *(*r.Results[0].Keys)[0].Fingerprint) + s.Equal("user@host", *(*r.Results[0].Keys)[0].Comment) + }, + }, + { + name: "success with key without comment", + request: gen.GetNodeUserSshKeyRequestObject{ + Hostname: "server1", + Name: "testuser", + }, + setupMock: func() { + s.mockJobClient.EXPECT(). + Query( + gomock.Any(), + "server1", + "user", + job.OperationSSHKeyList, + map[string]string{"username": "testuser"}, + ). + Return("550e8400-e29b-41d4-a716-446655440000", &job.Response{ + Hostname: "agent1", + Data: json.RawMessage( + `[{"type":"ssh-rsa","fingerprint":"SHA256:xyz789"}]`, + ), + }, nil) + }, + validateFunc: func(resp gen.GetNodeUserSshKeyResponseObject) { + r, ok := resp.(gen.GetNodeUserSshKey200JSONResponse) + s.True(ok) + s.Require().Len(r.Results, 1) + s.Require().NotNil(r.Results[0].Keys) + keys := *r.Results[0].Keys + s.Require().Len(keys, 1) + s.Nil(keys[0].Comment) + }, + }, + { + name: "success with nil response data", + request: gen.GetNodeUserSshKeyRequestObject{ + Hostname: "server1", + Name: "testuser", + }, + setupMock: func() { + s.mockJobClient.EXPECT(). + Query( + gomock.Any(), + "server1", + "user", + job.OperationSSHKeyList, + map[string]string{"username": "testuser"}, + ). + Return("550e8400-e29b-41d4-a716-446655440000", &job.Response{ + Hostname: "agent1", + Data: nil, + }, nil) + }, + validateFunc: func(resp gen.GetNodeUserSshKeyResponseObject) { + r, ok := resp.(gen.GetNodeUserSshKey200JSONResponse) + s.True(ok) + s.Require().NotNil(r.JobId) + s.Require().Len(r.Results, 1) + }, + }, + { + name: "when job skipped", + request: gen.GetNodeUserSshKeyRequestObject{ + Hostname: "server1", + Name: "testuser", + }, + setupMock: func() { + s.mockJobClient.EXPECT(). + Query( + gomock.Any(), + "server1", + "user", + job.OperationSSHKeyList, + map[string]string{"username": "testuser"}, + ). + Return("550e8400-e29b-41d4-a716-446655440000", &job.Response{ + Status: job.StatusSkipped, + Hostname: "server1", + Error: "ssh key: operation not supported on this OS family", + }, nil) + }, + validateFunc: func(resp gen.GetNodeUserSshKeyResponseObject) { + r, ok := resp.(gen.GetNodeUserSshKey200JSONResponse) + s.True(ok) + s.Require().Len(r.Results, 1) + s.Equal(gen.SSHKeyEntryStatusSkipped, r.Results[0].Status) + s.Contains(*r.Results[0].Error, "not supported") + }, + }, + { + name: "job client error", + request: gen.GetNodeUserSshKeyRequestObject{ + Hostname: "server1", + Name: "testuser", + }, + setupMock: func() { + s.mockJobClient.EXPECT(). + Query( + gomock.Any(), + "server1", + "user", + job.OperationSSHKeyList, + map[string]string{"username": "testuser"}, + ). + Return("", nil, assert.AnError) + }, + validateFunc: func(resp gen.GetNodeUserSshKeyResponseObject) { + _, ok := resp.(gen.GetNodeUserSshKey500JSONResponse) + s.True(ok) + }, + }, + { + name: "validation error empty hostname", + request: gen.GetNodeUserSshKeyRequestObject{ + Hostname: "", + Name: "testuser", + }, + setupMock: func() {}, + validateFunc: func(resp gen.GetNodeUserSshKeyResponseObject) { + r, ok := resp.(gen.GetNodeUserSshKey500JSONResponse) + s.True(ok) + s.Require().NotNil(r.Error) + s.Contains(*r.Error, "required") + }, + }, + { + name: "broadcast target _all", + request: gen.GetNodeUserSshKeyRequestObject{ + Hostname: "_all", + Name: "testuser", + }, + setupMock: func() { + s.mockJobClient.EXPECT(). + QueryBroadcast( + gomock.Any(), + "_all", + "user", + job.OperationSSHKeyList, + map[string]string{"username": "testuser"}, + ). + Return( + "550e8400-e29b-41d4-a716-446655440000", + map[string]*job.Response{ + "server1": { + Hostname: "server1", + Status: job.StatusCompleted, + Data: json.RawMessage( + `[{"type":"ssh-ed25519","fingerprint":"SHA256:abc123","comment":"user@host"}]`, + ), + }, + }, + nil, + ) + }, + validateFunc: func(resp gen.GetNodeUserSshKeyResponseObject) { + r, ok := resp.(gen.GetNodeUserSshKey200JSONResponse) + s.True(ok) + s.Require().NotNil(r.JobId) + s.Len(r.Results, 1) + }, + }, + { + name: "broadcast includes failed and skipped", + request: gen.GetNodeUserSshKeyRequestObject{ + Hostname: "_all", + Name: "testuser", + }, + setupMock: func() { + s.mockJobClient.EXPECT(). + QueryBroadcast( + gomock.Any(), + "_all", + "user", + job.OperationSSHKeyList, + map[string]string{"username": "testuser"}, + ). + Return( + "550e8400-e29b-41d4-a716-446655440000", + map[string]*job.Response{ + "server1": { + Hostname: "server1", + Status: job.StatusCompleted, + Data: json.RawMessage( + `[{"type":"ssh-ed25519","fingerprint":"SHA256:abc123"}]`, + ), + }, + "server2": { + Hostname: "server2", + Status: job.StatusFailed, + Error: "connection timeout", + }, + "server3": { + Hostname: "server3", + Status: job.StatusSkipped, + Error: "unsupported", + }, + }, + nil, + ) + }, + validateFunc: func(resp gen.GetNodeUserSshKeyResponseObject) { + r, ok := resp.(gen.GetNodeUserSshKey200JSONResponse) + s.True(ok) + s.Len(r.Results, 3) + }, + }, + { + name: "broadcast job client error", + request: gen.GetNodeUserSshKeyRequestObject{ + Hostname: "_all", + Name: "testuser", + }, + setupMock: func() { + s.mockJobClient.EXPECT(). + QueryBroadcast( + gomock.Any(), + "_all", + "user", + job.OperationSSHKeyList, + map[string]string{"username": "testuser"}, + ). + Return("", nil, assert.AnError) + }, + validateFunc: func(resp gen.GetNodeUserSshKeyResponseObject) { + _, ok := resp.(gen.GetNodeUserSshKey500JSONResponse) + s.True(ok) + }, + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + tt.setupMock() + + resp, err := s.handler.GetNodeUserSshKey(s.ctx, tt.request) + s.NoError(err) + tt.validateFunc(resp) + }) + } +} + +func (s *SSHKeyListGetPublicTestSuite) TestGetNodeUserSshKeyValidationHTTP() { + tests := []struct { + name string + path string + setupJobMock func() *jobmocks.MockJobClient + wantCode int + wantContains []string + }{ + { + name: "when valid request", + path: "/node/server1/user/testuser/ssh-key", + setupJobMock: func() *jobmocks.MockJobClient { + mock := jobmocks.NewMockJobClient(s.mockCtrl) + mock.EXPECT(). + Query( + gomock.Any(), + "server1", + "user", + job.OperationSSHKeyList, + map[string]string{"username": "testuser"}, + ). + Return("550e8400-e29b-41d4-a716-446655440000", &job.Response{ + Hostname: "agent1", + Data: json.RawMessage(`[]`), + }, nil) + return mock + }, + wantCode: http.StatusOK, + wantContains: []string{`"job_id"`, `"results"`}, + }, + { + name: "when target agent not found", + path: "/node/nonexistent/user/testuser/ssh-key", + setupJobMock: func() *jobmocks.MockJobClient { + return jobmocks.NewMockJobClient(s.mockCtrl) + }, + wantCode: http.StatusInternalServerError, + wantContains: []string{`"error"`, "valid_target"}, + }, + } + + for _, tc := range tests { + s.Run(tc.name, func() { + jobMock := tc.setupJobMock() + + userHandler := apiuser.New(s.logger, jobMock) + strictHandler := gen.NewStrictHandler(userHandler, nil) + + a := api.New(s.appConfig, s.logger) + gen.RegisterHandlers(a.Echo, strictHandler) + + req := httptest.NewRequest(http.MethodGet, tc.path, nil) + rec := httptest.NewRecorder() + + a.Echo.ServeHTTP(rec, req) + + s.Equal(tc.wantCode, rec.Code) + for _, str := range tc.wantContains { + s.Contains(rec.Body.String(), str) + } + }) + } +} + +const rbacSSHKeyListTestSigningKey = "test-signing-key-for-rbac-ssh-key-list" + +func (s *SSHKeyListGetPublicTestSuite) TestGetNodeUserSshKeyRBACHTTP() { + tokenManager := authtoken.New(s.logger) + + tests := []struct { + name string + setupAuth func(req *http.Request) + setupJobMock func() *jobmocks.MockJobClient + wantCode int + wantContains []string + }{ + { + name: "when no token returns 401", + setupAuth: func(_ *http.Request) { + }, + setupJobMock: func() *jobmocks.MockJobClient { + return jobmocks.NewMockJobClient(s.mockCtrl) + }, + wantCode: http.StatusUnauthorized, + wantContains: []string{"Bearer token required"}, + }, + { + name: "when insufficient permissions returns 403", + setupAuth: func(req *http.Request) { + token, err := tokenManager.Generate( + rbacSSHKeyListTestSigningKey, + []string{"write"}, + "test-user", + []string{"node:write"}, + ) + s.Require().NoError(err) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + }, + setupJobMock: func() *jobmocks.MockJobClient { + return jobmocks.NewMockJobClient(s.mockCtrl) + }, + wantCode: http.StatusForbidden, + wantContains: []string{"Insufficient permissions"}, + }, + { + name: "when valid admin token returns 200", + setupAuth: func(req *http.Request) { + token, err := tokenManager.Generate( + rbacSSHKeyListTestSigningKey, + []string{"admin"}, + "test-user", + nil, + ) + s.Require().NoError(err) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + }, + setupJobMock: func() *jobmocks.MockJobClient { + mock := jobmocks.NewMockJobClient(s.mockCtrl) + mock.EXPECT(). + Query( + gomock.Any(), + "server1", + "user", + job.OperationSSHKeyList, + map[string]string{"username": "testuser"}, + ). + Return("550e8400-e29b-41d4-a716-446655440000", &job.Response{ + Hostname: "agent1", + Data: json.RawMessage(`[]`), + }, nil) + return mock + }, + wantCode: http.StatusOK, + wantContains: []string{`"job_id"`, `"results"`}, + }, + } + + for _, tc := range tests { + s.Run(tc.name, func() { + jobMock := tc.setupJobMock() + + appConfig := config.Config{ + Controller: config.Controller{ + API: config.APIServer{ + Security: config.ServerSecurity{ + SigningKey: rbacSSHKeyListTestSigningKey, + }, + }, + }, + } + + server := api.New(appConfig, s.logger) + handlers := apiuser.Handler( + s.logger, + jobMock, + appConfig.Controller.API.Security.SigningKey, + nil, + ) + server.RegisterHandlers(handlers) + + req := httptest.NewRequest( + http.MethodGet, + "/node/server1/user/testuser/ssh-key", + nil, + ) + tc.setupAuth(req) + rec := httptest.NewRecorder() + + server.Echo.ServeHTTP(rec, req) + + s.Equal(tc.wantCode, rec.Code) + for _, str := range tc.wantContains { + s.Contains(rec.Body.String(), str) + } + }) + } +} + +func TestSSHKeyListGetPublicTestSuite(t *testing.T) { + suite.Run(t, new(SSHKeyListGetPublicTestSuite)) +} From 2b352e0a5264fc6717f2335fee76a39ffbe8c0ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Wed, 1 Apr 2026 11:53:51 -0700 Subject: [PATCH 07/11] feat(user): add SSH key SDK methods with tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add ListKeys, AddKey, and RemoveKey methods to UserService with SSH key types, gen-to-SDK conversion functions, export bridges, and full httptest coverage for all status code paths. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- pkg/sdk/client/export_test.go | 40 ++ pkg/sdk/client/user.go | 100 ++++ pkg/sdk/client/user_ssh_key_public_test.go | 520 ++++++++++++++++++ .../client/user_ssh_key_types_public_test.go | 381 +++++++++++++ pkg/sdk/client/user_types.go | 107 ++++ 5 files changed, 1148 insertions(+) create mode 100644 pkg/sdk/client/user_ssh_key_public_test.go create mode 100644 pkg/sdk/client/user_ssh_key_types_public_test.go diff --git a/pkg/sdk/client/export_test.go b/pkg/sdk/client/export_test.go index dc3c7cecf..176704a8b 100644 --- a/pkg/sdk/client/export_test.go +++ b/pkg/sdk/client/export_test.go @@ -550,6 +550,46 @@ func UserMutationCollectionFromPassword( return userMutationCollectionFromPassword(input) } +// SSHKeyCollectionFromGen exposes the private +// sshKeyCollectionFromGen for testing. +func SSHKeyCollectionFromGen( + input *gen.SSHKeyCollectionResponse, +) Collection[SSHKeyInfoResult] { + return sshKeyCollectionFromGen(input) +} + +// SSHKeyInfoResultFromGen exposes the private +// sshKeyInfoResultFromGen for testing. +func SSHKeyInfoResultFromGen( + input gen.SSHKeyEntry, +) SSHKeyInfoResult { + return sshKeyInfoResultFromGen(input) +} + +// SSHKeyInfoFromGen exposes the private +// sshKeyInfoFromGen for testing. +func SSHKeyInfoFromGen( + input gen.SSHKeyInfo, +) SSHKeyInfo { + return sshKeyInfoFromGen(input) +} + +// SSHKeyMutationCollectionFromGen exposes the private +// sshKeyMutationCollectionFromGen for testing. +func SSHKeyMutationCollectionFromGen( + input *gen.SSHKeyMutationResponse, +) Collection[SSHKeyMutationResult] { + return sshKeyMutationCollectionFromGen(input) +} + +// SSHKeyMutationResultFromGen exposes the private +// sshKeyMutationResultFromGen for testing. +func SSHKeyMutationResultFromGen( + input gen.SSHKeyMutationEntry, +) SSHKeyMutationResult { + return sshKeyMutationResultFromGen(input) +} + // GroupInfoCollectionFromList exposes the private // groupInfoCollectionFromList for testing. func GroupInfoCollectionFromList( diff --git a/pkg/sdk/client/user.go b/pkg/sdk/client/user.go index 577c7651f..3e052c286 100644 --- a/pkg/sdk/client/user.go +++ b/pkg/sdk/client/user.go @@ -237,6 +237,106 @@ func (s *UserService) Delete( return NewResponse(userMutationCollectionFromDelete(resp.JSON200), resp.Body), nil } +// ListKeys returns SSH authorized keys for a user on the target host. +func (s *UserService) ListKeys( + ctx context.Context, + hostname string, + username string, +) (*Response[Collection[SSHKeyInfoResult]], error) { + resp, err := s.client.GetNodeUserSshKeyWithResponse(ctx, hostname, username) + if err != nil { + return nil, fmt.Errorf("user list keys: %w", err) + } + + if err := checkError( + resp.StatusCode(), + resp.JSON401, + resp.JSON403, + resp.JSON500, + ); err != nil { + return nil, err + } + + if resp.JSON200 == nil { + return nil, &UnexpectedStatusError{APIError{ + StatusCode: resp.StatusCode(), + Message: "nil response body", + }} + } + + return NewResponse(sshKeyCollectionFromGen(resp.JSON200), resp.Body), nil +} + +// AddKey adds an SSH authorized key for a user on the target host. +func (s *UserService) AddKey( + ctx context.Context, + hostname string, + username string, + opts SSHKeyAddOpts, +) (*Response[Collection[SSHKeyMutationResult]], error) { + body := gen.SSHKeyAddRequest{ + Key: opts.Key, + } + + resp, err := s.client.PostNodeUserSshKeyWithResponse(ctx, hostname, username, body) + if err != nil { + return nil, fmt.Errorf("user add key: %w", err) + } + + if err := checkError( + resp.StatusCode(), + resp.JSON400, + resp.JSON401, + resp.JSON403, + resp.JSON500, + ); err != nil { + return nil, err + } + + if resp.JSON200 == nil { + return nil, &UnexpectedStatusError{APIError{ + StatusCode: resp.StatusCode(), + Message: "nil response body", + }} + } + + return NewResponse(sshKeyMutationCollectionFromGen(resp.JSON200), resp.Body), nil +} + +// RemoveKey removes an SSH authorized key by fingerprint for a user on the +// target host. +func (s *UserService) RemoveKey( + ctx context.Context, + hostname string, + username string, + fingerprint string, +) (*Response[Collection[SSHKeyMutationResult]], error) { + resp, err := s.client.DeleteNodeUserSshKeyWithResponse( + ctx, hostname, username, fingerprint, + ) + if err != nil { + return nil, fmt.Errorf("user remove key: %w", err) + } + + if err := checkError( + resp.StatusCode(), + resp.JSON401, + resp.JSON403, + resp.JSON500, + ); err != nil { + return nil, err + } + + if resp.JSON200 == nil { + return nil, &UnexpectedStatusError{APIError{ + StatusCode: resp.StatusCode(), + Message: "nil response body", + }} + } + + return NewResponse(sshKeyMutationCollectionFromGen(resp.JSON200), resp.Body), nil +} + // ChangePassword changes a user's password on the target host. func (s *UserService) ChangePassword( ctx context.Context, diff --git a/pkg/sdk/client/user_ssh_key_public_test.go b/pkg/sdk/client/user_ssh_key_public_test.go new file mode 100644 index 000000000..6e5b7c282 --- /dev/null +++ b/pkg/sdk/client/user_ssh_key_public_test.go @@ -0,0 +1,520 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package client_test + +import ( + "context" + "errors" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/suite" + + "github.com/retr0h/osapi/pkg/sdk/client" +) + +type UserSSHKeyPublicTestSuite struct { + suite.Suite + + ctx context.Context +} + +func (suite *UserSSHKeyPublicTestSuite) SetupTest() { + suite.ctx = context.Background() +} + +func (suite *UserSSHKeyPublicTestSuite) TestListKeys() { + tests := []struct { + name string + handler http.HandlerFunc + serverURL string + validateFunc func(*client.Response[client.Collection[client.SSHKeyInfoResult]], error) + }{ + { + name: "when listing keys returns results", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write( + []byte( + `{"job_id":"00000000-0000-0000-0000-000000000001","results":[{"hostname":"agent1","status":"ok","keys":[{"type":"ssh-ed25519","fingerprint":"SHA256:abc123","comment":"user@host"}]}]}`, + ), + ) + }, + validateFunc: func( + resp *client.Response[client.Collection[client.SSHKeyInfoResult]], + err error, + ) { + suite.NoError(err) + suite.NotNil(resp) + suite.Equal("00000000-0000-0000-0000-000000000001", resp.Data.JobID) + suite.Len(resp.Data.Results, 1) + suite.Equal("agent1", resp.Data.Results[0].Hostname) + suite.Equal("ok", resp.Data.Results[0].Status) + suite.Require().Len(resp.Data.Results[0].Keys, 1) + suite.Equal("ssh-ed25519", resp.Data.Results[0].Keys[0].Type) + suite.Equal("SHA256:abc123", resp.Data.Results[0].Keys[0].Fingerprint) + suite.Equal("user@host", resp.Data.Results[0].Keys[0].Comment) + }, + }, + { + name: "when server returns 403 returns AuthError", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"error":"forbidden"}`)) + }, + validateFunc: func( + resp *client.Response[client.Collection[client.SSHKeyInfoResult]], + err error, + ) { + suite.Error(err) + suite.Nil(resp) + + var target *client.AuthError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusForbidden, target.StatusCode) + }, + }, + { + name: "when server returns 401 returns AuthError", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":"unauthorized"}`)) + }, + validateFunc: func( + resp *client.Response[client.Collection[client.SSHKeyInfoResult]], + err error, + ) { + suite.Error(err) + suite.Nil(resp) + + var target *client.AuthError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusUnauthorized, target.StatusCode) + }, + }, + { + name: "when server returns 500 returns ServerError", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"error":"internal error"}`)) + }, + validateFunc: func( + resp *client.Response[client.Collection[client.SSHKeyInfoResult]], + err error, + ) { + suite.Error(err) + suite.Nil(resp) + + var target *client.ServerError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusInternalServerError, target.StatusCode) + }, + }, + { + name: "when client HTTP call fails returns error", + serverURL: "http://127.0.0.1:0", + validateFunc: func( + resp *client.Response[client.Collection[client.SSHKeyInfoResult]], + err error, + ) { + suite.Error(err) + suite.Nil(resp) + suite.Contains(err.Error(), "user list keys") + }, + }, + { + name: "when server returns 200 with no JSON body returns UnexpectedStatusError", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }, + validateFunc: func( + resp *client.Response[client.Collection[client.SSHKeyInfoResult]], + err error, + ) { + suite.Error(err) + suite.Nil(resp) + + var target *client.UnexpectedStatusError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusOK, target.StatusCode) + suite.Equal("nil response body", target.Message) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + var ( + serverURL string + cleanup func() + ) + + if tc.serverURL != "" { + serverURL = tc.serverURL + cleanup = func() {} + } else { + server := httptest.NewServer(tc.handler) + serverURL = server.URL + cleanup = server.Close + } + defer cleanup() + + sut := client.New( + serverURL, + "test-token", + client.WithLogger(slog.Default()), + ) + + resp, err := sut.User.ListKeys(suite.ctx, "_any", "testuser") + tc.validateFunc(resp, err) + }) + } +} + +func (suite *UserSSHKeyPublicTestSuite) TestAddKey() { + tests := []struct { + name string + handler http.HandlerFunc + serverURL string + opts client.SSHKeyAddOpts + validateFunc func(*client.Response[client.Collection[client.SSHKeyMutationResult]], error) + }{ + { + name: "when adding key returns result", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write( + []byte( + `{"job_id":"00000000-0000-0000-0000-000000000001","results":[{"hostname":"agent1","status":"ok","changed":true}]}`, + ), + ) + }, + opts: client.SSHKeyAddOpts{ + Key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITest user@host", + }, + validateFunc: func( + resp *client.Response[client.Collection[client.SSHKeyMutationResult]], + err error, + ) { + suite.NoError(err) + suite.NotNil(resp) + suite.Equal("00000000-0000-0000-0000-000000000001", resp.Data.JobID) + suite.Len(resp.Data.Results, 1) + suite.Equal("agent1", resp.Data.Results[0].Hostname) + suite.Equal("ok", resp.Data.Results[0].Status) + suite.True(resp.Data.Results[0].Changed) + }, + }, + { + name: "when server returns 400 returns ValidationError", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"validation failed"}`)) + }, + opts: client.SSHKeyAddOpts{Key: ""}, + validateFunc: func( + resp *client.Response[client.Collection[client.SSHKeyMutationResult]], + err error, + ) { + suite.Error(err) + suite.Nil(resp) + + var target *client.ValidationError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusBadRequest, target.StatusCode) + }, + }, + { + name: "when server returns 401 returns AuthError", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":"unauthorized"}`)) + }, + opts: client.SSHKeyAddOpts{Key: "ssh-ed25519 AAAA test"}, + validateFunc: func( + resp *client.Response[client.Collection[client.SSHKeyMutationResult]], + err error, + ) { + suite.Error(err) + suite.Nil(resp) + + var target *client.AuthError + suite.True(errors.As(err, &target)) + }, + }, + { + name: "when server returns 403 returns AuthError", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"error":"forbidden"}`)) + }, + opts: client.SSHKeyAddOpts{Key: "ssh-ed25519 AAAA test"}, + validateFunc: func( + resp *client.Response[client.Collection[client.SSHKeyMutationResult]], + err error, + ) { + suite.Error(err) + suite.Nil(resp) + + var target *client.AuthError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusForbidden, target.StatusCode) + }, + }, + { + name: "when server returns 500 returns ServerError", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"error":"internal error"}`)) + }, + opts: client.SSHKeyAddOpts{Key: "ssh-ed25519 AAAA test"}, + validateFunc: func( + resp *client.Response[client.Collection[client.SSHKeyMutationResult]], + err error, + ) { + suite.Error(err) + suite.Nil(resp) + + var target *client.ServerError + suite.True(errors.As(err, &target)) + }, + }, + { + name: "when client HTTP call fails returns error", + serverURL: "http://127.0.0.1:0", + opts: client.SSHKeyAddOpts{Key: "ssh-ed25519 AAAA test"}, + validateFunc: func( + resp *client.Response[client.Collection[client.SSHKeyMutationResult]], + err error, + ) { + suite.Error(err) + suite.Nil(resp) + suite.Contains(err.Error(), "user add key") + }, + }, + { + name: "when server returns 200 with no JSON body returns UnexpectedStatusError", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }, + opts: client.SSHKeyAddOpts{Key: "ssh-ed25519 AAAA test"}, + validateFunc: func( + resp *client.Response[client.Collection[client.SSHKeyMutationResult]], + err error, + ) { + suite.Error(err) + suite.Nil(resp) + + var target *client.UnexpectedStatusError + suite.True(errors.As(err, &target)) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + var ( + serverURL string + cleanup func() + ) + + if tc.serverURL != "" { + serverURL = tc.serverURL + cleanup = func() {} + } else { + server := httptest.NewServer(tc.handler) + serverURL = server.URL + cleanup = server.Close + } + defer cleanup() + + sut := client.New( + serverURL, + "test-token", + client.WithLogger(slog.Default()), + ) + + resp, err := sut.User.AddKey(suite.ctx, "_any", "testuser", tc.opts) + tc.validateFunc(resp, err) + }) + } +} + +func (suite *UserSSHKeyPublicTestSuite) TestRemoveKey() { + tests := []struct { + name string + handler http.HandlerFunc + serverURL string + validateFunc func(*client.Response[client.Collection[client.SSHKeyMutationResult]], error) + }{ + { + name: "when removing key returns result", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write( + []byte( + `{"job_id":"00000000-0000-0000-0000-000000000001","results":[{"hostname":"agent1","status":"ok","changed":true}]}`, + ), + ) + }, + validateFunc: func( + resp *client.Response[client.Collection[client.SSHKeyMutationResult]], + err error, + ) { + suite.NoError(err) + suite.NotNil(resp) + suite.Equal("00000000-0000-0000-0000-000000000001", resp.Data.JobID) + suite.Len(resp.Data.Results, 1) + suite.Equal("agent1", resp.Data.Results[0].Hostname) + suite.True(resp.Data.Results[0].Changed) + }, + }, + { + name: "when server returns 403 returns AuthError", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"error":"forbidden"}`)) + }, + validateFunc: func( + resp *client.Response[client.Collection[client.SSHKeyMutationResult]], + err error, + ) { + suite.Error(err) + suite.Nil(resp) + + var target *client.AuthError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusForbidden, target.StatusCode) + }, + }, + { + name: "when server returns 401 returns AuthError", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":"unauthorized"}`)) + }, + validateFunc: func( + resp *client.Response[client.Collection[client.SSHKeyMutationResult]], + err error, + ) { + suite.Error(err) + suite.Nil(resp) + + var target *client.AuthError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusUnauthorized, target.StatusCode) + }, + }, + { + name: "when server returns 500 returns ServerError", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"error":"internal error"}`)) + }, + validateFunc: func( + resp *client.Response[client.Collection[client.SSHKeyMutationResult]], + err error, + ) { + suite.Error(err) + suite.Nil(resp) + + var target *client.ServerError + suite.True(errors.As(err, &target)) + }, + }, + { + name: "when client HTTP call fails returns error", + serverURL: "http://127.0.0.1:0", + validateFunc: func( + resp *client.Response[client.Collection[client.SSHKeyMutationResult]], + err error, + ) { + suite.Error(err) + suite.Nil(resp) + suite.Contains(err.Error(), "user remove key") + }, + }, + { + name: "when server returns 200 with no JSON body returns UnexpectedStatusError", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }, + validateFunc: func( + resp *client.Response[client.Collection[client.SSHKeyMutationResult]], + err error, + ) { + suite.Error(err) + suite.Nil(resp) + + var target *client.UnexpectedStatusError + suite.True(errors.As(err, &target)) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + var ( + serverURL string + cleanup func() + ) + + if tc.serverURL != "" { + serverURL = tc.serverURL + cleanup = func() {} + } else { + server := httptest.NewServer(tc.handler) + serverURL = server.URL + cleanup = server.Close + } + defer cleanup() + + sut := client.New( + serverURL, + "test-token", + client.WithLogger(slog.Default()), + ) + + resp, err := sut.User.RemoveKey(suite.ctx, "_any", "testuser", "SHA256:abc123") + tc.validateFunc(resp, err) + }) + } +} + +func TestUserSSHKeyPublicTestSuite(t *testing.T) { + t.Parallel() + suite.Run(t, new(UserSSHKeyPublicTestSuite)) +} diff --git a/pkg/sdk/client/user_ssh_key_types_public_test.go b/pkg/sdk/client/user_ssh_key_types_public_test.go new file mode 100644 index 000000000..43ffadc84 --- /dev/null +++ b/pkg/sdk/client/user_ssh_key_types_public_test.go @@ -0,0 +1,381 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package client_test + +import ( + "testing" + + openapi_types "github.com/oapi-codegen/runtime/types" + "github.com/stretchr/testify/suite" + + "github.com/retr0h/osapi/pkg/sdk/client" + "github.com/retr0h/osapi/pkg/sdk/client/gen" +) + +type UserSSHKeyTypesPublicTestSuite struct { + suite.Suite +} + +func (suite *UserSSHKeyTypesPublicTestSuite) TestSSHKeyCollectionFromGen() { + testUUID := openapi_types.UUID{ + 0x55, 0x0e, 0x84, 0x00, + 0xe2, 0x9b, 0x41, 0xd4, + 0xa7, 0x16, 0x44, 0x66, + 0x55, 0x44, 0x00, 0x00, + } + + tests := []struct { + name string + input *gen.SSHKeyCollectionResponse + validateFunc func(client.Collection[client.SSHKeyInfoResult]) + }{ + { + name: "when all fields are populated", + input: func() *gen.SSHKeyCollectionResponse { + keyType := "ssh-ed25519" + fingerprint := "SHA256:abc123" + comment := "user@host" + + return &gen.SSHKeyCollectionResponse{ + JobId: &testUUID, + Results: []gen.SSHKeyEntry{ + { + Hostname: "web-01", + Status: gen.SSHKeyEntryStatusOk, + Keys: &[]gen.SSHKeyInfo{ + { + Type: &keyType, + Fingerprint: &fingerprint, + Comment: &comment, + }, + }, + }, + }, + } + }(), + validateFunc: func(c client.Collection[client.SSHKeyInfoResult]) { + suite.Equal("550e8400-e29b-41d4-a716-446655440000", c.JobID) + suite.Require().Len(c.Results, 1) + + r := c.Results[0] + suite.Equal("web-01", r.Hostname) + suite.Equal("ok", r.Status) + suite.Empty(r.Error) + suite.Require().Len(r.Keys, 1) + + k := r.Keys[0] + suite.Equal("ssh-ed25519", k.Type) + suite.Equal("SHA256:abc123", k.Fingerprint) + suite.Equal("user@host", k.Comment) + }, + }, + { + name: "when minimal with error", + input: func() *gen.SSHKeyCollectionResponse { + errMsg := "permission denied" + + return &gen.SSHKeyCollectionResponse{ + Results: []gen.SSHKeyEntry{ + { + Hostname: "web-01", + Status: gen.SSHKeyEntryStatusFailed, + Error: &errMsg, + }, + }, + } + }(), + validateFunc: func(c client.Collection[client.SSHKeyInfoResult]) { + suite.Empty(c.JobID) + suite.Require().Len(c.Results, 1) + + r := c.Results[0] + suite.Equal("web-01", r.Hostname) + suite.Equal("failed", r.Status) + suite.Equal("permission denied", r.Error) + suite.Nil(r.Keys) + }, + }, + { + name: "when multiple hosts", + input: func() *gen.SSHKeyCollectionResponse { + keyType1 := "ssh-rsa" + keyType2 := "ssh-ed25519" + + return &gen.SSHKeyCollectionResponse{ + JobId: &testUUID, + Results: []gen.SSHKeyEntry{ + { + Hostname: "web-01", + Status: gen.SSHKeyEntryStatusOk, + Keys: &[]gen.SSHKeyInfo{{Type: &keyType1}}, + }, + { + Hostname: "web-02", + Status: gen.SSHKeyEntryStatusOk, + Keys: &[]gen.SSHKeyInfo{{Type: &keyType2}}, + }, + }, + } + }(), + validateFunc: func(c client.Collection[client.SSHKeyInfoResult]) { + suite.Require().Len(c.Results, 2) + suite.Equal("web-01", c.Results[0].Hostname) + suite.Equal("web-02", c.Results[1].Hostname) + suite.Equal("ssh-rsa", c.Results[0].Keys[0].Type) + suite.Equal("ssh-ed25519", c.Results[1].Keys[0].Type) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + result := client.SSHKeyCollectionFromGen(tc.input) + tc.validateFunc(result) + }) + } +} + +func (suite *UserSSHKeyTypesPublicTestSuite) TestSSHKeyInfoResultFromGen() { + tests := []struct { + name string + input gen.SSHKeyEntry + validateFunc func(client.SSHKeyInfoResult) + }{ + { + name: "when entry has keys", + input: func() gen.SSHKeyEntry { + keyType := "ssh-ed25519" + fp := "SHA256:xyz" + comment := "admin@server" + + return gen.SSHKeyEntry{ + Hostname: "web-01", + Status: gen.SSHKeyEntryStatusOk, + Keys: &[]gen.SSHKeyInfo{ + { + Type: &keyType, + Fingerprint: &fp, + Comment: &comment, + }, + }, + } + }(), + validateFunc: func(r client.SSHKeyInfoResult) { + suite.Equal("web-01", r.Hostname) + suite.Equal("ok", r.Status) + suite.Require().Len(r.Keys, 1) + suite.Equal("ssh-ed25519", r.Keys[0].Type) + suite.Equal("SHA256:xyz", r.Keys[0].Fingerprint) + suite.Equal("admin@server", r.Keys[0].Comment) + }, + }, + { + name: "when entry has no keys", + input: gen.SSHKeyEntry{ + Hostname: "web-01", + Status: gen.SSHKeyEntryStatusOk, + }, + validateFunc: func(r client.SSHKeyInfoResult) { + suite.Equal("web-01", r.Hostname) + suite.Nil(r.Keys) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + result := client.SSHKeyInfoResultFromGen(tc.input) + tc.validateFunc(result) + }) + } +} + +func (suite *UserSSHKeyTypesPublicTestSuite) TestSSHKeyInfoFromGen() { + tests := []struct { + name string + input gen.SSHKeyInfo + validateFunc func(client.SSHKeyInfo) + }{ + { + name: "when all fields populated", + input: func() gen.SSHKeyInfo { + keyType := "ssh-rsa" + fp := "SHA256:def456" + comment := "test@laptop" + + return gen.SSHKeyInfo{ + Type: &keyType, + Fingerprint: &fp, + Comment: &comment, + } + }(), + validateFunc: func(k client.SSHKeyInfo) { + suite.Equal("ssh-rsa", k.Type) + suite.Equal("SHA256:def456", k.Fingerprint) + suite.Equal("test@laptop", k.Comment) + }, + }, + { + name: "when all fields nil", + input: gen.SSHKeyInfo{}, + validateFunc: func(k client.SSHKeyInfo) { + suite.Empty(k.Type) + suite.Empty(k.Fingerprint) + suite.Empty(k.Comment) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + result := client.SSHKeyInfoFromGen(tc.input) + tc.validateFunc(result) + }) + } +} + +func (suite *UserSSHKeyTypesPublicTestSuite) TestSSHKeyMutationCollectionFromGen() { + testUUID := openapi_types.UUID{ + 0x55, 0x0e, 0x84, 0x00, + 0xe2, 0x9b, 0x41, 0xd4, + 0xa7, 0x16, 0x44, 0x66, + 0x55, 0x44, 0x00, 0x00, + } + + tests := []struct { + name string + input *gen.SSHKeyMutationResponse + validateFunc func(client.Collection[client.SSHKeyMutationResult]) + }{ + { + name: "when all fields are populated", + input: func() *gen.SSHKeyMutationResponse { + changed := true + + return &gen.SSHKeyMutationResponse{ + JobId: &testUUID, + Results: []gen.SSHKeyMutationEntry{ + { + Hostname: "web-01", + Status: gen.SSHKeyMutationEntryStatusOk, + Changed: &changed, + }, + }, + } + }(), + validateFunc: func(c client.Collection[client.SSHKeyMutationResult]) { + suite.Equal("550e8400-e29b-41d4-a716-446655440000", c.JobID) + suite.Require().Len(c.Results, 1) + + r := c.Results[0] + suite.Equal("web-01", r.Hostname) + suite.Equal("ok", r.Status) + suite.True(r.Changed) + suite.Empty(r.Error) + }, + }, + { + name: "when error result", + input: func() *gen.SSHKeyMutationResponse { + errMsg := "key already exists" + changed := false + + return &gen.SSHKeyMutationResponse{ + Results: []gen.SSHKeyMutationEntry{ + { + Hostname: "web-01", + Status: gen.SSHKeyMutationEntryStatusFailed, + Changed: &changed, + Error: &errMsg, + }, + }, + } + }(), + validateFunc: func(c client.Collection[client.SSHKeyMutationResult]) { + suite.Require().Len(c.Results, 1) + + r := c.Results[0] + suite.Equal("failed", r.Status) + suite.False(r.Changed) + suite.Equal("key already exists", r.Error) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + result := client.SSHKeyMutationCollectionFromGen(tc.input) + tc.validateFunc(result) + }) + } +} + +func (suite *UserSSHKeyTypesPublicTestSuite) TestSSHKeyMutationResultFromGen() { + tests := []struct { + name string + input gen.SSHKeyMutationEntry + validateFunc func(client.SSHKeyMutationResult) + }{ + { + name: "when successful mutation", + input: func() gen.SSHKeyMutationEntry { + changed := true + + return gen.SSHKeyMutationEntry{ + Hostname: "web-01", + Status: gen.SSHKeyMutationEntryStatusOk, + Changed: &changed, + } + }(), + validateFunc: func(r client.SSHKeyMutationResult) { + suite.Equal("web-01", r.Hostname) + suite.Equal("ok", r.Status) + suite.True(r.Changed) + suite.Empty(r.Error) + }, + }, + { + name: "when nil optional fields", + input: gen.SSHKeyMutationEntry{ + Hostname: "web-01", + Status: gen.SSHKeyMutationEntryStatusSkipped, + }, + validateFunc: func(r client.SSHKeyMutationResult) { + suite.Equal("web-01", r.Hostname) + suite.Equal("skipped", r.Status) + suite.False(r.Changed) + suite.Empty(r.Error) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + result := client.SSHKeyMutationResultFromGen(tc.input) + tc.validateFunc(result) + }) + } +} + +func TestUserSSHKeyTypesPublicTestSuite(t *testing.T) { + t.Parallel() + suite.Run(t, new(UserSSHKeyTypesPublicTestSuite)) +} diff --git a/pkg/sdk/client/user_types.go b/pkg/sdk/client/user_types.go index 4f1bfd5b1..0393a8708 100644 --- a/pkg/sdk/client/user_types.go +++ b/pkg/sdk/client/user_types.go @@ -85,6 +85,35 @@ type UserUpdateOpts struct { Lock *bool } +// SSHKeyInfoResult represents SSH key list result for one host. +type SSHKeyInfoResult struct { + Hostname string `json:"hostname"` + Status string `json:"status"` + Keys []SSHKeyInfo `json:"keys,omitempty"` + Error string `json:"error,omitempty"` +} + +// SSHKeyInfo represents a single SSH authorized key. +type SSHKeyInfo struct { + Type string `json:"type,omitempty"` + Fingerprint string `json:"fingerprint,omitempty"` + Comment string `json:"comment,omitempty"` +} + +// SSHKeyMutationResult represents SSH key add/remove result for one host. +type SSHKeyMutationResult struct { + Hostname string `json:"hostname"` + Status string `json:"status"` + Changed bool `json:"changed"` + Error string `json:"error,omitempty"` +} + +// SSHKeyAddOpts contains options for adding an SSH key. +type SSHKeyAddOpts struct { + // Key is the full SSH public key line (e.g., "ssh-ed25519 AAAA... user@host"). + Key string +} + // userInfoCollectionFromList converts a gen.UserCollectionResponse // to a Collection[UserInfoResult]. func userInfoCollectionFromList( @@ -192,3 +221,81 @@ func userInfoFromGen( Locked: derefBool(g.Locked), } } + +// sshKeyCollectionFromGen converts a gen.SSHKeyCollectionResponse +// to a Collection[SSHKeyInfoResult]. +func sshKeyCollectionFromGen( + g *gen.SSHKeyCollectionResponse, +) Collection[SSHKeyInfoResult] { + results := make([]SSHKeyInfoResult, 0, len(g.Results)) + for _, r := range g.Results { + results = append(results, sshKeyInfoResultFromGen(r)) + } + + return Collection[SSHKeyInfoResult]{ + Results: results, + JobID: jobIDFromGen(g.JobId), + } +} + +// sshKeyInfoResultFromGen converts a gen.SSHKeyEntry to an SSHKeyInfoResult. +func sshKeyInfoResultFromGen( + r gen.SSHKeyEntry, +) SSHKeyInfoResult { + result := SSHKeyInfoResult{ + Hostname: r.Hostname, + Status: string(r.Status), + Error: derefString(r.Error), + } + + if r.Keys != nil { + keys := make([]SSHKeyInfo, 0, len(*r.Keys)) + for _, k := range *r.Keys { + keys = append(keys, sshKeyInfoFromGen(k)) + } + + result.Keys = keys + } + + return result +} + +// sshKeyInfoFromGen converts a gen.SSHKeyInfo to an SSHKeyInfo. +func sshKeyInfoFromGen( + k gen.SSHKeyInfo, +) SSHKeyInfo { + return SSHKeyInfo{ + Type: derefString(k.Type), + Fingerprint: derefString(k.Fingerprint), + Comment: derefString(k.Comment), + } +} + +// sshKeyMutationCollectionFromGen converts a gen.SSHKeyMutationResponse +// to a Collection[SSHKeyMutationResult]. +func sshKeyMutationCollectionFromGen( + g *gen.SSHKeyMutationResponse, +) Collection[SSHKeyMutationResult] { + results := make([]SSHKeyMutationResult, 0, len(g.Results)) + for _, r := range g.Results { + results = append(results, sshKeyMutationResultFromGen(r)) + } + + return Collection[SSHKeyMutationResult]{ + Results: results, + JobID: jobIDFromGen(g.JobId), + } +} + +// sshKeyMutationResultFromGen converts a gen.SSHKeyMutationEntry +// to an SSHKeyMutationResult. +func sshKeyMutationResultFromGen( + r gen.SSHKeyMutationEntry, +) SSHKeyMutationResult { + return SSHKeyMutationResult{ + Hostname: r.Hostname, + Status: string(r.Status), + Changed: derefBool(r.Changed), + Error: derefString(r.Error), + } +} From f46f0550e8f1e8a3173f584b86bfa37b00a24caa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Wed, 1 Apr 2026 11:55:57 -0700 Subject: [PATCH 08/11] feat(user): add SSH key CLI commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add ssh-key subcommand under user with list, add, and remove operations for managing SSH authorized keys. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- cmd/client_node_user_ssh_key.go | 35 +++++++++ cmd/client_node_user_ssh_key_add.go | 88 +++++++++++++++++++++++ cmd/client_node_user_ssh_key_list.go | 98 ++++++++++++++++++++++++++ cmd/client_node_user_ssh_key_remove.go | 87 +++++++++++++++++++++++ 4 files changed, 308 insertions(+) create mode 100644 cmd/client_node_user_ssh_key.go create mode 100644 cmd/client_node_user_ssh_key_add.go create mode 100644 cmd/client_node_user_ssh_key_list.go create mode 100644 cmd/client_node_user_ssh_key_remove.go diff --git a/cmd/client_node_user_ssh_key.go b/cmd/client_node_user_ssh_key.go new file mode 100644 index 000000000..17069595d --- /dev/null +++ b/cmd/client_node_user_ssh_key.go @@ -0,0 +1,35 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package cmd + +import ( + "github.com/spf13/cobra" +) + +// clientNodeUserSshKeyCmd represents the user ssh-key command. +var clientNodeUserSshKeyCmd = &cobra.Command{ + Use: "ssh-key", + Short: "Manage SSH authorized keys", +} + +func init() { + clientNodeUserCmd.AddCommand(clientNodeUserSshKeyCmd) +} diff --git a/cmd/client_node_user_ssh_key_add.go b/cmd/client_node_user_ssh_key_add.go new file mode 100644 index 000000000..9bdf316b6 --- /dev/null +++ b/cmd/client_node_user_ssh_key_add.go @@ -0,0 +1,88 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/retr0h/osapi/internal/cli" + "github.com/retr0h/osapi/pkg/sdk/client" +) + +// clientNodeUserSshKeyAddCmd represents the user ssh-key add command. +var clientNodeUserSshKeyAddCmd = &cobra.Command{ + Use: "add", + Short: "Add an SSH authorized key", + Run: func(cmd *cobra.Command, _ []string) { + ctx := cmd.Context() + host, _ := cmd.Flags().GetString("target") + name, _ := cmd.Flags().GetString("name") + key, _ := cmd.Flags().GetString("key") + + resp, err := sdkClient.User.AddKey(ctx, host, name, client.SSHKeyAddOpts{Key: key}) + if err != nil { + cli.HandleError(err, logger) + return + } + + if jsonOutput { + fmt.Println(string(resp.RawJSON())) + return + } + + if resp.Data.JobID != "" { + fmt.Println() + cli.PrintKV("Job ID", resp.Data.JobID) + } + + results := make([]cli.MutationResultRow, 0, len(resp.Data.Results)) + for _, r := range resp.Data.Results { + var errPtr *string + if r.Error != "" { + errPtr = &r.Error + } + changed := r.Changed + results = append(results, cli.MutationResultRow{ + Hostname: r.Hostname, + Status: r.Status, + Changed: &changed, + Error: errPtr, + Fields: []string{fmt.Sprintf("%t", r.Changed)}, + }) + } + headers, rows := cli.BuildMutationTable(results, []string{"CHANGED"}) + cli.PrintCompactTable([]cli.Section{{Headers: headers, Rows: rows}}) + }, +} + +func init() { + clientNodeUserSshKeyCmd.AddCommand(clientNodeUserSshKeyAddCmd) + + clientNodeUserSshKeyAddCmd.PersistentFlags(). + String("name", "", "Username to add SSH key for (required)") + clientNodeUserSshKeyAddCmd.PersistentFlags(). + String("key", "", "Full SSH public key line (required)") + + _ = clientNodeUserSshKeyAddCmd.MarkPersistentFlagRequired("name") + _ = clientNodeUserSshKeyAddCmd.MarkPersistentFlagRequired("key") +} diff --git a/cmd/client_node_user_ssh_key_list.go b/cmd/client_node_user_ssh_key_list.go new file mode 100644 index 000000000..c164b89f3 --- /dev/null +++ b/cmd/client_node_user_ssh_key_list.go @@ -0,0 +1,98 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/retr0h/osapi/internal/cli" +) + +// clientNodeUserSshKeyListCmd represents the user ssh-key list command. +var clientNodeUserSshKeyListCmd = &cobra.Command{ + Use: "list", + Short: "List SSH authorized keys", + Run: func(cmd *cobra.Command, _ []string) { + ctx := cmd.Context() + host, _ := cmd.Flags().GetString("target") + name, _ := cmd.Flags().GetString("name") + + resp, err := sdkClient.User.ListKeys(ctx, host, name) + if err != nil { + cli.HandleError(err, logger) + return + } + + if jsonOutput { + fmt.Println(string(resp.RawJSON())) + return + } + + if resp.Data.JobID != "" { + fmt.Println() + cli.PrintKV("Job ID", resp.Data.JobID) + } + + results := make([]cli.ResultRow, 0) + for _, r := range resp.Data.Results { + var errPtr *string + if r.Error != "" { + errPtr = &r.Error + } + if errPtr != nil || len(r.Keys) == 0 { + results = append(results, cli.ResultRow{ + Hostname: r.Hostname, + Status: r.Status, + Error: errPtr, + Fields: []string{"", "", ""}, + }) + + continue + } + for _, k := range r.Keys { + results = append(results, cli.ResultRow{ + Hostname: r.Hostname, + Status: r.Status, + Fields: []string{ + k.Type, + k.Fingerprint, + k.Comment, + }, + }) + } + } + headers, rows := cli.BuildBroadcastTable(results, []string{ + "TYPE", "FINGERPRINT", "COMMENT", + }) + cli.PrintCompactTable([]cli.Section{{Headers: headers, Rows: rows}}) + }, +} + +func init() { + clientNodeUserSshKeyCmd.AddCommand(clientNodeUserSshKeyListCmd) + + clientNodeUserSshKeyListCmd.PersistentFlags(). + String("name", "", "Username to list SSH keys for (required)") + + _ = clientNodeUserSshKeyListCmd.MarkPersistentFlagRequired("name") +} diff --git a/cmd/client_node_user_ssh_key_remove.go b/cmd/client_node_user_ssh_key_remove.go new file mode 100644 index 000000000..1f614a3b8 --- /dev/null +++ b/cmd/client_node_user_ssh_key_remove.go @@ -0,0 +1,87 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/retr0h/osapi/internal/cli" +) + +// clientNodeUserSshKeyRemoveCmd represents the user ssh-key remove command. +var clientNodeUserSshKeyRemoveCmd = &cobra.Command{ + Use: "remove", + Short: "Remove an SSH authorized key", + Run: func(cmd *cobra.Command, _ []string) { + ctx := cmd.Context() + host, _ := cmd.Flags().GetString("target") + name, _ := cmd.Flags().GetString("name") + fingerprint, _ := cmd.Flags().GetString("fingerprint") + + resp, err := sdkClient.User.RemoveKey(ctx, host, name, fingerprint) + if err != nil { + cli.HandleError(err, logger) + return + } + + if jsonOutput { + fmt.Println(string(resp.RawJSON())) + return + } + + if resp.Data.JobID != "" { + fmt.Println() + cli.PrintKV("Job ID", resp.Data.JobID) + } + + results := make([]cli.MutationResultRow, 0, len(resp.Data.Results)) + for _, r := range resp.Data.Results { + var errPtr *string + if r.Error != "" { + errPtr = &r.Error + } + changed := r.Changed + results = append(results, cli.MutationResultRow{ + Hostname: r.Hostname, + Status: r.Status, + Changed: &changed, + Error: errPtr, + Fields: []string{fmt.Sprintf("%t", r.Changed)}, + }) + } + headers, rows := cli.BuildMutationTable(results, []string{"CHANGED"}) + cli.PrintCompactTable([]cli.Section{{Headers: headers, Rows: rows}}) + }, +} + +func init() { + clientNodeUserSshKeyCmd.AddCommand(clientNodeUserSshKeyRemoveCmd) + + clientNodeUserSshKeyRemoveCmd.PersistentFlags(). + String("name", "", "Username to remove SSH key from (required)") + clientNodeUserSshKeyRemoveCmd.PersistentFlags(). + String("fingerprint", "", "Fingerprint of the SSH key to remove (required)") + + _ = clientNodeUserSshKeyRemoveCmd.MarkPersistentFlagRequired("name") + _ = clientNodeUserSshKeyRemoveCmd.MarkPersistentFlagRequired("fingerprint") +} From 6c8b7ad953f41ffc144be8f3b524dcbac6218fea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Wed, 1 Apr 2026 11:59:09 -0700 Subject: [PATCH 09/11] docs: add SSH key management to user docs and SDK example Co-Authored-By: Claude --- .../sidebar/architecture/api-guidelines.md | 2 + docs/docs/sidebar/features/user-management.md | 44 +++++++++-- .../sidebar/sdk/client/management/user.md | 73 ++++++++++++++++--- .../usage/cli/client/node/user/ssh-key-add.md | 24 ++++++ .../cli/client/node/user/ssh-key-list.md | 28 +++++++ .../cli/client/node/user/ssh-key-remove.md | 24 ++++++ .../usage/cli/client/node/user/ssh-key.md | 5 ++ examples/sdk/client/user.go | 40 +++++++++- 8 files changed, 222 insertions(+), 18 deletions(-) create mode 100644 docs/docs/sidebar/usage/cli/client/node/user/ssh-key-add.md create mode 100644 docs/docs/sidebar/usage/cli/client/node/user/ssh-key-list.md create mode 100644 docs/docs/sidebar/usage/cli/client/node/user/ssh-key-remove.md create mode 100644 docs/docs/sidebar/usage/cli/client/node/user/ssh-key.md diff --git a/docs/docs/sidebar/architecture/api-guidelines.md b/docs/docs/sidebar/architecture/api-guidelines.md index 5b3a7e8da..773f0bc70 100644 --- a/docs/docs/sidebar/architecture/api-guidelines.md +++ b/docs/docs/sidebar/architecture/api-guidelines.md @@ -59,6 +59,8 @@ Sub-resources represent distinct capabilities of the node: | `/node/{hostname}/user` | User | | `/node/{hostname}/user/{name}` | User | | `/node/{hostname}/user/{name}/password` | User | +| `/node/{hostname}/user/{name}/ssh-key` | User | +| `/node/{hostname}/user/{name}/ssh-key/{fingerprint}` | User | | `/node/{hostname}/group` | Group | | `/node/{hostname}/group/{name}` | Group | | `/node/{hostname}/package` | Package | diff --git a/docs/docs/sidebar/features/user-management.md b/docs/docs/sidebar/features/user-management.md index beb4f9fa2..0f16c3811 100644 --- a/docs/docs/sidebar/features/user-management.md +++ b/docs/docs/sidebar/features/user-management.md @@ -28,6 +28,20 @@ User operations manage local accounts: - **Password** -- change a user's password (plaintext input, hashed by the agent) +### SSH Keys + +SSH key operations manage the `~/.ssh/authorized_keys` file for a given user: + +- **ListKeys** -- enumerate all authorized keys with type, fingerprint, and + comment +- **AddKey** -- append a public key to the authorized_keys file (idempotent -- + duplicate keys are not added) +- **RemoveKey** -- remove a key by its SHA256 fingerprint + +The provider reads and writes the user's `~/.ssh/authorized_keys` file +directly. It creates the `~/.ssh` directory and `authorized_keys` file with +correct permissions (`700` and `600`) if they do not exist. + ### Groups Group operations manage local groups: @@ -64,6 +78,21 @@ $ osapi client node user password --target web-01 \ $ osapi client node user delete --target web-01 --name deploy ``` +### SSH Keys + +```bash +# List SSH keys for a user +$ osapi client node user ssh-key list --target web-01 --name deploy + +# Add an SSH key +$ osapi client node user ssh-key add --target web-01 \ + --name deploy --key 'ssh-ed25519 AAAA... user@laptop' + +# Remove an SSH key by fingerprint +$ osapi client node user ssh-key remove --target web-01 \ + --name deploy --fingerprint 'SHA256:abc123...' +``` + ### Groups ```bash @@ -99,12 +128,14 @@ $ osapi client node user create --target group:web \ ## Permissions -| Operation | Permission | -| --------------- | ------------ | -| User list/get | `user:read` | -| User mutations | `user:write` | -| Group list/get | `user:read` | -| Group mutations | `user:write` | +| Operation | Permission | +| ------------------ | ------------ | +| User list/get | `user:read` | +| User mutations | `user:write` | +| SSH key list | `user:read` | +| SSH key add/remove | `user:write` | +| Group list/get | `user:read` | +| Group mutations | `user:write` | ## Platform Support @@ -120,6 +151,7 @@ message. ## Further Reading - [CLI Reference -- User](../usage/cli/client/node/user/user.md) +- [CLI Reference -- SSH Key](../usage/cli/client/node/user/ssh-key.md) - [CLI Reference -- Group](../usage/cli/client/node/group/group.md) - [SDK -- User](../sdk/client/management/user.md) - [SDK -- Group](../sdk/client/management/group.md) diff --git a/docs/docs/sidebar/sdk/client/management/user.md b/docs/docs/sidebar/sdk/client/management/user.md index 6e85a2c28..ce728c84e 100644 --- a/docs/docs/sidebar/sdk/client/management/user.md +++ b/docs/docs/sidebar/sdk/client/management/user.md @@ -8,14 +8,17 @@ User account management on target hosts. ## Methods -| Method | Description | -| ----------------------------------------------- | ------------------------ | -| `List(ctx, hostname)` | List all user accounts | -| `Get(ctx, hostname, name)` | Get a user by name | -| `Create(ctx, hostname, opts)` | Create a user account | -| `Update(ctx, hostname, name, opts)` | Update a user account | -| `Delete(ctx, hostname, name)` | Delete a user account | -| `ChangePassword(ctx, hostname, name, password)` | Change a user's password | +| Method | Description | +| ----------------------------------------------- | ------------------------------ | +| `List(ctx, hostname)` | List all user accounts | +| `Get(ctx, hostname, name)` | Get a user by name | +| `Create(ctx, hostname, opts)` | Create a user account | +| `Update(ctx, hostname, name, opts)` | Update a user account | +| `Delete(ctx, hostname, name)` | Delete a user account | +| `ChangePassword(ctx, hostname, name, password)` | Change a user's password | +| `ListKeys(ctx, hostname, name)` | List SSH authorized keys | +| `AddKey(ctx, hostname, name, opts)` | Add an SSH authorized key | +| `RemoveKey(ctx, hostname, name, fingerprint)` | Remove an SSH key by fingerprint | ## Usage @@ -53,6 +56,22 @@ resp, err := c.User.ChangePassword(ctx, "web-01", "deploy", "newpass123") // Delete a user resp, err := c.User.Delete(ctx, "web-01", "deploy") + +// List SSH keys for a user +keysResp, err := c.User.ListKeys(ctx, "web-01", "deploy") +for _, r := range keysResp.Data.Results { + for _, k := range r.Keys { + fmt.Printf("%s %s %s\n", k.Type, k.Fingerprint, k.Comment) + } +} + +// Add an SSH key +addResp, err := c.User.AddKey(ctx, "web-01", "deploy", client.SSHKeyAddOpts{ + Key: "ssh-ed25519 AAAA... user@laptop", +}) + +// Remove an SSH key by fingerprint +removeResp, err := c.User.RemoveKey(ctx, "web-01", "deploy", "SHA256:abc123...") ``` ## Example @@ -61,7 +80,41 @@ See [`examples/sdk/client/user.go`](https://github.com/retr0h/osapi/blob/main/examples/sdk/client/user.go) for a complete working example. +## SSH Key Types + +### `SSHKeyInfoResult` + +| Field | Type | Description | +| ---------- | ------------- | ------------------------------ | +| `Hostname` | `string` | Target hostname | +| `Status` | `string` | Operation status | +| `Keys` | `[]SSHKeyInfo`| List of authorized keys | +| `Error` | `string` | Error message (if any) | + +### `SSHKeyInfo` + +| Field | Type | Description | +| ------------- | -------- | ------------------------------- | +| `Type` | `string` | Key type (e.g., `ssh-ed25519`) | +| `Fingerprint` | `string` | SHA256 fingerprint | +| `Comment` | `string` | Key comment | + +### `SSHKeyMutationResult` + +| Field | Type | Description | +| ---------- | -------- | ------------------------------ | +| `Hostname` | `string` | Target hostname | +| `Status` | `string` | Operation status | +| `Changed` | `bool` | Whether the operation changed state | +| `Error` | `string` | Error message (if any) | + +### `SSHKeyAddOpts` + +| Field | Type | Description | +| ----- | -------- | ----------------------------------------------------- | +| `Key` | `string` | Full SSH public key line (e.g., `ssh-ed25519 AAAA...`) | + ## Permissions -Requires `user:read` for List and Get. Create, Update, Delete, and -ChangePassword require `user:write`. +Requires `user:read` for List, Get, and ListKeys. Create, Update, Delete, +ChangePassword, AddKey, and RemoveKey require `user:write`. diff --git a/docs/docs/sidebar/usage/cli/client/node/user/ssh-key-add.md b/docs/docs/sidebar/usage/cli/client/node/user/ssh-key-add.md new file mode 100644 index 000000000..3b86caea9 --- /dev/null +++ b/docs/docs/sidebar/usage/cli/client/node/user/ssh-key-add.md @@ -0,0 +1,24 @@ +# Add + +Add an SSH authorized key for a user: + +```bash +$ osapi client node user ssh-key add --target web-01 \ + --name deploy --key 'ssh-ed25519 AAAA... user@laptop' + + HOSTNAME CHANGED STATUS + web-01 true ok +``` + +The key is appended to the user's `~/.ssh/authorized_keys` file. If the file +or `~/.ssh` directory does not exist, it is created with correct permissions +(`700` for the directory, `600` for the file). Duplicate keys are not added. + +## Flags + +| Flag | Description | Default | +| -------------- | -------------------------------------------------------- | ------- | +| `-T, --target` | Target: `_any`, `_all`, hostname, or label (`group:web`) | `_all` | +| `--name` | Username to add SSH key for (required) | | +| `--key` | Full SSH public key line (required) | | +| `-j, --json` | Output raw JSON response | | diff --git a/docs/docs/sidebar/usage/cli/client/node/user/ssh-key-list.md b/docs/docs/sidebar/usage/cli/client/node/user/ssh-key-list.md new file mode 100644 index 000000000..c99d669ec --- /dev/null +++ b/docs/docs/sidebar/usage/cli/client/node/user/ssh-key-list.md @@ -0,0 +1,28 @@ +# List + +List SSH authorized keys for a user: + +```bash +$ osapi client node user ssh-key list --target web-01 --name deploy + + HOSTNAME TYPE FINGERPRINT COMMENT STATUS + web-01 ssh-ed25519 SHA256:abc123... user@laptop ok + web-01 ssh-rsa SHA256:def456... deploy-ci ok +``` + +## JSON Output + +Use `--json` to get the full API response: + +```bash +$ osapi client node user ssh-key list --target web-01 --name deploy --json +{"results":[{"hostname":"web-01","keys":[{"type":"ssh-ed25519","fingerprint":"SHA256:abc123...","comment":"user@laptop"}],"status":"ok"}],"job_id":"..."} +``` + +## Flags + +| Flag | Description | Default | +| -------------- | -------------------------------------------------------- | ------- | +| `-T, --target` | Target: `_any`, `_all`, hostname, or label (`group:web`) | `_all` | +| `--name` | Username to list SSH keys for (required) | | +| `-j, --json` | Output raw JSON response | | diff --git a/docs/docs/sidebar/usage/cli/client/node/user/ssh-key-remove.md b/docs/docs/sidebar/usage/cli/client/node/user/ssh-key-remove.md new file mode 100644 index 000000000..84613f146 --- /dev/null +++ b/docs/docs/sidebar/usage/cli/client/node/user/ssh-key-remove.md @@ -0,0 +1,24 @@ +# Remove + +Remove an SSH authorized key by fingerprint: + +```bash +$ osapi client node user ssh-key remove --target web-01 \ + --name deploy --fingerprint 'SHA256:abc123...' + + HOSTNAME CHANGED STATUS + web-01 true ok +``` + +The key matching the given SHA256 fingerprint is removed from the user's +`~/.ssh/authorized_keys` file. Returns `changed: false` if the fingerprint +is not found. + +## Flags + +| Flag | Description | Default | +| ----------------- | -------------------------------------------------------- | ------- | +| `-T, --target` | Target: `_any`, `_all`, hostname, or label (`group:web`) | `_all` | +| `--name` | Username to remove SSH key from (required) | | +| `--fingerprint` | SHA256 fingerprint of the key to remove (required) | | +| `-j, --json` | Output raw JSON response | | diff --git a/docs/docs/sidebar/usage/cli/client/node/user/ssh-key.md b/docs/docs/sidebar/usage/cli/client/node/user/ssh-key.md new file mode 100644 index 000000000..aaf5b99d4 --- /dev/null +++ b/docs/docs/sidebar/usage/cli/client/node/user/ssh-key.md @@ -0,0 +1,5 @@ +# SSH Key + +Manage SSH authorized keys for user accounts on target hosts. + + diff --git a/examples/sdk/client/user.go b/examples/sdk/client/user.go index 09b6f87c1..b6b871669 100644 --- a/examples/sdk/client/user.go +++ b/examples/sdk/client/user.go @@ -18,8 +18,9 @@ // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. -// Package main demonstrates user account management: list, get, and create -// user accounts on managed hosts using the OSAPI SDK. +// Package main demonstrates user account management: list, get, create +// user accounts, and manage SSH authorized keys on managed hosts using +// the OSAPI SDK. // // Run with: OSAPI_TOKEN="" go run user.go package main @@ -99,4 +100,39 @@ func main() { r.Hostname, r.Name, r.Changed, r.Error) } } + + // List SSH authorized keys for root. + fmt.Println("\n=== Listing SSH keys for root ===") + keysResp, err := c.User.ListKeys(ctx, target, "root") + if err != nil { + log.Fatalf("list keys failed: %v", err) + } + for _, r := range keysResp.Data.Results { + if r.Error != "" { + fmt.Printf(" %s: ERROR %s\n", r.Hostname, r.Error) + continue + } + if len(r.Keys) == 0 { + fmt.Printf(" %s: no authorized keys\n", r.Hostname) + continue + } + for _, k := range r.Keys { + fmt.Printf(" %s: type=%s fingerprint=%s comment=%s\n", + r.Hostname, k.Type, k.Fingerprint, k.Comment) + } + } + + // Add an SSH key (may fail on non-Debian platforms). + fmt.Println("\n=== Adding SSH key for testuser ===") + addResp, err := c.User.AddKey(ctx, target, "testuser", client.SSHKeyAddOpts{ + Key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExample test@example", + }) + if err != nil { + fmt.Printf("add key failed (may be unsupported on this platform): %v\n", err) + } else { + for _, r := range addResp.Data.Results { + fmt.Printf(" %s: changed=%v error=%s\n", + r.Hostname, r.Changed, r.Error) + } + } } From b4f4dc5c4d41ec87b5b4fcbe1e63cec6e3b88e38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Wed, 1 Apr 2026 12:11:49 -0700 Subject: [PATCH 10/11] =?UTF-8?q?refactor(user):=20fix=20SSH=20naming=20co?= =?UTF-8?q?nvention=20(Ssh=20=E2=86=92=20SSH)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix Go naming convention violations flagged by revive linter. OpenAPI operationIds changed from SshKey to SSHKey, then regenerated all code and updated Go identifiers across handlers, processors, CLI commands, SDK client, and tests. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- cmd/client_node_user_ssh_key.go | 6 +- cmd/client_node_user_ssh_key_add.go | 14 +- cmd/client_node_user_ssh_key_list.go | 10 +- cmd/client_node_user_ssh_key_remove.go | 14 +- .../gen/api/delete-node-user-ssh-key.api.mdx | 2 +- .../gen/api/get-node-user-ssh-key.api.mdx | 2 +- .../gen/api/post-node-user-ssh-key.api.mdx | 2 +- internal/agent/processor.go | 2 +- internal/agent/processor_ssh_key.go | 24 +-- .../agent/processor_ssh_key_public_test.go | 40 +++-- internal/controller/api/gen/api.yaml | 6 +- .../controller/api/node/user/gen/api.yaml | 6 +- .../controller/api/node/user/gen/user.gen.go | 156 +++++++++--------- .../api/node/user/ssh_key_create.go | 30 ++-- .../node/user/ssh_key_create_public_test.go | 60 +++---- .../api/node/user/ssh_key_delete.go | 28 ++-- .../node/user/ssh_key_delete_public_test.go | 54 +++--- .../api/node/user/ssh_key_list_get.go | 28 ++-- .../node/user/ssh_key_list_get_public_test.go | 66 ++++---- .../node/user/debian_ssh_key_public_test.go | 6 +- pkg/sdk/client/gen/client.gen.go | 132 +++++++-------- pkg/sdk/client/user.go | 6 +- 22 files changed, 349 insertions(+), 345 deletions(-) diff --git a/cmd/client_node_user_ssh_key.go b/cmd/client_node_user_ssh_key.go index 17069595d..db933c872 100644 --- a/cmd/client_node_user_ssh_key.go +++ b/cmd/client_node_user_ssh_key.go @@ -24,12 +24,12 @@ import ( "github.com/spf13/cobra" ) -// clientNodeUserSshKeyCmd represents the user ssh-key command. -var clientNodeUserSshKeyCmd = &cobra.Command{ +// clientNodeUserSSHKeyCmd represents the user ssh-key command. +var clientNodeUserSSHKeyCmd = &cobra.Command{ Use: "ssh-key", Short: "Manage SSH authorized keys", } func init() { - clientNodeUserCmd.AddCommand(clientNodeUserSshKeyCmd) + clientNodeUserCmd.AddCommand(clientNodeUserSSHKeyCmd) } diff --git a/cmd/client_node_user_ssh_key_add.go b/cmd/client_node_user_ssh_key_add.go index 9bdf316b6..e389e8239 100644 --- a/cmd/client_node_user_ssh_key_add.go +++ b/cmd/client_node_user_ssh_key_add.go @@ -29,8 +29,8 @@ import ( "github.com/retr0h/osapi/pkg/sdk/client" ) -// clientNodeUserSshKeyAddCmd represents the user ssh-key add command. -var clientNodeUserSshKeyAddCmd = &cobra.Command{ +// clientNodeUserSSHKeyAddCmd represents the user ssh-key add command. +var clientNodeUserSSHKeyAddCmd = &cobra.Command{ Use: "add", Short: "Add an SSH authorized key", Run: func(cmd *cobra.Command, _ []string) { @@ -76,13 +76,13 @@ var clientNodeUserSshKeyAddCmd = &cobra.Command{ } func init() { - clientNodeUserSshKeyCmd.AddCommand(clientNodeUserSshKeyAddCmd) + clientNodeUserSSHKeyCmd.AddCommand(clientNodeUserSSHKeyAddCmd) - clientNodeUserSshKeyAddCmd.PersistentFlags(). + clientNodeUserSSHKeyAddCmd.PersistentFlags(). String("name", "", "Username to add SSH key for (required)") - clientNodeUserSshKeyAddCmd.PersistentFlags(). + clientNodeUserSSHKeyAddCmd.PersistentFlags(). String("key", "", "Full SSH public key line (required)") - _ = clientNodeUserSshKeyAddCmd.MarkPersistentFlagRequired("name") - _ = clientNodeUserSshKeyAddCmd.MarkPersistentFlagRequired("key") + _ = clientNodeUserSSHKeyAddCmd.MarkPersistentFlagRequired("name") + _ = clientNodeUserSSHKeyAddCmd.MarkPersistentFlagRequired("key") } diff --git a/cmd/client_node_user_ssh_key_list.go b/cmd/client_node_user_ssh_key_list.go index c164b89f3..fa3af807a 100644 --- a/cmd/client_node_user_ssh_key_list.go +++ b/cmd/client_node_user_ssh_key_list.go @@ -28,8 +28,8 @@ import ( "github.com/retr0h/osapi/internal/cli" ) -// clientNodeUserSshKeyListCmd represents the user ssh-key list command. -var clientNodeUserSshKeyListCmd = &cobra.Command{ +// clientNodeUserSSHKeyListCmd represents the user ssh-key list command. +var clientNodeUserSSHKeyListCmd = &cobra.Command{ Use: "list", Short: "List SSH authorized keys", Run: func(cmd *cobra.Command, _ []string) { @@ -89,10 +89,10 @@ var clientNodeUserSshKeyListCmd = &cobra.Command{ } func init() { - clientNodeUserSshKeyCmd.AddCommand(clientNodeUserSshKeyListCmd) + clientNodeUserSSHKeyCmd.AddCommand(clientNodeUserSSHKeyListCmd) - clientNodeUserSshKeyListCmd.PersistentFlags(). + clientNodeUserSSHKeyListCmd.PersistentFlags(). String("name", "", "Username to list SSH keys for (required)") - _ = clientNodeUserSshKeyListCmd.MarkPersistentFlagRequired("name") + _ = clientNodeUserSSHKeyListCmd.MarkPersistentFlagRequired("name") } diff --git a/cmd/client_node_user_ssh_key_remove.go b/cmd/client_node_user_ssh_key_remove.go index 1f614a3b8..460d762ab 100644 --- a/cmd/client_node_user_ssh_key_remove.go +++ b/cmd/client_node_user_ssh_key_remove.go @@ -28,8 +28,8 @@ import ( "github.com/retr0h/osapi/internal/cli" ) -// clientNodeUserSshKeyRemoveCmd represents the user ssh-key remove command. -var clientNodeUserSshKeyRemoveCmd = &cobra.Command{ +// clientNodeUserSSHKeyRemoveCmd represents the user ssh-key remove command. +var clientNodeUserSSHKeyRemoveCmd = &cobra.Command{ Use: "remove", Short: "Remove an SSH authorized key", Run: func(cmd *cobra.Command, _ []string) { @@ -75,13 +75,13 @@ var clientNodeUserSshKeyRemoveCmd = &cobra.Command{ } func init() { - clientNodeUserSshKeyCmd.AddCommand(clientNodeUserSshKeyRemoveCmd) + clientNodeUserSSHKeyCmd.AddCommand(clientNodeUserSSHKeyRemoveCmd) - clientNodeUserSshKeyRemoveCmd.PersistentFlags(). + clientNodeUserSSHKeyRemoveCmd.PersistentFlags(). String("name", "", "Username to remove SSH key from (required)") - clientNodeUserSshKeyRemoveCmd.PersistentFlags(). + clientNodeUserSSHKeyRemoveCmd.PersistentFlags(). String("fingerprint", "", "Fingerprint of the SSH key to remove (required)") - _ = clientNodeUserSshKeyRemoveCmd.MarkPersistentFlagRequired("name") - _ = clientNodeUserSshKeyRemoveCmd.MarkPersistentFlagRequired("fingerprint") + _ = clientNodeUserSSHKeyRemoveCmd.MarkPersistentFlagRequired("name") + _ = clientNodeUserSSHKeyRemoveCmd.MarkPersistentFlagRequired("fingerprint") } diff --git a/docs/docs/gen/api/delete-node-user-ssh-key.api.mdx b/docs/docs/gen/api/delete-node-user-ssh-key.api.mdx index 535680678..e2a32d75e 100644 --- a/docs/docs/gen/api/delete-node-user-ssh-key.api.mdx +++ b/docs/docs/gen/api/delete-node-user-ssh-key.api.mdx @@ -5,7 +5,7 @@ description: "Remove an SSH authorized key by fingerprint for a user on the targ sidebar_label: "Remove SSH authorized key" hide_title: true hide_table_of_contents: true -api: eJztV0tv20YQ/iuLOTkA9XIktyXQg4o4jZukCCwHObiCsOKOxLXIXWZ3qFgV+N+LWVIyLSlo4yRADzmJpHbn8X3z3IJCnzhdkLYGYrjG3K5RSCMmk1dClpRap/9GJVa4EfONWGizRFc4bUgsrBNSlB6dsEZQioKkWyIJYxV2/zIQAcmlh/gW3nt0M2nU7Hdny2L2Vhq5xBwNzcbvrmYsYmYLdJKt8DCNYP92pSCGF5gh4Z9WIQua+PQ1biACj0npNG0gvt3CbygdunFJKStkkfEnpwlhWk0jKKSTORI6Hw4bmSPEkFpP4TECzd4XklKIwOHHUjtUEJMrMTqA6KZ2Ui7RkNhJiIRDj26NSjhbkjZLsZZZieJsJs0mEjOZZc8iYZ3I5Bwz4THDhKwTZyvcxOHosxqy+46Vhe4kVuESTQfvyclOjeMW1jLTShLbvjMyyrX5dRCFf2Y1AVBF4JMUc8l3aFPweU9OmyVEkGvzBs2ScRpUVbQH44uBYC6ETBJbGhJ8+2sceJrNrXD8ItM5uDmiJ6/G56OLR1F9ht1lN2r+ieU8GZw/73a7X8XOlzg3Zdt9YY3HIPS83+efx/a/xo1wIVVVFyJIrCE0xOdkUWQ6CanTu/N8eHus2s7vMGHECseJRrpWdWfnM61OmbiwLpcEMZSlVnCUESmKOzsXVy+4GihBVhTOJui9oFR7wWCgJ7YU72VeZCx7NOrjz8N+v4Pnv8w7w4EaduRPg4vOcHhxMRoNh/1+v8+4OfRlRr5llXROcvprwtyf8uo013lJARZRS2zKl9dmmWGdzd0jRPYF4gQmxxjsTgu7COWwEcrkk6TSn5KCpsy5YNkVwyx1hoyvX+miQMWV8FhNLWynZF8pg0MBb7YjqE1SaZbYZnRubYbSHJn/IUVK0R1IzK3SC41K+I0nzINmDJLROev+HZVLPiZy9F4uUegWLKL2tQtV1U7W23ZNblCbRkCaQtBMJq9e4+Ztw+SlIbeB6lDCLmI+e++6yS++WkUw7A+OM+y9aTW/jhi/uwpBtNfzzZLuPyI5Fq33HffhrqBUkrBJUjpXl4OHJHsZQOaEdEhO43oXPYFEhSR1djIsD5QrpflRZqK5I+TclvRgxEm1qkRWbZA+WbcSpHO0ZROZVrWTShvCJbqTWVU7yRceKRn1+0zejuEQaUfEPj8m9qV1c60UGtERV8aXi4VONAdkgS7X3ocJ5Ae7/392R6caY11wQmvkGayp/d+wR/4g9DsRWkWQI6WW530V5n3Gnoe5GHq8UfS2u9ZQ9Xi8723rZ+/Tzgo3vW1riqvCduDW9bw/fVgVJkxzzWR7Ydh7lhIV0AxroV+GQxA1Dy93c9AfH25C59JmYcP1xq9x6G0P+w03DoiADakhGnT73TDXFNZTLkPsNcNss3sdL16HOG8fgvmbL2w1DIT31CsyqQ2bWrqMldZk3AKfhgjiVqdmmfxp17hrSvhLe0KfRqG7s4ztdi49vndZVfHnjyW6Tc3UWjot5wzm7RaU9vysIF7IzB+O8W0czq6bzvxMfOcF7SREu9HUsNPhNMQAETAKrT2TV5cn+fSZXesJtnydHU9enJ5gaTt0qmkVQYpSoQuBUZ8YJwkW1Lp7VNl5ndqXlReXby5vLiEC+TjpD5I8KDhp2nZbn7ixKzRVtbeU+J1trKp/AGuF9S4= +api: eJztV0tv20YQ/iuLOTkA9XIktyXQg4s4jZukCGwHObiCsOKOyLXIXWZ3qFgV+N+LWVIyLSlo4yRADzmJpHbn8X3z3IBCnzhdkrYGYrjCwq5QSCOur18JWVFmnf4blVjiWszXYqFNiq502pBYWCekqDw6YY2gDAVJlyIJYxX2/zIQAcnUQ3wL7z26mTRq9ruzVTl7K41MsUBDs/N3lzMWMbMlOslWeJhGsHu7VBDDC8yR8E+rkAVdX796jWuIwGNSOU1riG838BtKh+68oowVssj4k9OEMK2nEZTSyQIJnQ+HjSwQYsisp/AYgWbvS0kZRODwY6UdKojJVRjtQXTTOClTNCS2EiLh0KNboRLOVqRNKlYyr1CczKRZR2Im8/xZJKwTuZxjLjzmmJB14mSJ6zgcfdZAdt+zstS9xCpM0fTwnpzsNThuYCVzrSSx7Vsjo0KbX0dR+GfWEAB1BD7JsJB8h9Yln/fktEkhgkKbN2hSxmlU19EOjC8GgrkQMklsZUjw7a9x4Gk2d8Lxi0zn4OaIvn51fjo5exTVJ9hP+1H7Tyznyej0eb/f/yp2vsS5KdvuS2s8BqGnwyH/PLb/Na6FC6mq+hBBYg2hIT4nyzLXSUidwZ3nw5tD1XZ+hwkjVjpONNKNqjs7n2l1zMSFdYUkiKGqtIKDjMhQ3Nm5uHzB1UAJsqJ0NkHvBWXaCwYDPbGleC+LMmfZk8kQfx4Phz08/WXeG4/UuCd/Gp31xuOzs8lkPB4Oh0PGzaGvcvIdq6RzktNfExb+mFfHuS4qCrCIRmJbvrw2aY5NNvcPENkViCOYHGKwPS3sIpTDViiTT5Iqf0wKmqrggmWXDLPUOTK+fqnLEhVXwkM1jbCtkl2lDA4FvNmOoDbJpEmxy+jc2hylOTD/Q4aUoduTWFilFxqV8GtPWATNGCSjc9b9OyoXfEwU6L1MUegOLKLxtQ913U3W225NblGbRkCaQtA0pf9ty+SFIbeGel/CNmI+e++qzS++WkcwHo4OM+y96TS/njh/dxmCaKfnmyXdf0TyXHTet9yHu4IyScImSeVcUw4ekuxlAJkT0iE5jatt9AQSFZLU+dGw3FOulOZHmYv2jpBzW9GDEUfVqgpZtUH6ZN1SkC7QVm1kWtVNKm0IU3RHs6pxki88UjIZDpm8LcMh0g6IfX5I7Evr5lopNKInLo2vFgudaA7IEl2hvQ8TyA92///sTo41xqbghNbIM1hb+79hj/xB6HcitI6gQMosz/sqzPuMPQ9zMQx4oxhstq2hHvB4P9g0z95nvSWuB5vOFFeH7cCtmnl/+rAqXDPNDZPdhWHnWUZUQjushX4ZDkHUPrzczkF/fLgJnUubhQ3XW7/OQ2972G+4cUAEbEgD0ag/7Ie5prSeChlirx1m293rcPHax3nzEMzffGFrYCC8p0GZS23Y1MrlrLQh4xb4NEQQdzo1y+RP28bdUMJfuhP6NArdnWVsNnPp8b3L65o/f6zQrRumVtJpOWcwbzegtOdnBfFC5n5/jO/icHLVduZn4jsvaEch2o6mhp0OpyEGiIBR6OyZvLo8yafP7FpPsOXr7Hjy4vQES7uhU0/rCDKUCl0IjObEeZJgSZ27B5Wd16ldWXlx8ebi5gIikI+Tfi/Jg4Kjpm02zYkbu0RT1ztLid/Zxrr+B0Rp9O4= sidebar_class_name: "delete api-method" info_path: gen/api/agent-management-api custom_edit_url: null diff --git a/docs/docs/gen/api/get-node-user-ssh-key.api.mdx b/docs/docs/gen/api/get-node-user-ssh-key.api.mdx index c28336867..d2c85cfbe 100644 --- a/docs/docs/gen/api/get-node-user-ssh-key.api.mdx +++ b/docs/docs/gen/api/get-node-user-ssh-key.api.mdx @@ -5,7 +5,7 @@ description: "List SSH authorized keys for a user on the target node." sidebar_label: "List SSH authorized keys" hide_title: true hide_table_of_contents: true -api: eJztV21v2zYQ/ivEfUoA2rFTO1sNDKi3pW3WbiiaFPuQGQYtnS3GEqmSpyyaof8+HCU7iq2u6RuwD/1kSb7X5x7e8TYQo4+czklbAxN4rT2Jy8uXQhWUWKf/wVissfRiaZ1QovDohDWCEhSk3ApJGBtj/y8DEkitPEyu4Z1HN1cmnr9wtsjnvyujVpihofn0zcWcTcxtjk6xSw8zCbu3ixgm8ALpDxsjW7n0ySssQYLHqHCaSphcb+BnVA7dtKCEvbG9iUMVw6yaSciVUxkSOh9kjcoQJpBYT+FRguY0c0UJSHD4vtAOY5iQK1DuYXFVJ6hWaEhsLUjh0KO7xVg4W5A2K3Gr0gLF0VyZUoq5StNjKawTqVpgKjymGJF14miN5SSIHtdw3fWsynUvsjGu0PTwjpzq1Rhu4FalOlbEsW+DlJk2Pw1l+Gdegw+VBB8lmCnWoTJneU9OmxVIyLR5jWbFMA2rSu7A+GQguBRCRZEtDAnW/pIEPiXmGUfmc2s8BqOngwH/dFDWLrtY2wcJkTWEhlhP5Xmqo8C0kxvPypvDUOziBiMCCbljXpKuXd/YxVzHXSEvrcsUwQSKQsdwQKIExY1diItf+fDEgqzInY3Qe0GJ9oLBQU8cKd6pLE/Z9ng8wB9Hg0EPT58ueqNhPOqpH4ZnvdHo7Gw8Ho0Gg8GAcXToi5R8KyrlnOIDowkz35XVw+gYszWWImUMa2vNSffarFKsyd8/QGN3njrwOMx/K81V4s7RGGUikKLCd1lBU2R8vO2aIVY6RcbWr3WeY8xN49BNbWzrZNdUQkIBa44juGVudIB2iM1+FwytT/t7WB6J89R00FOgIVceglub+hiwr7AULCGOsL/qS+F90nNe1Q8Yn47Hw6fHD2nV+odRWGqzQpc7XZ+O/3Z3+XJ6Oj4TLZ0t0GssH7qpRSdqEQ1Pn/T7AfHIZhk+xg+n1QiLIypzHak0LcPgecb120vpxibmWapysjlUlQTSVMdw+fIVlhdmacNndM66jzs/ZzGRofdqhUK32CpqCvaDtftued2eLA2ZZ/tRnHOZodrX3B7eA/lfbMozQ1vztml+rFxJGA2Gh+3vnWmxqiemby4Ct3aevloHfCSGU9F633Ik6ApKFAkbRYVzDGW7js8DvNwdHZLTeLs9zoE9MZLSaWef2HMex5ofVSoaHaEWtqD7IDrdxgWya4P0t3VrQTpDW1BD3Lh9GLUhXKHrbHN1kqzwwMl4MGgzM3DsoLBPDgv73LqFjmM0oicujC+WSx1ppmKOLtPeh9vT9+r+/6s77rq11K2G5y5fIZs5/DUvLN8L+o0KWknIkBLLuwrfwWV9iZ7ACa9CJ5vtRKhOeGidbOpnnr3rZpdxt/V6MrtfbC65qnXh2uvNLpGEKIfm4szviyAEsnl4vr2D/vbnVRhRmicfqzdpTMMQu1/FeE6ABA6kRmTYH/TDnTK3njIVqNbsCx/aCfdR3dxT98v2yDplwjs6yVOlDYdVuJQ91FBfA0uDhElr/LJN/rSdxg3gMxlmNCttNgvl8Z1Lq4o/vy/QlXUZbpXTasFIXW8g1p6fY5gsVer3t6F2lkdvmyl7LL7xstiJyfb6aphWQRomAOF62955efX7rJw+sPd9Rix1HLNKQoIqRhdwrv+aRhHm1FI6aHq8Bu5O3IvzK5CgHp6OvdMQrHcGtNnUEld2jaaqdvERv3OAVfUvgBbeIQ== +api: eJztV21v2zYQ/ivEfUoA2rFTO1sFDKi3JW3WbiiaFPuQGQYtnS3GEqmSpyyeof8+HCU7iq2u78A+9JMl+V6fe3jH20CCPna6IG0NRPBKexJXVy+EKim1Tv+DiVjh2ouFdUKJ0qMT1ghKUZBySyRhbIL9vwxIILX0EN3AW49upkwye+5sWcx+V0YtMUdDs8nryxmbmNkCnWKXHqYSdm+XCUTwHOkPmyBbubp68RLXIMFjXDpNa4huNvAzKoduUlLK3the5FAlMK2mEgrlVI6EzgdZo3KECFLrKTxK0JxmoSgFCQ7fldphAhG5EuUeFtd1gmqJhsTWghQOPbo7TISzJWmzFHcqK1EczZRZSzFTWXYshXUiU3PMhMcMY7JOHK1wHQXR4xqu+55Vhe7FNsElmh7ek1O9GsMN3KlMJ4o49m2QMtfmp6EM/8xq8KGS4OMUc8U6tC5Y3pPTZgkScm1eoVkyTMOqkjswPhkILoVQcWxLQ4K1vySBT4l5ypH5whqPwejpYMA/HZS1iy7W9kFCbA2hIdZTRZHpODDt5Naz8uYwFDu/xZhAQuGYl6Rr17d2PtNJV8gL63JFEEFZ6gQOSJSiuLVzcfkrH55EkBWFszF6LyjVXjA46IkjxXuVFxnbHo8H+ONoMOjh6dN5bzRMRj31w/CsNxqdnY3Ho9FgMBgwjg59mZFvRaWcU3xgNGHuu7J6HB1jtsK1yBjD2lpz0r02ywxr8vcP0Nidpw48DvPfSnOVuHM0RpkIpKj0XVbQlDkfb7tiiJXOkLH1K10UmHDTOHRTG9s62TWVkFDAmuMIbpkbHaAdYrPfBUPr0/4Blo/EeWI66CnQkFsfglub+hCwL3EtWEIcYX/Zl8L7tOe8qh8wOR2Ph0+PH9Oq9Q+jsNBmia5wuj4d/+3u6sXkdHwmWjpboFe4fuymFo3UPB6ePun3A+KxzXP8GD+cViMsjmhd6Fhl2ToMnmdcv72Ubm1qnmWqIFtAVUkgTXUMYXJcmoUNn9E56z7s/JzFRI7eqyUK3WKrqCnYD9YeuuVNe7I0ZJ7uR3HOZYZqX3N7eA/kf7EZzwxtzZum+bFyJWE0GB62v7emxaqemLy+DNzaefpqHfAjMZyI1vuWI0FXUKpI2DgunWMo23W8CPByd3RITuPd9jgH9iRISmedfWLPeZJoflSZaHSEmtuSHoLodJuUyK4N0t/WrQTpHG1JDXGT9mHUhnCJrrPN1UmywiMn48GgzczAsYPCPjks7IV1c50kaERPXBpfLhY61kzFAl2uvQ+3p+/V/f9Xd9x1a6lbDc9dvkI2c/hrXli+F/QbFbSSkCOllncVvoPL+hIdwQmvQieb7USoTnhonWzqZ569q2aXcXf1ejJ9WGyuuKp14drrzS6RlKiA5uLM7/MgBLJ5uNjeQX/78zqMKM2Tj9WbNCZhiD2sYjwnQAIHUiMy7A/64U5ZWE+5ClRr9oX37YT7qG4eqPtle2SdMuE9nRSZ0obDKl3GHmqob4ClQULUGr9skz9tp3ED+FSGGc1Km81ceXzrsqriz+9KdOu6DHfKaTVnpG42kGjPzwlEC5X5/W2oneXRm2bKHotvvCx2YrK9vhqmVZCGCCBcb9s7L69+n5XTe/a+z4iljmNaSUhRJegCzvVfkzjGglpKB02P18DdiXt+fg0S1OPTsXcagvXOgDabWuLartBU1S4+4ncOsKr+BWM63eE= sidebar_class_name: "get api-method" info_path: gen/api/agent-management-api custom_edit_url: null diff --git a/docs/docs/gen/api/post-node-user-ssh-key.api.mdx b/docs/docs/gen/api/post-node-user-ssh-key.api.mdx index 53716b9d5..a9b5346a8 100644 --- a/docs/docs/gen/api/post-node-user-ssh-key.api.mdx +++ b/docs/docs/gen/api/post-node-user-ssh-key.api.mdx @@ -5,7 +5,7 @@ description: "Add an SSH authorized key for a user on the target node." sidebar_label: "Add SSH authorized key" hide_title: true hide_table_of_contents: true -api: eJztWG1v2zYQ/isEP6WA7Did3a0CBszdmi3LugVJin5IDYMWzxJjiVTJUxJN0H8fjpRsx3bXtd2ADug3vRzv5bnnTndquASXWFWiMprHfColE5pdXf3CRIWZsepPkGwFNVsaywSrHFhmNMMMGAqbAjJtJAzfah5xFKnj8Q1/7cDOhZbzn62pyvkroUUKBWicTy/O5qRibkqwgmw6Pov4+u5M8phfGIe/Gwmk5spl51DziDtIKquw5vFNw1+AsGCnFWZkjhTG91Yh8Fk7i3gprCgAwTovrEUBPOaZcegvI64o0lJgxiNu4V2lLEgeo60g2oHjOoQoUtDIeg0Rs+DA3oFk1lSodMruRF4BO5oLXUdsLvL8ScSMZblYQM4c5JCgsexoBXXsRZ8EwB4GRpRqkBgJKegBPKAVg4Biw+9ErqRA8r13MiqU/v4k8m/mAX7eRtwlGRSCzmBdkrxDq3TKI14o/RvolHA6adtoDcZHA0G5YCJJTKWR0enPCeBjfJ4Fz8DhCyNrkn/sGDG1rBa5SjxL0TAh5fBAPInRCBpJgyjLXCWecMe3jtQ0+/6YxS0k+EjRDV9BTXwtLTEWFfg46eGBOB77eVrlOdtxNlca2BEM02HE3nLnsgHIp5PJyXM2nU6nw+HQl9sPxLu3/LMow9u2jTgqzCFgdg71VMrLgCy9bSlQVxrtQlBPR6N9sM+hJnjBA/zJgD5G79Ys5koeAnBpbCGQx7yqlNwD9DoDdmsW7OwnQklS5ktrEnCOYaYc61hDnsKDKEof+WQygu/Go9EAnj5fDMYncjwQ3548G4zHz55NJuPxaDQa8QBFlaPb8kpYK6gPKYTCHYpqn5aU4qJCDwsLGrsu6pROcwhtZbiHyLpTfZBUhEEvzczSd+VOKZUYCqzcIS2gq4LobFYEs1A5EL5upcoSJBF830xQ1htZN2wfkMeb/PBmk0zoFLYzujAmB6H33H+TAWZgdzQWRqqlAslc7RAKbxm8ZrDW2A+j8pLEWAHOiRSY2oKFhViHPNB9U9dbH4cOtdluubzqMvlSo615u6uhZ8x7z1121dUV2/hQfZ1pX8E9d1kp6tyIf7Pa/iGEU7Z13yfdn2WYCWQmSSprQx/YVNepR5cq0QJaBXc9bXz2JKBQ+UE+7s0gii5FzrozTCxMhRsnDpqVFZBpDXhv7IqhKsBUHSWN3K4mpRFSsAfLKQRJBx4ZmYxG2x3UU2wvoyf7GX2tt8aoAZtenPm2sGbO18T+HxL7zX5iT41dKClBswE7065aLlWiqMWUYAvlnB9tv2b3y8/u5FAjDp8QISUN9923/GsX/vLT2Ua8AMwMrZGlcR55Wm9ifkxr6nHTf+jbYxqtj5twTaP3qlsz7V1YHGebnfOK0hoyt715riPJEEverTR+3vFCPOouTvs59tc3137yILpcbpaal3143SrRZ6SlBW1pvKEu4qmfYjYLNX1QeMTJ5QDeyXA09BMsRV8Iz8pu56PVfn+v34W/2XD8M38GBGwQHvC4zIXS5FVlczIRcnLDSZpHPN4av0gnPeqnsS4zs8jPaHSoaRbCwWubty09fleBrUO+7oRVYkFA3TRcKkfXksdLkbvdhXY7zKPL7mv8hP3H+/5BTPoFQ1MuvDSPOY86Lqyhoe39k2J6z+r+Cb4EP2ZtxDMQEqzHObz6MVgeXJOCzdG9JklRhBPTJIES/1Z2tlXNF39cXVNFdT8BCt9LuBX3tKGL++CmKcMvpbgJzxqeC51WIiXZoJPqTzwu351y9VEdBKJpgsS1WYFu2zUuSPcETNv+BVUoqXI= +api: eJztWG1v2zYQ/isEP6WA7Did3a0CBszdmjVruwVJin5IDeMsniXGEqmSVBJN0H8fjpRsx3bXtd2ADug3vRzv5bnnTndquECbGFk6qRWP+VQIBopdXr5gULlMG/knCrbCmi21YcAqi4ZpxVyGzIFJ0TGlBQ7fKR5xB6nl8TV/Y9HMQYn5r0ZX5fw1KEixQOXm0/OzOamY6xINkE3LZxFf350JHvNzbd3vWiCpubx88RJrHnGLSWWkq3l83fBnCAbNtHIZmSOF8Z2RDvmsnUW8BAMFOjTWCysokMc809b5y4hLirQEl/GIG3xfSYOCx85UGO3AcRVChBSVY72GiBm0aG5RMKMrJ1XKbiGvkB3NQdURm0OeP4qYNiyHBebMYo6J04YdrbCOveijANj9QEMpB4kWmKIa4L0zMAgoNvwWcinAke+9k1Eh1Y8nkX8zD/DzNuI2ybAAOuPqkuStM1KlPOKFVK9QpYTTSdtGazA+GQjKBYMk0ZVyjE5/SQCf4vMseIbWPdOiJvmHjhFTy2qRy8Sz1GkGQgwPxJNo5VA50gBlmcvEE+74xpKaZt8fvbjBxD1QdM1XWBNfS0OMdRJ9nPTwQBwP/Tyt8pztOJtLhewIh+kwYu+4tdkAxePJ5OQpm06n0+Fw6MvtJ+LdO/5FlOFt20bcSZdjwOwl1lMhLgKy9LalQG2plQ1BPR6N9sF+iTXBix7gzwb0IXo3ejGX4hCAS20KcDzmVSXFHqBXGbIbvWBnvxBKgjJfGp2gtcxl0rKONeQp3kNR+sgnkxH+MB6NBvj46WIwPhHjAXx/8mQwHj95MpmMx6PRaMQDFFXu7JZXYAxQH5IOC3soqn1aUoqLynlYWNDYdVErVZpjaCvDPUTWneqjpCIMemmml74rd0qpxBy4yh7SgqoqiM56RTCDzJHwtStZliiI4PtmgrLeyLph+4A83uSHN5tkoFLczuhC6xxB7bn/NkOXodnRWGghlxIFs7V1WHjL6DWjMdp8HJXnJMYKtBZSZHILFhZiHfJA901db30cOtRmu+Xyusvkc+VMzdtdDT1jPnjuoquurtjGh+rrTPkK7rnLSqhzDf9mtf1DCKds675Puj/LXAaO6SSpjAl9YFNdpx5dqkSDzki87WnjsyfQgcwP8nFvBpF0CTnrzjBY6MptnDhoVlRIphW6O21WzMkCddVRUovtapLKYYrmYDmFIOnAAyOT0Wi7g3qK7WX0ZD+jb9TWGDVg0/Mz3xbWzPmW2P9DYr/bT+ypNgspBCo2YGfKVsulTCS1mBJNIa31o+237H792Z0casThEwJC0HDffcu/deGvP51txAt0maY1stTWI0/rTcyPaU09bvoPfXtMo/VxE65p9F51a6a5DYvjbLNzXlJaQ+a2N891JJlzJe9WGj/veCEedRen/Rz729srP3kQXS42S83zPrxulegz0tKCttTeUBfx1E8xm4WaPig84uRyAO9kOBr6CZaiL8Czstv5aLXf3+t34W82HP/CnwEBG4f37rjMQSryqjI5mQg5ueYkzSMeb41fpJMe9dNYl5lZ5Gc0OtQ0C7D4xuRtS4/fV2jqkK9bMBIWBNR1w4W0dC14vITc7i6022EeXXRf40fsP973D2LSLxiKcuGlecx51HFhDQ1t758V0wdW98/wJfgxayOeIQg0Hufw6udgeXBFCjZH95okRRFOTJMES/e3srOtaj7/4/KKKqr7CVD4XsIN3NGGDnfBTV2GX0pxE541PAeVVpCSbNBJ9QcPy3enXH1UB4FomiBxpVeo2naNi6N7AqZt/wKpPaky sidebar_class_name: "post api-method" info_path: gen/api/agent-management-api custom_edit_url: null diff --git a/internal/agent/processor.go b/internal/agent/processor.go index 4af5f5721..a2bdb62af 100644 --- a/internal/agent/processor.go +++ b/internal/agent/processor.go @@ -108,7 +108,7 @@ func NewNodeProcessor( case "group": return processGroupOperation(userProvider, logger, req) case "sshKey": - return processSshKeyOperation(userProvider, logger, req) + return processSSHKeyOperation(userProvider, logger, req) case "package": return processPackageOperation(packageProvider, logger, req) case "log": diff --git a/internal/agent/processor_ssh_key.go b/internal/agent/processor_ssh_key.go index 2d2b9ec92..df9368eb2 100644 --- a/internal/agent/processor_ssh_key.go +++ b/internal/agent/processor_ssh_key.go @@ -31,8 +31,8 @@ import ( "github.com/retr0h/osapi/internal/provider/node/user" ) -// processSshKeyOperation dispatches SSH key sub-operations. -func processSshKeyOperation( +// processSSHKeyOperation dispatches SSH key sub-operations. +func processSSHKeyOperation( userProvider user.Provider, logger *slog.Logger, jobRequest job.Request, @@ -52,18 +52,18 @@ func processSshKeyOperation( switch subOp { case "list": - return processSshKeyList(ctx, userProvider, logger, jobRequest) + return processSSHKeyList(ctx, userProvider, logger, jobRequest) case "add": - return processSshKeyAdd(ctx, userProvider, logger, jobRequest) + return processSSHKeyAdd(ctx, userProvider, logger, jobRequest) case "remove": - return processSshKeyRemove(ctx, userProvider, logger, jobRequest) + return processSSHKeyRemove(ctx, userProvider, logger, jobRequest) default: return nil, fmt.Errorf("unsupported sshKey operation: %s", jobRequest.Operation) } } -// processSshKeyList lists SSH keys for a user. -func processSshKeyList( +// processSSHKeyList lists SSH keys for a user. +func processSSHKeyList( ctx context.Context, userProvider user.Provider, logger *slog.Logger, @@ -88,15 +88,15 @@ func processSshKeyList( return json.Marshal(keys) } -// processSshKeyAdd adds an SSH key for a user. -func processSshKeyAdd( +// processSSHKeyAdd adds an SSH key for a user. +func processSSHKeyAdd( ctx context.Context, userProvider user.Provider, logger *slog.Logger, jobRequest job.Request, ) (json.RawMessage, error) { var data struct { - Username string `json:"username"` + Username string `json:"username"` Key user.SSHKey `json:"key"` } if err := json.Unmarshal(jobRequest.Data, &data); err != nil { @@ -115,8 +115,8 @@ func processSshKeyAdd( return json.Marshal(result) } -// processSshKeyRemove removes an SSH key for a user. -func processSshKeyRemove( +// processSSHKeyRemove removes an SSH key for a user. +func processSSHKeyRemove( ctx context.Context, userProvider user.Provider, logger *slog.Logger, diff --git a/internal/agent/processor_ssh_key_public_test.go b/internal/agent/processor_ssh_key_public_test.go index 7e48ba936..e5d9d1aa8 100644 --- a/internal/agent/processor_ssh_key_public_test.go +++ b/internal/agent/processor_ssh_key_public_test.go @@ -36,21 +36,21 @@ import ( userMocks "github.com/retr0h/osapi/internal/provider/node/user/mocks" ) -type ProcessorSshKeyPublicTestSuite struct { +type ProcessorSSHKeyPublicTestSuite struct { suite.Suite mockCtrl *gomock.Controller } -func (s *ProcessorSshKeyPublicTestSuite) SetupTest() { +func (s *ProcessorSSHKeyPublicTestSuite) SetupTest() { s.mockCtrl = gomock.NewController(s.T()) } -func (s *ProcessorSshKeyPublicTestSuite) TearDownTest() { +func (s *ProcessorSSHKeyPublicTestSuite) TearDownTest() { s.mockCtrl.Finish() } -func (s *ProcessorSshKeyPublicTestSuite) newProcessor( +func (s *ProcessorSSHKeyPublicTestSuite) newProcessor( userProvider user.Provider, ) agent.ProcessorFunc { return agent.NewNodeProcessor( @@ -65,7 +65,7 @@ func (s *ProcessorSshKeyPublicTestSuite) newProcessor( ) } -func (s *ProcessorSshKeyPublicTestSuite) TestProcessSshKeyOperation() { +func (s *ProcessorSSHKeyPublicTestSuite) TestProcessSSHKeyOperation() { tests := []struct { name string jobRequest job.Request @@ -137,7 +137,7 @@ func (s *ProcessorSshKeyPublicTestSuite) TestProcessSshKeyOperation() { } } -func (s *ProcessorSshKeyPublicTestSuite) TestProcessSshKeyList() { +func (s *ProcessorSSHKeyPublicTestSuite) TestProcessSSHKeyList() { tests := []struct { name string jobRequest job.Request @@ -204,7 +204,9 @@ func (s *ProcessorSshKeyPublicTestSuite) TestProcessSshKeyList() { }, setupMock: func() user.Provider { m := userMocks.NewMockProvider(s.mockCtrl) - m.EXPECT().ListKeys(gomock.Any(), "missing").Return(nil, errors.New("user not found")) + m.EXPECT(). + ListKeys(gomock.Any(), "missing"). + Return(nil, errors.New("user not found")) return m }, expectError: true, @@ -232,7 +234,7 @@ func (s *ProcessorSshKeyPublicTestSuite) TestProcessSshKeyList() { } } -func (s *ProcessorSshKeyPublicTestSuite) TestProcessSshKeyAdd() { +func (s *ProcessorSSHKeyPublicTestSuite) TestProcessSSHKeyAdd() { tests := []struct { name string jobRequest job.Request @@ -244,8 +246,8 @@ func (s *ProcessorSshKeyPublicTestSuite) TestProcessSshKeyAdd() { { name: "successful ssh key add", jobRequest: job.Request{ - Type: job.TypeModify, - Category: "node", + Type: job.TypeModify, + Category: "node", Operation: "sshKey.add", Data: json.RawMessage( `{"username":"john","key":{"type":"ssh-ed25519","fingerprint":"SHA256:abc123","comment":"john@laptop","raw_line":"ssh-ed25519 AAAA... john@laptop"}}`, @@ -287,8 +289,8 @@ func (s *ProcessorSshKeyPublicTestSuite) TestProcessSshKeyAdd() { { name: "ssh key add provider error", jobRequest: job.Request{ - Type: job.TypeModify, - Category: "node", + Type: job.TypeModify, + Category: "node", Operation: "sshKey.add", Data: json.RawMessage( `{"username":"john","key":{"type":"ssh-ed25519","fingerprint":"SHA256:abc123","raw_line":"ssh-ed25519 AAAA..."}}`, @@ -326,7 +328,7 @@ func (s *ProcessorSshKeyPublicTestSuite) TestProcessSshKeyAdd() { } } -func (s *ProcessorSshKeyPublicTestSuite) TestProcessSshKeyRemove() { +func (s *ProcessorSSHKeyPublicTestSuite) TestProcessSSHKeyRemove() { tests := []struct { name string jobRequest job.Request @@ -345,9 +347,11 @@ func (s *ProcessorSshKeyPublicTestSuite) TestProcessSshKeyRemove() { }, setupMock: func() user.Provider { m := userMocks.NewMockProvider(s.mockCtrl) - m.EXPECT().RemoveKey(gomock.Any(), "john", "SHA256:abc123").Return(&user.SSHKeyResult{ - Changed: true, - }, nil) + m.EXPECT(). + RemoveKey(gomock.Any(), "john", "SHA256:abc123"). + Return(&user.SSHKeyResult{ + Changed: true, + }, nil) return m }, validate: func(result json.RawMessage) { @@ -411,6 +415,6 @@ func (s *ProcessorSshKeyPublicTestSuite) TestProcessSshKeyRemove() { } } -func TestProcessorSshKeyPublicTestSuite(t *testing.T) { - suite.Run(t, new(ProcessorSshKeyPublicTestSuite)) +func TestProcessorSSHKeyPublicTestSuite(t *testing.T) { + suite.Run(t, new(ProcessorSSHKeyPublicTestSuite)) } diff --git a/internal/controller/api/gen/api.yaml b/internal/controller/api/gen/api.yaml index a486fe03c..c7e17eab8 100644 --- a/internal/controller/api/gen/api.yaml +++ b/internal/controller/api/gen/api.yaml @@ -4300,7 +4300,7 @@ paths: List SSH authorized keys for a user on the target node. tags: - User_and_Group_Management_API_user_operations - operationId: GetNodeUserSshKey + operationId: GetNodeUserSSHKey security: - BearerAuth: - user:read @@ -4338,7 +4338,7 @@ paths: Add an SSH authorized key for a user on the target node. tags: - User_and_Group_Management_API_user_operations - operationId: PostNodeUserSshKey + operationId: PostNodeUserSSHKey security: - BearerAuth: - user:write @@ -4392,7 +4392,7 @@ paths: node. tags: - User_and_Group_Management_API_user_operations - operationId: DeleteNodeUserSshKey + operationId: DeleteNodeUserSSHKey security: - BearerAuth: - user:write diff --git a/internal/controller/api/node/user/gen/api.yaml b/internal/controller/api/node/user/gen/api.yaml index 87f684137..e215349a8 100644 --- a/internal/controller/api/node/user/gen/api.yaml +++ b/internal/controller/api/node/user/gen/api.yaml @@ -345,7 +345,7 @@ paths: List SSH authorized keys for a user on the target node. tags: - user_operations - operationId: GetNodeUserSshKey + operationId: GetNodeUserSSHKey security: - BearerAuth: - user:read @@ -384,7 +384,7 @@ paths: Add an SSH authorized key for a user on the target node. tags: - user_operations - operationId: PostNodeUserSshKey + operationId: PostNodeUserSSHKey security: - BearerAuth: - user:write @@ -438,7 +438,7 @@ paths: target node. tags: - user_operations - operationId: DeleteNodeUserSshKey + operationId: DeleteNodeUserSSHKey security: - BearerAuth: - user:write diff --git a/internal/controller/api/node/user/gen/user.gen.go b/internal/controller/api/node/user/gen/user.gen.go index 47378c2d3..ff1b9b731 100644 --- a/internal/controller/api/node/user/gen/user.gen.go +++ b/internal/controller/api/node/user/gen/user.gen.go @@ -368,8 +368,8 @@ type PutNodeUserJSONRequestBody = UserUpdateRequest // PostNodeUserPasswordJSONRequestBody defines body for PostNodeUserPassword for application/json ContentType. type PostNodeUserPasswordJSONRequestBody = UserPasswordRequest -// PostNodeUserSshKeyJSONRequestBody defines body for PostNodeUserSshKey for application/json ContentType. -type PostNodeUserSshKeyJSONRequestBody = SSHKeyAddRequest +// PostNodeUserSSHKeyJSONRequestBody defines body for PostNodeUserSSHKey for application/json ContentType. +type PostNodeUserSSHKeyJSONRequestBody = SSHKeyAddRequest // ServerInterface represents all server handlers. type ServerInterface interface { @@ -408,13 +408,13 @@ type ServerInterface interface { PostNodeUserPassword(ctx echo.Context, hostname Hostname, name UserName) error // List SSH authorized keys // (GET /node/{hostname}/user/{name}/ssh-key) - GetNodeUserSshKey(ctx echo.Context, hostname Hostname, name UserName) error + GetNodeUserSSHKey(ctx echo.Context, hostname Hostname, name UserName) error // Add SSH authorized key // (POST /node/{hostname}/user/{name}/ssh-key) - PostNodeUserSshKey(ctx echo.Context, hostname Hostname, name UserName) error + PostNodeUserSSHKey(ctx echo.Context, hostname Hostname, name UserName) error // Remove SSH authorized key // (DELETE /node/{hostname}/user/{name}/ssh-key/{fingerprint}) - DeleteNodeUserSshKey(ctx echo.Context, hostname Hostname, name UserName, fingerprint SSHKeyFingerprint) error + DeleteNodeUserSSHKey(ctx echo.Context, hostname Hostname, name UserName, fingerprint SSHKeyFingerprint) error } // ServerInterfaceWrapper converts echo contexts to parameters. @@ -676,8 +676,8 @@ func (w *ServerInterfaceWrapper) PostNodeUserPassword(ctx echo.Context) error { return err } -// GetNodeUserSshKey converts echo context to params. -func (w *ServerInterfaceWrapper) GetNodeUserSshKey(ctx echo.Context) error { +// GetNodeUserSSHKey converts echo context to params. +func (w *ServerInterfaceWrapper) GetNodeUserSSHKey(ctx echo.Context) error { var err error // ------------- Path parameter "hostname" ------------- var hostname Hostname @@ -698,12 +698,12 @@ func (w *ServerInterfaceWrapper) GetNodeUserSshKey(ctx echo.Context) error { ctx.Set(BearerAuthScopes, []string{"user:read"}) // Invoke the callback with all the unmarshaled arguments - err = w.Handler.GetNodeUserSshKey(ctx, hostname, name) + err = w.Handler.GetNodeUserSSHKey(ctx, hostname, name) return err } -// PostNodeUserSshKey converts echo context to params. -func (w *ServerInterfaceWrapper) PostNodeUserSshKey(ctx echo.Context) error { +// PostNodeUserSSHKey converts echo context to params. +func (w *ServerInterfaceWrapper) PostNodeUserSSHKey(ctx echo.Context) error { var err error // ------------- Path parameter "hostname" ------------- var hostname Hostname @@ -724,12 +724,12 @@ func (w *ServerInterfaceWrapper) PostNodeUserSshKey(ctx echo.Context) error { ctx.Set(BearerAuthScopes, []string{"user:write"}) // Invoke the callback with all the unmarshaled arguments - err = w.Handler.PostNodeUserSshKey(ctx, hostname, name) + err = w.Handler.PostNodeUserSSHKey(ctx, hostname, name) return err } -// DeleteNodeUserSshKey converts echo context to params. -func (w *ServerInterfaceWrapper) DeleteNodeUserSshKey(ctx echo.Context) error { +// DeleteNodeUserSSHKey converts echo context to params. +func (w *ServerInterfaceWrapper) DeleteNodeUserSSHKey(ctx echo.Context) error { var err error // ------------- Path parameter "hostname" ------------- var hostname Hostname @@ -758,7 +758,7 @@ func (w *ServerInterfaceWrapper) DeleteNodeUserSshKey(ctx echo.Context) error { ctx.Set(BearerAuthScopes, []string{"user:write"}) // Invoke the callback with all the unmarshaled arguments - err = w.Handler.DeleteNodeUserSshKey(ctx, hostname, name, fingerprint) + err = w.Handler.DeleteNodeUserSSHKey(ctx, hostname, name, fingerprint) return err } @@ -801,9 +801,9 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL router.GET(baseURL+"/node/:hostname/user/:name", wrapper.GetNodeUserByName) router.PUT(baseURL+"/node/:hostname/user/:name", wrapper.PutNodeUser) router.POST(baseURL+"/node/:hostname/user/:name/password", wrapper.PostNodeUserPassword) - router.GET(baseURL+"/node/:hostname/user/:name/ssh-key", wrapper.GetNodeUserSshKey) - router.POST(baseURL+"/node/:hostname/user/:name/ssh-key", wrapper.PostNodeUserSshKey) - router.DELETE(baseURL+"/node/:hostname/user/:name/ssh-key/:fingerprint", wrapper.DeleteNodeUserSshKey) + router.GET(baseURL+"/node/:hostname/user/:name/ssh-key", wrapper.GetNodeUserSSHKey) + router.POST(baseURL+"/node/:hostname/user/:name/ssh-key", wrapper.PostNodeUserSSHKey) + router.DELETE(baseURL+"/node/:hostname/user/:name/ssh-key/:fingerprint", wrapper.DeleteNodeUserSSHKey) } @@ -1411,146 +1411,146 @@ func (response PostNodeUserPassword500JSONResponse) VisitPostNodeUserPasswordRes return json.NewEncoder(w).Encode(response) } -type GetNodeUserSshKeyRequestObject struct { +type GetNodeUserSSHKeyRequestObject struct { Hostname Hostname `json:"hostname"` Name UserName `json:"name"` } -type GetNodeUserSshKeyResponseObject interface { - VisitGetNodeUserSshKeyResponse(w http.ResponseWriter) error +type GetNodeUserSSHKeyResponseObject interface { + VisitGetNodeUserSSHKeyResponse(w http.ResponseWriter) error } -type GetNodeUserSshKey200JSONResponse SSHKeyCollectionResponse +type GetNodeUserSSHKey200JSONResponse SSHKeyCollectionResponse -func (response GetNodeUserSshKey200JSONResponse) VisitGetNodeUserSshKeyResponse(w http.ResponseWriter) error { +func (response GetNodeUserSSHKey200JSONResponse) VisitGetNodeUserSSHKeyResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") w.WriteHeader(200) return json.NewEncoder(w).Encode(response) } -type GetNodeUserSshKey401JSONResponse externalRef0.ErrorResponse +type GetNodeUserSSHKey401JSONResponse externalRef0.ErrorResponse -func (response GetNodeUserSshKey401JSONResponse) VisitGetNodeUserSshKeyResponse(w http.ResponseWriter) error { +func (response GetNodeUserSSHKey401JSONResponse) VisitGetNodeUserSSHKeyResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") w.WriteHeader(401) return json.NewEncoder(w).Encode(response) } -type GetNodeUserSshKey403JSONResponse externalRef0.ErrorResponse +type GetNodeUserSSHKey403JSONResponse externalRef0.ErrorResponse -func (response GetNodeUserSshKey403JSONResponse) VisitGetNodeUserSshKeyResponse(w http.ResponseWriter) error { +func (response GetNodeUserSSHKey403JSONResponse) VisitGetNodeUserSSHKeyResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") w.WriteHeader(403) return json.NewEncoder(w).Encode(response) } -type GetNodeUserSshKey500JSONResponse externalRef0.ErrorResponse +type GetNodeUserSSHKey500JSONResponse externalRef0.ErrorResponse -func (response GetNodeUserSshKey500JSONResponse) VisitGetNodeUserSshKeyResponse(w http.ResponseWriter) error { +func (response GetNodeUserSSHKey500JSONResponse) VisitGetNodeUserSSHKeyResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") w.WriteHeader(500) return json.NewEncoder(w).Encode(response) } -type PostNodeUserSshKeyRequestObject struct { +type PostNodeUserSSHKeyRequestObject struct { Hostname Hostname `json:"hostname"` Name UserName `json:"name"` - Body *PostNodeUserSshKeyJSONRequestBody + Body *PostNodeUserSSHKeyJSONRequestBody } -type PostNodeUserSshKeyResponseObject interface { - VisitPostNodeUserSshKeyResponse(w http.ResponseWriter) error +type PostNodeUserSSHKeyResponseObject interface { + VisitPostNodeUserSSHKeyResponse(w http.ResponseWriter) error } -type PostNodeUserSshKey200JSONResponse SSHKeyMutationResponse +type PostNodeUserSSHKey200JSONResponse SSHKeyMutationResponse -func (response PostNodeUserSshKey200JSONResponse) VisitPostNodeUserSshKeyResponse(w http.ResponseWriter) error { +func (response PostNodeUserSSHKey200JSONResponse) VisitPostNodeUserSSHKeyResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") w.WriteHeader(200) return json.NewEncoder(w).Encode(response) } -type PostNodeUserSshKey400JSONResponse externalRef0.ErrorResponse +type PostNodeUserSSHKey400JSONResponse externalRef0.ErrorResponse -func (response PostNodeUserSshKey400JSONResponse) VisitPostNodeUserSshKeyResponse(w http.ResponseWriter) error { +func (response PostNodeUserSSHKey400JSONResponse) VisitPostNodeUserSSHKeyResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") w.WriteHeader(400) return json.NewEncoder(w).Encode(response) } -type PostNodeUserSshKey401JSONResponse externalRef0.ErrorResponse +type PostNodeUserSSHKey401JSONResponse externalRef0.ErrorResponse -func (response PostNodeUserSshKey401JSONResponse) VisitPostNodeUserSshKeyResponse(w http.ResponseWriter) error { +func (response PostNodeUserSSHKey401JSONResponse) VisitPostNodeUserSSHKeyResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") w.WriteHeader(401) return json.NewEncoder(w).Encode(response) } -type PostNodeUserSshKey403JSONResponse externalRef0.ErrorResponse +type PostNodeUserSSHKey403JSONResponse externalRef0.ErrorResponse -func (response PostNodeUserSshKey403JSONResponse) VisitPostNodeUserSshKeyResponse(w http.ResponseWriter) error { +func (response PostNodeUserSSHKey403JSONResponse) VisitPostNodeUserSSHKeyResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") w.WriteHeader(403) return json.NewEncoder(w).Encode(response) } -type PostNodeUserSshKey500JSONResponse externalRef0.ErrorResponse +type PostNodeUserSSHKey500JSONResponse externalRef0.ErrorResponse -func (response PostNodeUserSshKey500JSONResponse) VisitPostNodeUserSshKeyResponse(w http.ResponseWriter) error { +func (response PostNodeUserSSHKey500JSONResponse) VisitPostNodeUserSSHKeyResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") w.WriteHeader(500) return json.NewEncoder(w).Encode(response) } -type DeleteNodeUserSshKeyRequestObject struct { +type DeleteNodeUserSSHKeyRequestObject struct { Hostname Hostname `json:"hostname"` Name UserName `json:"name"` Fingerprint SSHKeyFingerprint `json:"fingerprint"` } -type DeleteNodeUserSshKeyResponseObject interface { - VisitDeleteNodeUserSshKeyResponse(w http.ResponseWriter) error +type DeleteNodeUserSSHKeyResponseObject interface { + VisitDeleteNodeUserSSHKeyResponse(w http.ResponseWriter) error } -type DeleteNodeUserSshKey200JSONResponse SSHKeyMutationResponse +type DeleteNodeUserSSHKey200JSONResponse SSHKeyMutationResponse -func (response DeleteNodeUserSshKey200JSONResponse) VisitDeleteNodeUserSshKeyResponse(w http.ResponseWriter) error { +func (response DeleteNodeUserSSHKey200JSONResponse) VisitDeleteNodeUserSSHKeyResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") w.WriteHeader(200) return json.NewEncoder(w).Encode(response) } -type DeleteNodeUserSshKey401JSONResponse externalRef0.ErrorResponse +type DeleteNodeUserSSHKey401JSONResponse externalRef0.ErrorResponse -func (response DeleteNodeUserSshKey401JSONResponse) VisitDeleteNodeUserSshKeyResponse(w http.ResponseWriter) error { +func (response DeleteNodeUserSSHKey401JSONResponse) VisitDeleteNodeUserSSHKeyResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") w.WriteHeader(401) return json.NewEncoder(w).Encode(response) } -type DeleteNodeUserSshKey403JSONResponse externalRef0.ErrorResponse +type DeleteNodeUserSSHKey403JSONResponse externalRef0.ErrorResponse -func (response DeleteNodeUserSshKey403JSONResponse) VisitDeleteNodeUserSshKeyResponse(w http.ResponseWriter) error { +func (response DeleteNodeUserSSHKey403JSONResponse) VisitDeleteNodeUserSSHKeyResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") w.WriteHeader(403) return json.NewEncoder(w).Encode(response) } -type DeleteNodeUserSshKey500JSONResponse externalRef0.ErrorResponse +type DeleteNodeUserSSHKey500JSONResponse externalRef0.ErrorResponse -func (response DeleteNodeUserSshKey500JSONResponse) VisitDeleteNodeUserSshKeyResponse(w http.ResponseWriter) error { +func (response DeleteNodeUserSSHKey500JSONResponse) VisitDeleteNodeUserSSHKeyResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") w.WriteHeader(500) @@ -1594,13 +1594,13 @@ type StrictServerInterface interface { PostNodeUserPassword(ctx context.Context, request PostNodeUserPasswordRequestObject) (PostNodeUserPasswordResponseObject, error) // List SSH authorized keys // (GET /node/{hostname}/user/{name}/ssh-key) - GetNodeUserSshKey(ctx context.Context, request GetNodeUserSshKeyRequestObject) (GetNodeUserSshKeyResponseObject, error) + GetNodeUserSSHKey(ctx context.Context, request GetNodeUserSSHKeyRequestObject) (GetNodeUserSSHKeyResponseObject, error) // Add SSH authorized key // (POST /node/{hostname}/user/{name}/ssh-key) - PostNodeUserSshKey(ctx context.Context, request PostNodeUserSshKeyRequestObject) (PostNodeUserSshKeyResponseObject, error) + PostNodeUserSSHKey(ctx context.Context, request PostNodeUserSSHKeyRequestObject) (PostNodeUserSSHKeyResponseObject, error) // Remove SSH authorized key // (DELETE /node/{hostname}/user/{name}/ssh-key/{fingerprint}) - DeleteNodeUserSshKey(ctx context.Context, request DeleteNodeUserSshKeyRequestObject) (DeleteNodeUserSshKeyResponseObject, error) + DeleteNodeUserSSHKey(ctx context.Context, request DeleteNodeUserSSHKeyRequestObject) (DeleteNodeUserSSHKeyResponseObject, error) } type StrictHandlerFunc = strictecho.StrictEchoHandlerFunc @@ -1927,85 +1927,85 @@ func (sh *strictHandler) PostNodeUserPassword(ctx echo.Context, hostname Hostnam return nil } -// GetNodeUserSshKey operation middleware -func (sh *strictHandler) GetNodeUserSshKey(ctx echo.Context, hostname Hostname, name UserName) error { - var request GetNodeUserSshKeyRequestObject +// GetNodeUserSSHKey operation middleware +func (sh *strictHandler) GetNodeUserSSHKey(ctx echo.Context, hostname Hostname, name UserName) error { + var request GetNodeUserSSHKeyRequestObject request.Hostname = hostname request.Name = name handler := func(ctx echo.Context, request interface{}) (interface{}, error) { - return sh.ssi.GetNodeUserSshKey(ctx.Request().Context(), request.(GetNodeUserSshKeyRequestObject)) + return sh.ssi.GetNodeUserSSHKey(ctx.Request().Context(), request.(GetNodeUserSSHKeyRequestObject)) } for _, middleware := range sh.middlewares { - handler = middleware(handler, "GetNodeUserSshKey") + handler = middleware(handler, "GetNodeUserSSHKey") } response, err := handler(ctx, request) if err != nil { return err - } else if validResponse, ok := response.(GetNodeUserSshKeyResponseObject); ok { - return validResponse.VisitGetNodeUserSshKeyResponse(ctx.Response()) + } else if validResponse, ok := response.(GetNodeUserSSHKeyResponseObject); ok { + return validResponse.VisitGetNodeUserSSHKeyResponse(ctx.Response()) } else if response != nil { return fmt.Errorf("unexpected response type: %T", response) } return nil } -// PostNodeUserSshKey operation middleware -func (sh *strictHandler) PostNodeUserSshKey(ctx echo.Context, hostname Hostname, name UserName) error { - var request PostNodeUserSshKeyRequestObject +// PostNodeUserSSHKey operation middleware +func (sh *strictHandler) PostNodeUserSSHKey(ctx echo.Context, hostname Hostname, name UserName) error { + var request PostNodeUserSSHKeyRequestObject request.Hostname = hostname request.Name = name - var body PostNodeUserSshKeyJSONRequestBody + var body PostNodeUserSSHKeyJSONRequestBody if err := ctx.Bind(&body); err != nil { return err } request.Body = &body handler := func(ctx echo.Context, request interface{}) (interface{}, error) { - return sh.ssi.PostNodeUserSshKey(ctx.Request().Context(), request.(PostNodeUserSshKeyRequestObject)) + return sh.ssi.PostNodeUserSSHKey(ctx.Request().Context(), request.(PostNodeUserSSHKeyRequestObject)) } for _, middleware := range sh.middlewares { - handler = middleware(handler, "PostNodeUserSshKey") + handler = middleware(handler, "PostNodeUserSSHKey") } response, err := handler(ctx, request) if err != nil { return err - } else if validResponse, ok := response.(PostNodeUserSshKeyResponseObject); ok { - return validResponse.VisitPostNodeUserSshKeyResponse(ctx.Response()) + } else if validResponse, ok := response.(PostNodeUserSSHKeyResponseObject); ok { + return validResponse.VisitPostNodeUserSSHKeyResponse(ctx.Response()) } else if response != nil { return fmt.Errorf("unexpected response type: %T", response) } return nil } -// DeleteNodeUserSshKey operation middleware -func (sh *strictHandler) DeleteNodeUserSshKey(ctx echo.Context, hostname Hostname, name UserName, fingerprint SSHKeyFingerprint) error { - var request DeleteNodeUserSshKeyRequestObject +// DeleteNodeUserSSHKey operation middleware +func (sh *strictHandler) DeleteNodeUserSSHKey(ctx echo.Context, hostname Hostname, name UserName, fingerprint SSHKeyFingerprint) error { + var request DeleteNodeUserSSHKeyRequestObject request.Hostname = hostname request.Name = name request.Fingerprint = fingerprint handler := func(ctx echo.Context, request interface{}) (interface{}, error) { - return sh.ssi.DeleteNodeUserSshKey(ctx.Request().Context(), request.(DeleteNodeUserSshKeyRequestObject)) + return sh.ssi.DeleteNodeUserSSHKey(ctx.Request().Context(), request.(DeleteNodeUserSSHKeyRequestObject)) } for _, middleware := range sh.middlewares { - handler = middleware(handler, "DeleteNodeUserSshKey") + handler = middleware(handler, "DeleteNodeUserSSHKey") } response, err := handler(ctx, request) if err != nil { return err - } else if validResponse, ok := response.(DeleteNodeUserSshKeyResponseObject); ok { - return validResponse.VisitDeleteNodeUserSshKeyResponse(ctx.Response()) + } else if validResponse, ok := response.(DeleteNodeUserSSHKeyResponseObject); ok { + return validResponse.VisitDeleteNodeUserSSHKeyResponse(ctx.Response()) } else if response != nil { return fmt.Errorf("unexpected response type: %T", response) } diff --git a/internal/controller/api/node/user/ssh_key_create.go b/internal/controller/api/node/user/ssh_key_create.go index 7701f49ac..eea99047c 100644 --- a/internal/controller/api/node/user/ssh_key_create.go +++ b/internal/controller/api/node/user/ssh_key_create.go @@ -33,17 +33,17 @@ import ( "github.com/retr0h/osapi/internal/validation" ) -// PostNodeUserSshKey adds an SSH authorized key for a user on a target node. -func (u *User) PostNodeUserSshKey( +// PostNodeUserSSHKey adds an SSH authorized key for a user on a target node. +func (u *User) PostNodeUserSSHKey( ctx context.Context, - request gen.PostNodeUserSshKeyRequestObject, -) (gen.PostNodeUserSshKeyResponseObject, error) { + request gen.PostNodeUserSSHKeyRequestObject, +) (gen.PostNodeUserSSHKeyResponseObject, error) { if errMsg, ok := validateHostname(request.Hostname); !ok { - return gen.PostNodeUserSshKey400JSONResponse{Error: &errMsg}, nil + return gen.PostNodeUserSSHKey400JSONResponse{Error: &errMsg}, nil } if errMsg, ok := validation.Struct(request.Body); !ok { - return gen.PostNodeUserSshKey400JSONResponse{Error: &errMsg}, nil + return gen.PostNodeUserSSHKey400JSONResponse{Error: &errMsg}, nil } hostname := request.Hostname @@ -61,7 +61,7 @@ func (u *User) PostNodeUserSshKey( } if job.IsBroadcastTarget(hostname) { - return u.postNodeUserSshKeyBroadcast(ctx, hostname, data) + return u.postNodeUserSSHKeyBroadcast(ctx, hostname, data) } jobID, resp, err := u.JobClient.Modify( @@ -73,13 +73,13 @@ func (u *User) PostNodeUserSshKey( ) if err != nil { errMsg := err.Error() - return gen.PostNodeUserSshKey500JSONResponse{Error: &errMsg}, nil + return gen.PostNodeUserSSHKey500JSONResponse{Error: &errMsg}, nil } if resp.Status == job.StatusSkipped { jobUUID := uuid.MustParse(jobID) e := resp.Error - return gen.PostNodeUserSshKey200JSONResponse{ + return gen.PostNodeUserSSHKey200JSONResponse{ JobId: &jobUUID, Results: []gen.SSHKeyMutationEntry{ { @@ -100,7 +100,7 @@ func (u *User) PostNodeUserSshKey( changed := resp.Changed agentHostname := resp.Hostname - return gen.PostNodeUserSshKey200JSONResponse{ + return gen.PostNodeUserSSHKey200JSONResponse{ JobId: &jobUUID, Results: []gen.SSHKeyMutationEntry{ { @@ -112,12 +112,12 @@ func (u *User) PostNodeUserSshKey( }, nil } -// postNodeUserSshKeyBroadcast handles broadcast targets for SSH key add. -func (u *User) postNodeUserSshKeyBroadcast( +// postNodeUserSSHKeyBroadcast handles broadcast targets for SSH key add. +func (u *User) postNodeUserSSHKeyBroadcast( ctx context.Context, target string, data map[string]string, -) (gen.PostNodeUserSshKeyResponseObject, error) { +) (gen.PostNodeUserSSHKeyResponseObject, error) { jobID, responses, err := u.JobClient.ModifyBroadcast( ctx, target, @@ -127,7 +127,7 @@ func (u *User) postNodeUserSshKeyBroadcast( ) if err != nil { errMsg := err.Error() - return gen.PostNodeUserSshKey500JSONResponse{Error: &errMsg}, nil + return gen.PostNodeUserSSHKey500JSONResponse{Error: &errMsg}, nil } var apiResponses []gen.SSHKeyMutationEntry @@ -153,7 +153,7 @@ func (u *User) postNodeUserSshKeyBroadcast( jobUUID := uuid.MustParse(jobID) - return gen.PostNodeUserSshKey200JSONResponse{ + return gen.PostNodeUserSSHKey200JSONResponse{ JobId: &jobUUID, Results: apiResponses, }, nil diff --git a/internal/controller/api/node/user/ssh_key_create_public_test.go b/internal/controller/api/node/user/ssh_key_create_public_test.go index ad76a510b..e6b3d3e1e 100644 --- a/internal/controller/api/node/user/ssh_key_create_public_test.go +++ b/internal/controller/api/node/user/ssh_key_create_public_test.go @@ -78,16 +78,16 @@ func (s *SSHKeyCreatePublicTestSuite) TearDownTest() { s.mockCtrl.Finish() } -func (s *SSHKeyCreatePublicTestSuite) TestPostNodeUserSshKey() { +func (s *SSHKeyCreatePublicTestSuite) TestPostNodeUserSSHKey() { tests := []struct { name string - request gen.PostNodeUserSshKeyRequestObject + request gen.PostNodeUserSSHKeyRequestObject setupMock func() - validateFunc func(resp gen.PostNodeUserSshKeyResponseObject) + validateFunc func(resp gen.PostNodeUserSSHKeyResponseObject) }{ { name: "success", - request: gen.PostNodeUserSshKeyRequestObject{ + request: gen.PostNodeUserSSHKeyRequestObject{ Hostname: "server1", Name: "testuser", Body: &gen.SSHKeyAddRequest{ @@ -112,8 +112,8 @@ func (s *SSHKeyCreatePublicTestSuite) TestPostNodeUserSshKey() { Data: json.RawMessage(`{"changed":true}`), }, nil) }, - validateFunc: func(resp gen.PostNodeUserSshKeyResponseObject) { - r, ok := resp.(gen.PostNodeUserSshKey200JSONResponse) + validateFunc: func(resp gen.PostNodeUserSSHKeyResponseObject) { + r, ok := resp.(gen.PostNodeUserSSHKey200JSONResponse) s.True(ok) s.Require().Len(r.Results, 1) s.Equal(gen.SSHKeyMutationEntryStatusOk, r.Results[0].Status) @@ -123,7 +123,7 @@ func (s *SSHKeyCreatePublicTestSuite) TestPostNodeUserSshKey() { }, { name: "validation error empty key", - request: gen.PostNodeUserSshKeyRequestObject{ + request: gen.PostNodeUserSSHKeyRequestObject{ Hostname: "server1", Name: "testuser", Body: &gen.SSHKeyAddRequest{ @@ -131,14 +131,14 @@ func (s *SSHKeyCreatePublicTestSuite) TestPostNodeUserSshKey() { }, }, setupMock: func() {}, - validateFunc: func(resp gen.PostNodeUserSshKeyResponseObject) { - _, ok := resp.(gen.PostNodeUserSshKey400JSONResponse) + validateFunc: func(resp gen.PostNodeUserSSHKeyResponseObject) { + _, ok := resp.(gen.PostNodeUserSSHKey400JSONResponse) s.True(ok) }, }, { name: "validation error empty hostname", - request: gen.PostNodeUserSshKeyRequestObject{ + request: gen.PostNodeUserSSHKeyRequestObject{ Hostname: "", Name: "testuser", Body: &gen.SSHKeyAddRequest{ @@ -146,14 +146,14 @@ func (s *SSHKeyCreatePublicTestSuite) TestPostNodeUserSshKey() { }, }, setupMock: func() {}, - validateFunc: func(resp gen.PostNodeUserSshKeyResponseObject) { - _, ok := resp.(gen.PostNodeUserSshKey400JSONResponse) + validateFunc: func(resp gen.PostNodeUserSSHKeyResponseObject) { + _, ok := resp.(gen.PostNodeUserSSHKey400JSONResponse) s.True(ok) }, }, { name: "when job skipped", - request: gen.PostNodeUserSshKeyRequestObject{ + request: gen.PostNodeUserSSHKeyRequestObject{ Hostname: "server1", Name: "testuser", Body: &gen.SSHKeyAddRequest{ @@ -175,15 +175,15 @@ func (s *SSHKeyCreatePublicTestSuite) TestPostNodeUserSshKey() { Error: "unsupported", }, nil) }, - validateFunc: func(resp gen.PostNodeUserSshKeyResponseObject) { - r, ok := resp.(gen.PostNodeUserSshKey200JSONResponse) + validateFunc: func(resp gen.PostNodeUserSSHKeyResponseObject) { + r, ok := resp.(gen.PostNodeUserSSHKey200JSONResponse) s.True(ok) s.Equal(gen.SSHKeyMutationEntryStatusSkipped, r.Results[0].Status) }, }, { name: "job client error", - request: gen.PostNodeUserSshKeyRequestObject{ + request: gen.PostNodeUserSSHKeyRequestObject{ Hostname: "server1", Name: "testuser", Body: &gen.SSHKeyAddRequest{ @@ -201,14 +201,14 @@ func (s *SSHKeyCreatePublicTestSuite) TestPostNodeUserSshKey() { ). Return("", nil, assert.AnError) }, - validateFunc: func(resp gen.PostNodeUserSshKeyResponseObject) { - _, ok := resp.(gen.PostNodeUserSshKey500JSONResponse) + validateFunc: func(resp gen.PostNodeUserSSHKeyResponseObject) { + _, ok := resp.(gen.PostNodeUserSSHKey500JSONResponse) s.True(ok) }, }, { name: "broadcast target _all", - request: gen.PostNodeUserSshKeyRequestObject{ + request: gen.PostNodeUserSSHKeyRequestObject{ Hostname: "_all", Name: "testuser", Body: &gen.SSHKeyAddRequest{ @@ -234,15 +234,15 @@ func (s *SSHKeyCreatePublicTestSuite) TestPostNodeUserSshKey() { }, }, nil) }, - validateFunc: func(resp gen.PostNodeUserSshKeyResponseObject) { - r, ok := resp.(gen.PostNodeUserSshKey200JSONResponse) + validateFunc: func(resp gen.PostNodeUserSSHKeyResponseObject) { + r, ok := resp.(gen.PostNodeUserSSHKey200JSONResponse) s.True(ok) s.Len(r.Results, 1) }, }, { name: "broadcast with failed and skipped agents", - request: gen.PostNodeUserSshKeyRequestObject{ + request: gen.PostNodeUserSSHKeyRequestObject{ Hostname: "_all", Name: "testuser", Body: &gen.SSHKeyAddRequest{ @@ -278,8 +278,8 @@ func (s *SSHKeyCreatePublicTestSuite) TestPostNodeUserSshKey() { }, }, nil) }, - validateFunc: func(resp gen.PostNodeUserSshKeyResponseObject) { - r, ok := resp.(gen.PostNodeUserSshKey200JSONResponse) + validateFunc: func(resp gen.PostNodeUserSSHKeyResponseObject) { + r, ok := resp.(gen.PostNodeUserSSHKey200JSONResponse) s.True(ok) s.Len(r.Results, 3) @@ -296,7 +296,7 @@ func (s *SSHKeyCreatePublicTestSuite) TestPostNodeUserSshKey() { }, { name: "broadcast job client error", - request: gen.PostNodeUserSshKeyRequestObject{ + request: gen.PostNodeUserSSHKeyRequestObject{ Hostname: "_all", Name: "testuser", Body: &gen.SSHKeyAddRequest{ @@ -314,8 +314,8 @@ func (s *SSHKeyCreatePublicTestSuite) TestPostNodeUserSshKey() { ). Return("", nil, assert.AnError) }, - validateFunc: func(resp gen.PostNodeUserSshKeyResponseObject) { - _, ok := resp.(gen.PostNodeUserSshKey500JSONResponse) + validateFunc: func(resp gen.PostNodeUserSSHKeyResponseObject) { + _, ok := resp.(gen.PostNodeUserSSHKey500JSONResponse) s.True(ok) }, }, @@ -324,14 +324,14 @@ func (s *SSHKeyCreatePublicTestSuite) TestPostNodeUserSshKey() { for _, tt := range tests { s.Run(tt.name, func() { tt.setupMock() - resp, err := s.handler.PostNodeUserSshKey(s.ctx, tt.request) + resp, err := s.handler.PostNodeUserSSHKey(s.ctx, tt.request) s.NoError(err) tt.validateFunc(resp) }) } } -func (s *SSHKeyCreatePublicTestSuite) TestPostNodeUserSshKeyValidationHTTP() { +func (s *SSHKeyCreatePublicTestSuite) TestPostNodeUserSSHKeyValidationHTTP() { tests := []struct { name string body string @@ -389,7 +389,7 @@ func (s *SSHKeyCreatePublicTestSuite) TestPostNodeUserSshKeyValidationHTTP() { const rbacSSHKeyCreateTestSigningKey = "test-signing-key-for-rbac-ssh-key-create" -func (s *SSHKeyCreatePublicTestSuite) TestPostNodeUserSshKeyRBACHTTP() { +func (s *SSHKeyCreatePublicTestSuite) TestPostNodeUserSSHKeyRBACHTTP() { tokenManager := authtoken.New(s.logger) tests := []struct { diff --git a/internal/controller/api/node/user/ssh_key_delete.go b/internal/controller/api/node/user/ssh_key_delete.go index 3bed27962..ac8dcc5b5 100644 --- a/internal/controller/api/node/user/ssh_key_delete.go +++ b/internal/controller/api/node/user/ssh_key_delete.go @@ -30,14 +30,14 @@ import ( "github.com/retr0h/osapi/internal/job" ) -// DeleteNodeUserSshKey removes an SSH authorized key by fingerprint for a user +// DeleteNodeUserSSHKey removes an SSH authorized key by fingerprint for a user // on a target node. -func (u *User) DeleteNodeUserSshKey( +func (u *User) DeleteNodeUserSSHKey( ctx context.Context, - request gen.DeleteNodeUserSshKeyRequestObject, -) (gen.DeleteNodeUserSshKeyResponseObject, error) { + request gen.DeleteNodeUserSSHKeyRequestObject, +) (gen.DeleteNodeUserSSHKeyResponseObject, error) { if errMsg, ok := validateHostname(request.Hostname); !ok { - return gen.DeleteNodeUserSshKey500JSONResponse{Error: &errMsg}, nil + return gen.DeleteNodeUserSSHKey500JSONResponse{Error: &errMsg}, nil } hostname := request.Hostname @@ -57,7 +57,7 @@ func (u *User) DeleteNodeUserSshKey( } if job.IsBroadcastTarget(hostname) { - return u.deleteNodeUserSshKeyBroadcast(ctx, hostname, data) + return u.deleteNodeUserSSHKeyBroadcast(ctx, hostname, data) } jobID, resp, err := u.JobClient.Modify( @@ -69,13 +69,13 @@ func (u *User) DeleteNodeUserSshKey( ) if err != nil { errMsg := err.Error() - return gen.DeleteNodeUserSshKey500JSONResponse{Error: &errMsg}, nil + return gen.DeleteNodeUserSSHKey500JSONResponse{Error: &errMsg}, nil } if resp.Status == job.StatusSkipped { jobUUID := uuid.MustParse(jobID) e := resp.Error - return gen.DeleteNodeUserSshKey200JSONResponse{ + return gen.DeleteNodeUserSSHKey200JSONResponse{ JobId: &jobUUID, Results: []gen.SSHKeyMutationEntry{ { @@ -91,7 +91,7 @@ func (u *User) DeleteNodeUserSshKey( changed := resp.Changed agentHostname := resp.Hostname - return gen.DeleteNodeUserSshKey200JSONResponse{ + return gen.DeleteNodeUserSSHKey200JSONResponse{ JobId: &jobUUID, Results: []gen.SSHKeyMutationEntry{ { @@ -103,12 +103,12 @@ func (u *User) DeleteNodeUserSshKey( }, nil } -// deleteNodeUserSshKeyBroadcast handles broadcast targets for SSH key remove. -func (u *User) deleteNodeUserSshKeyBroadcast( +// deleteNodeUserSSHKeyBroadcast handles broadcast targets for SSH key remove. +func (u *User) deleteNodeUserSSHKeyBroadcast( ctx context.Context, target string, data map[string]string, -) (gen.DeleteNodeUserSshKeyResponseObject, error) { +) (gen.DeleteNodeUserSSHKeyResponseObject, error) { jobID, responses, err := u.JobClient.ModifyBroadcast( ctx, target, @@ -118,7 +118,7 @@ func (u *User) deleteNodeUserSshKeyBroadcast( ) if err != nil { errMsg := err.Error() - return gen.DeleteNodeUserSshKey500JSONResponse{Error: &errMsg}, nil + return gen.DeleteNodeUserSSHKey500JSONResponse{Error: &errMsg}, nil } var apiResponses []gen.SSHKeyMutationEntry @@ -144,7 +144,7 @@ func (u *User) deleteNodeUserSshKeyBroadcast( jobUUID := uuid.MustParse(jobID) - return gen.DeleteNodeUserSshKey200JSONResponse{ + return gen.DeleteNodeUserSSHKey200JSONResponse{ JobId: &jobUUID, Results: apiResponses, }, nil diff --git a/internal/controller/api/node/user/ssh_key_delete_public_test.go b/internal/controller/api/node/user/ssh_key_delete_public_test.go index 9e4c826c5..6f4ff71a6 100644 --- a/internal/controller/api/node/user/ssh_key_delete_public_test.go +++ b/internal/controller/api/node/user/ssh_key_delete_public_test.go @@ -76,16 +76,16 @@ func (s *SSHKeyDeletePublicTestSuite) TearDownTest() { s.mockCtrl.Finish() } -func (s *SSHKeyDeletePublicTestSuite) TestDeleteNodeUserSshKey() { +func (s *SSHKeyDeletePublicTestSuite) TestDeleteNodeUserSSHKey() { tests := []struct { name string - request gen.DeleteNodeUserSshKeyRequestObject + request gen.DeleteNodeUserSSHKeyRequestObject setupMock func() - validateFunc func(resp gen.DeleteNodeUserSshKeyResponseObject) + validateFunc func(resp gen.DeleteNodeUserSSHKeyResponseObject) }{ { name: "success", - request: gen.DeleteNodeUserSshKeyRequestObject{ + request: gen.DeleteNodeUserSSHKeyRequestObject{ Hostname: "server1", Name: "testuser", Fingerprint: "SHA256:abc123", @@ -107,8 +107,8 @@ func (s *SSHKeyDeletePublicTestSuite) TestDeleteNodeUserSshKey() { Changed: boolPtr(true), }, nil) }, - validateFunc: func(resp gen.DeleteNodeUserSshKeyResponseObject) { - r, ok := resp.(gen.DeleteNodeUserSshKey200JSONResponse) + validateFunc: func(resp gen.DeleteNodeUserSSHKeyResponseObject) { + r, ok := resp.(gen.DeleteNodeUserSSHKey200JSONResponse) s.True(ok) s.Require().Len(r.Results, 1) s.Equal(gen.SSHKeyMutationEntryStatusOk, r.Results[0].Status) @@ -118,7 +118,7 @@ func (s *SSHKeyDeletePublicTestSuite) TestDeleteNodeUserSshKey() { }, { name: "when job skipped", - request: gen.DeleteNodeUserSshKeyRequestObject{ + request: gen.DeleteNodeUserSSHKeyRequestObject{ Hostname: "server1", Name: "testuser", Fingerprint: "SHA256:abc123", @@ -138,15 +138,15 @@ func (s *SSHKeyDeletePublicTestSuite) TestDeleteNodeUserSshKey() { Error: "unsupported", }, nil) }, - validateFunc: func(resp gen.DeleteNodeUserSshKeyResponseObject) { - r, ok := resp.(gen.DeleteNodeUserSshKey200JSONResponse) + validateFunc: func(resp gen.DeleteNodeUserSSHKeyResponseObject) { + r, ok := resp.(gen.DeleteNodeUserSSHKey200JSONResponse) s.True(ok) s.Equal(gen.SSHKeyMutationEntryStatusSkipped, r.Results[0].Status) }, }, { name: "job client error", - request: gen.DeleteNodeUserSshKeyRequestObject{ + request: gen.DeleteNodeUserSSHKeyRequestObject{ Hostname: "server1", Name: "testuser", Fingerprint: "SHA256:abc123", @@ -162,21 +162,21 @@ func (s *SSHKeyDeletePublicTestSuite) TestDeleteNodeUserSshKey() { ). Return("", nil, assert.AnError) }, - validateFunc: func(resp gen.DeleteNodeUserSshKeyResponseObject) { - _, ok := resp.(gen.DeleteNodeUserSshKey500JSONResponse) + validateFunc: func(resp gen.DeleteNodeUserSSHKeyResponseObject) { + _, ok := resp.(gen.DeleteNodeUserSSHKey500JSONResponse) s.True(ok) }, }, { name: "validation error empty hostname", - request: gen.DeleteNodeUserSshKeyRequestObject{ + request: gen.DeleteNodeUserSSHKeyRequestObject{ Hostname: "", Name: "testuser", Fingerprint: "SHA256:abc123", }, setupMock: func() {}, - validateFunc: func(resp gen.DeleteNodeUserSshKeyResponseObject) { - r, ok := resp.(gen.DeleteNodeUserSshKey500JSONResponse) + validateFunc: func(resp gen.DeleteNodeUserSSHKeyResponseObject) { + r, ok := resp.(gen.DeleteNodeUserSSHKey500JSONResponse) s.True(ok) s.Require().NotNil(r.Error) s.Contains(*r.Error, "required") @@ -184,7 +184,7 @@ func (s *SSHKeyDeletePublicTestSuite) TestDeleteNodeUserSshKey() { }, { name: "broadcast target _all", - request: gen.DeleteNodeUserSshKeyRequestObject{ + request: gen.DeleteNodeUserSSHKeyRequestObject{ Hostname: "_all", Name: "testuser", Fingerprint: "SHA256:abc123", @@ -207,15 +207,15 @@ func (s *SSHKeyDeletePublicTestSuite) TestDeleteNodeUserSshKey() { }, }, nil) }, - validateFunc: func(resp gen.DeleteNodeUserSshKeyResponseObject) { - r, ok := resp.(gen.DeleteNodeUserSshKey200JSONResponse) + validateFunc: func(resp gen.DeleteNodeUserSSHKeyResponseObject) { + r, ok := resp.(gen.DeleteNodeUserSSHKey200JSONResponse) s.True(ok) s.Len(r.Results, 1) }, }, { name: "broadcast with failed and skipped agents", - request: gen.DeleteNodeUserSshKeyRequestObject{ + request: gen.DeleteNodeUserSSHKeyRequestObject{ Hostname: "_all", Name: "testuser", Fingerprint: "SHA256:abc123", @@ -248,8 +248,8 @@ func (s *SSHKeyDeletePublicTestSuite) TestDeleteNodeUserSshKey() { }, }, nil) }, - validateFunc: func(resp gen.DeleteNodeUserSshKeyResponseObject) { - r, ok := resp.(gen.DeleteNodeUserSshKey200JSONResponse) + validateFunc: func(resp gen.DeleteNodeUserSSHKeyResponseObject) { + r, ok := resp.(gen.DeleteNodeUserSSHKey200JSONResponse) s.True(ok) s.Len(r.Results, 3) @@ -266,7 +266,7 @@ func (s *SSHKeyDeletePublicTestSuite) TestDeleteNodeUserSshKey() { }, { name: "broadcast job client error", - request: gen.DeleteNodeUserSshKeyRequestObject{ + request: gen.DeleteNodeUserSSHKeyRequestObject{ Hostname: "_all", Name: "testuser", Fingerprint: "SHA256:abc123", @@ -282,8 +282,8 @@ func (s *SSHKeyDeletePublicTestSuite) TestDeleteNodeUserSshKey() { ). Return("", nil, assert.AnError) }, - validateFunc: func(resp gen.DeleteNodeUserSshKeyResponseObject) { - _, ok := resp.(gen.DeleteNodeUserSshKey500JSONResponse) + validateFunc: func(resp gen.DeleteNodeUserSSHKeyResponseObject) { + _, ok := resp.(gen.DeleteNodeUserSSHKey500JSONResponse) s.True(ok) }, }, @@ -292,14 +292,14 @@ func (s *SSHKeyDeletePublicTestSuite) TestDeleteNodeUserSshKey() { for _, tt := range tests { s.Run(tt.name, func() { tt.setupMock() - resp, err := s.handler.DeleteNodeUserSshKey(s.ctx, tt.request) + resp, err := s.handler.DeleteNodeUserSSHKey(s.ctx, tt.request) s.NoError(err) tt.validateFunc(resp) }) } } -func (s *SSHKeyDeletePublicTestSuite) TestDeleteNodeUserSshKeyValidationHTTP() { +func (s *SSHKeyDeletePublicTestSuite) TestDeleteNodeUserSSHKeyValidationHTTP() { tests := []struct { name string path string @@ -368,7 +368,7 @@ func (s *SSHKeyDeletePublicTestSuite) TestDeleteNodeUserSshKeyValidationHTTP() { const rbacSSHKeyDeleteTestSigningKey = "test-signing-key-for-rbac-ssh-key-delete" -func (s *SSHKeyDeletePublicTestSuite) TestDeleteNodeUserSshKeyRBACHTTP() { +func (s *SSHKeyDeletePublicTestSuite) TestDeleteNodeUserSSHKeyRBACHTTP() { tokenManager := authtoken.New(s.logger) tests := []struct { diff --git a/internal/controller/api/node/user/ssh_key_list_get.go b/internal/controller/api/node/user/ssh_key_list_get.go index bf3b25891..aef326c13 100644 --- a/internal/controller/api/node/user/ssh_key_list_get.go +++ b/internal/controller/api/node/user/ssh_key_list_get.go @@ -32,13 +32,13 @@ import ( userProv "github.com/retr0h/osapi/internal/provider/node/user" ) -// GetNodeUserSshKey lists SSH authorized keys for a user on a target node. -func (u *User) GetNodeUserSshKey( +// GetNodeUserSSHKey lists SSH authorized keys for a user on a target node. +func (u *User) GetNodeUserSSHKey( ctx context.Context, - request gen.GetNodeUserSshKeyRequestObject, -) (gen.GetNodeUserSshKeyResponseObject, error) { + request gen.GetNodeUserSSHKeyRequestObject, +) (gen.GetNodeUserSSHKeyResponseObject, error) { if errMsg, ok := validateHostname(request.Hostname); !ok { - return gen.GetNodeUserSshKey500JSONResponse{Error: &errMsg}, nil + return gen.GetNodeUserSSHKey500JSONResponse{Error: &errMsg}, nil } hostname := request.Hostname @@ -55,7 +55,7 @@ func (u *User) GetNodeUserSshKey( } if job.IsBroadcastTarget(hostname) { - return u.getNodeUserSshKeyBroadcast(ctx, hostname, data) + return u.getNodeUserSSHKeyBroadcast(ctx, hostname, data) } jobID, resp, err := u.JobClient.Query( @@ -67,13 +67,13 @@ func (u *User) GetNodeUserSshKey( ) if err != nil { errMsg := err.Error() - return gen.GetNodeUserSshKey500JSONResponse{Error: &errMsg}, nil + return gen.GetNodeUserSSHKey500JSONResponse{Error: &errMsg}, nil } if resp.Status == job.StatusSkipped { e := resp.Error jobUUID := uuid.MustParse(jobID) - return gen.GetNodeUserSshKey200JSONResponse{ + return gen.GetNodeUserSSHKey200JSONResponse{ JobId: &jobUUID, Results: []gen.SSHKeyEntry{ { @@ -88,18 +88,18 @@ func (u *User) GetNodeUserSshKey( results := sshKeyInfoListFromResponse(resp) jobUUID := uuid.MustParse(jobID) - return gen.GetNodeUserSshKey200JSONResponse{ + return gen.GetNodeUserSSHKey200JSONResponse{ JobId: &jobUUID, Results: results, }, nil } -// getNodeUserSshKeyBroadcast handles broadcast targets for SSH key list. -func (u *User) getNodeUserSshKeyBroadcast( +// getNodeUserSSHKeyBroadcast handles broadcast targets for SSH key list. +func (u *User) getNodeUserSSHKeyBroadcast( ctx context.Context, target string, data map[string]string, -) (gen.GetNodeUserSshKeyResponseObject, error) { +) (gen.GetNodeUserSSHKeyResponseObject, error) { jobID, responses, err := u.JobClient.QueryBroadcast( ctx, target, @@ -109,7 +109,7 @@ func (u *User) getNodeUserSshKeyBroadcast( ) if err != nil { errMsg := err.Error() - return gen.GetNodeUserSshKey500JSONResponse{Error: &errMsg}, nil + return gen.GetNodeUserSSHKey500JSONResponse{Error: &errMsg}, nil } allResults := make([]gen.SSHKeyEntry, 0) @@ -136,7 +136,7 @@ func (u *User) getNodeUserSshKeyBroadcast( jobUUID := uuid.MustParse(jobID) - return gen.GetNodeUserSshKey200JSONResponse{ + return gen.GetNodeUserSSHKey200JSONResponse{ JobId: &jobUUID, Results: allResults, }, nil diff --git a/internal/controller/api/node/user/ssh_key_list_get_public_test.go b/internal/controller/api/node/user/ssh_key_list_get_public_test.go index 1a9fa44b7..874e02885 100644 --- a/internal/controller/api/node/user/ssh_key_list_get_public_test.go +++ b/internal/controller/api/node/user/ssh_key_list_get_public_test.go @@ -77,16 +77,16 @@ func (s *SSHKeyListGetPublicTestSuite) TearDownTest() { s.mockCtrl.Finish() } -func (s *SSHKeyListGetPublicTestSuite) TestGetNodeUserSshKey() { +func (s *SSHKeyListGetPublicTestSuite) TestGetNodeUserSSHKey() { tests := []struct { name string - request gen.GetNodeUserSshKeyRequestObject + request gen.GetNodeUserSSHKeyRequestObject setupMock func() - validateFunc func(resp gen.GetNodeUserSshKeyResponseObject) + validateFunc func(resp gen.GetNodeUserSSHKeyResponseObject) }{ { name: "success with keys", - request: gen.GetNodeUserSshKeyRequestObject{ + request: gen.GetNodeUserSSHKeyRequestObject{ Hostname: "server1", Name: "testuser", }, @@ -106,8 +106,8 @@ func (s *SSHKeyListGetPublicTestSuite) TestGetNodeUserSshKey() { ), }, nil) }, - validateFunc: func(resp gen.GetNodeUserSshKeyResponseObject) { - r, ok := resp.(gen.GetNodeUserSshKey200JSONResponse) + validateFunc: func(resp gen.GetNodeUserSSHKeyResponseObject) { + r, ok := resp.(gen.GetNodeUserSSHKey200JSONResponse) s.True(ok) s.Require().NotNil(r.JobId) s.Require().Len(r.Results, 1) @@ -120,7 +120,7 @@ func (s *SSHKeyListGetPublicTestSuite) TestGetNodeUserSshKey() { }, { name: "success with key without comment", - request: gen.GetNodeUserSshKeyRequestObject{ + request: gen.GetNodeUserSSHKeyRequestObject{ Hostname: "server1", Name: "testuser", }, @@ -140,8 +140,8 @@ func (s *SSHKeyListGetPublicTestSuite) TestGetNodeUserSshKey() { ), }, nil) }, - validateFunc: func(resp gen.GetNodeUserSshKeyResponseObject) { - r, ok := resp.(gen.GetNodeUserSshKey200JSONResponse) + validateFunc: func(resp gen.GetNodeUserSSHKeyResponseObject) { + r, ok := resp.(gen.GetNodeUserSSHKey200JSONResponse) s.True(ok) s.Require().Len(r.Results, 1) s.Require().NotNil(r.Results[0].Keys) @@ -152,7 +152,7 @@ func (s *SSHKeyListGetPublicTestSuite) TestGetNodeUserSshKey() { }, { name: "success with nil response data", - request: gen.GetNodeUserSshKeyRequestObject{ + request: gen.GetNodeUserSSHKeyRequestObject{ Hostname: "server1", Name: "testuser", }, @@ -170,8 +170,8 @@ func (s *SSHKeyListGetPublicTestSuite) TestGetNodeUserSshKey() { Data: nil, }, nil) }, - validateFunc: func(resp gen.GetNodeUserSshKeyResponseObject) { - r, ok := resp.(gen.GetNodeUserSshKey200JSONResponse) + validateFunc: func(resp gen.GetNodeUserSSHKeyResponseObject) { + r, ok := resp.(gen.GetNodeUserSSHKey200JSONResponse) s.True(ok) s.Require().NotNil(r.JobId) s.Require().Len(r.Results, 1) @@ -179,7 +179,7 @@ func (s *SSHKeyListGetPublicTestSuite) TestGetNodeUserSshKey() { }, { name: "when job skipped", - request: gen.GetNodeUserSshKeyRequestObject{ + request: gen.GetNodeUserSSHKeyRequestObject{ Hostname: "server1", Name: "testuser", }, @@ -198,8 +198,8 @@ func (s *SSHKeyListGetPublicTestSuite) TestGetNodeUserSshKey() { Error: "ssh key: operation not supported on this OS family", }, nil) }, - validateFunc: func(resp gen.GetNodeUserSshKeyResponseObject) { - r, ok := resp.(gen.GetNodeUserSshKey200JSONResponse) + validateFunc: func(resp gen.GetNodeUserSSHKeyResponseObject) { + r, ok := resp.(gen.GetNodeUserSSHKey200JSONResponse) s.True(ok) s.Require().Len(r.Results, 1) s.Equal(gen.SSHKeyEntryStatusSkipped, r.Results[0].Status) @@ -208,7 +208,7 @@ func (s *SSHKeyListGetPublicTestSuite) TestGetNodeUserSshKey() { }, { name: "job client error", - request: gen.GetNodeUserSshKeyRequestObject{ + request: gen.GetNodeUserSSHKeyRequestObject{ Hostname: "server1", Name: "testuser", }, @@ -223,20 +223,20 @@ func (s *SSHKeyListGetPublicTestSuite) TestGetNodeUserSshKey() { ). Return("", nil, assert.AnError) }, - validateFunc: func(resp gen.GetNodeUserSshKeyResponseObject) { - _, ok := resp.(gen.GetNodeUserSshKey500JSONResponse) + validateFunc: func(resp gen.GetNodeUserSSHKeyResponseObject) { + _, ok := resp.(gen.GetNodeUserSSHKey500JSONResponse) s.True(ok) }, }, { name: "validation error empty hostname", - request: gen.GetNodeUserSshKeyRequestObject{ + request: gen.GetNodeUserSSHKeyRequestObject{ Hostname: "", Name: "testuser", }, setupMock: func() {}, - validateFunc: func(resp gen.GetNodeUserSshKeyResponseObject) { - r, ok := resp.(gen.GetNodeUserSshKey500JSONResponse) + validateFunc: func(resp gen.GetNodeUserSSHKeyResponseObject) { + r, ok := resp.(gen.GetNodeUserSSHKey500JSONResponse) s.True(ok) s.Require().NotNil(r.Error) s.Contains(*r.Error, "required") @@ -244,7 +244,7 @@ func (s *SSHKeyListGetPublicTestSuite) TestGetNodeUserSshKey() { }, { name: "broadcast target _all", - request: gen.GetNodeUserSshKeyRequestObject{ + request: gen.GetNodeUserSSHKeyRequestObject{ Hostname: "_all", Name: "testuser", }, @@ -271,8 +271,8 @@ func (s *SSHKeyListGetPublicTestSuite) TestGetNodeUserSshKey() { nil, ) }, - validateFunc: func(resp gen.GetNodeUserSshKeyResponseObject) { - r, ok := resp.(gen.GetNodeUserSshKey200JSONResponse) + validateFunc: func(resp gen.GetNodeUserSSHKeyResponseObject) { + r, ok := resp.(gen.GetNodeUserSSHKey200JSONResponse) s.True(ok) s.Require().NotNil(r.JobId) s.Len(r.Results, 1) @@ -280,7 +280,7 @@ func (s *SSHKeyListGetPublicTestSuite) TestGetNodeUserSshKey() { }, { name: "broadcast includes failed and skipped", - request: gen.GetNodeUserSshKeyRequestObject{ + request: gen.GetNodeUserSSHKeyRequestObject{ Hostname: "_all", Name: "testuser", }, @@ -317,15 +317,15 @@ func (s *SSHKeyListGetPublicTestSuite) TestGetNodeUserSshKey() { nil, ) }, - validateFunc: func(resp gen.GetNodeUserSshKeyResponseObject) { - r, ok := resp.(gen.GetNodeUserSshKey200JSONResponse) + validateFunc: func(resp gen.GetNodeUserSSHKeyResponseObject) { + r, ok := resp.(gen.GetNodeUserSSHKey200JSONResponse) s.True(ok) s.Len(r.Results, 3) }, }, { name: "broadcast job client error", - request: gen.GetNodeUserSshKeyRequestObject{ + request: gen.GetNodeUserSSHKeyRequestObject{ Hostname: "_all", Name: "testuser", }, @@ -340,8 +340,8 @@ func (s *SSHKeyListGetPublicTestSuite) TestGetNodeUserSshKey() { ). Return("", nil, assert.AnError) }, - validateFunc: func(resp gen.GetNodeUserSshKeyResponseObject) { - _, ok := resp.(gen.GetNodeUserSshKey500JSONResponse) + validateFunc: func(resp gen.GetNodeUserSSHKeyResponseObject) { + _, ok := resp.(gen.GetNodeUserSSHKey500JSONResponse) s.True(ok) }, }, @@ -351,14 +351,14 @@ func (s *SSHKeyListGetPublicTestSuite) TestGetNodeUserSshKey() { s.Run(tt.name, func() { tt.setupMock() - resp, err := s.handler.GetNodeUserSshKey(s.ctx, tt.request) + resp, err := s.handler.GetNodeUserSSHKey(s.ctx, tt.request) s.NoError(err) tt.validateFunc(resp) }) } } -func (s *SSHKeyListGetPublicTestSuite) TestGetNodeUserSshKeyValidationHTTP() { +func (s *SSHKeyListGetPublicTestSuite) TestGetNodeUserSSHKeyValidationHTTP() { tests := []struct { name string path string @@ -424,7 +424,7 @@ func (s *SSHKeyListGetPublicTestSuite) TestGetNodeUserSshKeyValidationHTTP() { const rbacSSHKeyListTestSigningKey = "test-signing-key-for-rbac-ssh-key-list" -func (s *SSHKeyListGetPublicTestSuite) TestGetNodeUserSshKeyRBACHTTP() { +func (s *SSHKeyListGetPublicTestSuite) TestGetNodeUserSSHKeyRBACHTTP() { tokenManager := authtoken.New(s.logger) tests := []struct { diff --git a/internal/provider/node/user/debian_ssh_key_public_test.go b/internal/provider/node/user/debian_ssh_key_public_test.go index 3701dfa8a..b47a6a689 100644 --- a/internal/provider/node/user/debian_ssh_key_public_test.go +++ b/internal/provider/node/user/debian_ssh_key_public_test.go @@ -97,7 +97,7 @@ func (suite *DebianSSHKeyPublicTestSuite) writePasswd(content string) { } func (suite *DebianSSHKeyPublicTestSuite) writeAuthorizedKeys( - username string, + _ string, homeDir string, content string, ) { @@ -318,7 +318,7 @@ func (suite *DebianSSHKeyPublicTestSuite) TestListKeys() { { name: "when passwd has comments and malformed lines", username: "testuser", - passwd: "# comment\n\nshort:line\ntestuser:x:1000:1000:Test:/home/testuser:/bin/bash\n", + passwd: "# comment\n\nshort:line\ntestuser:x:1000:1000:Test:/home/testuser:/bin/bash\n", setupFS: func() { suite.writeAuthorizedKeys( "testuser", @@ -826,7 +826,7 @@ func (suite *DebianSSHKeyPublicTestSuite) TestRemoveKey() { openFileCalls := 0 suite.provider = suite.newFailFSProvider(baseFs, - func(_ avfs.VFSBase, fn avfs.FnVFS, fp *failfs.FailParam) error { + func(_ avfs.VFSBase, fn avfs.FnVFS, _ *failfs.FailParam) error { // WriteFile uses OpenFile internally. // The first OpenFile is from ReadFile, the second is // from WriteFile (the rewrite). Fail the second. diff --git a/pkg/sdk/client/gen/client.gen.go b/pkg/sdk/client/gen/client.gen.go index 4c3479d1a..9f3f71c7b 100644 --- a/pkg/sdk/client/gen/client.gen.go +++ b/pkg/sdk/client/gen/client.gen.go @@ -3198,8 +3198,8 @@ type PutNodeUserJSONRequestBody = UserUpdateRequest // PostNodeUserPasswordJSONRequestBody defines body for PostNodeUserPassword for application/json ContentType. type PostNodeUserPasswordJSONRequestBody = UserPasswordRequest -// PostNodeUserSshKeyJSONRequestBody defines body for PostNodeUserSshKey for application/json ContentType. -type PostNodeUserSshKeyJSONRequestBody = SSHKeyAddRequest +// PostNodeUserSSHKeyJSONRequestBody defines body for PostNodeUserSSHKey for application/json ContentType. +type PostNodeUserSSHKeyJSONRequestBody = SSHKeyAddRequest // RequestEditorFn is the function signature for the RequestEditor callback function type RequestEditorFn func(ctx context.Context, req *http.Request) error @@ -3603,16 +3603,16 @@ type ClientInterface interface { PostNodeUserPassword(ctx context.Context, hostname Hostname, name UserName, body PostNodeUserPasswordJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) - // GetNodeUserSshKey request - GetNodeUserSshKey(ctx context.Context, hostname Hostname, name UserName, reqEditors ...RequestEditorFn) (*http.Response, error) + // GetNodeUserSSHKey request + GetNodeUserSSHKey(ctx context.Context, hostname Hostname, name UserName, reqEditors ...RequestEditorFn) (*http.Response, error) - // PostNodeUserSshKeyWithBody request with any body - PostNodeUserSshKeyWithBody(ctx context.Context, hostname Hostname, name UserName, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + // PostNodeUserSSHKeyWithBody request with any body + PostNodeUserSSHKeyWithBody(ctx context.Context, hostname Hostname, name UserName, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) - PostNodeUserSshKey(ctx context.Context, hostname Hostname, name UserName, body PostNodeUserSshKeyJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + PostNodeUserSSHKey(ctx context.Context, hostname Hostname, name UserName, body PostNodeUserSSHKeyJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) - // DeleteNodeUserSshKey request - DeleteNodeUserSshKey(ctx context.Context, hostname Hostname, name UserName, fingerprint SSHKeyFingerprint, reqEditors ...RequestEditorFn) (*http.Response, error) + // DeleteNodeUserSSHKey request + DeleteNodeUserSSHKey(ctx context.Context, hostname Hostname, name UserName, fingerprint SSHKeyFingerprint, reqEditors ...RequestEditorFn) (*http.Response, error) // GetVersion request GetVersion(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -5058,8 +5058,8 @@ func (c *Client) PostNodeUserPassword(ctx context.Context, hostname Hostname, na return c.Client.Do(req) } -func (c *Client) GetNodeUserSshKey(ctx context.Context, hostname Hostname, name UserName, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewGetNodeUserSshKeyRequest(c.Server, hostname, name) +func (c *Client) GetNodeUserSSHKey(ctx context.Context, hostname Hostname, name UserName, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetNodeUserSSHKeyRequest(c.Server, hostname, name) if err != nil { return nil, err } @@ -5070,8 +5070,8 @@ func (c *Client) GetNodeUserSshKey(ctx context.Context, hostname Hostname, name return c.Client.Do(req) } -func (c *Client) PostNodeUserSshKeyWithBody(ctx context.Context, hostname Hostname, name UserName, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewPostNodeUserSshKeyRequestWithBody(c.Server, hostname, name, contentType, body) +func (c *Client) PostNodeUserSSHKeyWithBody(ctx context.Context, hostname Hostname, name UserName, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewPostNodeUserSSHKeyRequestWithBody(c.Server, hostname, name, contentType, body) if err != nil { return nil, err } @@ -5082,8 +5082,8 @@ func (c *Client) PostNodeUserSshKeyWithBody(ctx context.Context, hostname Hostna return c.Client.Do(req) } -func (c *Client) PostNodeUserSshKey(ctx context.Context, hostname Hostname, name UserName, body PostNodeUserSshKeyJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewPostNodeUserSshKeyRequest(c.Server, hostname, name, body) +func (c *Client) PostNodeUserSSHKey(ctx context.Context, hostname Hostname, name UserName, body PostNodeUserSSHKeyJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewPostNodeUserSSHKeyRequest(c.Server, hostname, name, body) if err != nil { return nil, err } @@ -5094,8 +5094,8 @@ func (c *Client) PostNodeUserSshKey(ctx context.Context, hostname Hostname, name return c.Client.Do(req) } -func (c *Client) DeleteNodeUserSshKey(ctx context.Context, hostname Hostname, name UserName, fingerprint SSHKeyFingerprint, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewDeleteNodeUserSshKeyRequest(c.Server, hostname, name, fingerprint) +func (c *Client) DeleteNodeUserSSHKey(ctx context.Context, hostname Hostname, name UserName, fingerprint SSHKeyFingerprint, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewDeleteNodeUserSSHKeyRequest(c.Server, hostname, name, fingerprint) if err != nil { return nil, err } @@ -8972,8 +8972,8 @@ func NewPostNodeUserPasswordRequestWithBody(server string, hostname Hostname, na return req, nil } -// NewGetNodeUserSshKeyRequest generates requests for GetNodeUserSshKey -func NewGetNodeUserSshKeyRequest(server string, hostname Hostname, name UserName) (*http.Request, error) { +// NewGetNodeUserSSHKeyRequest generates requests for GetNodeUserSSHKey +func NewGetNodeUserSSHKeyRequest(server string, hostname Hostname, name UserName) (*http.Request, error) { var err error var pathParam0 string @@ -9013,19 +9013,19 @@ func NewGetNodeUserSshKeyRequest(server string, hostname Hostname, name UserName return req, nil } -// NewPostNodeUserSshKeyRequest calls the generic PostNodeUserSshKey builder with application/json body -func NewPostNodeUserSshKeyRequest(server string, hostname Hostname, name UserName, body PostNodeUserSshKeyJSONRequestBody) (*http.Request, error) { +// NewPostNodeUserSSHKeyRequest calls the generic PostNodeUserSSHKey builder with application/json body +func NewPostNodeUserSSHKeyRequest(server string, hostname Hostname, name UserName, body PostNodeUserSSHKeyJSONRequestBody) (*http.Request, error) { var bodyReader io.Reader buf, err := json.Marshal(body) if err != nil { return nil, err } bodyReader = bytes.NewReader(buf) - return NewPostNodeUserSshKeyRequestWithBody(server, hostname, name, "application/json", bodyReader) + return NewPostNodeUserSSHKeyRequestWithBody(server, hostname, name, "application/json", bodyReader) } -// NewPostNodeUserSshKeyRequestWithBody generates requests for PostNodeUserSshKey with any type of body -func NewPostNodeUserSshKeyRequestWithBody(server string, hostname Hostname, name UserName, contentType string, body io.Reader) (*http.Request, error) { +// NewPostNodeUserSSHKeyRequestWithBody generates requests for PostNodeUserSSHKey with any type of body +func NewPostNodeUserSSHKeyRequestWithBody(server string, hostname Hostname, name UserName, contentType string, body io.Reader) (*http.Request, error) { var err error var pathParam0 string @@ -9067,8 +9067,8 @@ func NewPostNodeUserSshKeyRequestWithBody(server string, hostname Hostname, name return req, nil } -// NewDeleteNodeUserSshKeyRequest generates requests for DeleteNodeUserSshKey -func NewDeleteNodeUserSshKeyRequest(server string, hostname Hostname, name UserName, fingerprint SSHKeyFingerprint) (*http.Request, error) { +// NewDeleteNodeUserSSHKeyRequest generates requests for DeleteNodeUserSSHKey +func NewDeleteNodeUserSSHKeyRequest(server string, hostname Hostname, name UserName, fingerprint SSHKeyFingerprint) (*http.Request, error) { var err error var pathParam0 string @@ -9514,16 +9514,16 @@ type ClientWithResponsesInterface interface { PostNodeUserPasswordWithResponse(ctx context.Context, hostname Hostname, name UserName, body PostNodeUserPasswordJSONRequestBody, reqEditors ...RequestEditorFn) (*PostNodeUserPasswordResponse, error) - // GetNodeUserSshKeyWithResponse request - GetNodeUserSshKeyWithResponse(ctx context.Context, hostname Hostname, name UserName, reqEditors ...RequestEditorFn) (*GetNodeUserSshKeyResponse, error) + // GetNodeUserSSHKeyWithResponse request + GetNodeUserSSHKeyWithResponse(ctx context.Context, hostname Hostname, name UserName, reqEditors ...RequestEditorFn) (*GetNodeUserSSHKeyResponse, error) - // PostNodeUserSshKeyWithBodyWithResponse request with any body - PostNodeUserSshKeyWithBodyWithResponse(ctx context.Context, hostname Hostname, name UserName, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PostNodeUserSshKeyResponse, error) + // PostNodeUserSSHKeyWithBodyWithResponse request with any body + PostNodeUserSSHKeyWithBodyWithResponse(ctx context.Context, hostname Hostname, name UserName, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PostNodeUserSSHKeyResponse, error) - PostNodeUserSshKeyWithResponse(ctx context.Context, hostname Hostname, name UserName, body PostNodeUserSshKeyJSONRequestBody, reqEditors ...RequestEditorFn) (*PostNodeUserSshKeyResponse, error) + PostNodeUserSSHKeyWithResponse(ctx context.Context, hostname Hostname, name UserName, body PostNodeUserSSHKeyJSONRequestBody, reqEditors ...RequestEditorFn) (*PostNodeUserSSHKeyResponse, error) - // DeleteNodeUserSshKeyWithResponse request - DeleteNodeUserSshKeyWithResponse(ctx context.Context, hostname Hostname, name UserName, fingerprint SSHKeyFingerprint, reqEditors ...RequestEditorFn) (*DeleteNodeUserSshKeyResponse, error) + // DeleteNodeUserSSHKeyWithResponse request + DeleteNodeUserSSHKeyWithResponse(ctx context.Context, hostname Hostname, name UserName, fingerprint SSHKeyFingerprint, reqEditors ...RequestEditorFn) (*DeleteNodeUserSSHKeyResponse, error) // GetVersionWithResponse request GetVersionWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetVersionResponse, error) @@ -11841,7 +11841,7 @@ func (r PostNodeUserPasswordResponse) StatusCode() int { return 0 } -type GetNodeUserSshKeyResponse struct { +type GetNodeUserSSHKeyResponse struct { Body []byte HTTPResponse *http.Response JSON200 *SSHKeyCollectionResponse @@ -11851,7 +11851,7 @@ type GetNodeUserSshKeyResponse struct { } // Status returns HTTPResponse.Status -func (r GetNodeUserSshKeyResponse) Status() string { +func (r GetNodeUserSSHKeyResponse) Status() string { if r.HTTPResponse != nil { return r.HTTPResponse.Status } @@ -11859,14 +11859,14 @@ func (r GetNodeUserSshKeyResponse) Status() string { } // StatusCode returns HTTPResponse.StatusCode -func (r GetNodeUserSshKeyResponse) StatusCode() int { +func (r GetNodeUserSSHKeyResponse) StatusCode() int { if r.HTTPResponse != nil { return r.HTTPResponse.StatusCode } return 0 } -type PostNodeUserSshKeyResponse struct { +type PostNodeUserSSHKeyResponse struct { Body []byte HTTPResponse *http.Response JSON200 *SSHKeyMutationResponse @@ -11877,7 +11877,7 @@ type PostNodeUserSshKeyResponse struct { } // Status returns HTTPResponse.Status -func (r PostNodeUserSshKeyResponse) Status() string { +func (r PostNodeUserSSHKeyResponse) Status() string { if r.HTTPResponse != nil { return r.HTTPResponse.Status } @@ -11885,14 +11885,14 @@ func (r PostNodeUserSshKeyResponse) Status() string { } // StatusCode returns HTTPResponse.StatusCode -func (r PostNodeUserSshKeyResponse) StatusCode() int { +func (r PostNodeUserSSHKeyResponse) StatusCode() int { if r.HTTPResponse != nil { return r.HTTPResponse.StatusCode } return 0 } -type DeleteNodeUserSshKeyResponse struct { +type DeleteNodeUserSSHKeyResponse struct { Body []byte HTTPResponse *http.Response JSON200 *SSHKeyMutationResponse @@ -11902,7 +11902,7 @@ type DeleteNodeUserSshKeyResponse struct { } // Status returns HTTPResponse.Status -func (r DeleteNodeUserSshKeyResponse) Status() string { +func (r DeleteNodeUserSSHKeyResponse) Status() string { if r.HTTPResponse != nil { return r.HTTPResponse.Status } @@ -11910,7 +11910,7 @@ func (r DeleteNodeUserSshKeyResponse) Status() string { } // StatusCode returns HTTPResponse.StatusCode -func (r DeleteNodeUserSshKeyResponse) StatusCode() int { +func (r DeleteNodeUserSSHKeyResponse) StatusCode() int { if r.HTTPResponse != nil { return r.HTTPResponse.StatusCode } @@ -12988,39 +12988,39 @@ func (c *ClientWithResponses) PostNodeUserPasswordWithResponse(ctx context.Conte return ParsePostNodeUserPasswordResponse(rsp) } -// GetNodeUserSshKeyWithResponse request returning *GetNodeUserSshKeyResponse -func (c *ClientWithResponses) GetNodeUserSshKeyWithResponse(ctx context.Context, hostname Hostname, name UserName, reqEditors ...RequestEditorFn) (*GetNodeUserSshKeyResponse, error) { - rsp, err := c.GetNodeUserSshKey(ctx, hostname, name, reqEditors...) +// GetNodeUserSSHKeyWithResponse request returning *GetNodeUserSSHKeyResponse +func (c *ClientWithResponses) GetNodeUserSSHKeyWithResponse(ctx context.Context, hostname Hostname, name UserName, reqEditors ...RequestEditorFn) (*GetNodeUserSSHKeyResponse, error) { + rsp, err := c.GetNodeUserSSHKey(ctx, hostname, name, reqEditors...) if err != nil { return nil, err } - return ParseGetNodeUserSshKeyResponse(rsp) + return ParseGetNodeUserSSHKeyResponse(rsp) } -// PostNodeUserSshKeyWithBodyWithResponse request with arbitrary body returning *PostNodeUserSshKeyResponse -func (c *ClientWithResponses) PostNodeUserSshKeyWithBodyWithResponse(ctx context.Context, hostname Hostname, name UserName, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PostNodeUserSshKeyResponse, error) { - rsp, err := c.PostNodeUserSshKeyWithBody(ctx, hostname, name, contentType, body, reqEditors...) +// PostNodeUserSSHKeyWithBodyWithResponse request with arbitrary body returning *PostNodeUserSSHKeyResponse +func (c *ClientWithResponses) PostNodeUserSSHKeyWithBodyWithResponse(ctx context.Context, hostname Hostname, name UserName, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PostNodeUserSSHKeyResponse, error) { + rsp, err := c.PostNodeUserSSHKeyWithBody(ctx, hostname, name, contentType, body, reqEditors...) if err != nil { return nil, err } - return ParsePostNodeUserSshKeyResponse(rsp) + return ParsePostNodeUserSSHKeyResponse(rsp) } -func (c *ClientWithResponses) PostNodeUserSshKeyWithResponse(ctx context.Context, hostname Hostname, name UserName, body PostNodeUserSshKeyJSONRequestBody, reqEditors ...RequestEditorFn) (*PostNodeUserSshKeyResponse, error) { - rsp, err := c.PostNodeUserSshKey(ctx, hostname, name, body, reqEditors...) +func (c *ClientWithResponses) PostNodeUserSSHKeyWithResponse(ctx context.Context, hostname Hostname, name UserName, body PostNodeUserSSHKeyJSONRequestBody, reqEditors ...RequestEditorFn) (*PostNodeUserSSHKeyResponse, error) { + rsp, err := c.PostNodeUserSSHKey(ctx, hostname, name, body, reqEditors...) if err != nil { return nil, err } - return ParsePostNodeUserSshKeyResponse(rsp) + return ParsePostNodeUserSSHKeyResponse(rsp) } -// DeleteNodeUserSshKeyWithResponse request returning *DeleteNodeUserSshKeyResponse -func (c *ClientWithResponses) DeleteNodeUserSshKeyWithResponse(ctx context.Context, hostname Hostname, name UserName, fingerprint SSHKeyFingerprint, reqEditors ...RequestEditorFn) (*DeleteNodeUserSshKeyResponse, error) { - rsp, err := c.DeleteNodeUserSshKey(ctx, hostname, name, fingerprint, reqEditors...) +// DeleteNodeUserSSHKeyWithResponse request returning *DeleteNodeUserSSHKeyResponse +func (c *ClientWithResponses) DeleteNodeUserSSHKeyWithResponse(ctx context.Context, hostname Hostname, name UserName, fingerprint SSHKeyFingerprint, reqEditors ...RequestEditorFn) (*DeleteNodeUserSSHKeyResponse, error) { + rsp, err := c.DeleteNodeUserSSHKey(ctx, hostname, name, fingerprint, reqEditors...) if err != nil { return nil, err } - return ParseDeleteNodeUserSshKeyResponse(rsp) + return ParseDeleteNodeUserSSHKeyResponse(rsp) } // GetVersionWithResponse request returning *GetVersionResponse @@ -17800,15 +17800,15 @@ func ParsePostNodeUserPasswordResponse(rsp *http.Response) (*PostNodeUserPasswor return response, nil } -// ParseGetNodeUserSshKeyResponse parses an HTTP response from a GetNodeUserSshKeyWithResponse call -func ParseGetNodeUserSshKeyResponse(rsp *http.Response) (*GetNodeUserSshKeyResponse, error) { +// ParseGetNodeUserSSHKeyResponse parses an HTTP response from a GetNodeUserSSHKeyWithResponse call +func ParseGetNodeUserSSHKeyResponse(rsp *http.Response) (*GetNodeUserSSHKeyResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) defer func() { _ = rsp.Body.Close() }() if err != nil { return nil, err } - response := &GetNodeUserSshKeyResponse{ + response := &GetNodeUserSSHKeyResponse{ Body: bodyBytes, HTTPResponse: rsp, } @@ -17847,15 +17847,15 @@ func ParseGetNodeUserSshKeyResponse(rsp *http.Response) (*GetNodeUserSshKeyRespo return response, nil } -// ParsePostNodeUserSshKeyResponse parses an HTTP response from a PostNodeUserSshKeyWithResponse call -func ParsePostNodeUserSshKeyResponse(rsp *http.Response) (*PostNodeUserSshKeyResponse, error) { +// ParsePostNodeUserSSHKeyResponse parses an HTTP response from a PostNodeUserSSHKeyWithResponse call +func ParsePostNodeUserSSHKeyResponse(rsp *http.Response) (*PostNodeUserSSHKeyResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) defer func() { _ = rsp.Body.Close() }() if err != nil { return nil, err } - response := &PostNodeUserSshKeyResponse{ + response := &PostNodeUserSSHKeyResponse{ Body: bodyBytes, HTTPResponse: rsp, } @@ -17901,15 +17901,15 @@ func ParsePostNodeUserSshKeyResponse(rsp *http.Response) (*PostNodeUserSshKeyRes return response, nil } -// ParseDeleteNodeUserSshKeyResponse parses an HTTP response from a DeleteNodeUserSshKeyWithResponse call -func ParseDeleteNodeUserSshKeyResponse(rsp *http.Response) (*DeleteNodeUserSshKeyResponse, error) { +// ParseDeleteNodeUserSSHKeyResponse parses an HTTP response from a DeleteNodeUserSSHKeyWithResponse call +func ParseDeleteNodeUserSSHKeyResponse(rsp *http.Response) (*DeleteNodeUserSSHKeyResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) defer func() { _ = rsp.Body.Close() }() if err != nil { return nil, err } - response := &DeleteNodeUserSshKeyResponse{ + response := &DeleteNodeUserSSHKeyResponse{ Body: bodyBytes, HTTPResponse: rsp, } diff --git a/pkg/sdk/client/user.go b/pkg/sdk/client/user.go index 3e052c286..cfe2138f4 100644 --- a/pkg/sdk/client/user.go +++ b/pkg/sdk/client/user.go @@ -243,7 +243,7 @@ func (s *UserService) ListKeys( hostname string, username string, ) (*Response[Collection[SSHKeyInfoResult]], error) { - resp, err := s.client.GetNodeUserSshKeyWithResponse(ctx, hostname, username) + resp, err := s.client.GetNodeUserSSHKeyWithResponse(ctx, hostname, username) if err != nil { return nil, fmt.Errorf("user list keys: %w", err) } @@ -278,7 +278,7 @@ func (s *UserService) AddKey( Key: opts.Key, } - resp, err := s.client.PostNodeUserSshKeyWithResponse(ctx, hostname, username, body) + resp, err := s.client.PostNodeUserSSHKeyWithResponse(ctx, hostname, username, body) if err != nil { return nil, fmt.Errorf("user add key: %w", err) } @@ -311,7 +311,7 @@ func (s *UserService) RemoveKey( username string, fingerprint string, ) (*Response[Collection[SSHKeyMutationResult]], error) { - resp, err := s.client.DeleteNodeUserSshKeyWithResponse( + resp, err := s.client.DeleteNodeUserSSHKeyWithResponse( ctx, hostname, username, fingerprint, ) if err != nil { From ebf5316b4c076ed75d59c3407308826aefa059ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Wed, 1 Apr 2026 12:21:43 -0700 Subject: [PATCH 11/11] chore: fix docs formatting Co-Authored-By: Claude --- .../sidebar/architecture/api-guidelines.md | 68 +-- docs/docs/sidebar/features/user-management.md | 6 +- .../sidebar/sdk/client/management/user.md | 56 +- .../usage/cli/client/node/user/ssh-key-add.md | 6 +- .../cli/client/node/user/ssh-key-remove.md | 16 +- ...4-01-ssh-key-management-provider-design.md | 88 ++- .../2026-04-01-ssh-key-management-provider.md | 574 +++++++++--------- 7 files changed, 400 insertions(+), 414 deletions(-) diff --git a/docs/docs/sidebar/architecture/api-guidelines.md b/docs/docs/sidebar/architecture/api-guidelines.md index 773f0bc70..355c88faa 100644 --- a/docs/docs/sidebar/architecture/api-guidelines.md +++ b/docs/docs/sidebar/architecture/api-guidelines.md @@ -38,40 +38,40 @@ selectors (`key:value`). Sub-resources represent distinct capabilities of the node: -| Path Pattern | Domain | -| ---------------------------------------------- | ----------- | -| `/node/{hostname}` | Status | -| `/node/{hostname}/disk` | Node | -| `/node/{hostname}/memory` | Node | -| `/node/{hostname}/network/dns/{interfaceName}` | Network | -| `/node/{hostname}/command/exec` | Command | -| `/node/{hostname}/schedule/cron` | Schedule | -| `/node/{hostname}/schedule/cron/{name}` | Schedule | -| `/node/{hostname}/sysctl` | Sysctl | -| `/node/{hostname}/sysctl/{key}` | Sysctl | -| `/node/{hostname}/ntp` | NTP | -| `/node/{hostname}/timezone` | Timezone | -| `/node/{hostname}/power/reboot` | Power | -| `/node/{hostname}/power/shutdown` | Power | -| `/node/{hostname}/process` | Process | -| `/node/{hostname}/process/{pid}` | Process | -| `/node/{hostname}/process/{pid}/signal` | Process | -| `/node/{hostname}/user` | User | -| `/node/{hostname}/user/{name}` | User | -| `/node/{hostname}/user/{name}/password` | User | -| `/node/{hostname}/user/{name}/ssh-key` | User | -| `/node/{hostname}/user/{name}/ssh-key/{fingerprint}` | User | -| `/node/{hostname}/group` | Group | -| `/node/{hostname}/group/{name}` | Group | -| `/node/{hostname}/package` | Package | -| `/node/{hostname}/package/{name}` | Package | -| `/node/{hostname}/package/update` | Package | -| `/node/{hostname}/package/updates` | Package | -| `/node/{hostname}/log` | Log | -| `/node/{hostname}/log/source` | Log | -| `/node/{hostname}/log/unit/{name}` | Log | -| `/node/{hostname}/certificate/ca` | Certificate | -| `/node/{hostname}/certificate/ca/{name}` | Certificate | +| Path Pattern | Domain | +| ---------------------------------------------------- | ----------- | +| `/node/{hostname}` | Status | +| `/node/{hostname}/disk` | Node | +| `/node/{hostname}/memory` | Node | +| `/node/{hostname}/network/dns/{interfaceName}` | Network | +| `/node/{hostname}/command/exec` | Command | +| `/node/{hostname}/schedule/cron` | Schedule | +| `/node/{hostname}/schedule/cron/{name}` | Schedule | +| `/node/{hostname}/sysctl` | Sysctl | +| `/node/{hostname}/sysctl/{key}` | Sysctl | +| `/node/{hostname}/ntp` | NTP | +| `/node/{hostname}/timezone` | Timezone | +| `/node/{hostname}/power/reboot` | Power | +| `/node/{hostname}/power/shutdown` | Power | +| `/node/{hostname}/process` | Process | +| `/node/{hostname}/process/{pid}` | Process | +| `/node/{hostname}/process/{pid}/signal` | Process | +| `/node/{hostname}/user` | User | +| `/node/{hostname}/user/{name}` | User | +| `/node/{hostname}/user/{name}/password` | User | +| `/node/{hostname}/user/{name}/ssh-key` | User | +| `/node/{hostname}/user/{name}/ssh-key/{fingerprint}` | User | +| `/node/{hostname}/group` | Group | +| `/node/{hostname}/group/{name}` | Group | +| `/node/{hostname}/package` | Package | +| `/node/{hostname}/package/{name}` | Package | +| `/node/{hostname}/package/update` | Package | +| `/node/{hostname}/package/updates` | Package | +| `/node/{hostname}/log` | Log | +| `/node/{hostname}/log/source` | Log | +| `/node/{hostname}/log/unit/{name}` | Log | +| `/node/{hostname}/certificate/ca` | Certificate | +| `/node/{hostname}/certificate/ca/{name}` | Certificate | 6. **Path Parameters Over Query Parameters** diff --git a/docs/docs/sidebar/features/user-management.md b/docs/docs/sidebar/features/user-management.md index 0f16c3811..3bd0ede5b 100644 --- a/docs/docs/sidebar/features/user-management.md +++ b/docs/docs/sidebar/features/user-management.md @@ -38,9 +38,9 @@ SSH key operations manage the `~/.ssh/authorized_keys` file for a given user: duplicate keys are not added) - **RemoveKey** -- remove a key by its SHA256 fingerprint -The provider reads and writes the user's `~/.ssh/authorized_keys` file -directly. It creates the `~/.ssh` directory and `authorized_keys` file with -correct permissions (`700` and `600`) if they do not exist. +The provider reads and writes the user's `~/.ssh/authorized_keys` file directly. +It creates the `~/.ssh` directory and `authorized_keys` file with correct +permissions (`700` and `600`) if they do not exist. ### Groups diff --git a/docs/docs/sidebar/sdk/client/management/user.md b/docs/docs/sidebar/sdk/client/management/user.md index ce728c84e..77c621c64 100644 --- a/docs/docs/sidebar/sdk/client/management/user.md +++ b/docs/docs/sidebar/sdk/client/management/user.md @@ -8,16 +8,16 @@ User account management on target hosts. ## Methods -| Method | Description | -| ----------------------------------------------- | ------------------------------ | -| `List(ctx, hostname)` | List all user accounts | -| `Get(ctx, hostname, name)` | Get a user by name | -| `Create(ctx, hostname, opts)` | Create a user account | -| `Update(ctx, hostname, name, opts)` | Update a user account | -| `Delete(ctx, hostname, name)` | Delete a user account | -| `ChangePassword(ctx, hostname, name, password)` | Change a user's password | -| `ListKeys(ctx, hostname, name)` | List SSH authorized keys | -| `AddKey(ctx, hostname, name, opts)` | Add an SSH authorized key | +| Method | Description | +| ----------------------------------------------- | -------------------------------- | +| `List(ctx, hostname)` | List all user accounts | +| `Get(ctx, hostname, name)` | Get a user by name | +| `Create(ctx, hostname, opts)` | Create a user account | +| `Update(ctx, hostname, name, opts)` | Update a user account | +| `Delete(ctx, hostname, name)` | Delete a user account | +| `ChangePassword(ctx, hostname, name, password)` | Change a user's password | +| `ListKeys(ctx, hostname, name)` | List SSH authorized keys | +| `AddKey(ctx, hostname, name, opts)` | Add an SSH authorized key | | `RemoveKey(ctx, hostname, name, fingerprint)` | Remove an SSH key by fingerprint | ## Usage @@ -84,34 +84,34 @@ for a complete working example. ### `SSHKeyInfoResult` -| Field | Type | Description | -| ---------- | ------------- | ------------------------------ | -| `Hostname` | `string` | Target hostname | -| `Status` | `string` | Operation status | -| `Keys` | `[]SSHKeyInfo`| List of authorized keys | -| `Error` | `string` | Error message (if any) | +| Field | Type | Description | +| ---------- | -------------- | ----------------------- | +| `Hostname` | `string` | Target hostname | +| `Status` | `string` | Operation status | +| `Keys` | `[]SSHKeyInfo` | List of authorized keys | +| `Error` | `string` | Error message (if any) | ### `SSHKeyInfo` -| Field | Type | Description | -| ------------- | -------- | ------------------------------- | -| `Type` | `string` | Key type (e.g., `ssh-ed25519`) | -| `Fingerprint` | `string` | SHA256 fingerprint | -| `Comment` | `string` | Key comment | +| Field | Type | Description | +| ------------- | -------- | ------------------------------ | +| `Type` | `string` | Key type (e.g., `ssh-ed25519`) | +| `Fingerprint` | `string` | SHA256 fingerprint | +| `Comment` | `string` | Key comment | ### `SSHKeyMutationResult` -| Field | Type | Description | -| ---------- | -------- | ------------------------------ | -| `Hostname` | `string` | Target hostname | -| `Status` | `string` | Operation status | +| Field | Type | Description | +| ---------- | -------- | ----------------------------------- | +| `Hostname` | `string` | Target hostname | +| `Status` | `string` | Operation status | | `Changed` | `bool` | Whether the operation changed state | -| `Error` | `string` | Error message (if any) | +| `Error` | `string` | Error message (if any) | ### `SSHKeyAddOpts` -| Field | Type | Description | -| ----- | -------- | ----------------------------------------------------- | +| Field | Type | Description | +| ----- | -------- | ------------------------------------------------------ | | `Key` | `string` | Full SSH public key line (e.g., `ssh-ed25519 AAAA...`) | ## Permissions diff --git a/docs/docs/sidebar/usage/cli/client/node/user/ssh-key-add.md b/docs/docs/sidebar/usage/cli/client/node/user/ssh-key-add.md index 3b86caea9..f8d3ed5c6 100644 --- a/docs/docs/sidebar/usage/cli/client/node/user/ssh-key-add.md +++ b/docs/docs/sidebar/usage/cli/client/node/user/ssh-key-add.md @@ -10,9 +10,9 @@ $ osapi client node user ssh-key add --target web-01 \ web-01 true ok ``` -The key is appended to the user's `~/.ssh/authorized_keys` file. If the file -or `~/.ssh` directory does not exist, it is created with correct permissions -(`700` for the directory, `600` for the file). Duplicate keys are not added. +The key is appended to the user's `~/.ssh/authorized_keys` file. If the file or +`~/.ssh` directory does not exist, it is created with correct permissions (`700` +for the directory, `600` for the file). Duplicate keys are not added. ## Flags diff --git a/docs/docs/sidebar/usage/cli/client/node/user/ssh-key-remove.md b/docs/docs/sidebar/usage/cli/client/node/user/ssh-key-remove.md index 84613f146..631684db2 100644 --- a/docs/docs/sidebar/usage/cli/client/node/user/ssh-key-remove.md +++ b/docs/docs/sidebar/usage/cli/client/node/user/ssh-key-remove.md @@ -11,14 +11,14 @@ $ osapi client node user ssh-key remove --target web-01 \ ``` The key matching the given SHA256 fingerprint is removed from the user's -`~/.ssh/authorized_keys` file. Returns `changed: false` if the fingerprint -is not found. +`~/.ssh/authorized_keys` file. Returns `changed: false` if the fingerprint is +not found. ## Flags -| Flag | Description | Default | -| ----------------- | -------------------------------------------------------- | ------- | -| `-T, --target` | Target: `_any`, `_all`, hostname, or label (`group:web`) | `_all` | -| `--name` | Username to remove SSH key from (required) | | -| `--fingerprint` | SHA256 fingerprint of the key to remove (required) | | -| `-j, --json` | Output raw JSON response | | +| Flag | Description | Default | +| --------------- | -------------------------------------------------------- | ------- | +| `-T, --target` | Target: `_any`, `_all`, hostname, or label (`group:web`) | `_all` | +| `--name` | Username to remove SSH key from (required) | | +| `--fingerprint` | SHA256 fingerprint of the key to remove (required) | | +| `-j, --json` | Output raw JSON response | | diff --git a/docs/plans/2026-04-01-ssh-key-management-provider-design.md b/docs/plans/2026-04-01-ssh-key-management-provider-design.md index 8b511d9d5..8744bc569 100644 --- a/docs/plans/2026-04-01-ssh-key-management-provider-design.md +++ b/docs/plans/2026-04-01-ssh-key-management-provider-design.md @@ -2,24 +2,23 @@ ## Overview -Add SSH authorized key management to OSAPI. List, add, and remove -SSH public keys in a user's `~/.ssh/authorized_keys` file. Extends -the existing user provider — no new provider package or -permissions. Manages any key regardless of who added it. +Add SSH authorized key management to OSAPI. List, add, and remove SSH public +keys in a user's `~/.ssh/authorized_keys` file. Extends the existing user +provider — no new provider package or permissions. Manages any key regardless of +who added it. ## Architecture -Extends the existing user provider at -`internal/provider/node/user/`. +Extends the existing user provider at `internal/provider/node/user/`. - **Category**: `node` - **Path prefix**: `/node/{hostname}/user/{name}/ssh-key` - **Permissions**: `user:read` (list), `user:write` (add, remove) - **Provider type**: direct-write (avfs.VFS) -No state tracking, no file.Deployer. The provider reads and -writes `authorized_keys` directly. The orchestrator is -responsible for desired-state management. +No state tracking, no file.Deployer. The provider reads and writes +`authorized_keys` directly. The orchestrator is responsible for desired-state +management. ## Provider Interface Additions @@ -47,22 +46,19 @@ type SSHKeyResult struct { ## Debian Implementation -The provider resolves the user's home directory from -`/etc/passwd` (already parsed by the user provider), then -operates on `~/.ssh/authorized_keys`. - -- **ListKeys**: Read `authorized_keys`, parse each non-empty, - non-comment line into type + base64 key + comment. Compute - SHA256 fingerprint from decoded key bytes. Return all entries. -- **AddKey**: Check if key already exists by fingerprint. If - present, return `changed: false`. Otherwise append the raw - public key line. Create `~/.ssh/` (mode `0700`) and - `authorized_keys` (mode `0600`) if they don't exist. Set - ownership to the target user via `exec.Manager` - (`chown user:user`). -- **RemoveKey**: Read file, filter out the line matching the - fingerprint, rewrite file. Return `changed: false` if - fingerprint not found. Return error if file doesn't exist. +The provider resolves the user's home directory from `/etc/passwd` (already +parsed by the user provider), then operates on `~/.ssh/authorized_keys`. + +- **ListKeys**: Read `authorized_keys`, parse each non-empty, non-comment line + into type + base64 key + comment. Compute SHA256 fingerprint from decoded key + bytes. Return all entries. +- **AddKey**: Check if key already exists by fingerprint. If present, return + `changed: false`. Otherwise append the raw public key line. Create `~/.ssh/` + (mode `0700`) and `authorized_keys` (mode `0600`) if they don't exist. Set + ownership to the target user via `exec.Manager` (`chown user:user`). +- **RemoveKey**: Read file, filter out the line matching the fingerprint, + rewrite file. Return `changed: false` if fingerprint not found. Return error + if file doesn't exist. ## Platform Implementations @@ -94,25 +90,26 @@ All endpoints support broadcast targeting. } ``` -The full public key line as it would appear in -`authorized_keys`. +The full public key line as it would appear in `authorized_keys`. ### Response Shape (List) ```json { "job_id": "...", - "results": [{ - "hostname": "web-01", - "status": "ok", - "keys": [ - { - "type": "ssh-ed25519", - "fingerprint": "SHA256:abc123...", - "comment": "john@laptop" - } - ] - }] + "results": [ + { + "hostname": "web-01", + "status": "ok", + "keys": [ + { + "type": "ssh-ed25519", + "fingerprint": "SHA256:abc123...", + "comment": "john@laptop" + } + ] + } + ] } ``` @@ -121,11 +118,13 @@ The full public key line as it would appear in ```json { "job_id": "...", - "results": [{ - "hostname": "web-01", - "status": "ok", - "changed": true - }] + "results": [ + { + "hostname": "web-01", + "status": "ok", + "changed": true + } + ] } ``` @@ -137,8 +136,7 @@ client.User.AddKey(ctx, host, username, opts) client.User.RemoveKey(ctx, host, username, fingerprint) ``` -`SSHKeyAddOpts` struct with `Key` field (the full public key -string). +`SSHKeyAddOpts` struct with `Key` field (the full public key string). ## CLI diff --git a/docs/plans/2026-04-01-ssh-key-management-provider.md b/docs/plans/2026-04-01-ssh-key-management-provider.md index cb6a87140..3c49f5931 100644 --- a/docs/plans/2026-04-01-ssh-key-management-provider.md +++ b/docs/plans/2026-04-01-ssh-key-management-provider.md @@ -2,22 +2,21 @@ > **For agentic workers:** REQUIRED SUB-SKILL: Use > superpowers:subagent-driven-development (recommended) or -> superpowers:executing-plans to implement this plan task-by-task. -> Steps use checkbox (`- [ ]`) syntax for tracking. +> superpowers:executing-plans to implement this plan task-by-task. Steps use +> checkbox (`- [ ]`) syntax for tracking. -**Goal:** Add SSH authorized key management to OSAPI — list, add, -and remove SSH public keys in a user's `~/.ssh/authorized_keys` -file by extending the existing user provider. +**Goal:** Add SSH authorized key management to OSAPI — list, add, and remove SSH +public keys in a user's `~/.ssh/authorized_keys` file by extending the existing +user provider. -**Architecture:** Extends `internal/provider/node/user/` with three -new methods (ListKeys, AddKey, RemoveKey). New SSH key endpoints -added to the existing user OpenAPI spec. Operations dispatched via -a new `sshKey` case in the node processor. Reuses existing -`user:read`/`user:write` permissions. No new provider package, -agent category, or permissions needed. +**Architecture:** Extends `internal/provider/node/user/` with three new methods +(ListKeys, AddKey, RemoveKey). New SSH key endpoints added to the existing user +OpenAPI spec. Operations dispatched via a new `sshKey` case in the node +processor. Reuses existing `user:read`/`user:write` permissions. No new provider +package, agent category, or permissions needed. -**Tech Stack:** Go, avfs.VFS, crypto/sha256 for fingerprints, -encoding/base64 for key decoding, oapi-codegen strict-server +**Tech Stack:** Go, avfs.VFS, crypto/sha256 for fingerprints, encoding/base64 +for key decoding, oapi-codegen strict-server --- @@ -25,97 +24,72 @@ encoding/base64 for key decoding, oapi-codegen strict-server ### Provider Layer -- Modify: `internal/provider/node/user/types.go` — add SSHKey, - SSHKeyResult types + 3 methods to Provider interface +- Modify: `internal/provider/node/user/types.go` — add SSHKey, SSHKeyResult + types + 3 methods to Provider interface - Create: `internal/provider/node/user/debian_ssh_key.go` — Debian implementation (list/add/remove authorized_keys) -- Modify: `internal/provider/node/user/darwin.go` — add 3 stub - methods -- Modify: `internal/provider/node/user/linux.go` — add 3 stub - methods +- Modify: `internal/provider/node/user/darwin.go` — add 3 stub methods +- Modify: `internal/provider/node/user/linux.go` — add 3 stub methods - Test: `internal/provider/node/user/debian_ssh_key_public_test.go` -- Modify: `internal/provider/node/user/darwin_public_test.go` — add - stub tests -- Modify: `internal/provider/node/user/linux_public_test.go` — add - stub tests +- Modify: `internal/provider/node/user/darwin_public_test.go` — add stub tests +- Modify: `internal/provider/node/user/linux_public_test.go` — add stub tests ### Agent Layer -- Create: `internal/agent/processor_ssh_key.go` — SSH key operation - dispatcher -- Modify: `internal/agent/processor.go` — add `sshKey` case to - NewNodeProcessor +- Create: `internal/agent/processor_ssh_key.go` — SSH key operation dispatcher +- Modify: `internal/agent/processor.go` — add `sshKey` case to NewNodeProcessor - Test: `internal/agent/processor_ssh_key_public_test.go` ### Operations -- Modify: `pkg/sdk/client/operations.go` — add SSH key operation - constants +- Modify: `pkg/sdk/client/operations.go` — add SSH key operation constants - Modify: `internal/job/types.go` — add SSH key operation aliases ### API Layer -- Modify: `internal/controller/api/node/user/gen/api.yaml` — add - 3 ssh-key endpoints + schemas -- Create: - `internal/controller/api/node/user/ssh_key_list_get.go` — list - handler -- Create: - `internal/controller/api/node/user/ssh_key_add_post.go` — add +- Modify: `internal/controller/api/node/user/gen/api.yaml` — add 3 ssh-key + endpoints + schemas +- Create: `internal/controller/api/node/user/ssh_key_list_get.go` — list handler +- Create: `internal/controller/api/node/user/ssh_key_add_post.go` — add handler +- Create: `internal/controller/api/node/user/ssh_key_remove_delete.go` — remove handler -- Create: - `internal/controller/api/node/user/ssh_key_remove_delete.go` — - remove handler -- Test: - `internal/controller/api/node/user/ssh_key_list_get_public_test.go` -- Test: - `internal/controller/api/node/user/ssh_key_add_post_public_test.go` -- Test: - `internal/controller/api/node/user/ssh_key_remove_delete_public_test.go` +- Test: `internal/controller/api/node/user/ssh_key_list_get_public_test.go` +- Test: `internal/controller/api/node/user/ssh_key_add_post_public_test.go` +- Test: `internal/controller/api/node/user/ssh_key_remove_delete_public_test.go` ### SDK Layer -- Modify: `pkg/sdk/client/user.go` — add ListKeys, AddKey, - RemoveKey methods -- Modify: `pkg/sdk/client/user_types.go` — add SSHKey result - types + conversions +- Modify: `pkg/sdk/client/user.go` — add ListKeys, AddKey, RemoveKey methods +- Modify: `pkg/sdk/client/user_types.go` — add SSHKey result types + conversions - Modify: `pkg/sdk/client/user_public_test.go` — add tests -- Modify: `pkg/sdk/client/user_types_public_test.go` — add - conversion tests +- Modify: `pkg/sdk/client/user_types_public_test.go` — add conversion tests ### CLI Layer - Create: `cmd/client_node_user_ssh_key.go` — parent command -- Create: `cmd/client_node_user_ssh_key_list.go` — list - subcommand +- Create: `cmd/client_node_user_ssh_key_list.go` — list subcommand - Create: `cmd/client_node_user_ssh_key_add.go` — add subcommand -- Create: `cmd/client_node_user_ssh_key_remove.go` — remove - subcommand +- Create: `cmd/client_node_user_ssh_key_remove.go` — remove subcommand ### Documentation -- Modify: `docs/docs/sidebar/features/user-management.md` — add - SSH key section -- Create: - `docs/docs/sidebar/usage/cli/client/node/user/ssh-key.md` — CLI +- Modify: `docs/docs/sidebar/features/user-management.md` — add SSH key section +- Create: `docs/docs/sidebar/usage/cli/client/node/user/ssh-key.md` — CLI landing -- Create: - `docs/docs/sidebar/usage/cli/client/node/user/ssh-key-list.md` -- Create: - `docs/docs/sidebar/usage/cli/client/node/user/ssh-key-add.md` -- Create: - `docs/docs/sidebar/usage/cli/client/node/user/ssh-key-remove.md` -- Modify: `docs/docs/sidebar/sdk/client/management/user.md` — add - SSH key methods +- Create: `docs/docs/sidebar/usage/cli/client/node/user/ssh-key-list.md` +- Create: `docs/docs/sidebar/usage/cli/client/node/user/ssh-key-add.md` +- Create: `docs/docs/sidebar/usage/cli/client/node/user/ssh-key-remove.md` +- Modify: `docs/docs/sidebar/sdk/client/management/user.md` — add SSH key + methods - Modify: `examples/sdk/client/user.go` — add SSH key demo -- Modify: `docs/docs/sidebar/architecture/api-guidelines.md` — add - endpoints +- Modify: `docs/docs/sidebar/architecture/api-guidelines.md` — add endpoints --- ### Task 1: Provider Types and Stubs **Files:** + - Modify: `internal/provider/node/user/types.go` - Modify: `internal/provider/node/user/darwin.go` - Modify: `internal/provider/node/user/linux.go` @@ -187,9 +161,8 @@ func (d *Darwin) RemoveKey( - [ ] **Step 3: Add stub tests** -Add test cases to the existing test tables in -`darwin_public_test.go` and `linux_public_test.go` for -ListKeys, AddKey, and RemoveKey all returning +Add test cases to the existing test tables in `darwin_public_test.go` and +`linux_public_test.go` for ListKeys, AddKey, and RemoveKey all returning ErrUnsupported. - [ ] **Step 4: Regenerate mocks** @@ -198,8 +171,8 @@ Run: `go generate ./internal/provider/node/user/mocks/...` - [ ] **Step 5: Run tests** -Run: `go test -v ./internal/provider/node/user/...` -Expected: all pass, new stub tests included +Run: `go test -v ./internal/provider/node/user/...` Expected: all pass, new stub +tests included - [ ] **Step 6: Commit** @@ -213,23 +186,26 @@ git commit -m "feat(user): add SSH key types and platform stubs" ### Task 2: Debian SSH Key Implementation **Files:** + - Create: `internal/provider/node/user/debian_ssh_key.go` - Test: `internal/provider/node/user/debian_ssh_key_public_test.go` - [ ] **Step 1: Write tests** -Create `debian_ssh_key_public_test.go` with testify/suite. -Use `memfs.New()` for filesystem and gomock for exec.Manager. +Create `debian_ssh_key_public_test.go` with testify/suite. Use `memfs.New()` for +filesystem and gomock for exec.Manager. Set up a memfs with `/etc/passwd` containing: + ``` root:x:0:0:root:/root:/bin/bash john:x:1000:1000:John:/home/john:/bin/bash ``` **TestListKeys** — table-driven: + - success (authorized_keys with 2 keys, verify type + fingerprint - + comment) + - comment) - user not found in /etc/passwd → error - no authorized_keys file → empty list, no error - empty authorized_keys → empty list @@ -237,6 +213,7 @@ john:x:1000:1000:John:/home/john:/bin/bash - malformed key line skipped (logged as debug) **TestAddKey** — table-driven: + - success (creates .ssh dir + file, appends key) - key already exists (same fingerprint) → changed: false - user not found → error @@ -245,6 +222,7 @@ john:x:1000:1000:John:/home/john:/bin/bash - appends to existing file **TestRemoveKey** — table-driven: + - success (rewrites file without matching key) - fingerprint not found → changed: false - user not found → error @@ -520,8 +498,8 @@ func fingerprintFromLine( } ``` -**IMPORTANT**: The SSHKey type needs a `RawLine` field to store -the full public key string for AddKey. Update the types: +**IMPORTANT**: The SSHKey type needs a `RawLine` field to store the full public +key string for AddKey. Update the types: ```go type SSHKey struct { @@ -532,16 +510,14 @@ type SSHKey struct { } ``` -The API handler populates `RawLine` from the POST body's `key` -field. The provider uses `RawLine` to append to -`authorized_keys`. ListKeys does NOT populate `RawLine` (we -don't expose raw key data in list responses — just type, +The API handler populates `RawLine` from the POST body's `key` field. The +provider uses `RawLine` to append to `authorized_keys`. ListKeys does NOT +populate `RawLine` (we don't expose raw key data in list responses — just type, fingerprint, comment). - [ ] **Step 3: Run tests** -Run: `go test -v ./internal/provider/node/user/...` -Expected: all pass +Run: `go test -v ./internal/provider/node/user/...` Expected: all pass - [ ] **Step 4: Verify 100% coverage on new file** @@ -566,6 +542,7 @@ git commit -m "feat(user): add SSH key management to debian provider" ### Task 3: Operations and Agent Processor **Files:** + - Modify: `pkg/sdk/client/operations.go` - Modify: `internal/job/types.go` - Create: `internal/agent/processor_ssh_key.go` @@ -598,26 +575,30 @@ const ( - [ ] **Step 2: Write processor tests** -Create `internal/agent/processor_ssh_key_public_test.go`. -The processor dispatches to the existing `userProvider` (same -as user/group operations). Test via `NewNodeProcessor`. +Create `internal/agent/processor_ssh_key_public_test.go`. The processor +dispatches to the existing `userProvider` (same as user/group operations). Test +via `NewNodeProcessor`. **TestProcessSSHKeyOperation** — dispatch-level table: + - nil user provider → error - invalid operation format - unsupported sub-operation **TestProcessSSHKeyList** — table-driven: + - success (returns keys) - unmarshal error (invalid JSON) - provider error **TestProcessSSHKeyAdd** — table-driven: + - success - unmarshal error - provider error **TestProcessSSHKeyRemove** — table-driven: + - success - unmarshal error - provider error @@ -665,14 +646,12 @@ func processSshKeyOperation( } ``` -Each sub-handler unmarshals username (and key data for add, -fingerprint for remove) from `jobRequest.Data`, calls the -provider, and marshals the result. +Each sub-handler unmarshals username (and key data for add, fingerprint for +remove) from `jobRequest.Data`, calls the provider, and marshals the result. - [ ] **Step 4: Wire into node processor** -In `internal/agent/processor.go`, add case to the -`NewNodeProcessor` switch: +In `internal/agent/processor.go`, add case to the `NewNodeProcessor` switch: ```go case "sshKey": @@ -707,202 +686,202 @@ git commit -m "feat(user): add SSH key operations and agent processor" ### Task 4: OpenAPI Spec Update and Code Generation **Files:** + - Modify: `internal/controller/api/node/user/gen/api.yaml` - [ ] **Step 1: Add endpoints to existing user OpenAPI spec** -Add to `internal/controller/api/node/user/gen/api.yaml` after -the password endpoint section: +Add to `internal/controller/api/node/user/gen/api.yaml` after the password +endpoint section: ```yaml - # -- SSH Key management ------------------------------------------------ - - /node/{hostname}/user/{name}/ssh-key: - get: - summary: List SSH authorized keys - description: > - List SSH authorized keys for a user on the target node. - tags: - - user_operations - operationId: GetNodeUserSshKey - security: - - BearerAuth: - - user:read - parameters: - - $ref: '#/components/parameters/Hostname' - - $ref: '#/components/parameters/UserName' - responses: - '200': - description: List of SSH authorized keys. - content: - application/json: - schema: - $ref: '#/components/schemas/SSHKeyCollectionResponse' - '401': ... - '403': ... - '500': ... - - post: - summary: Add SSH authorized key - description: > - Add an SSH authorized key for a user on the target node. - tags: - - user_operations - operationId: PostNodeUserSshKey - security: - - BearerAuth: - - user:write - parameters: - - $ref: '#/components/parameters/Hostname' - - $ref: '#/components/parameters/UserName' - requestBody: - required: true +# -- SSH Key management ------------------------------------------------ + +/node/{hostname}/user/{name}/ssh-key: + get: + summary: List SSH authorized keys + description: > + List SSH authorized keys for a user on the target node. + tags: + - user_operations + operationId: GetNodeUserSshKey + security: + - BearerAuth: + - user:read + parameters: + - $ref: '#/components/parameters/Hostname' + - $ref: '#/components/parameters/UserName' + responses: + '200': + description: List of SSH authorized keys. content: application/json: schema: - $ref: '#/components/schemas/SSHKeyAddRequest' - responses: - '200': - description: Key added. - content: - application/json: - schema: - $ref: '#/components/schemas/SSHKeyMutationResponse' - '400': ... - '401': ... - '403': ... - '500': ... - - /node/{hostname}/user/{name}/ssh-key/{fingerprint}: - delete: - summary: Remove SSH authorized key - description: > - Remove an SSH authorized key by fingerprint. - tags: - - user_operations - operationId: DeleteNodeUserSshKey - security: - - BearerAuth: - - user:write - parameters: - - $ref: '#/components/parameters/Hostname' - - $ref: '#/components/parameters/UserName' - - $ref: '#/components/parameters/SSHKeyFingerprint' - responses: - '200': - description: Key removed. - content: - application/json: - schema: - $ref: '#/components/schemas/SSHKeyMutationResponse' - '401': ... - '403': ... - '500': ... + $ref: '#/components/schemas/SSHKeyCollectionResponse' + '401': ... + '403': ... + '500': ... + + post: + summary: Add SSH authorized key + description: > + Add an SSH authorized key for a user on the target node. + tags: + - user_operations + operationId: PostNodeUserSshKey + security: + - BearerAuth: + - user:write + parameters: + - $ref: '#/components/parameters/Hostname' + - $ref: '#/components/parameters/UserName' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SSHKeyAddRequest' + responses: + '200': + description: Key added. + content: + application/json: + schema: + $ref: '#/components/schemas/SSHKeyMutationResponse' + '400': ... + '401': ... + '403': ... + '500': ... + +/node/{hostname}/user/{name}/ssh-key/{fingerprint}: + delete: + summary: Remove SSH authorized key + description: > + Remove an SSH authorized key by fingerprint. + tags: + - user_operations + operationId: DeleteNodeUserSshKey + security: + - BearerAuth: + - user:write + parameters: + - $ref: '#/components/parameters/Hostname' + - $ref: '#/components/parameters/UserName' + - $ref: '#/components/parameters/SSHKeyFingerprint' + responses: + '200': + description: Key removed. + content: + application/json: + schema: + $ref: '#/components/schemas/SSHKeyMutationResponse' + '401': ... + '403': ... + '500': ... ``` Add schemas: ```yaml - SSHKeyAddRequest: - type: object - required: - - key - properties: - key: - type: string - description: > - Full SSH public key line (e.g., - "ssh-ed25519 AAAA... user@host"). - x-oapi-codegen-extra-tags: - validate: required,min=1 - - SSHKeyInfo: - type: object - properties: - type: - type: string - example: "ssh-ed25519" - fingerprint: - type: string - example: "SHA256:abc123..." - comment: - type: string - example: "john@laptop" - - SSHKeyEntry: - type: object - properties: - hostname: - type: string - status: - type: string - enum: [ok, failed, skipped] - keys: - type: array - items: - $ref: '#/components/schemas/SSHKeyInfo' - error: - type: string - required: - - hostname - - status - - SSHKeyMutationEntry: - type: object - properties: - hostname: - type: string - status: - type: string - enum: [ok, failed, skipped] - changed: - type: boolean - error: - type: string - required: - - hostname - - status - - SSHKeyCollectionResponse: - type: object - properties: - job_id: - type: string - format: uuid - results: - type: array - items: - $ref: '#/components/schemas/SSHKeyEntry' - required: - - results - - SSHKeyMutationResponse: - type: object - properties: - job_id: - type: string - format: uuid - results: - type: array - items: - $ref: '#/components/schemas/SSHKeyMutationEntry' - required: - - results +SSHKeyAddRequest: + type: object + required: + - key + properties: + key: + type: string + description: > + Full SSH public key line (e.g., "ssh-ed25519 AAAA... user@host"). + x-oapi-codegen-extra-tags: + validate: required,min=1 + +SSHKeyInfo: + type: object + properties: + type: + type: string + example: 'ssh-ed25519' + fingerprint: + type: string + example: 'SHA256:abc123...' + comment: + type: string + example: 'john@laptop' + +SSHKeyEntry: + type: object + properties: + hostname: + type: string + status: + type: string + enum: [ok, failed, skipped] + keys: + type: array + items: + $ref: '#/components/schemas/SSHKeyInfo' + error: + type: string + required: + - hostname + - status + +SSHKeyMutationEntry: + type: object + properties: + hostname: + type: string + status: + type: string + enum: [ok, failed, skipped] + changed: + type: boolean + error: + type: string + required: + - hostname + - status + +SSHKeyCollectionResponse: + type: object + properties: + job_id: + type: string + format: uuid + results: + type: array + items: + $ref: '#/components/schemas/SSHKeyEntry' + required: + - results + +SSHKeyMutationResponse: + type: object + properties: + job_id: + type: string + format: uuid + results: + type: array + items: + $ref: '#/components/schemas/SSHKeyMutationEntry' + required: + - results ``` Add parameter: ```yaml - SSHKeyFingerprint: - name: fingerprint - in: path - required: true - description: SSH key SHA256 fingerprint. - x-oapi-codegen-extra-tags: - validate: required,min=1 - schema: - type: string - minLength: 1 +SSHKeyFingerprint: + name: fingerprint + in: path + required: true + description: SSH key SHA256 fingerprint. + x-oapi-codegen-extra-tags: + validate: required,min=1 + schema: + type: string + minLength: 1 ``` - [ ] **Step 2: Generate code and rebuild** @@ -927,6 +906,7 @@ git commit -m "feat(user): add SSH key endpoints to OpenAPI spec" ### Task 5: API Handler Implementation **Files:** + - Create: `internal/controller/api/node/user/ssh_key_list_get.go` - Create: `internal/controller/api/node/user/ssh_key_add_post.go` - Create: `internal/controller/api/node/user/ssh_key_remove_delete.go` @@ -934,29 +914,30 @@ git commit -m "feat(user): add SSH key endpoints to OpenAPI spec" - [ ] **Step 1: Implement list handler** -`GetNodeUserSshKey` method on the existing `User` handler -struct: +`GetNodeUserSshKey` method on the existing `User` handler struct: + - Validate hostname - username from `request.Name` -- Query with category `"node"`, operation - `job.OperationSSHKeyList`, data `{"username": username}` -- Parse response: unmarshal `[]userProv.SSHKey`, convert to - `[]gen.SSHKeyInfo` +- Query with category `"node"`, operation `job.OperationSSHKeyList`, data + `{"username": username}` +- Parse response: unmarshal `[]userProv.SSHKey`, convert to `[]gen.SSHKeyInfo` - Broadcast support - [ ] **Step 2: Implement add handler** `PostNodeUserSshKey`: + - Validate hostname, body (`key` field) - Parse the raw key line to extract type, fingerprint, comment - Build `userProv.SSHKey{Type, Fingerprint, Comment, RawLine}` -- Modify with `job.OperationSSHKeyAdd`, data includes - `username` + the SSHKey struct +- Modify with `job.OperationSSHKeyAdd`, data includes `username` + the SSHKey + struct - Parse mutation response - [ ] **Step 3: Implement remove handler** `DeleteNodeUserSshKey`: + - Validate hostname - fingerprint from `request.Fingerprint` - Modify with `job.OperationSSHKeyRemove`, data @@ -965,9 +946,9 @@ struct: - [ ] **Step 4: Write tests** -Each handler test file needs: success, skipped, broadcast, -validation error, job error, HTTP wiring, RBAC (401/403/200). -One suite method per handler, all scenarios as table rows. +Each handler test file needs: success, skipped, broadcast, validation error, job +error, HTTP wiring, RBAC (401/403/200). One suite method per handler, all +scenarios as table rows. - [ ] **Step 5: Run tests and verify coverage** @@ -991,6 +972,7 @@ git commit -m "feat(user): add SSH key API handlers with broadcast support" ### Task 6: SDK Service Extension **Files:** + - Modify: `pkg/sdk/client/user.go` - Modify: `pkg/sdk/client/user_types.go` - Modify: `pkg/sdk/client/user_public_test.go` @@ -1062,10 +1044,9 @@ go generate ./pkg/sdk/client/gen/... - [ ] **Step 4: Write tests** -Add tests to existing test files (or create new -`user_ssh_key_public_test.go` / `user_ssh_key_types_public_test.go` -if the existing files are already large). Follow existing -patterns with httptest.Server. +Add tests to existing test files (or create new `user_ssh_key_public_test.go` / +`user_ssh_key_types_public_test.go` if the existing files are already large). +Follow existing patterns with httptest.Server. - [ ] **Step 5: Verify 100% coverage** @@ -1088,6 +1069,7 @@ git commit -m "feat(user): add SSH key SDK methods with tests" ### Task 7: CLI Commands **Files:** + - Create: `cmd/client_node_user_ssh_key.go` - Create: `cmd/client_node_user_ssh_key_list.go` - Create: `cmd/client_node_user_ssh_key_add.go` @@ -1112,20 +1094,22 @@ Wait — check whether `clientNodeUserCmd` exists. Look at - [ ] **Step 2: Create list subcommand** Flags: `--name` (username, required) + - Calls `sdkClient.User.ListKeys(ctx, host, name)` - Table headers: `TYPE`, `FINGERPRINT`, `COMMENT` - Uses `BuildBroadcastTable` - [ ] **Step 3: Create add subcommand** -Flags: `--name` (required), `--key` (required, full public -key line) +Flags: `--name` (required), `--key` (required, full public key line) + - Calls `sdkClient.User.AddKey(ctx, host, name, opts)` - Uses `BuildMutationTable` with headers `CHANGED` - [ ] **Step 4: Create remove subcommand** Flags: `--name` (required), `--fingerprint` (required) + - Calls `sdkClient.User.RemoveKey(ctx, host, name, fp)` - Uses `BuildMutationTable` @@ -1147,6 +1131,7 @@ git commit -m "feat(user): add SSH key CLI commands" ### Task 8: Documentation **Files:** + - Modify: `docs/docs/sidebar/features/user-management.md` - Create: CLI doc pages for ssh-key commands - Modify: `docs/docs/sidebar/sdk/client/management/user.md` @@ -1157,6 +1142,7 @@ git commit -m "feat(user): add SSH key CLI commands" Add SSH Key Management section to `docs/docs/sidebar/features/user-management.md`: + - How It Works (list, add, remove) - Add to Operations table - Add CLI examples for ssh-key subcommands @@ -1169,8 +1155,8 @@ Create landing page + list.md, add.md, remove.md under - [ ] **Step 3: Update SDK doc** -Add ListKeys, AddKey, RemoveKey to the user SDK doc page -with code examples and result type tables. +Add ListKeys, AddKey, RemoveKey to the user SDK doc page with code examples and +result type tables. - [ ] **Step 4: Update SDK example** @@ -1179,6 +1165,7 @@ Add SSH key demo to `examples/sdk/client/user.go`. - [ ] **Step 5: Update api-guidelines** Add endpoint rows: + ``` | `/node/{hostname}/user/{name}/ssh-key` | User | | `/node/{hostname}/user/{name}/ssh-key/{fingerprint}` | User | @@ -1196,13 +1183,14 @@ git commit -m "docs: add SSH key management to user docs and SDK example" ### Task 9: Integration Test and Final Verification **Files:** -- Modify or create: `test/integration/user_test.go` (add SSH - key tests) + +- Modify or create: `test/integration/user_test.go` (add SSH key tests) - [ ] **Step 1: Add integration test** -Add SSH key list test to the existing user integration test -file (or create new if it doesn't exist). Test: +Add SSH key list test to the existing user integration test file (or create new +if it doesn't exist). Test: + - `osapi client node user ssh-key list --target _any --name root --json` - [ ] **Step 2: Run full suite**