Research domains (delegated to ci-researcher x2, codebase-grounded): - golang.org/x/crypto/ssh v0.54.0: API surface, Ed25519 keygen, TOFU via knownhosts.New, file upload via session heredoc (no SFTP dep) - /etc/os-release: confirmed ID= values (ubuntu/debian/alpine/pve), parsing approach, fallback strategy - Proxmox VE 8/9: pveum syntax (space-separated --privs), orca@pam realm (not @pve), OrcaOperator role, sudoers with NOEXEC on pct/qm, pvesh excluded (API execute bypasses NOEXEC) - Codebase: 12 files to modify/create, 6 reuse opportunities, 12 pitfalls Persona roster updated: data-engineer + security-engineer reactivated, devops-engineer deactivated. ARCHITECTURE.md addendum with AD-017..021. ---ci--- project: orca phase: 0 milestone: v0.6 status: research ---/ci---
14 KiB
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
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 + capturessh.ClientConfig{User, Auth, HostKeyCallback, Timeout}ssh.Password(secret) ssh.AuthMethod— password authssh.PublicKeys(signer) ssh.AuthMethod— pubkey authssh.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 linessh.FixedHostKey(key) ssh.HostKeyCallback— strict pin (subsequent connects)knownhosts.New(path) (ssh.HostKeyCallback, error)— TOFU via known_hosts file (cleaner than custom callback; avoids deprecatedInsecureIgnoreHostKey)
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 <node> (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)
# 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 initcalls 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 viawriteAtomic; reuse for SSH key.store.Open(migrate.go) — runs migrations on open; calling it inorca initauto-applies 0006.- Migration runner — FS-embedded, sorts lexicographically, idempotent per-file. Adding
0006_*.sqlis the entire change. doctor.Network()(doctor.go:222) — exact pattern to clone fordoctor.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
pveumflag is--privs(space-separated), not--privs "a,b,c". Confirmed by both researchers + official docs.orca@pamnotorca@pve— PVE-internal realm requires interactive password prompt over non-PTY SSH (hangs). PAM realm maps to the Linux system user orca creates.- Exclude
pveshfrom sudoers —pveshcan trigger APIexecuteendpoint spawning shell commands server-side, bypassingNOEXEC. Use PVE API via OrcaOperator role for API access instead. NOEXEConly on dynamically-linked binaries —pct/qmare Perl scripts via dynamically-linked/usr/bin/perl→ effective.apt-get/dpkgneed exec → no NOEXEC.- sudoers file mode 0440 — or sudo silently refuses to load it.
chmod 0440+visudo -cfvalidate after write. - Migration 0006 NULL handling —
scanNodemust usesql.NullStringforkind/osand map NULL →""(Go struct fields arestring, not*string). - localhost node idempotency —
NodeRepo.Insertfails on UNIQUE constraint iforca initre-runs. NeedGetByName("localhost")check first; if found,UpdateLastSeenAndOSinstead ofInsert. Don't changeidorjoined_at(D-036). orca initmust not regenerate server cert (D-036) — checkcertpaths.ServerCertPath()existence beforeGenerateCSR.CAInithas a fast-path; server cert gen needs an explicit existence check.- Password handling (D-031) —
--passwordflag visible inps//procbriefly. Prefer$ORCA_PROXMOX_PASSWORDenv var. Never log the password (slog redaction). Zero the byte slice after use. knownhosts.Newfor TOFU — avoids deprecatedssh.InsecureIgnoreHostKey. Handles both capture and verify in one callback.- PKCS8 PEM parses with
ssh.ParsePrivateKey— no need for OpenSSH-format marshaller. Consistent withca.key/server.keyformat. /etc/os-releaseis a symlink on most distros →os.ReadFilefollows it. Fall back to/usr/lib/os-releasefor 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) vsinternal/cli/node.go(cli-engineer flag wiring) — boundary: security package exposesBootstrapProxmox(ctx, opts) error, CLI just calls it.internal/doctor/doctor.goProxmox()reuses SSH client frominternal/proxmox(security) but check scaffolding clonesdoctor.Network()pattern (backend adjudicates since network-engineer deactivated).