797bc2f412
orca node join --type proxmox bootstraps a remote Proxmox VE 8/9 host
via SSH (REQ-050, REQ-051). The password is used only for initial auth;
subsequent access uses the deployed orca SSH key (D-031).
Changes:
- go.mod: add golang.org/x/crypto v0.54.0 (ssh + ssh/knownhosts + ed25519)
bump x/sys to v0.47.0, add x/term (indirect)
- internal/certpaths: SSHKeyPath, SSHPubPath, KnownHostsPath (D-037)
- internal/security/sshkey.go: GenerateOrLoadSSHKey (Ed25519, PKCS8 PEM,
0600/0644 modes, idempotent load per D-036)
- internal/proxmox/bootstrap.go: BootstrapProxmox SSH dance:
1. Generate/load SSH key
2. SSH dial (password + knownhosts.New TOFU per D-035)
3. Deploy pubkey to ~orca/.ssh/authorized_keys (idempotent)
4. useradd -m orca (idempotent)
5. pveum role add OrcaOperator --privs 'VM.Audit Datastore.AllocateSpace SDN.Use'
6. pveum user add orca@pam (AD-019: PAM realm, not @pve)
7. pveum acl modify / -user orca@pam -role OrcaOperator
8. Write /etc/sudoers.d/orca (AD-020: NOEXEC on pct/qm, no NOEXEC on
apt-get/dpkg, pvesh EXCLUDED — API execute bypasses NOEXEC)
9. visudo -cf validation (abort on failure)
All steps idempotent; audit-logged.
- internal/cli/node.go: --type/--host/--ssh-user/--password/--ssh-port/
--proxmox-user/--proxmox-role flags; joinProxmox() wires to
proxmox.BootstrapProxmox + registers node with kind=proxmox, os=pve.
Password zeroed after use (D-031).
- tests: sshkey generate/load round-trip, idempotency, file modes;
proxmox sudoers content (NOEXEC/NOPASSWD/pvesh-excluded),
privilege set, validation; node join flag wiring
---ci---
project: orca
phase: 2
milestone: v0.6
status: execute
---/ci---
363 lines
10 KiB
Go
363 lines
10 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
|
|
joinPassword string
|
|
joinSSHPort int
|
|
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 + --password)`,
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
if joinType == "proxmox" {
|
|
return joinProxmox(cmd)
|
|
}
|
|
return joinLocal(cmd)
|
|
},
|
|
}
|
|
|
|
// joinLocal is the existing localhost/Linux node join flow (fingerprint
|
|
// check + registry.Insert).
|
|
func joinLocal(cmd *cobra.Command) error {
|
|
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). The password is
|
|
// never persisted (D-031).
|
|
func joinProxmox(cmd *cobra.Command) error {
|
|
if joinHost == "" {
|
|
return fmt.Errorf("--host is required for --type proxmox")
|
|
}
|
|
password := joinPassword
|
|
if password == "" {
|
|
password = os.Getenv("ORCA_PROXMOX_PASSWORD")
|
|
}
|
|
if password == "" {
|
|
return fmt.Errorf("password is required for --type proxmox (use --password or $ORCA_PROXMOX_PASSWORD)")
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(cmd.Context(), 60*time.Second)
|
|
defer cancel()
|
|
|
|
result, err := proxmox.BootstrapProxmox(ctx, proxmox.Options{
|
|
Host: joinHost,
|
|
SSHUser: joinSSHUser,
|
|
Password: password,
|
|
ProxmoxUser: proxmoxUser,
|
|
ProxmoxRole: proxmoxRole,
|
|
SSHPort: joinSSHPort,
|
|
Logger: newLogger(),
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("proxmox bootstrap: %w", err)
|
|
}
|
|
|
|
// Zero the password byte slice (D-031 — never persist, minimize memory exposure).
|
|
pwBytes := []byte(password)
|
|
for i := range pwBytes {
|
|
pwBytes[i] = 0
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
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
|
|
}
|
|
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
|
|
}
|
|
|
|
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(&joinPassword, "password", "", "SSH password for proxmox bootstrap (never persisted; prefer $ORCA_PROXMOX_PASSWORD)")
|
|
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)")
|
|
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)
|
|
rootCmd.AddCommand(nodeCmd)
|
|
}
|