package cli import ( "bufio" "context" "database/sql" "encoding/json" "fmt" "log/slog" "net" "os" "os/signal" "strings" "syscall" "time" "github.com/google/uuid" "github.com/spf13/cobra" "golang.org/x/crypto/ssh" "git.cloudinit.dev/coreci/orca/internal/certpaths" "git.cloudinit.dev/coreci/orca/internal/engine" "git.cloudinit.dev/coreci/orca/internal/linux" "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 joinLXCTemplate string proxmoxUser string proxmoxRole string ingressMode string floatingIP string gateway string macAddr string netPrefix int 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) linux: SSH-bootstrap a remote generic Linux worker (Ubuntu/Debian/Alpine; deploys orca pubkey, creates orca user + drift-events dir; requires --host + --ssh-key) 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" && joinType != "linux" { return fmt.Errorf("--host-key-fingerprint requires --type proxmox or --type linux") } if joinType == "proxmox" { return joinProxmox(cmd) } if joinType == "linux" { return joinLinux(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 } // REQ-156 / P07 T5: invalidate the nodes cache so the next // `orca node list` does not surface a stale list missing the // just-joined node. cacheInvalidate(cacheNodeClass) 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)") } // Default ingress mode to "native" if not specified (R-024). effectiveIngressMode := ingressMode if effectiveIngressMode == "" { if !jsonOutput { // Interactive mode: prompt for ingress mode. fmt.Fprint(cmd.OutOrStdout(), "Ingress mode [native/floating-ip] (default native): ") scanner := bufio.NewScanner(os.Stdin) if scanner.Scan() { input := strings.TrimSpace(scanner.Text()) if input == "floating-ip" { effectiveIngressMode = "floating-ip" } else { effectiveIngressMode = "native" } } else { effectiveIngressMode = "native" } } else { effectiveIngressMode = "native" } } // Floating-IP mode: prompt for params if not provided. effectiveFloatingIP := floatingIP effectiveGateway := gateway effectiveMAC := macAddr if effectiveIngressMode == "floating-ip" { if effectiveFloatingIP == "" && !jsonOutput { fmt.Fprint(cmd.OutOrStdout(), "Floating IP: ") scanner := bufio.NewScanner(os.Stdin) if scanner.Scan() { effectiveFloatingIP = strings.TrimSpace(scanner.Text()) } } if effectiveGateway == "" && !jsonOutput { fmt.Fprint(cmd.OutOrStdout(), "Gateway: ") scanner := bufio.NewScanner(os.Stdin) if scanner.Scan() { effectiveGateway = strings.TrimSpace(scanner.Text()) } } if effectiveMAC == "" && !jsonOutput { // D-261: auto-generate a random locally-administered MAC. generated, err := proxmox.GenerateRandomMAC() if err == nil { fmt.Fprintf(cmd.OutOrStdout(), "Generated MAC: %s (press enter to accept, or type your own): ", generated) scanner := bufio.NewScanner(os.Stdin) if scanner.Scan() { input := strings.TrimSpace(scanner.Text()) if input != "" { effectiveMAC = input } else { effectiveMAC = generated } } else { effectiveMAC = generated } } } // Validate floating-IP mode params. if effectiveIngressMode == "floating-ip" { if net.ParseIP(effectiveFloatingIP) == nil { return fmt.Errorf("--floating-ip %q is not a valid IP", effectiveFloatingIP) } if net.ParseIP(effectiveGateway) == nil { return fmt.Errorf("--gateway %q is not a valid IP", effectiveGateway) } if _, err := net.ParseMAC(effectiveMAC); err != nil { return fmt.Errorf("--mac %q is not a valid MAC: %w", effectiveMAC, err) } if netPrefix < 8 || netPrefix > 32 { return fmt.Errorf("--net-prefix %d must be 8-32", netPrefix) } } } ctx, cancel := context.WithTimeout(cmd.Context(), 180*time.Second) // 3min for LXC creation 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(), LXCTemplate: joinLXCTemplate, IngressMode: effectiveIngressMode, }) 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", IngressMode: effectiveIngressMode, } if err := registry.Join(regCtx, node); err != nil { return fmt.Errorf("register proxmox node: %w", err) } // Floating-IP mode: provision the ingress LXC and register it as a // linux node (R-024, REQ-176). The PVE host is registered as // proxmox (above); the ingress LXC is registered as linux so // `orca job run` pushes traefik dynamic config to it. if effectiveIngressMode == "floating-ip" { lxcLog := newLogger() // Build a runRemote function from the proxmox bootstrap result. // We need SSH access to the PVE host to run pct commands. lxcCtx, lxcCancel := context.WithTimeout(ctx, 120*time.Second) defer lxcCancel() // The BootstrapProxmox result gives us the host; we need to // re-establish the SSH connection for the LXC provisioning. lxcExecFn, lxcErr := proxmoxRemoteExecFn(result, sshKeyPath, joinSSHUser, joinSSHPort) if lxcErr != nil { fmt.Fprintf(cmd.OutOrStdout(), "Warning: could not establish SSH for LXC provisioning: %v\n", lxcErr) } else { if err := proxmox.ProvisionIngressLXC(lxcCtx, lxcExecFn, proxmox.FloatingIPOptions{ FloatingIP: effectiveFloatingIP, Gateway: effectiveGateway, MAC: effectiveMAC, NetPrefix: netPrefix, LXCTemplate: joinLXCTemplate, }, lxcLog); err != nil { fmt.Fprintf(cmd.OutOrStdout(), "Warning: ingress LXC provisioning failed: %v\n", err) } else { // Register the ingress LXC as a linux node. ingressNode := &model.Node{ ID: uuid.NewString(), Name: "ingress", Address: fmt.Sprintf("%s:8443", effectiveFloatingIP), State: model.NodeStateReady, JoinedAt: time.Now().UTC(), LastSeen: time.Now().UTC(), Kind: string(model.NodeKindLinux), OS: "linux", IngressMode: "floating-ip", } if err := registry.Join(regCtx, ingressNode); err != nil { fmt.Fprintf(cmd.OutOrStdout(), "Warning: register ingress node: %v\n", err) } if !jsonOutput { fmt.Fprintf(cmd.OutOrStdout(), "✓ Ingress LXC joined: %s (%s) at %s\n", ingressNode.ID, ingressNode.Name, ingressNode.Address) } } } } // REQ-156 / P07 T5: invalidate the nodes cache. cacheInvalidate(cacheNodeClass) 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 } // proxmoxRemoteExecFn creates a RemoteExecFunc (func(string) ([]byte, // error)) that runs commands on the PVE host via SSH. Used by the // floating-IP LXC provisioning path (ProvisionIngressLXC). func proxmoxRemoteExecFn(result *proxmox.Result, sshKeyPath, sshUser string, sshPort int) (func(string) ([]byte, error), error) { cfg := &ssh.ClientConfig{ User: sshUser, HostKeyCallback: ssh.InsecureIgnoreHostKey(), Timeout: 10 * time.Second, } if sshKeyPath != "" { keyData, err := os.ReadFile(sshKeyPath) if err != nil { return nil, fmt.Errorf("read SSH key: %w", err) } signer, err := ssh.ParsePrivateKey(keyData) if err != nil { return nil, fmt.Errorf("parse SSH key: %w", err) } cfg.Auth = []ssh.AuthMethod{ssh.PublicKeys(signer)} } addr := result.NodeName if sshPort != 22 { addr = fmt.Sprintf("%s:%d", result.NodeName, sshPort) } else { addr = fmt.Sprintf("%s:%d", result.NodeName, sshPort) } client, err := ssh.Dial("tcp", addr, cfg) if err != nil { return nil, fmt.Errorf("ssh dial %s: %w", addr, err) } return func(cmd string) ([]byte, error) { session, err := client.NewSession() if err != nil { return nil, err } defer session.Close() return session.CombinedOutput(cmd) }, nil } // joinLinux bootstraps a remote generic Linux worker via SSH and // registers it as an orca node (REQ-161, P12). Uses SSH key auth // (R-021: no passwords). func joinLinux(cmd *cobra.Command) error { if joinHost == "" { return fmt.Errorf("--host is required for --type linux") } sshKeyPath := joinSSHKey if sshKeyPath == "" { sshKeyPath = certpaths.SSHKeyPath() } if sshKeyPath == "" { return fmt.Errorf("SSH key path is required for --type linux (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 := linux.BootstrapLinux(ctx, linux.Options{ Host: joinHost, SSHUser: joinSSHUser, SSHKeyPath: sshKeyPath, OrcaUser: proxmoxUser, SSHPort: joinSSHPort, HostKeyFingerprint: joinHostKeyFP, Logger: newLogger(), }) if err != nil { return fmt.Errorf("linux bootstrap: %w", err) } 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.NodeKindLinux), OS: "linux", } if err := registry.Join(regCtx, node); err != nil { return fmt.Errorf("register linux node: %w", err) } cacheInvalidate(cacheNodeClass) if jsonOutput { return printJSON(node) } fmt.Fprintf(cmd.OutOrStdout(), "\xe2\x9c\x93 Linux worker joined: %s (%s) at %s\n", node.ID, node.Name, node.Address) if result.HostKeyFingerprint != "" { fmt.Fprintf(cmd.OutOrStdout(), " host key: %s\n", result.HostKeyFingerprint) } 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 } // REQ-156 / P07 T5: invalidate the nodes cache so the next // `orca node list` does not surface the just-left node. cacheInvalidate(cacheNodeClass) 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 ", 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, actorFromCtx(ctx), "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), proxmox, or linux (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 or --type linux)") nodeJoinCmd.Flags().StringVar(&joinLXCTemplate, "lxc-template", "ubuntu-24.04", "LXC template for Proxmox (default ubuntu-24.04; alternatives: alpine-3.20, debian-12)") nodeJoinCmd.Flags().StringVar(&ingressMode, "ingress-mode", "", "proxmox ingress mode: native (default, traefik in LXC) or floating-ip (ingress LXC owns floating IP)") nodeJoinCmd.Flags().StringVar(&floatingIP, "floating-ip", "", "floating public IP for the ingress LXC (required for --ingress-mode floating-ip)") nodeJoinCmd.Flags().StringVar(&gateway, "gateway", "", "gateway for the ingress LXC (required for --ingress-mode floating-ip)") nodeJoinCmd.Flags().StringVar(&macAddr, "mac", "", "MAC address for the ingress LXC net0 (required for --ingress-mode floating-ip in --json mode; auto-generated in interactive mode)") nodeJoinCmd.Flags().IntVar(&netPrefix, "net-prefix", 24, "network prefix (CIDR) for the ingress LXC IP (default 24; valid 8-32)") 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) }