feat(cli): orca node key-reset <node> — local known_hosts reset (T02.8, REQ-059)
---ci--- project: orca phase: 2 milestone: v0.8 status: execute ---/ci---
This commit is contained in:
@@ -346,6 +346,64 @@ func renderNodeTable(nodes []*model.Node) string {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var nodeKeyResetCmd = &cobra.Command{
|
||||||
|
Use: "key-reset <node>",
|
||||||
|
Short: "Reset the SSH known_hosts entry for a node",
|
||||||
|
Long: `Remove the pinned SSH host key for <node> 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.
|
||||||
|
|
||||||
|
<node> 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() {
|
func init() {
|
||||||
nodeJoinCmd.Flags().StringVar(&joinName, "name", "", "node name (required for --type localhost)")
|
nodeJoinCmd.Flags().StringVar(&joinName, "name", "", "node name (required for --type localhost)")
|
||||||
nodeJoinCmd.Flags().StringVar(&joinAddr, "addr", "", "node address (default localhost:8443)")
|
nodeJoinCmd.Flags().StringVar(&joinAddr, "addr", "", "node address (default localhost:8443)")
|
||||||
@@ -364,5 +422,6 @@ func init() {
|
|||||||
nodeCmd.AddCommand(nodeJoinCmd)
|
nodeCmd.AddCommand(nodeJoinCmd)
|
||||||
nodeCmd.AddCommand(nodeLeaveCmd)
|
nodeCmd.AddCommand(nodeLeaveCmd)
|
||||||
nodeCmd.AddCommand(nodeListCmd)
|
nodeCmd.AddCommand(nodeListCmd)
|
||||||
|
nodeCmd.AddCommand(nodeKeyResetCmd)
|
||||||
rootCmd.AddCommand(nodeCmd)
|
rootCmd.AddCommand(nodeCmd)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -318,3 +320,120 @@ func padHex(n int) string {
|
|||||||
}
|
}
|
||||||
return string(b)
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -462,3 +462,59 @@ func validateSudoers() error {
|
|||||||
}
|
}
|
||||||
return nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -711,3 +711,76 @@ func TestBootstrapProxmox_PopulatesHostKeyFingerprint(t *testing.T) {
|
|||||||
t.Errorf("Result.HostKeyFingerprint = %q, want SHA256: prefix", result.HostKeyFingerprint)
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user