feat(P02): Proxmox SSH join + OrcaOperator role + sudoers

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---
This commit is contained in:
Jon Chery
2026-08-03 19:55:14 +00:00
parent e4edd9aeda
commit 797bc2f412
9 changed files with 835 additions and 68 deletions
+16
View File
@@ -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") }
+148 -50
View File
@@ -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)")
+346
View File
@@ -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
}
+114
View File
@@ -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
}
+95
View File
@@ -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
}
+93
View File
@@ -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)
}
}