From 13e6762f0f0bc6086a3b3de4308d3a7a5834439c Mon Sep 17 00:00:00 2001 From: Jon Chery Date: Tue, 4 Aug 2026 11:51:47 +0000 Subject: [PATCH] =?UTF-8?q?feat(cli):=20orca=20node=20key-reset=20?= =?UTF-8?q?=20=E2=80=94=20local=20known=5Fhosts=20reset=20(T02.8,=20REQ-05?= =?UTF-8?q?9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ---ci--- project: orca phase: 2 milestone: v0.8 status: execute ---/ci--- --- internal/cli/node.go | 59 ++++++++++++++ internal/cli/node_test.go | 119 +++++++++++++++++++++++++++++ internal/proxmox/bootstrap.go | 56 ++++++++++++++ internal/proxmox/bootstrap_test.go | 73 ++++++++++++++++++ 4 files changed, 307 insertions(+) diff --git a/internal/cli/node.go b/internal/cli/node.go index 7f324b8..adaa851 100644 --- a/internal/cli/node.go +++ b/internal/cli/node.go @@ -346,6 +346,64 @@ func renderNodeTable(nodes []*model.Node) string { return out } +var nodeKeyResetCmd = &cobra.Command{ + Use: "key-reset ", + Short: "Reset the SSH known_hosts entry for a node", + Long: `Remove the pinned SSH host key for from the local known_hosts +file. The next connect re-pins the key via TOFU or --host-key-fingerprint. + +LOCAL ONLY (D-046): does not touch the remote host's authorized_keys. + + is the node name (for proxmox nodes, this is the host address).`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + nodeArg := args[0] + + registry, closer, err := nodeRegistry() + if err != nil { + return err + } + defer closer() + + ctx, cancel := context.WithTimeout(cmd.Context(), 5*time.Second) + defer cancel() + + nodes, err := registry.List(ctx) + if err != nil { + return fmt.Errorf("list nodes: %w", err) + } + var node *model.Node + for _, n := range nodes { + if n.Name == nodeArg || n.ID == nodeArg { + node = n + break + } + } + if node == nil { + return fmt.Errorf("node %q not found in the registry", nodeArg) + } + host := node.Name + + if err := proxmox.ResetHostKey(host); err != nil { + return fmt.Errorf("reset host key: %w", err) + } + + // Audit-log the reset (REQ-059): actor=cli, action=node.key_reset. + db, dbCloser, dbErr := openDB() + if dbErr == nil { + defer dbCloser() + audit := engine.NewAudit(store.NewAuditRepo(db), newLogger()) + audit.Record(ctx, "cli", "node.key_reset", node.ID, "success", nil, map[string]any{ + "node": node.Name, + "host": host, + }) + } + + fmt.Fprintf(cmd.OutOrStdout(), "✓ Host key reset for %s (next connect will re-pin via TOFU or --host-key-fingerprint)\n", node.Name) + return nil + }, +} + func init() { nodeJoinCmd.Flags().StringVar(&joinName, "name", "", "node name (required for --type localhost)") nodeJoinCmd.Flags().StringVar(&joinAddr, "addr", "", "node address (default localhost:8443)") @@ -364,5 +422,6 @@ func init() { nodeCmd.AddCommand(nodeJoinCmd) nodeCmd.AddCommand(nodeLeaveCmd) nodeCmd.AddCommand(nodeListCmd) + nodeCmd.AddCommand(nodeKeyResetCmd) rootCmd.AddCommand(nodeCmd) } diff --git a/internal/cli/node_test.go b/internal/cli/node_test.go index 329488f..2b4f4a2 100644 --- a/internal/cli/node_test.go +++ b/internal/cli/node_test.go @@ -13,6 +13,8 @@ import ( "bytes" "context" "encoding/json" + "os" + "path/filepath" "strings" "testing" "time" @@ -318,3 +320,120 @@ func padHex(n int) string { } return string(b) } + +// TestNodeKeyReset removes the target node's known_hosts lines, leaves +// other hosts' lines intact, and inserts an audit row (T02.8, REQ-059). +func TestNodeKeyReset(t *testing.T) { + _, cleanup := initTestEnv(t) + defer cleanup() + + // Seed a proxmox node whose Name is the host address (matches the + // key-reset RunE, which uses node.Name as the known_hosts match key). + seedProxmoxNode(t, "10.0.0.1", "10.0.0.1:8443") + + // Pre-populate known_hosts: 2 lines for the target + 1 for another host. + knownHosts := certpaths.KnownHostsPath() + if err := os.MkdirAll(filepath.Dir(knownHosts), 0o755); err != nil { + t.Fatalf("mkdir known_hosts dir: %v", err) + } + original := []byte("[10.0.0.1]:22 ssh-ed25519 AAAAKEY1 host1\n" + + "10.0.0.1 ssh-ed25519 AAAAKEY1ALT host1-alt\n" + + "[10.0.0.2]:22 ssh-ed25519 AAAAKEY2 host2\n") + if err := os.WriteFile(knownHosts, original, 0o600); err != nil { + t.Fatalf("write known_hosts: %v", err) + } + + resetRootFlags(t) + var buf bytes.Buffer + rootCmd.SetOut(&buf) + rootCmd.SetErr(&buf) + rootCmd.SetArgs([]string{"node", "key-reset", "10.0.0.1"}) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("node key-reset: %v", err) + } + out := buf.String() + if !strings.Contains(out, "Host key reset for 10.0.0.1") { + t.Errorf("output missing reset confirmation: %s", out) + } + + // known_hosts: target's 2 lines removed, other host's line intact. + data, err := os.ReadFile(knownHosts) + if err != nil { + t.Fatalf("read known_hosts: %v", err) + } + result := string(data) + if strings.Contains(result, "AAAAKEY1") { + t.Errorf("target key line 1 not removed: %s", result) + } + if strings.Contains(result, "AAAAKEY1ALT") { + t.Errorf("target key line 2 not removed: %s", result) + } + if !strings.Contains(result, "AAAAKEY2") { + t.Errorf("other host's line was removed (should be intact): %s", result) + } + + // Audit row inserted with action=node.key_reset. + db, err := store.Open(certpaths.DBPath()) + if err != nil { + t.Fatalf("open db: %v", err) + } + defer db.Close() + entries, err := store.NewAuditRepo(db).List(context.Background(), 50) + if err != nil { + t.Fatalf("list audit: %v", err) + } + found := false + for _, e := range entries { + if e.Action == "node.key_reset" && strings.Contains(e.Resource, "10.0.0.1") { + found = true + break + } + } + if !found { + t.Errorf("audit row for node.key_reset not inserted: %+v", entries) + } +} + +// TestNodeKeyReset_NodeNotFound verifies key-reset errors when the +// node is not in the registry (T02.8). +func TestNodeKeyReset_NodeNotFound(t *testing.T) { + _, cleanup := initTestEnv(t) + defer cleanup() + resetRootFlags(t) + var buf bytes.Buffer + rootCmd.SetOut(&buf) + rootCmd.SetErr(&buf) + rootCmd.SetArgs([]string{"node", "key-reset", "no.such.host"}) + err := rootCmd.Execute() + if err == nil { + t.Fatal("expected error for unknown node, got nil") + } + if !strings.Contains(err.Error(), "not found") { + t.Errorf("error should mention not found, got: %v", err) + } +} + +func seedProxmoxNode(t *testing.T, name, addr string) string { + t.Helper() + db, err := store.Open(certpaths.DBPath()) + if err != nil { + t.Fatalf("open db: %v", err) + } + defer db.Close() + repo := store.NewNodeRepo(db) + ctx := context.Background() + n := &model.Node{ + ID: "node-" + name, + Name: name, + Address: addr, + State: model.NodeStateReady, + JoinedAt: time.Now().UTC(), + LastSeen: time.Now().UTC(), + Kind: string(model.NodeKindProxmox), + OS: "pve", + } + if err := repo.Insert(ctx, n); err != nil { + t.Fatalf("insert proxmox node: %v", err) + } + return n.ID +} diff --git a/internal/proxmox/bootstrap.go b/internal/proxmox/bootstrap.go index 16b0ebd..f5f610d 100644 --- a/internal/proxmox/bootstrap.go +++ b/internal/proxmox/bootstrap.go @@ -462,3 +462,59 @@ func validateSudoers() error { } return nil } + +// ResetHostKey removes all known_hosts entries for the given host from +// certpaths.KnownHostsPath() (REQ-059, D-046, AD-029). It rewrites the +// file atomically via security.WriteAtomic. LOCAL ONLY — it does NOT +// touch the remote host's authorized_keys (D-046). The next connect +// re-pins the host key via TOFU (T02.6) or the --host-key-fingerprint +// pinned path (T02.5). +// +// A line matches when its first whitespace-delimited field (the host +// pattern, normalized via knownhosts.Normalize) equals the normalized +// target host. Comment/blank lines are preserved. +func ResetHostKey(host string) error { + if host == "" { + return fmt.Errorf("ResetHostKey: host is required") + } + path := certpaths.KnownHostsPath() + existing, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil // nothing to reset + } + return fmt.Errorf("ResetHostKey: read known_hosts: %w", err) + } + target := knownhosts.Normalize(host) + var kept []byte + removed := 0 + for _, line := range strings.Split(string(existing), "\n") { + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + kept = append(kept, []byte(line+"\n")...) + continue + } + fields := strings.Fields(trimmed) + if len(fields) == 0 { + kept = append(kept, []byte(line+"\n")...) + continue + } + if knownhosts.Normalize(fields[0]) == target { + removed++ + continue + } + kept = append(kept, []byte(line+"\n")...) + } + if removed == 0 { + return nil + } + // Ensure the kept buffer ends with exactly one trailing newline. + kept = bytes.TrimRight(kept, "\n") + if len(kept) > 0 { + kept = append(kept, '\n') + } + if err := security.WriteAtomic(path, 0o600, kept); err != nil { + return fmt.Errorf("ResetHostKey: rewrite known_hosts: %w", err) + } + return nil +} diff --git a/internal/proxmox/bootstrap_test.go b/internal/proxmox/bootstrap_test.go index 0ccba34..2aa0b59 100644 --- a/internal/proxmox/bootstrap_test.go +++ b/internal/proxmox/bootstrap_test.go @@ -711,3 +711,76 @@ func TestBootstrapProxmox_PopulatesHostKeyFingerprint(t *testing.T) { t.Errorf("Result.HostKeyFingerprint = %q, want SHA256: prefix", result.HostKeyFingerprint) } } + +// TestResetHostKey_RemovesTargetLines verifies that ResetHostKey +// removes all known_hosts lines for the target host while leaving +// other hosts' lines intact (T02.8, REQ-059, D-046). +func TestResetHostKey_RemovesTargetLines(t *testing.T) { + home := setupORCAHome(t) + path := filepath.Join(home, "known_hosts") + original := []byte("[10.0.0.1]:22 ssh-ed25519 AAAAKEY1 host1\n" + + "10.0.0.1 ssh-ed25519 AAAAKEY1ALT host1-alt\n" + + "[10.0.0.2]:22 ssh-ed25519 AAAAKEY2 host2\n") + if err := os.WriteFile(path, original, 0o600); err != nil { + t.Fatalf("write known_hosts: %v", err) + } + + if err := ResetHostKey("10.0.0.1"); err != nil { + t.Fatalf("ResetHostKey: %v", err) + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read known_hosts: %v", err) + } + result := string(data) + if strings.Contains(result, "AAAAKEY1") { + t.Errorf("target host key line not removed: %s", result) + } + if strings.Contains(result, "AAAAKEY1ALT") { + t.Errorf("target host alt key line not removed: %s", result) + } + if !strings.Contains(result, "AAAAKEY2") { + t.Errorf("other host's line was removed (should be intact): %s", result) + } +} + +// TestResetHostKey_NoMatchingLinesIsNoop verifies that ResetHostKey is +// a no-op when no lines match (T02.8). +func TestResetHostKey_NoMatchingLinesIsNoop(t *testing.T) { + home := setupORCAHome(t) + path := filepath.Join(home, "known_hosts") + original := []byte("[10.0.0.2]:22 ssh-ed25519 AAAAKEY2 host2\n") + if err := os.WriteFile(path, original, 0o600); err != nil { + t.Fatalf("write known_hosts: %v", err) + } + + if err := ResetHostKey("10.0.0.99"); err != nil { + t.Fatalf("ResetHostKey: %v", err) + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read known_hosts: %v", err) + } + if string(data) != string(original) { + t.Errorf("known_hosts changed on no-match: got %q, want %q", data, original) + } +} + +// TestResetHostKey_MissingFileIsNoop verifies ResetHostKey returns nil +// when known_hosts does not exist (T02.8). +func TestResetHostKey_MissingFileIsNoop(t *testing.T) { + setupORCAHome(t) + if err := ResetHostKey("10.0.0.1"); err != nil { + t.Errorf("ResetHostKey on missing file should be no-op, got: %v", err) + } +} + +// TestResetHostKey_EmptyHostErrors verifies ResetHostKey rejects an +// empty host (T02.8). +func TestResetHostKey_EmptyHostErrors(t *testing.T) { + if err := ResetHostKey(""); err == nil { + t.Error("expected error for empty host, got nil") + } +}