Files
orca/internal/proxmox/bootstrap.go
T
Jon Chery 20523ac045 fix(P07): remove all password/token paths (REQ-146, R-021, C-34) -- BREAKING
---ci---
project: orca
phase: 7
milestone: v0.12
status: execute
---/ci---

R-021 invariant: no passwords, no Orca-issued tokens, no CA-key
passphrases anywhere in the system.

Removed:
- proxmox/bootstrap.go: ssh.Password auth -> ssh.PublicKeys (key-based).
  --password/ removed from node join; replaced
  with --ssh-key (default: orca SSH key). Pre-staged key required.
- stepca/stepca.go: --password-file /dev/stdin removed from Init and
  issueCert. Provisioner changed to 'orca-oidc' (OIDC provisioner).
- identity/spiffe.go: --password-file removed from MintSVID. Provisioner
  changed to 'orca-oidc'.

Tests: all proxmox, stepca, identity, cli tests updated + pass. 3 new
password-rejection regression tests. Fake SSH server gains
PublicKeyCallback. go vet clean. Full build green.
2026-08-07 11:12:18 +00:00

545 lines
19 KiB
Go

// 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 (
"bytes"
"context"
"errors"
"fmt"
"log/slog"
"net"
"os"
"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
// 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
}
// 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
}
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 + 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 := fmt.Sprintf("%s:%d", opts.Host, 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)
}
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
}
// 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).
func TOFUHostKeyCallback(addr string, capturedKey *ssh.PublicKey) (ssh.HostKeyCallback, error) {
cb, err := knownhosts.New(certpaths.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)
path := certpaths.KnownHostsPath()
release, lockErr := security.Flock(path)
if lockErr != nil {
return fmt.Errorf("tofu lock known_hosts: %w", lockErr)
}
defer release()
existing, readErr := os.ReadFile(path)
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(path, 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 {
cmd := fmt.Sprintf("id -u %s 2>/dev/null || useradd -m -s /bin/bash %s", user, 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 {
cmd := fmt.Sprintf(
"pveum role list 2>/dev/null | grep -q '^%s' || pveum role add %s --privs '%s'",
role, 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"
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(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"
cmd := fmt.Sprintf("pveum acl modify / -user %s -role %s", pveUserID, 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
%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(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(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() error {
cmd := "visudo -cf /etc/sudoers.d/orca"
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
}
// 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
}