Files
orca/internal/cli/node.go
T
Jon Chery 20523ac045 fix(P07): remove all password/token paths (REQ-146, R-021, C-34) -- BREAKING
---ci---
project: orca
phase: 7
milestone: v0.12
status: execute
---/ci---

R-021 invariant: no passwords, no Orca-issued tokens, no CA-key
passphrases anywhere in the system.

Removed:
- proxmox/bootstrap.go: ssh.Password auth -> ssh.PublicKeys (key-based).
  --password/ removed from node join; replaced
  with --ssh-key (default: orca SSH key). Pre-staged key required.
- stepca/stepca.go: --password-file /dev/stdin removed from Init and
  issueCert. Provisioner changed to 'orca-oidc' (OIDC provisioner).
- identity/spiffe.go: --password-file removed from MintSVID. Provisioner
  changed to 'orca-oidc'.

Tests: all proxmox, stepca, identity, cli tests updated + pass. 3 new
password-rejection regression tests. Fake SSH server gains
PublicKeyCallback. go vet clean. Full build green.
2026-08-07 11:12:18 +00:00

443 lines
13 KiB
Go

package cli
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"log/slog"
"os"
"os/signal"
"syscall"
"time"
"github.com/google/uuid"
"github.com/spf13/cobra"
"git.cloudinit.dev/coreci/orca/internal/certpaths"
"git.cloudinit.dev/coreci/orca/internal/engine"
"git.cloudinit.dev/coreci/orca/internal/model"
"git.cloudinit.dev/coreci/orca/internal/proxmox"
"git.cloudinit.dev/coreci/orca/internal/security"
"git.cloudinit.dev/coreci/orca/internal/store"
)
func openDB() (*sql.DB, func() error, error) {
db, err := store.Open(certpaths.DBPath())
if err != nil {
return nil, nil, err
}
return db, db.Close, nil
}
func newLogger() *slog.Logger {
return slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo}))
}
func nodeRegistry() (*engine.NodeRegistry, func() error, error) {
db, closer, err := openDB()
if err != nil {
return nil, nil, err
}
repo := store.NewNodeRepo(db)
audit := engine.NewAudit(store.NewAuditRepo(db), newLogger())
return engine.NewNodeRegistry(repo, audit, newLogger()), closer, nil
}
var (
joinName string
joinAddr string
joinCAFinger string
joinType string
joinHost string
joinSSHUser string
joinSSHKey string
joinSSHPort int
joinHostKeyFP string
proxmoxUser string
proxmoxRole string
leaveID string
nodeWatch bool
)
var nodeCmd = &cobra.Command{
Use: "node",
Short: "Manage orca nodes",
Long: "Join, leave, or list orca nodes in the registry.",
}
var nodeJoinCmd = &cobra.Command{
Use: "join",
Short: "Join a node to the orca registry",
Long: `Register a node in the local orca registry. Persisted to SQLite.
Node types (via --type):
localhost (default): register a local or Linux node (existing behavior)
proxmox: SSH-bootstrap a remote Proxmox VE 8/9 host
(deploys orca pubkey, creates orca user + PVE role +
sudoers allowlist; requires --host + --ssh-key (R-021: no passwords))`,
RunE: func(cmd *cobra.Command, args []string) error {
if joinHostKeyFP != "" && joinType != "proxmox" {
return fmt.Errorf("--host-key-fingerprint requires --type proxmox today")
}
if joinType == "proxmox" {
return joinProxmox(cmd)
}
return joinLocal(cmd)
},
}
// joinLocal is the existing localhost/Linux node join flow (fingerprint
// check + registry.Insert).
//
// Deprecated: v0.9 re-architecture replaces daemon-to-daemon mTLS join
// with SSH-push bootstrap (R-001). The mTLS join path is retained for
// the dual-write window and scheduled for deletion in v0.10-P14. See
// .ciagent/PRD_v0.9.md.
func joinLocal(cmd *cobra.Command) error {
warnDeprecated("orca node join (mTLS path): v0.9 R-001 replaces daemon-to-daemon mTLS join with SSH-push bootstrap; the mTLS join path is deprecated — see .ciagent/PRD_v0.9.md")
if joinName == "" {
return fmt.Errorf("--name is required")
}
if joinAddr == "" {
joinAddr = "localhost:8443"
}
// REQ-026: if --ca-fingerprint is set, verify the on-disk CA
// matches the pinned value before we touch the registry. This
// prevents typos in the operator-supplied fingerprint from
// silently degrading to "no pin" and accepting any cert.
if joinCAFinger != "" {
fp, err := security.Fingerprint(certpaths.CACertPath())
if err != nil {
return fmt.Errorf("--ca-fingerprint set but local CA is missing: %w (run `orca cert ca-init` first)", err)
}
if fp != joinCAFinger {
return fmt.Errorf(
"CA fingerprint mismatch: on-disk=%s, pinned=%s — refusing to join (REQ-026)",
fp, joinCAFinger,
)
}
}
ctx, cancel := context.WithTimeout(cmd.Context(), 5*time.Second)
defer cancel()
registry, closer, err := nodeRegistry()
if err != nil {
return err
}
defer closer()
node := &model.Node{
ID: uuid.NewString(),
Name: joinName,
Address: joinAddr,
State: model.NodeStateReady,
JoinedAt: time.Now().UTC(),
LastSeen: time.Now().UTC(),
}
if err := registry.Join(ctx, node); err != nil {
return err
}
if jsonOutput {
return printJSON(node)
}
fmt.Fprintf(cmd.OutOrStdout(), "✓ Node joined: %s (%s) at %s\n", node.ID, node.Name, node.Address)
return nil
}
// joinProxmox bootstraps a remote Proxmox VE 8/9 host via SSH and
// registers it as an orca node (REQ-050, REQ-051). Uses SSH key auth
// (R-021: no passwords). The operator pre-stages the orca SSH public
// key on the remote host out-of-band.
func joinProxmox(cmd *cobra.Command) error {
if joinHost == "" {
return fmt.Errorf("--host is required for --type proxmox")
}
sshKeyPath := joinSSHKey
if sshKeyPath == "" {
sshKeyPath = certpaths.SSHKeyPath()
}
if sshKeyPath == "" {
return fmt.Errorf("SSH key path is required for --type proxmox (R-021: no passwords; use --ssh-key or pre-stage the orca key)")
}
ctx, cancel := context.WithTimeout(cmd.Context(), 60*time.Second)
defer cancel()
result, err := proxmox.BootstrapProxmox(ctx, proxmox.Options{
Host: joinHost,
SSHUser: joinSSHUser,
SSHKeyPath: sshKeyPath,
ProxmoxUser: proxmoxUser,
ProxmoxRole: proxmoxRole,
SSHPort: joinSSHPort,
HostKeyFingerprint: joinHostKeyFP,
Logger: newLogger(),
})
if err != nil {
return fmt.Errorf("proxmox bootstrap: %w", err)
}
// Register the proxmox node in the orca registry.
registry, closer, err := nodeRegistry()
if err != nil {
return err
}
defer closer()
regCtx, regCancel := context.WithTimeout(ctx, 5*time.Second)
defer regCancel()
node := &model.Node{
ID: uuid.NewString(),
Name: result.NodeName,
Address: result.NodeAddress,
State: model.NodeStateReady,
JoinedAt: time.Now().UTC(),
LastSeen: time.Now().UTC(),
Kind: string(model.NodeKindProxmox),
OS: "pve",
}
if err := registry.Join(regCtx, node); err != nil {
return fmt.Errorf("register proxmox node: %w", err)
}
if jsonOutput {
return printJSON(node)
}
fmt.Fprintf(cmd.OutOrStdout(), "✓ Proxmox node joined: %s (%s) at %s\n", node.ID, node.Name, node.Address)
fmt.Fprintf(cmd.OutOrStdout(), " role: %s, user: %s@pam\n", proxmoxRole, proxmoxUser)
return nil
}
var nodeLeaveCmd = &cobra.Command{
Use: "leave [node-id]",
Short: "Remove a node from the orca registry",
Long: "Mark a node as left. Use --id to specify, or pass as argument.",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
id := leaveID
if id == "" && len(args) > 0 {
id = args[0]
}
if id == "" {
return fmt.Errorf("node id required (use --id or pass as argument)")
}
ctx, cancel := context.WithTimeout(cmd.Context(), 5*time.Second)
defer cancel()
registry, closer, err := nodeRegistry()
if err != nil {
return err
}
defer closer()
if err := registry.Leave(ctx, id); err != nil {
return err
}
if jsonOutput {
return printJSON(map[string]string{"id": id, "state": "left"})
}
fmt.Fprintf(cmd.OutOrStdout(), "✓ Node left: %s\n", id)
return nil
},
}
var nodeListCmd = &cobra.Command{
Use: "list",
Short: "List all nodes in the orca registry",
Long: "Display all registered nodes and their state. Use --watch to stream updates until Ctrl-C.",
RunE: func(cmd *cobra.Command, args []string) error {
if nodeWatch {
return watchNodes(cmd)
}
// Cache (R-008): read path only; --watch bypasses. On hit,
// unmarshal cached JSON and render without touching the DB.
var cachedNodes []*model.Node
if cacheGetList(cacheNodeClass, cacheListKey, &cachedNodes) {
return renderNodes(cmd, cachedNodes)
}
ctx, cancel := context.WithTimeout(cmd.Context(), 5*time.Second)
defer cancel()
registry, closer, err := nodeRegistry()
if err != nil {
return err
}
defer closer()
nodes, err := registry.List(ctx)
if err != nil {
return err
}
cachePutList(cacheNodeClass, cacheListKey, nodes, cacheNodeTTL)
return renderNodes(cmd, nodes)
},
}
// renderNodes prints the node list in either JSON or table form.
func renderNodes(cmd *cobra.Command, nodes []*model.Node) error {
if jsonOutput {
return printJSON(nodes)
}
if len(nodes) == 0 {
fmt.Fprintln(cmd.OutOrStdout(), "No nodes registered. Use 'orca node join' to add one.")
return nil
}
fmt.Fprintf(cmd.OutOrStdout(), "%-36s %-20s %-22s %-10s\n", "ID", "NAME", "ADDRESS", "STATE")
for _, n := range nodes {
fmt.Fprintf(cmd.OutOrStdout(), "%-36s %-20s %-22s %-10s\n", n.ID, n.Name, n.Address, n.State)
}
return nil
}
func watchNodes(cmd *cobra.Command) error {
ctx, cancel := signal.NotifyContext(cmd.Context(), os.Interrupt, syscall.SIGTERM)
defer cancel()
return watchNodesCtx(cmd, ctx)
}
func watchNodesCtx(cmd *cobra.Command, ctx context.Context) error {
db, closer, err := openDB()
if err != nil {
return err
}
defer closer()
out := cmd.OutOrStdout()
if jsonOutput {
seen := make(map[string]string)
for snapshot := range store.NewNodeRepo(db).Watch(ctx) {
current := make(map[string]bool, len(snapshot))
for _, n := range snapshot {
current[n.ID] = true
compact, _ := json.Marshal(n)
key := string(compact)
if prev, ok := seen[n.ID]; !ok || prev != key {
event := "init"
if ok {
event = "update"
}
line, _ := json.Marshal(map[string]any{"event": event, "node": n})
fmt.Fprintln(out, string(line))
seen[n.ID] = key
}
}
for id := range seen {
if !current[id] {
line, _ := json.Marshal(map[string]any{"event": "delete", "id": id})
fmt.Fprintln(out, string(line))
delete(seen, id)
}
}
}
return nil
}
prevTable := ""
for snapshot := range store.NewNodeRepo(db).Watch(ctx) {
table := renderNodeTable(snapshot)
if table != prevTable {
fmt.Fprint(out, "\033[2J\033[H")
fmt.Fprint(out, table)
prevTable = table
}
}
return nil
}
func renderNodeTable(nodes []*model.Node) string {
if len(nodes) == 0 {
return "No nodes registered.\n"
}
out := fmt.Sprintf("%-36s %-20s %-22s %-10s\n", "ID", "NAME", "ADDRESS", "STATE")
for _, n := range nodes {
out += fmt.Sprintf("%-36s %-20s %-22s %-10s\n", n.ID, n.Name, n.Address, n.State)
}
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() {
nodeJoinCmd.Flags().StringVar(&joinName, "name", "", "node name (required for --type localhost)")
nodeJoinCmd.Flags().StringVar(&joinAddr, "addr", "", "node address (default localhost:8443)")
nodeJoinCmd.Flags().StringVar(&joinCAFinger, "ca-fingerprint", "", "pin CA cert SHA-256 (REQ-026); fails if on-disk CA doesn't match")
nodeJoinCmd.Flags().StringVar(&joinType, "type", "localhost", "node type: localhost (default) or proxmox (SSH bootstrap)")
nodeJoinCmd.Flags().StringVar(&joinHost, "host", "", "proxmox host address (IP/hostname, no port; required for --type proxmox)")
nodeJoinCmd.Flags().StringVar(&joinSSHUser, "ssh-user", "root", "SSH username for proxmox bootstrap (default root)")
nodeJoinCmd.Flags().StringVar(&joinSSHKey, "ssh-key", "", "SSH private key path for proxmox bootstrap (R-021: no passwords; default: orca key)")
nodeJoinCmd.Flags().IntVar(&joinSSHPort, "ssh-port", 22, "SSH port for proxmox bootstrap (default 22)")
nodeJoinCmd.Flags().StringVar(&proxmoxUser, "proxmox-user", "orca", "Linux system user to create on the proxmox host (config-overridable)")
nodeJoinCmd.Flags().StringVar(&proxmoxRole, "proxmox-role", "OrcaOperator", "PVE custom role to create (config-overridable)")
nodeJoinCmd.Flags().StringVar(&joinHostKeyFP, "host-key-fingerprint", "", "SSH host key SHA256:base64 fingerprint (pre-pin; supersedes TOFU for --type proxmox)")
nodeLeaveCmd.Flags().StringVar(&leaveID, "id", "", "node id")
nodeListCmd.Flags().BoolVar(&nodeWatch, "watch", false, "stream nodes until Ctrl-C (table refresh or --json per-event)")
nodeCmd.AddCommand(nodeJoinCmd)
nodeCmd.AddCommand(nodeLeaveCmd)
nodeCmd.AddCommand(nodeListCmd)
nodeCmd.AddCommand(nodeKeyResetCmd)
rootCmd.AddCommand(nodeCmd)
}