// 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 }