// 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 key 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. Authentication is key-based (R-021) // (D-031): the orca SSH key is used for the initial SSH auth and // pubkey deployment; subsequent orca→Proxmox access uses the same key. package proxmox import ( "bytes" "context" "errors" "fmt" "log/slog" "net" "os" "regexp" "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/emitter" "git.cloudinit.dev/coreci/orca/internal/security" "git.cloudinit.dev/coreci/orca/internal/traefik" ) // 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 // SSHKeyPath is the path to the private SSH key for key-based auth // (R-021: no passwords). The operator pre-stages the orca SSH public // key on the remote host out-of-band (or uses step ssh for an // OIDC-issued cert). Required. SSHKeyPath 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 // HostKeyFingerprint is the operator-pinned SSH host key fingerprint // in `SHA256:base64` form (REQ-058, D-044). When non-empty, the // bootstrap dialer uses a pinned-host-key callback instead of the // TOFU known_hosts capture path. Empty falls back to TOFU. HostKeyFingerprint string // Logger receives audit-log entries. If nil, slog.Default() is used. Logger *slog.Logger // LXCTemplate is the LXC template to download during bootstrap // (default "ubuntu-24.04"; alternatives: "alpine-3.20", "debian-12"). LXCTemplate string // IngressMode is the proxmox ingress mode (R-024, v0.14). // "native" (default): traefik runs in an unprivileged LXC with // nesting=1,keyctl=1,fuse=1 on the PVE host. nft on the PVE host // DNATs to the LXC bridge IP. // "floating-ip": a separate ingress LXC owns the floating IP; // nft runs inside that LXC. See ProvisionIngressLXC (P6). IngressMode string } // 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.SSHKeyPath == "" { return nil, fmt.Errorf("proxmox bootstrap: SSH key path is required (R-021: no passwords; pre-stage the orca SSH key or use step ssh)") } 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 } // F10: validate ProxmoxUser and ProxmoxRole before they are // interpolated into sudoers content, file paths, and shell commands // (useradd, pveum). An attacker-controlled value could inject shell // metacharacters or path traversal. Allowlist: lowercase letter or // underscore start, followed by lowercase alphanumerics, underscore, // or hyphen; max 32 chars. if !validProxmoxName(opts.ProxmoxUser) { return nil, fmt.Errorf("proxmox bootstrap: invalid ProxmoxUser %q (allowed: ^[a-zA-Z_][a-zA-Z0-9_-]{0,31}$)", opts.ProxmoxUser) } if !validProxmoxName(opts.ProxmoxRole) { return nil, fmt.Errorf("proxmox bootstrap: invalid ProxmoxRole %q (allowed: ^[a-zA-Z_][a-zA-Z0-9_-]{0,31}$)", opts.ProxmoxRole) } 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 key auth + host-key verification (D-035, // REQ-058). When opts.HostKeyFingerprint is set (D-044), use a pinned // callback that fails closed on mismatch (AD-028); otherwise use the // TOFU known_hosts capture callback (D-035). The TOFU wrapper fixes // the v0.6 ship-defect where knownhosts.New returned KeyError{Want:[]} // on first connect WITHOUT writing the captured key, so the first // `orca node join --type proxmox` always failed. sshAddr := net.JoinHostPort(opts.Host, fmt.Sprintf("%d", opts.SSHPort)) var capturedHostKey ssh.PublicKey var hostKeyCallback ssh.HostKeyCallback if opts.HostKeyFingerprint != "" { cb, err := pinnedHostKeyCallback(opts.HostKeyFingerprint, &capturedHostKey) if err != nil { return nil, fmt.Errorf("host-key fingerprint: %w", err) } hostKeyCallback = cb } else { cb, err := TOFUHostKeyCallback(sshAddr, &capturedHostKey) if err != nil { return nil, fmt.Errorf("tofu host-key callback: %w", err) } hostKeyCallback = cb } // Load the SSH private key for key-based auth (R-021: no passwords). keyBytes, err := os.ReadFile(opts.SSHKeyPath) if err != nil { return nil, fmt.Errorf("read SSH key %s: %w", opts.SSHKeyPath, err) } signer, err := ssh.ParsePrivateKey(keyBytes) if err != nil { return nil, fmt.Errorf("parse SSH key %s: %w", opts.SSHKeyPath, err) } sshConfig := &ssh.ClientConfig{ User: opts.SSHUser, Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)}, 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() if sessionRunner == nil { sessionRunner = &sshSessionRunner{client: conn} } hostKeyFP := "" if capturedHostKey != nil { hostKeyFP = security.SSHFingerprintSHA256(capturedHostKey) } log.Info("proxmox.ssh_connected", slog.String("event", "proxmox.ssh_connected"), slog.String("host", opts.Host), slog.String("ssh_user", opts.SSHUser), slog.String("host_key_fingerprint", hostKeyFP), ) // Step 3: Deploy orca pubkey to ~orca/.ssh/authorized_keys (idempotent). if err := deployPubKey(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(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(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(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(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(opts.ProxmoxUser); err != nil { return nil, fmt.Errorf("write sudoers: %w", err) } // Step 9: Validate sudoers with visudo -cf. if err := validateSudoers(); err != nil { return nil, fmt.Errorf("validate sudoers: %w", err) } // Step 9a: Proxmox native ingress mode (R-024, REQ-175). // Create an unprivileged LXC with nesting=1,keyctl=1,fuse=1 (research // Topic 3), install podman inside it, and run the orca-traefik // container. nft on the PVE host DNATs to the LXC bridge IP. // Default mode is "native"; floating-ip mode is handled separately // (P6 — ProvisionIngressLXC). template := opts.LXCTemplate if template == "" { template = "ubuntu-24.04" } _, _ = runRemote(fmt.Sprintf("pveam download local %s 2>/dev/null || true", shellQuote(template))) if opts.IngressMode != "floating-ip" { if err := provisionNativeIngressLXC(ctx, runRemote, template, log); err != nil { log.Warn("proxmox.native_ingress_lxc_failed", "err", 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", HostKeyFingerprint: hostKeyFP, }, nil } // provisionNativeIngressLXC creates an unprivileged LXC with // nesting=1,keyctl=1,fuse=1 (research Topic 3), installs podman inside // it, runs the orca-traefik container, and applies nft DNAT on the PVE // host targeting the LXC's bridge IP (R-024, REQ-175). // // The LXC is named "orca-traefik" and uses a deterministic VMID derived // from the host. It is idempotent: if the LXC already exists, it is // not re-created (C-53: apt-get install is skipped if podman present). func provisionNativeIngressLXC(ctx context.Context, runRemote func(string) ([]byte, error), template string, log *slog.Logger) error { // Deterministic VMID for the native ingress LXC. // Use a fixed VMID in the 200-299 range (Proxmox convention for CTs). const vmid = "200" const lxcName = "orca-traefik" // Check if the LXC already exists. existOut, _ := runRemote(fmt.Sprintf("pct status %s 2>/dev/null || echo absent", vmid)) existStr := strings.TrimSpace(string(existOut)) if existStr == "absent" { // Create the LXC (research Topic 3: nesting=1,keyctl=1,fuse=1). log.Info("proxmox.creating_native_ingress_lxc", "vmid", vmid, "name", lxcName) createCmd := fmt.Sprintf( "pct create %s local:vztmpl/%s --hostname %s --unprivileged 1 --features nesting=1,keyctl=1,fuse=1 --onboot 1 --memory 2048 --swap 0 --rootfs local:8 2>&1", vmid, shellQuote(template), lxcName, ) if out, err := runRemote(createCmd); err != nil { return fmt.Errorf("pct create native ingress LXC: %w (output: %s)", err, string(out)) } if out, err := runRemote(fmt.Sprintf("pct start %s", vmid)); err != nil { return fmt.Errorf("pct start native ingress LXC: %w (output: %s)", err, string(out)) } } // Wait for LXC network (retry for up to 60s). lxcIP := "" for i := 0; i < 12; i++ { ipOut, _ := runRemote(fmt.Sprintf("pct exec %s -- hostname -I 2>/dev/null", vmid)) ipStr := strings.TrimSpace(string(ipOut)) if ipStr != "" { fields := strings.Fields(ipStr) if len(fields) > 0 { lxcIP = fields[0] break } } time.Sleep(5 * time.Second) } if lxcIP == "" { return fmt.Errorf("native ingress LXC: could not discover IP after 60s") } log.Info("proxmox.native_ingress_lxc_ip", "vmid", vmid, "ip", lxcIP) // Install podman inside the LXC (C-53: idempotent — check first). _, _ = runRemote(fmt.Sprintf( "pct exec %s -- bash -c 'command -v podman >/dev/null 2>&1 || (apt-get update -qq && apt-get install -y -qq podman conmon crun fuse-overlayfs nftables 2>&1)' 2>&1", vmid, )) // Enable podman-restart.service inside the LXC (research Topic 6). _, _ = runRemote(fmt.Sprintf("pct exec %s -- systemctl enable --now podman-restart.service 2>/dev/null", vmid)) // Push step-ca root CA into the LXC (placeholder if absent locally). caPath := certpaths.CACertPath() caData, caErr := os.ReadFile(caPath) if caErr != nil { caData = []byte{} } // Write CA via pct exec heredoc. caDelim := "EOF_CA" _, _ = runRemote(fmt.Sprintf( "pct exec %s -- bash -c 'mkdir -p /etc/orca && cat > /etc/orca/step-ca-root.crt <<%s\\n%s\\n%s'", vmid, caDelim, string(caData), caDelim, )) // Render + write traefik static config inside the LXC. staticFiles, err := emitter.TraefikEmitter{}.RenderTraefikStaticConfig(emitter.TraefikStaticOpts{}) if err == nil { for _, f := range staticFiles { delim := "EOF_TF" _, _ = runRemote(fmt.Sprintf( "pct exec %s -- bash -c 'mkdir -p /etc/traefik/dynamic && cat > %s <<%s\\n%s\\n%s'", vmid, f.Path, delim, f.Content, delim, )) } } // Ensure podman orca-traefik container inside the LXC. traefikExecFn := func(cmd string) ([]byte, error) { return runRemote(fmt.Sprintf("pct exec %s -- bash -c %s 2>&1", vmid, shellQuote(cmd))) } if err := traefik.EnsureTraefikContainerRemote(ctx, "", traefikExecFn); err != nil { log.Warn("proxmox.native_ingress_lxc_traefik_failed", "err", err) } // Render + apply nft on the PVE host with DNATTarget = LXC IP. nftFiles, err := emitter.NftEmitter{}.RenderNftConfig(emitter.NftClusterConfig{ DNATTarget: lxcIP, }) if err == nil { for _, f := range nftFiles { nftDelim := "EOF_NF" _, _ = runRemote(fmt.Sprintf("mkdir -p /etc/nftables.d && cat > %s <<%s\\n%s\\n%s", f.Path, nftDelim, f.Content, nftDelim)) } _, _ = runRemote("nft add table inet orca-ingress 2>/dev/null || true") if out, err := runRemote("nft -f /etc/nftables.d/orca.nft 2>&1"); err != nil { log.Warn("proxmox.native_ingress_nft_apply_failed", "err", err, "output", string(out)) } } log.Info("proxmox.native_ingress_lxc_ok", "vmid", vmid, "ip", lxcIP) return 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{} // pinnedHostKeyCallback returns an ssh.HostKeyCallback that pins the // server's host key to the operator-supplied SHA256:base64 fingerprint // (REQ-058, AD-028). It validates the `SHA256:` prefix up front (D-045) // and fails closed on any mismatch. The capturedKey out-param records // the verified server key so the caller can populate Result. func pinnedHostKeyCallback(expectedSHA256Base64 string, capturedKey *ssh.PublicKey) (ssh.HostKeyCallback, error) { if !strings.HasPrefix(expectedSHA256Base64, "SHA256:") { return nil, fmt.Errorf("pinnedHostKeyCallback: fingerprint must be SHA256:-prefixed (D-045), got %q", expectedSHA256Base64) } return func(_ string, _ net.Addr, key ssh.PublicKey) error { got := security.SSHFingerprintSHA256(key) if got != expectedSHA256Base64 { return fmt.Errorf("REQ-058 host-key fingerprint mismatch: pinned=%s server=%s", expectedSHA256Base64, got) } if capturedKey != nil { *capturedKey = key } return nil }, nil } // TOFUHostKeyCallback returns an ssh.HostKeyCallback that wraps the // standard knownhosts.New verifier with TOFU first-connect capture // (D-035). On a host-unknown KeyError{Want:[]} it writes the // server-presented key to certpaths.KnownHostsPath() atomically // (security.WriteAtomic, AD-029) and allows the dial to proceed; on a // mismatch (Want non-empty) it fails closed (MITM detection). The // capturedKey out-param records the verified/captured server key so // the caller can populate Result. This fixes the v0.6 ship-defect // where knownhosts.New returned KeyError{Want:[]} on first connect // WITHOUT writing the captured key, so the first // `orca node join --type proxmox` always failed. // // Exported so the doctor proxmox probe (T02.9) can reuse the same // capture-fix wrapper for parity (GRILL condition #2). // // REQ-157 / P08 T4: TOFUHostKeyCallback now delegates to // TOFUHostKeyCallbackPath with the v0.8 flat layout // (certpaths.KnownHostsPath()). The path-accepting variant lets the // sshpush transport pass its stored known_hosts field (the v0.9 // paths.KnownHostsPath() location) instead of always reading the v0.8 // flat layout — fixing the bug where the dial() flock field was stored // but never read. func TOFUHostKeyCallback(addr string, capturedKey *ssh.PublicKey) (ssh.HostKeyCallback, error) { return TOFUHostKeyCallbackPath(certpaths.KnownHostsPath(), addr, capturedKey) } // TOFUHostKeyCallbackPath is the path-accepting variant. knownHostsPath // is the known_hosts file to verify against and capture new keys into; // it MUST be flock-protected on capture (security.Flock). When // knownHostsPath is empty, falls back to certpaths.KnownHostsPath() // (the v0.8 flat layout) for backward compatibility with callers that // relied on the implicit default. func TOFUHostKeyCallbackPath(knownHostsPath, addr string, capturedKey *ssh.PublicKey) (ssh.HostKeyCallback, error) { if knownHostsPath == "" { knownHostsPath = certpaths.KnownHostsPath() } // REQ-164 / Phase A2: create the known_hosts file if it doesn't // exist (knownhosts.New requires the file to be present). This is // defense-in-depth alongside init.go which also creates it. if _, err := os.Stat(knownHostsPath); err != nil { if os.IsNotExist(err) { if writeErr := security.WriteAtomic(knownHostsPath, 0o600, []byte{}); writeErr != nil { return nil, fmt.Errorf("tofu create known_hosts: %w", writeErr) } } else { return nil, fmt.Errorf("tofu stat known_hosts: %w", err) } } cb, err := knownhosts.New(knownHostsPath) if err != nil { return nil, err } return func(hostname string, remote net.Addr, key ssh.PublicKey) error { err := cb(hostname, remote, key) if err == nil { if capturedKey != nil { *capturedKey = key } return nil } var keyErr *knownhosts.KeyError if errors.As(err, &keyErr) && len(keyErr.Want) == 0 { line := knownhosts.Line([]string{knownhosts.Normalize(addr)}, key) release, lockErr := security.Flock(knownHostsPath) if lockErr != nil { return fmt.Errorf("tofu lock known_hosts: %w", lockErr) } defer release() existing, readErr := os.ReadFile(knownHostsPath) if readErr != nil && !os.IsNotExist(readErr) { return fmt.Errorf("tofu read known_hosts: %w", readErr) } if len(existing) > 0 && !bytes.HasSuffix(existing, []byte("\n")) { existing = append(existing, '\n') } updated := append(existing, []byte(line)...) if writeErr := security.WriteAtomic(knownHostsPath, 0o600, updated); writeErr != nil { return fmt.Errorf("tofu write known_hosts: %w", writeErr) } if capturedKey != nil { *capturedKey = key } return nil } return err }, nil } 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) } type sessionRunnerType interface { CombinedOutput(cmd string) ([]byte, error) } var sessionRunner sessionRunnerType type sshSessionRunner struct { client *ssh.Client } func (r *sshSessionRunner) CombinedOutput(cmd string) ([]byte, error) { session, err := r.client.NewSession() if err != nil { return nil, fmt.Errorf("new session: %w", err) } defer session.Close() return session.CombinedOutput(cmd) } // runRemote runs a command over the SSH connection and returns its // combined output. Returns an error if the command exits non-zero. func runRemote(cmd string) ([]byte, error) { out, err := sessionRunner.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(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(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(user string) error { // F10c: shellQuote the user (validated upstream, but defense-in-depth). cmd := fmt.Sprintf("id -u %s 2>/dev/null || useradd -r -s /usr/sbin/nologin %s", shellQuote(user), shellQuote(user)) if _, err := runRemote(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(role string) error { // Idempotent: check if the role already exists before creating. // Use pveum role list with grep -qF (fixed string, not regex) to // avoid shell-quoting issues with single quotes inside the pattern. cmd := fmt.Sprintf( "pveum role list 2>/dev/null | grep -qF %s && exit 0 || pveum role add %s --privs '%s' 2>/dev/null || pveum role mod %s --privs '%s'", shellQuote(role), shellQuote(role), OrcaOperatorPrivileges, shellQuote(role), OrcaOperatorPrivileges, ) if _, err := runRemote(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(user string) error { pveUserID := user + "@pam" // Idempotent: check if user exists, create if not, update comment if exists. cmd := fmt.Sprintf( "pveum user list 2>/dev/null | grep -qF %s && exit 0 || pveum user add %s -comment 'Orca automation user' 2>/dev/null || pveum user mod %s -comment 'Orca automation user'", shellQuote(pveUserID), shellQuote(pveUserID), shellQuote(pveUserID), ) if _, err := runRemote(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(user, role string) error { pveUserID := user + "@pam" // F10c: shellQuote the PVE user id and role (validated upstream, // but defense-in-depth). cmd := fmt.Sprintf("pveum acl modify / -user %s -role %s", shellQuote(pveUserID), shellQuote(role)) if _, err := runRemote(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 `, user, user) } // sudoersPath is the fixed on-peer path for the orca sudoers drop-in. // F10b: the file is always written here regardless of the configured // ProxmoxUser name, so a crafted username cannot redirect the sudoers // drop-in to an arbitrary path. const sudoersPath = "/etc/sudoers.d/orca" // writeSudoers writes the /etc/sudoers.d/orca file on the remote host // with mode 0440. Uses a heredoc via cat to avoid quoting issues. F10b: // the path is fixed (sudoersPath) regardless of the configured username. func writeSudoers(user string) error { content := sudoersContent(user) // Write via cat heredoc to the fixed path, then chmod 0440. cmd := fmt.Sprintf("cat > %s <<'ORCA_SUDOERS_EOF'\n%s\nORCA_SUDOERS_EOF\nchmod 0440 %s", sudoersPath, content, sudoersPath) if _, err := runRemote(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). // validateSudoers runs `visudo -cf` on the sudoers file. F10d: it // validates the actual file that writeSudoers wrote (sudoersPath, // /etc/sudoers.d/orca), which is now a fixed path — the prior version // hardcoded /etc/sudoers.d/orca while writeSudoers wrote to // /etc/sudoers.d/, so a custom username would validate the // wrong file. func validateSudoers() error { cmd := fmt.Sprintf("visudo -cf %s", sudoersPath) out, err := runRemote(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 } // proxmoxNameRe is the allowlist for ProxmoxUser and ProxmoxRole values // that are interpolated into sudoers content, file paths, and shell // commands (F10a). Letter or underscore start, followed by // alphanumerics, underscore, or hyphen; max 32 chars. Uppercase is // permitted (DefaultProxmoxRole is "OrcaOperator"); shell // metacharacters (spaces, ;, $, backticks, etc.) are blocked. var proxmoxNameRe = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_-]{0,31}$`) // validProxmoxName reports whether s is a safe ProxmoxUser or ProxmoxRole // value (F10a injection guard). func validProxmoxName(s string) bool { return proxmoxNameRe.MatchString(s) } // shellQuote single-quotes a string for safe shell interpolation over // the SSH exec session. It escapes embedded single-quotes via the // standard ”' idiom (POSIX shell). F10c: hardens pveum/useradd commands // against metacharacter injection (the validated allowlist is // defense-in-depth on top of this). func shellQuote(s string) string { return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'" } // ResetHostKey removes all known_hosts entries for the given host from // certpaths.KnownHostsPath() (REQ-059, D-046, AD-029). It rewrites the // file atomically via security.WriteAtomic. LOCAL ONLY — it does NOT // touch the remote host's authorized_keys (D-046). The next connect // re-pins the host key via TOFU (T02.6) or the --host-key-fingerprint // pinned path (T02.5). // // A line matches when its first whitespace-delimited field (the host // pattern, normalized via knownhosts.Normalize) equals the normalized // target host. Comment/blank lines are preserved. func ResetHostKey(host string) error { if host == "" { return fmt.Errorf("ResetHostKey: host is required") } path := certpaths.KnownHostsPath() release, lockErr := security.Flock(path) if lockErr != nil { return fmt.Errorf("ResetHostKey: lock known_hosts: %w", lockErr) } defer release() existing, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { return nil // nothing to reset } return fmt.Errorf("ResetHostKey: read known_hosts: %w", err) } target := knownhosts.Normalize(host) var kept []byte removed := 0 for _, line := range strings.Split(string(existing), "\n") { trimmed := strings.TrimSpace(line) if trimmed == "" || strings.HasPrefix(trimmed, "#") { kept = append(kept, []byte(line+"\n")...) continue } fields := strings.Fields(trimmed) if len(fields) == 0 { kept = append(kept, []byte(line+"\n")...) continue } if knownhosts.Normalize(fields[0]) == target { removed++ continue } kept = append(kept, []byte(line+"\n")...) } if removed == 0 { return nil } // Ensure the kept buffer ends with exactly one trailing newline. kept = bytes.TrimRight(kept, "\n") if len(kept) > 0 { kept = append(kept, '\n') } if err := security.WriteAtomic(path, 0o600, kept); err != nil { return fmt.Errorf("ResetHostKey: rewrite known_hosts: %w", err) } return nil }