# Research: Orca v0.6 — Node Bootstrap & Proxmox Findings grounded in codebase analysis (8 key files read) + verified against `golang.org/x/crypto` v0.54.0 (probe built clean), Proxmox VE 9.2.3 admin guide (§14.7-14.8 pveum + privileges), sudoers(5) man page (NOEXEC/NOPASSWD), and freedesktop.org os-release spec. ## A. SSH library — `golang.org/x/crypto/ssh` ### A.1 go.mod addition ``` require golang.org/x/crypto v0.54.0 ``` Latest available, compatible with go 1.25. Transitive deps (verified by probe build): - `golang.org/x/crypto v0.54.0` (direct) - `golang.org/x/sys v0.47.0` (indirect — bumps from v0.42.0) - `golang.org/x/term v0.45.0` (indirect — pulled by ssh for PTY) **3 module entries, 0 new heavy deps.** Matches D-030 minimal-deps rationale. `go.sum` gains ~6 lines. ### A.2 Minimal API surface ```go import ( "crypto/ed25519" "crypto/rand" "crypto/x509" "encoding/pem" "net" "time" "golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh/knownhosts" ) ``` Key functions: - `ssh.Dial(network, addr, config) (*ssh.Client, error)` — high-level dialer - `(*ssh.Client).NewSession() (*ssh.Session, error)` - `(*ssh.Session).CombinedOutput(cmd) ([]byte, error)` — run + capture - `ssh.ClientConfig{User, Auth, HostKeyCallback, Timeout}` - `ssh.Password(secret) ssh.AuthMethod` — password auth - `ssh.PublicKeys(signer) ssh.AuthMethod` — pubkey auth - `ssh.ParsePrivateKey(pem) (ssh.Signer, error)` — parse PKCS8 PEM (works with orca's existing key format) - `ssh.NewPublicKey(pub) (ssh.PublicKey, error)` + `ssh.MarshalAuthorizedKey(pub) []byte` — authorized_keys line - `ssh.FixedHostKey(key) ssh.HostKeyCallback` — strict pin (subsequent connects) - `knownhosts.New(path) (ssh.HostKeyCallback, error)` — TOFU via known_hosts file (cleaner than custom callback; avoids deprecated `InsecureIgnoreHostKey`) ### A.3 Ed25519 keygen (D-037) Verified end-to-end: `ed25519.GenerateKey(rand.Reader)` → `x509.MarshalPKCS8PrivateKey(priv)` → PEM encode → `ssh.ParsePrivateKey` round-trips cleanly. `ssh.MarshalAuthorizedKey` produces valid `ssh-ed25519 AAAA...` line. **PKCS8 PEM (orca's existing format) parses with `ssh.ParsePrivateKey` — no OpenSSH-format marshaller needed.** Reuse `security.WriteKey`/`writeAtomic` for persistence. ### A.4 File upload — `cat > file` via session, NOT SFTP SFTP lives in separate module `github.com/pkg/sftp` — would add a 4th direct dep beyond D-030. The only files orca uploads are: - `~orca/.ssh/authorized_keys` (1-line append) - `/etc/sudoers.d/orca` (few lines) Both are text. Use `session.CombinedOutput` with heredoc / `tee -a`. Keeps everything within `x/crypto/ssh`. ### A.5 TOFU host-key handling (D-035) Use `golang.org/x/crypto/ssh/knownhosts.New(path)` as the `HostKeyCallback`. On first connect, the callback writes the host key to `~/.orca/known_hosts` (OpenSSH format). On subsequent connects, it verifies and returns an error on mismatch. **Avoids `ssh.InsecureIgnoreHostKey` deprecation** — `knownhosts.New` handles both capture and verify in one callback. On host-key change (reinstall), fail closed with a clear error; operator runs `orca node key-reset ` (future) or manually edits `known_hosts`. ## B. `/etc/os-release` parsing (D-032) ### B.1 Confirmed `ID=` values | Distro | `ID=` | `ID_LIKE=` | Verified | |--------|-------|-----------|----------| | Ubuntu | `ubuntu` | `debian` | ✅ (this host: Ubuntu 24.04) | | Debian | `debian` | — | ✅ (freedesktop spec) | | Alpine | `alpine` | — | ✅ (Alpine policy) | | Proxmox VE | `pve` | `debian` | ✅ (PVE ships own os-release) | `VARIANT_ID` absent on all four target distros — not worth capturing for v0.6. ### B.2 Parsing approach No Go stdlib helper. Trivial: `bufio.Scanner` + `strings.SplitN(line, "=", 2)` + strip surrounding quotes. ~15 lines. Returns `map[string]string`; read `ID` field. Fallback `"linux"` if file missing or `ID` absent (D-032). Read `/etc/os-release` first; fall back to `/usr/lib/os-release` for minimal containers. Unknown `ID` values stored verbatim (not masked) — `doctor os` can warn. ## C. Proxmox VE role & user management ### C.1 Realm: `orca@pam` (NOT `orca@pve`) Confirmed by both researchers + PVE User Management docs: since `orca node join` SSHes in and creates a Linux system user via `useradd`, the PVE user must be `orca@pam` (PAM realm maps to host system users). `orca@pve` would require a separate PVE-internal password and interactive `-password` prompt over non-PTY SSH (hangs). `@pam` sidesteps both issues. **D-033 refined: `orca@pam`.** ### C.2 OrcaOperator PVE role — privilege set Per D-033 (operator-confirmed): `VM.Audit`, `Datastore.AllocateSpace`, `SDN.Use`. This is a **minimal API-level role** — the actual management capability comes from the sudoers allowlist (sudo runs as root, bypassing PVE RBAC). The PVE role governs non-sudo API access (future REST client). **Refinement from research**: `VM.Audit` covers containers (CTs) as well as VMs (both live under `/vms/{vmid}` path; no separate `CT.*` family). PVE 8→9: privilege set valid on both (no breaking changes to pveum or the core privilege names). Researcher 2 proposed an expanded 21-privilege set for fuller API-level management. **Decision: keep D-033's 3-priv minimal set for v0.6** — the operator explicitly confirmed it, and the sudoers allowlist is the primary management path. The expanded set is noted as a v0.7+ enhancement option if orca adds a direct PVE REST client. ### C.3 pveum command sequence (idempotent) ```bash # 1. Role — probe-then-add (pveum role add fails if exists) pveum role list | grep -q '^OrcaOperator' || \ pveum role add OrcaOperator --privs "VM.Audit Datastore.AllocateSpace SDN.Use" # 2. User — probe-then-add (maps to existing Linux system user) pveum user list | grep -q 'orca@pam' || \ pveum user add orca@pam -comment "Orca automation user" # 3. ACL — modify is idempotent (creates or updates) pveum acl modify / -user orca@pam -role OrcaOperator ``` Flag syntax: both `-privs` and `--privs` work (Perl Getopt::Long). Use `--privs` (canonical). Privs are **space-separated** inside quotes (NOT comma-separated). ### C.4 sudoers file `/etc/sudoers.d/orca` (D-033 refined) **Research refinement**: exclude `pvesh` from sudoers — `pvesh` can reach the `/nodes/{node}/execute` API endpoint which spawns shell commands server-side, bypassing sudo's `NOEXEC` tag. Keep `pct`/`qm` with `NOEXEC`; `apt-get`/`dpkg` without `NOEXEC` (they need to spawn child processes for maintainer scripts). ``` # /etc/sudoers.d/orca — mode 0440, owner root:root # Orca automation: VM/CT management + package management, no shell escape orca ALL=(root) NOPASSWD: NOEXEC: /usr/bin/pct, /usr/bin/qm orca ALL=(root) NOPASSWD: /usr/bin/apt-get, /usr/bin/dpkg ``` `NOEXEC` works via Linux seccomp (sudoers man page). `pct`/`qm` are Perl scripts run via dynamically-linked `/usr/bin/perl` → NOEXEC effective. `apt-get`/`dpkg` need exec for postinst scripts → no NOEXEC. File mode **0440** or sudo refuses to load. Validate with `visudo -cf /etc/sudoers.d/orca` after writing; abort bootstrap on validation failure. **Resolve binary paths at runtime** via `command -v pct` etc. before writing the sudoers file (cheap insurance against non-standard installs). ### C.5 PVE 8 vs 9 No breaking changes to pveum, privilege names, or sudo defaults between 8 and 9. `VM.Monitor` removed in 9.0 (OrcaOperator doesn't use it). Privileged container creation needs `Sys.Modify` in 9.0 (OrcaOperator doesn't have it → intended). Both versions: `orca@pam` flow identical. Binary paths identical (`/usr/bin/{pct,qm,pvesh}`). ## D. Codebase integration points (confirmed by reading files) ### D.1 Files to modify/create per requirement | File | Change | REQ | |------|--------|-----| | `go.mod` / `go.sum` | Add `golang.org/x/crypto v0.54.0`; bump sys, add term | REQ-050 | | `internal/model/node.go` | Add `Kind`, `OS` string fields + `NodeKind` constants | REQ-049 | | `internal/store/migrations/0006_node_kind_os.sql` | **NEW**: `ALTER TABLE nodes ADD COLUMN kind TEXT; ADD COLUMN os TEXT;` (nullable, backward-compatible) | REQ-049 | | `internal/store/node_repo.go` | Extend INSERT/SELECT/scanNode for `kind, os`; add `GetByName`, `UpdateLastSeenAndOS` helpers | REQ-049 | | `internal/cli/init.go` | Full bootstrap: MkdirAll → store.Open (runs migrations) → CAInit → server cert gen (if absent) → detectOS → localhost node upsert | REQ-047,048 | | `internal/cli/node.go` | Add `--type`, `--host`, `--user`, `--password`, `--proxmox-user`, `--proxmox-role` flags; `bootstrapProxmox` branch | REQ-050,051 | | `internal/security/sshkey.go` | **NEW**: `GenerateOrLoadSSHKey(dir)` — Ed25519 keygen, PKCS8 PEM, 0600/0644 modes | REQ-050 | | `internal/proxmox/bootstrap.go` | **NEW package**: `BootstrapProxmox(ctx, opts)` — SSH dial, pubkey deploy, useradd, pveum role/user/acl, sudoers write, visudo validate | REQ-050,051 | | `internal/doctor/doctor.go` | Add `OS()` and `Proxmox()` checks; extend `All()` | REQ-052 | | `internal/cli/doctor.go` | Add `doctor os` + `doctor proxmox` subcommands | REQ-052 | | `internal/certpaths/certpaths.go` | Add `SSHKeyPath`, `SSHPubPath`, `KnownHostsPath` | REQ-050 | ### D.2 Reuse opportunities (confirmed) - `security.CAInit` (ca.go:63) — **already idempotent** (fast-path loads existing). `orca init` calls it directly. - `security.GenerateCSR` (csr.go) — signature fits: `GenerateCSR("localhost", []string{"localhost","127.0.0.1"})`. - `security.WriteCert`/`WriteKey` (ca.go:292) — enforce 0644/0600 via `writeAtomic`; reuse for SSH key. - `store.Open` (migrate.go) — runs migrations on open; calling it in `orca init` auto-applies 0006. - Migration runner — FS-embedded, sorts lexicographically, idempotent per-file. Adding `0006_*.sql` is the entire change. - `doctor.Network()` (doctor.go:222) — exact pattern to clone for `doctor.Proxmox()` (list nodes, filter by kind, 3s timeout per peer, PASS/WARN/FAIL). ### D.3 No changes needed - `internal/security/ca.go`, `csr.go` — idempotent already, signatures fit. - `internal/store/migrate.go` — runner is generic. - `internal/transport/*` — mTLS transport not involved in SSH bootstrap. - `internal/engine/*` — NodeRegistry.Join works; new fields are metadata. ## E. Pitfalls & gotchas 1. **`pveum` flag is `--privs` (space-separated)**, not `--privs "a,b,c"`. Confirmed by both researchers + official docs. 2. **`orca@pam` not `orca@pve`** — PVE-internal realm requires interactive password prompt over non-PTY SSH (hangs). PAM realm maps to the Linux system user orca creates. 3. **Exclude `pvesh` from sudoers** — `pvesh` can trigger API `execute` endpoint spawning shell commands server-side, bypassing `NOEXEC`. Use PVE API via OrcaOperator role for API access instead. 4. **`NOEXEC` only on dynamically-linked binaries** — `pct`/`qm` are Perl scripts via dynamically-linked `/usr/bin/perl` → effective. `apt-get`/`dpkg` need exec → no NOEXEC. 5. **sudoers file mode 0440** — or sudo silently refuses to load it. `chmod 0440` + `visudo -cf` validate after write. 6. **Migration 0006 NULL handling** — `scanNode` must use `sql.NullString` for `kind`/`os` and map NULL → `""` (Go struct fields are `string`, not `*string`). 7. **localhost node idempotency** — `NodeRepo.Insert` fails on UNIQUE constraint if `orca init` re-runs. Need `GetByName("localhost")` check first; if found, `UpdateLastSeenAndOS` instead of `Insert`. Don't change `id` or `joined_at` (D-036). 8. **`orca init` must not regenerate server cert** (D-036) — check `certpaths.ServerCertPath()` existence before `GenerateCSR`. `CAInit` has a fast-path; server cert gen needs an explicit existence check. 9. **Password handling (D-031)** — `--password` flag visible in `ps`/`/proc` briefly. Prefer `$ORCA_PROXMOX_PASSWORD` env var. Never log the password (slog redaction). Zero the byte slice after use. 10. **`knownhosts.New` for TOFU** — avoids deprecated `ssh.InsecureIgnoreHostKey`. Handles both capture and verify in one callback. 11. **PKCS8 PEM parses with `ssh.ParsePrivateKey`** — no need for OpenSSH-format marshaller. Consistent with `ca.key`/`server.key` format. 12. **`/etc/os-release` is a symlink** on most distros → `os.ReadFile` follows it. Fall back to `/usr/lib/os-release` for minimal containers. ## F. Persona recommendations (v0.6 roster) | Persona | Active | Reason | |---------|--------|--------| | `lead-developer` | ✅ | Coordination across P01/P02/P03; SSH/bootstrap touches security + cli + store + doctor | | `backend-engineer` | ✅ | Owns `internal/cli/init.go` full-bootstrap orchestration + `internal/proxmox/bootstrap.go` SSH logic | | `cli-engineer` | ✅ | Owns `--type`/`--host`/`--password` flag wiring, `doctor os`/`doctor proxmox` subcommands, init output UX | | `data-engineer` | ✅ **REACTIVATE** | Owns migration 0006 + `NodeRepo` schema extension (kind/os columns, new helpers) | | `security-engineer` | ✅ **REACTIVATE** | Owns `internal/security/sshkey.go`, TOFU host-key, sudoers design, password redaction, audit logging | | `devops-engineer` | ❌ **DEACTIVATE** | No install.sh/Dockerfile/.coreci.yml surface in v0.6 | | `network-engineer` | ❌ | No transport/mTLS surface (SSH is point-to-point bootstrap, not mesh) | | `frontend-engineer` | ❌ | No web UI | **Territory overlaps to adjudicate (lead-developer)**: - `internal/proxmox/bootstrap.go` (security-engineer SSH/sudoers logic) vs `internal/cli/node.go` (cli-engineer flag wiring) — boundary: security package exposes `BootstrapProxmox(ctx, opts) error`, CLI just calls it. - `internal/doctor/doctor.go` `Proxmox()` reuses SSH client from `internal/proxmox` (security) but check scaffolding clones `doctor.Network()` pattern (backend adjudicates since network-engineer deactivated).