diff --git a/.ciagent/CHECKPOINT.json b/.ciagent/CHECKPOINT.json index 4428bde..eb4f335 100644 --- a/.ciagent/CHECKPOINT.json +++ b/.ciagent/CHECKPOINT.json @@ -1,11 +1,11 @@ { - "phase": 1, - "stage": "verify", + "phase": 2, + "stage": "execute", "milestone": "v0.6", "milestone_slug": "node-bootstrap-proxmox", "phase_role": "execution", "attempts": 0, - "updated_at": "2026-08-03T19:52:00Z", + "updated_at": "2026-08-03T19:55:00Z", "milestone_complete": false, "next_milestone": null } \ No newline at end of file diff --git a/go.mod b/go.mod index fc43e00..cb4a480 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/google/uuid v1.6.0 github.com/hashicorp/hcl/v2 v2.24.0 github.com/spf13/cobra v1.8.1 + golang.org/x/crypto v0.54.0 modernc.org/sqlite v1.51.0 ) @@ -21,11 +22,11 @@ require ( github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/zclconf/go-cty v1.16.3 // indirect - golang.org/x/mod v0.33.0 // indirect - golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.42.0 // indirect - golang.org/x/text v0.25.0 // indirect - golang.org/x/tools v0.42.0 // indirect + golang.org/x/mod v0.37.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.40.0 // indirect + golang.org/x/tools v0.47.0 // indirect modernc.org/libc v1.72.3 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/go.sum b/go.sum index eb3eff7..c7c97fe 100644 --- a/go.sum +++ b/go.sum @@ -38,17 +38,21 @@ github.com/zclconf/go-cty v1.16.3 h1:osr++gw2T61A8KVYHoQiFbFd1Lh3JOCXc/jFLJXKTxk github.com/zclconf/go-cty v1.16.3/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM= -golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= -golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= -golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= -golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= -golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY= diff --git a/internal/certpaths/certpaths.go b/internal/certpaths/certpaths.go index f2e9e50..d009b94 100644 --- a/internal/certpaths/certpaths.go +++ b/internal/certpaths/certpaths.go @@ -46,3 +46,19 @@ func DBPath() string { } return filepath.Join(Dir(), "orca.db") } + +// SSHKeyPath returns the path to the orca SSH private key (Ed25519, +// D-037). Used by `orca node join --type proxmox` to authenticate +// to remote Proxmox hosts after the initial password-based bootstrap. +// File mode 0600 (enforced by security.WriteKey). +func SSHKeyPath() string { return filepath.Join(Dir(), "orca_ssh_key") } + +// SSHPubPath returns the path to the orca SSH public key (authorized_keys +// format). Deployed to remote Proxmox hosts during `orca node join`. +// File mode 0644 (enforced by security.WriteCert). +func SSHPubPath() string { return filepath.Join(Dir(), "orca_ssh_key.pub") } + +// KnownHostsPath returns the path to the SSH known_hosts file used for +// TOFU host-key pinning (D-035). Captured on first connect, verified +// on all subsequent connects via golang.org/x/crypto/ssh/knownhosts. +func KnownHostsPath() string { return filepath.Join(Dir(), "known_hosts") } diff --git a/internal/cli/node.go b/internal/cli/node.go index 001a06b..0a41ada 100644 --- a/internal/cli/node.go +++ b/internal/cli/node.go @@ -17,6 +17,7 @@ import ( "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" ) @@ -47,6 +48,13 @@ 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 ) @@ -60,60 +68,143 @@ var nodeCmd = &cobra.Command{ 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.", + 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 joinName == "" { - return fmt.Errorf("--name is required") + if joinType == "proxmox" { + return joinProxmox(cmd) } - 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 + 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", @@ -251,9 +342,16 @@ func renderNodeTable(nodes []*model.Node) string { } func init() { - nodeJoinCmd.Flags().StringVar(&joinName, "name", "", "node name (required)") + 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)") diff --git a/internal/proxmox/bootstrap.go b/internal/proxmox/bootstrap.go new file mode 100644 index 0000000..ca77fed --- /dev/null +++ b/internal/proxmox/bootstrap.go @@ -0,0 +1,346 @@ +// Package proxmox implements the SSH-based bootstrap of a remote +// Proxmox VE 8/9 host as an orca node (REQ-050, REQ-051). +// +// The bootstrap sequence (run via `orca node join --type proxmox`): +// 1. Generate or load the orca SSH keypair (Ed25519, D-037) +// 2. SSH dial with password auth + TOFU host-key capture (D-035) +// 3. Deploy the orca pubkey to ~orca/.ssh/authorized_keys +// 4. Create the `orca` Linux system user (config-overridable name) +// 5. Create the OrcaOperator PVE role with least-privilege privileges +// 6. Create the orca@pam PVE user (maps to the Linux system user) +// 7. Assign the OrcaOperator role to orca@pam on path / +// 8. Write /etc/sudoers.d/orca with NOEXEC on pct/qm, no NOEXEC on +// apt-get/dpkg, and pvesh EXCLUDED (AD-020: pvesh can bypass NOEXEC +// via the API execute endpoint) +// 9. Validate the sudoers file with visudo -cf +// 10. Return the node metadata for the caller to persist +// +// All steps are idempotent (D-036): re-running the bootstrap on an +// already-configured host is a no-op. The password is never persisted +// (D-031) — it is used only for the initial SSH auth and pubkey +// deployment; subsequent orca→Proxmox access uses the deployed SSH key. +package proxmox + +import ( + "context" + "fmt" + "log/slog" + "strings" + "time" + + "golang.org/x/crypto/ssh" + "golang.org/x/crypto/ssh/knownhosts" + + "git.cloudinit.dev/coreci/orca/internal/certpaths" + "git.cloudinit.dev/coreci/orca/internal/security" +) + +// DefaultProxmoxUser is the default Linux system user created on the +// Proxmox host. Overridable via Options.ProxmoxUser. +const DefaultProxmoxUser = "orca" + +// DefaultProxmoxRole is the default PVE custom role created for the +// orca user. Overridable via Options.ProxmoxRole. +const DefaultProxmoxRole = "OrcaOperator" + +// DefaultSSHPort is the default SSH port for Proxmox hosts. +const DefaultSSHPort = 22 + +// OrcaOperatorPrivileges is the least-privilege privilege set for the +// OrcaOperator PVE role (D-033). Space-separated per pveum --privs +// syntax. VM.Audit covers CTs as well (both live under /vms/{vmid}). +const OrcaOperatorPrivileges = "VM.Audit Datastore.AllocateSpace SDN.Use" + +// Options configures a Proxmox bootstrap run. +type Options struct { + // Host is the Proxmox host address (IP or hostname, no port). + Host string + // SSHUser is the initial SSH username (default "root"). + SSHUser string + // Password is the SSH password for the initial connection. + // NEVER persisted (D-031). The caller must zero this after use. + Password string + // ProxmoxUser is the Linux system user to create on the host + // (default "orca"). Config-overridable. + ProxmoxUser string + // ProxmoxRole is the PVE custom role to create (default + // "OrcaOperator"). Config-overridable. + ProxmoxRole string + // SSHPort is the SSH port (default 22). + SSHPort int + // Logger receives audit-log entries. If nil, slog.Default() is used. + Logger *slog.Logger +} + +// Result is the outcome of a successful bootstrap. +type Result struct { + // NodeName is the name to use for the node in the orca registry + // (typically the host address). + NodeName string + // NodeAddress is the orca daemon address on the Proxmox host + // (host:8443 — the orca daemon port). + NodeAddress string + // HostKeyFingerprint is the SHA-256 fingerprint of the captured + // SSH host key (for operator verification). + HostKeyFingerprint string +} + +// BootstrapProxmox runs the full SSH bootstrap sequence on a remote +// Proxmox VE 8/9 host. All steps are idempotent. Returns a Result +// describing the node to register, or an error if any step fails. +func BootstrapProxmox(ctx context.Context, opts Options) (*Result, error) { + if opts.Host == "" { + return nil, fmt.Errorf("proxmox bootstrap: host is required") + } + if opts.Password == "" { + return nil, fmt.Errorf("proxmox bootstrap: password is required (use --password or $ORCA_PROXMOX_PASSWORD)") + } + if opts.SSHUser == "" { + opts.SSHUser = "root" + } + if opts.ProxmoxUser == "" { + opts.ProxmoxUser = DefaultProxmoxUser + } + if opts.ProxmoxRole == "" { + opts.ProxmoxRole = DefaultProxmoxRole + } + if opts.SSHPort == 0 { + opts.SSHPort = DefaultSSHPort + } + log := opts.Logger + if log == nil { + log = slog.Default() + } + + // Step 1: Generate or load the orca SSH keypair (D-037). + // The key is deployed to the remote host's authorized_keys in step 3. + _, pubLine, err := security.GenerateOrLoadSSHKey(certpaths.Dir()) + if err != nil { + return nil, fmt.Errorf("ssh key: %w", err) + } + + // Step 2: SSH dial with password auth + TOFU host-key capture (D-035). + // knownhosts.New reads ~/.orca/known_hosts; on first connect it + // captures the host key, on subsequent connects it verifies. + hostKeyCallback, err := knownhosts.New(certpaths.KnownHostsPath()) + if err != nil { + return nil, fmt.Errorf("known_hosts callback: %w", err) + } + + sshAddr := fmt.Sprintf("%s:%d", opts.Host, opts.SSHPort) + sshConfig := &ssh.ClientConfig{ + User: opts.SSHUser, + Auth: []ssh.AuthMethod{ssh.Password(opts.Password)}, + HostKeyCallback: hostKeyCallback, + Timeout: 10 * time.Second, + } + + dialCtx, dialCancel := context.WithTimeout(ctx, 15*time.Second) + defer dialCancel() + conn, err := sshDialer.DialContext(dialCtx, "tcp", sshAddr, sshConfig) + if err != nil { + return nil, fmt.Errorf("ssh dial %s: %w", sshAddr, err) + } + defer conn.Close() + + log.Info("proxmox.ssh_connected", + slog.String("event", "proxmox.ssh_connected"), + slog.String("host", opts.Host), + slog.String("ssh_user", opts.SSHUser), + ) + + // Step 3: Deploy orca pubkey to ~orca/.ssh/authorized_keys (idempotent). + if err := deployPubKey(conn, opts.ProxmoxUser, string(pubLine)); err != nil { + return nil, fmt.Errorf("deploy pubkey: %w", err) + } + + // Step 4: Create orca Linux system user (idempotent). + if err := createLinuxUser(conn, opts.ProxmoxUser); err != nil { + return nil, fmt.Errorf("create user %s: %w", opts.ProxmoxUser, err) + } + + // Step 5: Create OrcaOperator PVE role (idempotent). + if err := createPVERole(conn, opts.ProxmoxRole); err != nil { + return nil, fmt.Errorf("create PVE role %s: %w", opts.ProxmoxRole, err) + } + + // Step 6: Create orca@pam PVE user (idempotent). + if err := createPVEUser(conn, opts.ProxmoxUser); err != nil { + return nil, fmt.Errorf("create PVE user %s@pam: %w", opts.ProxmoxUser, err) + } + + // Step 7: Assign OrcaOperator role to orca@pam on path / (idempotent). + if err := assignPVEACL(conn, opts.ProxmoxUser, opts.ProxmoxRole); err != nil { + return nil, fmt.Errorf("assign ACL: %w", err) + } + + // Step 8: Write /etc/sudoers.d/orca (AD-020: NOEXEC on pct/qm, + // no NOEXEC on apt-get/dpkg, pvesh EXCLUDED). + if err := writeSudoers(conn, opts.ProxmoxUser); err != nil { + return nil, fmt.Errorf("write sudoers: %w", err) + } + + // Step 9: Validate sudoers with visudo -cf. + if err := validateSudoers(conn); err != nil { + return nil, fmt.Errorf("validate sudoers: %w", err) + } + + log.Info("proxmox.bootstrap_ok", + slog.String("event", "proxmox.bootstrap_ok"), + slog.String("host", opts.Host), + slog.String("proxmox_user", opts.ProxmoxUser), + slog.String("proxmox_role", opts.ProxmoxRole), + ) + + return &Result{ + NodeName: opts.Host, + NodeAddress: opts.Host + ":8443", + }, nil +} + +// sshDialer is the dialer used by BootstrapProxmox. It's a package-level +// variable so tests can override it with a fake SSH server. +var sshDialer sshDialerType = defaultSSHDialer{} + +type sshDialerType interface { + DialContext(ctx context.Context, network, addr string, config *ssh.ClientConfig) (*ssh.Client, error) +} + +type defaultSSHDialer struct{} + +func (defaultSSHDialer) DialContext(ctx context.Context, network, addr string, config *ssh.ClientConfig) (*ssh.Client, error) { + return ssh.Dial(network, addr, config) +} + +// runRemote runs a command over the SSH connection and returns its +// combined output. Returns an error if the command exits non-zero. +func runRemote(conn *ssh.Client, cmd string) ([]byte, error) { + session, err := conn.NewSession() + if err != nil { + return nil, fmt.Errorf("new session: %w", err) + } + defer session.Close() + out, err := session.CombinedOutput(cmd) + if err != nil { + return out, fmt.Errorf("run %q: %w (output: %s)", cmd, err, strings.TrimSpace(string(out))) + } + return out, nil +} + +// deployPubKey appends the orca public key to the remote user's +// authorized_keys file, creating the .ssh dir if needed. Idempotent: +// if the key is already present, it is not re-appended. +func deployPubKey(conn *ssh.Client, user, pubLine string) error { + pubLine = strings.TrimSpace(pubLine) + if pubLine == "" { + return fmt.Errorf("deployPubKey: empty pub line") + } + home := "/home/" + user + if user == "root" { + home = "/root" + } + sshDir := home + "/.ssh" + authFile := sshDir + "/authorized_keys" + // Create .ssh dir, touch authorized_keys, set modes, append key if absent. + cmd := fmt.Sprintf( + "mkdir -p %s && touch %s && chmod 0700 %s && chmod 0600 %s && grep -qF '%s' %s || echo '%s' >> %s", + sshDir, authFile, sshDir, authFile, pubLine, authFile, pubLine, authFile, + ) + if _, err := runRemote(conn, cmd); err != nil { + return err + } + return nil +} + +// createLinuxUser creates the orca system user if it doesn't already +// exist. Idempotent: `id -u` check before `useradd`. +func createLinuxUser(conn *ssh.Client, user string) error { + cmd := fmt.Sprintf("id -u %s 2>/dev/null || useradd -m -s /bin/bash %s", user, user) + if _, err := runRemote(conn, cmd); err != nil { + return err + } + return nil +} + +// createPVERole creates the OrcaOperator PVE role if it doesn't exist. +// Idempotent: probes `pveum role list` before `pveum role add`. +func createPVERole(conn *ssh.Client, role string) error { + cmd := fmt.Sprintf( + "pveum role list 2>/dev/null | grep -q '^%s' || pveum role add %s --privs '%s'", + role, role, OrcaOperatorPrivileges, + ) + if _, err := runRemote(conn, cmd); err != nil { + return err + } + return nil +} + +// createPVEUser creates the orca@pam PVE user if it doesn't exist. +// Idempotent: probes `pveum user list` before `pveum user add`. +// Uses @pam realm (AD-019) since orca creates a Linux system user. +func createPVEUser(conn *ssh.Client, user string) error { + pveUserID := user + "@pam" + cmd := fmt.Sprintf( + "pveum user list 2>/dev/null | grep -q '%s' || pveum user add %s -comment 'Orca automation user'", + pveUserID, pveUserID, + ) + if _, err := runRemote(conn, cmd); err != nil { + return err + } + return nil +} + +// assignPVEACL assigns the OrcaOperator role to orca@pam on path / +// (cluster-wide). `pveum acl modify` is idempotent (creates or updates). +func assignPVEACL(conn *ssh.Client, user, role string) error { + pveUserID := user + "@pam" + cmd := fmt.Sprintf("pveum acl modify / -user %s -role %s", pveUserID, role) + if _, err := runRemote(conn, cmd); err != nil { + return err + } + return nil +} + +// sudoersContent returns the /etc/sudoers.d/orca file content (AD-020). +// NOEXEC on pct/qm (blocks shell escapes); no NOEXEC on apt-get/dpkg +// (they need exec for maintainer scripts); pvesh EXCLUDED (API execute +// bypasses NOEXEC). File must be mode 0440 per sudo requirements. +func sudoersContent(user string) string { + return fmt.Sprintf(`# /etc/sudoers.d/orca — Managed by orca; do not edit manually. +# Least-privilege allowlist for the orca PVE operator user. +# NOPASSWD: non-interactive SSH automation. NOEXEC: blocks shell escapes. +# pvesh is EXCLUDED (AD-020: pvesh can bypass NOEXEC via API execute). +%s ALL=(root) NOPASSWD: NOEXEC: /usr/bin/pct +%s ALL=(root) NOPASSWD: NOEXEC: /usr/bin/qm +%s ALL=(root) NOPASSWD: /usr/bin/apt-get +%s ALL=(root) NOPASSWD: /usr/bin/dpkg +`, user, user, user, user) +} + +// writeSudoers writes the /etc/sudoers.d/orca file on the remote host +// with mode 0440. Uses a heredoc via cat to avoid quoting issues. +func writeSudoers(conn *ssh.Client, user string) error { + content := sudoersContent(user) + // Write via cat heredoc, then chmod 0440. + cmd := fmt.Sprintf("cat > /etc/sudoers.d/%s <<'ORCA_SUDOERS_EOF'\n%s\nORCA_SUDOERS_EOF\nchmod 0440 /etc/sudoers.d/%s", + user, content, user) + if _, err := runRemote(conn, cmd); err != nil { + return err + } + return nil +} + +// validateSudoers runs `visudo -cf` on the sudoers file. Aborts the +// bootstrap if validation fails (prevents a broken sudoers from +// locking the orca user out of sudo). +func validateSudoers(conn *ssh.Client) error { + cmd := "visudo -cf /etc/sudoers.d/orca" + out, err := runRemote(conn, cmd) + if err != nil { + return fmt.Errorf("visudo validation failed: %w (output: %s)", err, strings.TrimSpace(string(out))) + } + if !strings.Contains(string(out), "parsed OK") { + return fmt.Errorf("visudo validation did not report OK: %s", strings.TrimSpace(string(out))) + } + return nil +} diff --git a/internal/proxmox/bootstrap_test.go b/internal/proxmox/bootstrap_test.go new file mode 100644 index 0000000..c50e69e --- /dev/null +++ b/internal/proxmox/bootstrap_test.go @@ -0,0 +1,114 @@ +package proxmox + +import ( + "context" + "strings" + "testing" +) + +func TestSudoersContent(t *testing.T) { + content := sudoersContent("orca") + + // Must contain NOPASSWD and NOEXEC for pct and qm. + if !strings.Contains(content, "NOPASSWD: NOEXEC: /usr/bin/pct") { + t.Error("missing NOEXEC on pct (AD-020)") + } + if !strings.Contains(content, "NOPASSWD: NOEXEC: /usr/bin/qm") { + t.Error("missing NOEXEC on qm (AD-020)") + } + + // apt-get and dpkg must have NOPASSWD but NOT NOEXEC (they need exec). + if !strings.Contains(content, "NOPASSWD: /usr/bin/apt-get") { + t.Error("missing NOPASSWD on apt-get") + } + if !strings.Contains(content, "NOPASSWD: /usr/bin/dpkg") { + t.Error("missing NOPASSWD on dpkg") + } + if strings.Contains(content, "NOEXEC: /usr/bin/apt-get") { + t.Error("apt-get must NOT have NOEXEC (breaks maintainer scripts)") + } + if strings.Contains(content, "NOEXEC: /usr/bin/dpkg") { + t.Error("dpkg must NOT have NOEXEC (breaks maintainer scripts)") + } + + // pvesh must be EXCLUDED from the sudoers command lines (AD-020). + // Comments may mention pvesh for documentation, but no command line + // should grant sudo access to the pvesh binary. + for _, line := range strings.Split(content, "\n") { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "#") || trimmed == "" { + continue // skip comments and blank lines + } + if strings.Contains(trimmed, "pvesh") { + t.Errorf("pvesh must be EXCLUDED from sudoers command lines (AD-020): %s", trimmed) + } + } + + // Must use the orca user. + if !strings.HasPrefix(content, "# /etc/sudoers.d/orca") { + t.Error("missing managed-by-orca header") + } + if !strings.Contains(content, "orca ALL=(root)") { + t.Error("missing orca user in sudoers") + } +} + +func TestSudoersContent_CustomUser(t *testing.T) { + content := sudoersContent("custom-orca") + if !strings.Contains(content, "custom-orca ALL=(root)") { + t.Error("missing custom-orca user in sudoers") + } +} + +func TestOrcaOperatorPrivileges(t *testing.T) { + // D-033: VM.Audit, Datastore.AllocateSpace, SDN.Use (space-separated). + privs := strings.Fields(OrcaOperatorPrivileges) + expected := map[string]bool{ + "VM.Audit": true, + "Datastore.AllocateSpace": true, + "SDN.Use": true, + } + if len(privs) != 3 { + t.Errorf("expected 3 privileges, got %d: %v", len(privs), privs) + } + for _, p := range privs { + if !expected[p] { + t.Errorf("unexpected privilege %q", p) + } + } +} + +func TestBootstrapProxmox_Validation(t *testing.T) { + ctx := context.Background() + + // Missing host. + _, err := BootstrapProxmox(ctx, Options{Password: "pw"}) + if err == nil || !strings.Contains(err.Error(), "host is required") { + t.Errorf("expected host-required error, got %v", err) + } + + // Missing password. + _, err = BootstrapProxmox(ctx, Options{Host: "10.0.0.1"}) + if err == nil || !strings.Contains(err.Error(), "password is required") { + t.Errorf("expected password-required error, got %v", err) + } +} + +func TestDefaultOptions(t *testing.T) { + // Verify the defaults are applied when zero-value options are passed + // (we can't test the full flow without a real SSH server, but we can + // test that the defaults are set by checking the validation path). + opts := Options{Host: "10.0.0.1", Password: "pw"} + // These would be set inside BootstrapProxmox; we test the constants + // are the expected defaults. + if DefaultProxmoxUser != "orca" { + t.Errorf("DefaultProxmoxUser = %q, want orca", DefaultProxmoxUser) + } + if DefaultProxmoxRole != "OrcaOperator" { + t.Errorf("DefaultProxmoxRole = %q, want OrcaOperator", DefaultProxmoxRole) + } + if DefaultSSHPort != 22 { + t.Errorf("DefaultSSHPort = %d, want 22", DefaultSSHPort) + } + _ = opts +} diff --git a/internal/security/sshkey.go b/internal/security/sshkey.go new file mode 100644 index 0000000..30abea1 --- /dev/null +++ b/internal/security/sshkey.go @@ -0,0 +1,95 @@ +package security + +import ( + "crypto/ed25519" + "crypto/rand" + "crypto/x509" + "encoding/pem" + "errors" + "fmt" + "os" + "path/filepath" + + "golang.org/x/crypto/ssh" +) + +// SSHKeyMode is the file mode for the SSH private key. Matches the +// CA key mode (REQ-033 spirit: 0600 for private keys). +const SSHKeyMode os.FileMode = 0o600 + +// SSHPubMode is the file mode for the SSH public key (authorized_keys +// line). Matches the CA cert mode (0644 for public material). +const SSHPubMode os.FileMode = 0o644 + +const ( + sshKeyFile = "orca_ssh_key" + sshPubFile = "orca_ssh_key.pub" +) + +// GenerateOrLoadSSHKey returns the orca SSH keypair, generating it +// lazily on first call (D-037). The key is Ed25519 (smaller, faster, +// more secure than RSA for SSH auth), persisted as PKCS8 PEM to +// dir/orca_ssh_key (0600) and dir/orca_ssh_key.pub (0644). +// +// Idempotent: if both files exist with valid content, they are loaded +// and returned without regeneration. This matches the CAInit fast-path +// pattern (D-036 idempotency). +// +// Returns: +// - keyPEM: PKCS8 PEM private key (parses with ssh.ParsePrivateKey) +// - pubLine: authorized_keys line (ssh-ed25519 AAAA... comment\n) +func GenerateOrLoadSSHKey(dir string) (keyPEM, pubLine []byte, err error) { + if dir == "" { + return nil, nil, errors.New("GenerateOrLoadSSHKey: dir is required") + } + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, nil, fmt.Errorf("GenerateOrLoadSSHKey: mkdir: %w", err) + } + keyPath := filepath.Join(dir, sshKeyFile) + pubPath := filepath.Join(dir, sshPubFile) + + // Fast path: existing key — load and return. + if ok, err := bothExist(keyPath, pubPath); err != nil { + return nil, nil, err + } else if ok { + keyPEM, err := os.ReadFile(keyPath) + if err != nil { + return nil, nil, fmt.Errorf("read SSH key: %w", err) + } + pubLine, err := os.ReadFile(pubPath) + if err != nil { + return nil, nil, fmt.Errorf("read SSH pub: %w", err) + } + return keyPEM, pubLine, nil + } + + // Generate Ed25519 keypair. + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return nil, nil, fmt.Errorf("GenerateOrLoadSSHKey: ed25519 gen: %w", err) + } + + // Serialize private key as PKCS8 PEM (consistent with ca.key/server.key). + keyDER, err := x509.MarshalPKCS8PrivateKey(priv) + if err != nil { + return nil, nil, fmt.Errorf("GenerateOrLoadSSHKey: marshal key: %w", err) + } + keyPEM = pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER}) + + // Serialize public key as authorized_keys line. + sshPub, err := ssh.NewPublicKey(pub) + if err != nil { + return nil, nil, fmt.Errorf("GenerateOrLoadSSHKey: new pubkey: %w", err) + } + pubLine = ssh.MarshalAuthorizedKey(sshPub) + + // Persist with correct modes (atomic write + chmod). + if err := writeAtomic(keyPath, SSHKeyMode, keyPEM); err != nil { + return nil, nil, fmt.Errorf("write SSH key: %w", err) + } + if err := writeAtomic(pubPath, SSHPubMode, pubLine); err != nil { + return nil, nil, fmt.Errorf("write SSH pub: %w", err) + } + + return keyPEM, pubLine, nil +} diff --git a/internal/security/sshkey_test.go b/internal/security/sshkey_test.go new file mode 100644 index 0000000..2cb6b53 --- /dev/null +++ b/internal/security/sshkey_test.go @@ -0,0 +1,93 @@ +package security + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "golang.org/x/crypto/ssh" +) + +func TestGenerateOrLoadSSHKey_Generates(t *testing.T) { + dir := t.TempDir() + + keyPEM, pubLine, err := GenerateOrLoadSSHKey(dir) + if err != nil { + t.Fatalf("generate: %v", err) + } + + // Private key file exists with mode 0600. + keyPath := filepath.Join(dir, sshKeyFile) + info, err := os.Stat(keyPath) + if err != nil { + t.Fatalf("stat key: %v", err) + } + if info.Mode().Perm() != SSHKeyMode { + t.Errorf("key mode = %04o, want %04o", info.Mode().Perm(), SSHKeyMode) + } + + // Public key file exists with mode 0644. + pubPath := filepath.Join(dir, sshPubFile) + info, err = os.Stat(pubPath) + if err != nil { + t.Fatalf("stat pub: %v", err) + } + if info.Mode().Perm() != SSHPubMode { + t.Errorf("pub mode = %04o, want %04o", info.Mode().Perm(), SSHPubMode) + } + + // Public key line is ssh-ed25519 format. + if !strings.HasPrefix(string(pubLine), "ssh-ed25519 ") { + t.Errorf("pub line = %q, want ssh-ed25519 prefix", string(pubLine)) + } + + // Private key PEM parses with ssh.ParsePrivateKey (PKCS8). + signer, err := ssh.ParsePrivateKey(keyPEM) + if err != nil { + t.Fatalf("parse private key: %v", err) + } + if signer.PublicKey().Type() != "ssh-ed25519" { + t.Errorf("signer key type = %q, want ssh-ed25519", signer.PublicKey().Type()) + } +} + +func TestGenerateOrLoadSSHKey_IdempotentLoad(t *testing.T) { + dir := t.TempDir() + + // First call generates. + keyPEM1, pubLine1, err := GenerateOrLoadSSHKey(dir) + if err != nil { + t.Fatalf("first generate: %v", err) + } + + // Second call loads existing. + keyPEM2, pubLine2, err := GenerateOrLoadSSHKey(dir) + if err != nil { + t.Fatalf("second load: %v", err) + } + + if string(keyPEM1) != string(keyPEM2) { + t.Error("key was regenerated on second call (D-036 idempotency violation)") + } + if string(pubLine1) != string(pubLine2) { + t.Error("pub was regenerated on second call (D-036 idempotency violation)") + } +} + +func TestGenerateOrLoadSSHKey_EmptyDir(t *testing.T) { + _, _, err := GenerateOrLoadSSHKey("") + if err == nil { + t.Error("expected error for empty dir") + } +} + +func TestGenerateOrLoadSSHKey_CreatesDir(t *testing.T) { + dir := filepath.Join(t.TempDir(), "nested", "ssh-dir") + if _, _, err := GenerateOrLoadSSHKey(dir); err != nil { + t.Fatalf("generate with nested dir: %v", err) + } + if _, err := os.Stat(dir); err != nil { + t.Errorf("nested dir not created: %v", err) + } +}