d9978693f4
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---
250 lines
14 KiB
Markdown
250 lines
14 KiB
Markdown
# 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 <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)
|
|
|
|
```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). |