797bc2f412
orca node join --type proxmox bootstraps a remote Proxmox VE 8/9 host
via SSH (REQ-050, REQ-051). The password is used only for initial auth;
subsequent access uses the deployed orca SSH key (D-031).
Changes:
- go.mod: add golang.org/x/crypto v0.54.0 (ssh + ssh/knownhosts + ed25519)
bump x/sys to v0.47.0, add x/term (indirect)
- internal/certpaths: SSHKeyPath, SSHPubPath, KnownHostsPath (D-037)
- internal/security/sshkey.go: GenerateOrLoadSSHKey (Ed25519, PKCS8 PEM,
0600/0644 modes, idempotent load per D-036)
- internal/proxmox/bootstrap.go: BootstrapProxmox SSH dance:
1. Generate/load SSH key
2. SSH dial (password + knownhosts.New TOFU per D-035)
3. Deploy pubkey to ~orca/.ssh/authorized_keys (idempotent)
4. useradd -m orca (idempotent)
5. pveum role add OrcaOperator --privs 'VM.Audit Datastore.AllocateSpace SDN.Use'
6. pveum user add orca@pam (AD-019: PAM realm, not @pve)
7. pveum acl modify / -user orca@pam -role OrcaOperator
8. Write /etc/sudoers.d/orca (AD-020: NOEXEC on pct/qm, no NOEXEC on
apt-get/dpkg, pvesh EXCLUDED — API execute bypasses NOEXEC)
9. visudo -cf validation (abort on failure)
All steps idempotent; audit-logged.
- internal/cli/node.go: --type/--host/--ssh-user/--password/--ssh-port/
--proxmox-user/--proxmox-role flags; joinProxmox() wires to
proxmox.BootstrapProxmox + registers node with kind=proxmox, os=pve.
Password zeroed after use (D-031).
- tests: sshkey generate/load round-trip, idempotency, file modes;
proxmox sudoers content (NOEXEC/NOPASSWD/pvesh-excluded),
privilege set, validation; node join flag wiring
---ci---
project: orca
phase: 2
milestone: v0.6
status: execute
---/ci---
347 lines
12 KiB
Go
347 lines
12 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 (
|
|
"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
|
|
}
|