# Architecture: Orca ## System Overview Orca is a single-binary, offline-first orchestration engine. The system consists of three logical layers (CLI, Daemon, Engine) compiled into one `orca` binary and selected via subcommands. v0.2 adds a **cross-node transport layer** (mTLS) and a **dispatcher** for multi-node job execution. ``` ┌─────────────────────────────────────────────────────────────────────────────┐ │ orca (single binary, v0.2) │ ├─────────────────────────────────────────────────────────────────────────────┤ │ CLI Layer (Cobra) │ │ ├── orca version │ │ ├── orca init # local node bootstrap │ │ ├── orca cert {init,join,renew,show} # NEW (P01) │ │ ├── orca status │ │ ├── orca node {join,leave,list} # join = mTLS handshake (P01) │ │ │ └── orca node list --watch # NEW iter.Seq (P04) │ │ ├── orca job {run,list,stop,logs} │ │ │ └── orca job list --watch # NEW iter.Seq (P04) │ │ ├── orca doctor # NEW (P01) — diagnostics │ │ │ ├── orca doctor cert │ │ │ ├── orca doctor network │ │ │ └── orca doctor db │ │ └── orca daemon │ ├─────────────────────────────────────────────────────────────────────────────┤ │ Daemon Layer (net/http over h2c, mTLS in P01) │ │ ├── /healthz (liveness) │ │ ├── /readyz (readiness) │ │ ├── /v1/jobs/* (job control API) │ │ ├── /v1/nodes/* (node registry API) │ │ ├── /v1/tasks/* (task lifecycle API) │ │ ├── /orca.v1.Dispatch/... # NEW (P02) — cross-node dispatch │ │ └── /orca.v1.Register/... # NEW (P02) — peer join ack │ ├─────────────────────────────────────────────────────────────────────────────┤ │ Transport Layer (NEW — internal/transport) │ │ ├── mTLS client (dialer pool per peer) │ │ ├── mTLS server config (TLS 1.3 only, AEAD allowlist) │ │ ├── Retry+backoff (exponential, jittered, capped) │ │ └── Graceful disconnect (ctx-aware Conn.Close) │ ├─────────────────────────────────────────────────────────────────────────────┤ │ Core Engine │ │ ├── Node Registry (in-memory + SQLite persistence) │ │ ├── Task Executor (os/exec with WaitDelay, Go 1.25+) │ │ ├── Job Scheduler (single-node FIFO; bin-pack P02) │ │ ├── Dispatcher # NEW internal/engine/dispatcher.go │ │ │ ├── Local decision (does this job fit on this node?) │ │ │ ├── Remote dispatch (POST to peer via transport) │ │ │ └── Streaming callback (iter.Seq[DispatchResult] for CLI) │ │ ├── Security Manager # NEW internal/security (P01) │ │ │ ├── CA lifecycle (init, fingerprint, sign CSR) │ │ │ ├── Server cert lifecycle (issue, renew, rotate) │ │ │ ├── mTLS config builder │ │ │ └── Cert store (filesystem + SQLite metadata) │ │ └── Audit Logger (log/slog JSON handler) │ ├─────────────────────────────────────────────────────────────────────────────┤ │ State Store (modernc/sqlite, CGO-free) │ │ ~/.orca/orca.db │ │ ├── nodes, jobs, tasks, audit_log (v0.1) │ │ └── certs # NEW (P01) — CA + server certs │ └─────────────────────────────────────────────────────────────────────────────┘ ``` ## Component Details ### 1. CLI Layer (`cmd/orca`, `internal/cli`) - **Framework**: Cobra (industry standard, familiar to operators) - **v0.1 subcommands**: `version`, `init`, `status`, `node`, `job`, `daemon` - **v0.2 additions (P01)**: `orca cert {init,join,renew,show}` - **v0.2 additions (P04)**: `--watch` flag on `orca job list` and `orca node list` - **v0.2 additions (P01)**: `orca doctor` subcommand (see §5 below) - **Output**: Human-readable by default; `--json` flag for machine consumption - **Discovery**: All subcommands self-document via Cobra's auto-generated help - **Watch semantics (P04)**: `--watch` consumes `iter.Seq[Job|Node]`, exits on ctrl-c (via `signal.NotifyContext`), refreshes on internal change events. ### 2. Daemon Layer (`internal/daemon`) - **Server**: `net/http` with `http.ServeMux` (no external router) - **TLS (P01)**: `crypto/tls` with `MinVersion=tls.VersionTLS13` and AEAD cipher allowlist (`TLS_AES_256_GCM_SHA384`, `TLS_CHACHA20_POLY1305_SHA256`, `TLS_AES_128_GCM_SHA256`) - **Ports**: Configurable (default `:8443` for API+mTLS, `:8080` for health) - **Graceful Shutdown**: `signal.NotifyContext` with SIGINT/SIGTERM - **v0.2 endpoints (P02)**: - `POST /orca.v1.Dispatch/Submit` — receive cross-node job submission - `POST /orca.v1.Dispatch/Status` — query dispatched job status - `POST /orca.v1.Register/Hello` — peer join ack (used during `orca node join`) ### 3. Transport Layer (`internal/transport`, NEW in P01/P02) - **Client**: `http.Client` with `http.Transport.TLSClientConfig` populated from `internal/security.NewClientTLSConfig` - **Server**: `http.Server.TLSConfig` populated from `internal/security.NewServerTLSConfig` - **Retry policy**: exponential backoff with jitter (start 100ms, x2, cap 5s, max 5 attempts); only idempotent verbs (`GET`, `HEAD`, `OPTIONS`) are retried automatically; `POST` retries require an explicit `X-Orca-Idempotency-Key` header - **Conn lifecycle**: `context.Context`-aware dials and reads; graceful `Close` on `ctx.Done()` - **Peer dial pool**: small `sync.Map` of `peerID → *http.Client` to reuse TLS handshakes (TCP keep-alive) within a session ### 4. Core Engine (`internal/engine`) - **Node Registry**: In-memory map of node IDs → metadata, persisted to SQLite (CPU/memory capacity, available slots, last-seen) - **Task Executor**: `os/exec.CommandContext` with `WaitDelay` (Go 1.25+) for clean process termination - **Job Scheduler (v0.2 P02)**: - **Algorithm**: best-fit bin-packing by `available_cpu` and `available_memory` - **Within-node ordering**: FIFO queue - **Cross-node**: if local node is full, `Dispatcher.Submit(peer, job)` is invoked; peers are tried in round-robin order - **Fallback**: if all peers reject, return `ErrNoFit` and requeue - **Dispatcher (NEW, P02)**: - `Submit(peerID, spec) (jobID, error)` — blocking call with retry - `Watch(peerID) iter.Seq[DispatchEvent]` — pull-style event stream for the CLI's `--watch` flag - Stateless: every call uses the latest mTLS client config and peer address - **Security Manager (NEW, P01)**: - `InitCA(commonName) (*CA, error)` — generates a self-signed CA, writes `ca.crt` (0644) and `ca.key` (0600) to `~/.orca/` - `Fingerprint(certPath) (sha256hex, error)` — used by `orca cert join` - `SignServerCert(csr, validity) (*cert, error)` — signs a CSR with the CA - `IssueServerCert(nodeName, dnsNames, ips) (*cert, *key, error)` — generates a keypair + CSR + signs it, returns PEM bytes for `orca cert join --server` - `ServerTLSConfig() (*tls.Config, error)` — loads `server.crt`/`server.key` and the CA pool from disk - `ClientTLSConfig(caPath) (*tls.Config, error)` — returns a client config pinned to the supplied CA - **Rotation policy**: server certs valid 90d; CA cert valid 10y. On `orca cert renew`, `IssueServerCert` is called and the daemon gracefully reloads the in-process `tls.Config` via `GetCertificate` hot-swap (no restart required) - **Audit Logger**: `slog.NewJSONHandler(os.Stderr, ...)` with structured fields ### 5. Doctor (`internal/doctor`, NEW in P01) - **Purpose**: operator-facing diagnostics; runs read-only checks against the local state and reports PASS/WARN/FAIL. - **Subcommands**: - `orca doctor` — runs all checks - `orca doctor cert` — cert/CA health (file modes, expiry windows, SAN presence, fingerprint pinning match — see REQ-026, REQ-033, REQ-034, REQ-036) - `orca doctor network` — peer reachability over mTLS (per-peer handshake sanity, last-seen delta) - `orca doctor db` — SQLite integrity check (`PRAGMA integrity_check`) + migration version - **Output**: human-readable by default; `--json` for machine consumption - **No state changes**: doctor is strictly read-only. It can be run while the daemon is down (where possible) or while it's up. - **Initial implementation in P01** (cert checks only); `network` and `db` checks land in subsequent phases as their state becomes available. ### 6. State Store (`internal/store`) - **Driver**: `modernc.org/sqlite` (pure Go, CGO-free) - **Location**: `~/.orca/orca.db` (user-mode) or `/var/lib/orca/orca.db` (system-mode) - **Schema (v0.1)**: `nodes`, `jobs`, `tasks`, `audit_log` tables - **Schema (v0.2 P01)**: NEW `certs` table ```sql CREATE TABLE certs ( id INTEGER PRIMARY KEY AUTOINCREMENT, kind TEXT NOT NULL, -- 'ca' | 'server' node_id TEXT, -- NULL for CA serial_hex TEXT NOT NULL, -- x509.SerialNumber.Hex() subject_cn TEXT NOT NULL, issuer_cn TEXT NOT NULL, not_before INTEGER NOT NULL, -- unix seconds not_after INTEGER NOT NULL, -- unix seconds fingerprint TEXT NOT NULL, -- sha256 of DER, hex source_path TEXT NOT NULL, -- on-disk PEM path created_at INTEGER NOT NULL ); CREATE INDEX idx_certs_node_kind ON certs(node_id, kind); CREATE INDEX idx_certs_not_after ON certs(not_after); ``` - **Migrations**: Embedded SQL files, applied on startup - **Migration 0004** is added in P01 with the schema above ## Data Model ### Node (v0.1, extended in v0.2 P02) ```go type Node struct { ID string Name string Address string State string JoinedAt time.Time LastSeen time.Time Metadata map[string]string // v0.2 P02 — capacity for bin-packing Capacity NodeCapacity } type NodeCapacity struct { CPUMillicores int // total, e.g. 4000 = 4 cores MemoryBytes int64 // total RAM CPUUsed int // currently allocated MemoryUsed int64 // currently allocated } func (c NodeCapacity) AvailableCPU() int { return c.CPUMillicores - c.CPUUsed } func (c NodeCapacity) AvailableMemory() int64 { return c.MemoryBytes - c.MemoryUsed } ``` ### Job (v0.1) ```go type Job struct { ID string Name string Spec string Status string CreatedAt time.Time StartedAt *time.Time EndedAt *time.Time } ``` ### Task (v0.1) ```go type Task struct { ID string JobID string Command string Args []string Env []string PID int ExitCode int Status string CreatedAt time.Time StartedAt *time.Time EndedAt *time.Time } ``` ### Certificate (NEW, v0.2 P01) ```go type Cert struct { Kind CertKind // CertCA | CertServer NodeID string // empty for CA SerialHex string SubjectCN string IssuerCN string NotBefore time.Time NotAfter time.Time Fingerprint string // sha256 of DER (hex) SourcePath string // PEM path on disk CreatedAt time.Time } ``` ## v0.2 Component Graph (ASCII) ``` ┌─────────────────┐ │ Operator Host │ │ (orca CLI) │ └────────┬────────┘ │ 1. cert join --ca-fingerprint ▼ ┌──────────────────────────────────────────────────────────────────────────────┐ │ NODE A (Bootstrap / CA holder) │ │ │ │ ┌──────────────┐ CSR ┌────────────────────┐ PEM sign ┌────────┐ │ │ │ orca cert │──────────▶│ internal/security │─────────────▶│ CA │ │ │ │ {init,join} │ │ .SignServerCert() │ │ key │ │ │ └──────────────┘ └────────────────────┘ │ 0600 │ │ │ ┌──└────────┘ │ │ ┌─────────────────┐ │ │ │ │ internal/daemon │ ◀──tls.Config── internal/security │ │ │ │ (http.Server) │ .ServerTLSConfig() │ │ │ │ │ │ │ │ │ /v1/jobs/* │ │ │ │ │ /v1/nodes/* │ │ │ │ │ /orca.v1.* │ │ │ │ └────────┬────────┘ │ │ │ │ Submit(job) (bin-pack) │ │ │ ▼ │ │ │ ┌─────────────────┐ │ │ │ │ internal/engine │ │ │ │ │ .scheduler │──── if local fits → executor │ │ │ │ .dispatcher │──── else → Submit(peer, job) ───────────┼──┐ │ │ └─────────────────┘ │ │ │ │ │ │ mTLS │ └──────────────────────────────────────────────────────────────┼──┼───────────┘ │ │ ORCA NODE NETWORK │ │ │ │ ┌──────────────────────────────────────────────────────────────┼──┼───────────┐ │ NODE B (Peer) │ │ │ │ │ │ │ │ ┌─────────────────┐ ◀── TLS 1.3 handshake ─────────────────┘ │ │ │ │ internal/daemon │ │ │ │ │ (http.Server) │ POST /orca.v1.Dispatch/Submit │ │ │ │ │──── 200 + jobID │ │ │ └────────┬────────┘ │ │ │ │ │ │ │ ▼ │ │ │ ┌─────────────────┐ │ │ │ │ internal/engine │ │ │ │ │ .scheduler (FIFO) │ │ │ │ .executor │ │ │ └─────────────────┘ │ │ └──────────────────────────────────────────────────────────────────────────────┘ ``` ## v0.2 Flows ### Flow 1: Cert Issuance (CA-init → CSR → sign → install) — P01 ``` Operator (Node A) Operator (Node B) ───────────────── ───────────────── orca cert init ↳ InitCA("orca-ca") ↳ write ca.crt (0644), ca.key (0600) ↳ record in certs table (kind='ca') orca cert join --ca-fingerprint ↳ operator copies ca.crt → Node B ↳ verifies fingerprint matches local --ca-fingerprint arg ↳ IssueServerCert("node-b", SANs) ↳ generate 2048-bit RSA key ↳ build CSR with SANs ↳ read ca.crt + ca.key ↳ sign CSR (90d validity) ↳ write server.crt (0644), server.key (0600) ↳ record in certs table (kind='server', node_id='node-b') ``` ### Flow 2: mTLS Handshake at `node join` — P01 ``` Node B (joiner) Node A (CA holder) ──────────────── ───────────────── orca node join --name node-b --ca-fingerprint --peer node-a:8443 ↳ load ca.crt → verify sha256 == --ca-fingerprint ↳ load server.crt + server.key ↳ tls.Config{MinVersion: TLS1.3, ...} ↳ ClientHello (SNI=node-a) ◀── ServerHello (TLS 1.3) ◀── Certificate (Node A's cert) ↳ verify Node A's cert chains to ca.crt ◀── CertificateRequest ↳ send Certificate (Node B's cert) ◀── Finished ↳ Finished ↳ GET /healthz (over mTLS) — sanity check ↳ POST /v1/nodes (over mTLS) — register ↳ insert into nodes table ↳ audit log ↳ 200 OK ↳ record node_a in peers table ↳ audit log ``` ### Flow 3: Job Dispatch (CLI → dispatcher → peer) — P02 ``` User (Node A CLI) Node A (scheduler) Node B (peer) ────────────────── ─────────────────── ───────────── orca job run spec.hcl ↳ parse HCL ↳ POST /v1/jobs (mTLS, local) ↳ scheduler.Submit(spec) ↳ bin-pack: spec.cpu + spec.mem ↳ local node A has 2000mc + 4GiB free → fit! ↳ executor.Run(spec) ↳ 202 Accepted + jobID ↳ returns jobID ``` ``` User (Node A CLI) Node A (scheduler) Node B (peer) ────────────────── ─────────────────── ───────────── ↳ scheduler.Submit(spec) ↳ bin-pack: spec.cpu + spec.mem ↳ local node A has 0 free → NO FIT ↳ dispatcher.Submit(peer="node-b", spec) ↳ load transport.Client("node-b") ↳ POST /orca.v1.Dispatch/Submit ↳ mTLS handshake ↳ authenticate cert ↳ scheduler.Submit(spec) ↳ executor.Run(spec) ↳ 200 OK + jobID ↳ 200 OK + jobID ↳ return jobID to local caller ↳ returns jobID ``` ### Flow 4: iter.Seq Streaming (`--watch`) — P04 ``` User orca job list --watch ──── ───────────────────── ctx, cancel := signal.NotifyContext(ctx, os.Interrupt) defer cancel() seq := store.Jobs().Watch(ctx) for job := range seq { print(job) // human or --json } // ctrl-c → ctx.Done() → seq stops yielding ``` Internally `store.Jobs().Watch(ctx) iter.Seq[Job]` polls the `jobs` table on a 1s ticker (or subscribes to an in-process notifier channel) and yields the current snapshot of each job until `ctx.Done()`. The store repo implements `iter.Seq[Job]` as a function that takes a `yield func(Job) bool` callback. ## Security Architecture ### Authentication - **v0.1**: mTLS for all API endpoints (self-signed CA) - **v0.2 P01**: Internal CA with CSR join (see Flow 1 + 2) - **v0.2+**: Token-based auth deferred to v0.3+ ### Cert Rotation - Server certs: 90-day validity, rotate at 60 days (30d before expiry) - CA cert: 10-year validity, manual rotation - Hot-swap: `tls.Config.GetCertificate` callback re-reads the `server.crt`/`server.key` files on each handshake so `orca cert renew` takes effect without a daemon restart. ### Audit Logging - All state-changing operations emit structured log records - Fields: `timestamp`, `actor`, `action`, `resource`, `result`, `error` - Stored in SQLite `audit_log` table and stderr (JSON) - **v0.2 P01 additions**: `cert.issued`, `cert.renewed`, `cert.joined`, `node.handshake_ok`, `node.handshake_failed` ### Input Validation - All CLI inputs validated via Cobra's `Args`/`ValidArgs` functions - All API inputs validated at handler boundary - HCL/YAML specs parsed with strict schemas ## Key Architectural Decisions (v0.1 + v0.2) | ID | Decision | Rationale | |----|----------|-----------| | AD-001 | Single binary with subcommands | Simpler distribution, aligns with simplicity pillar | | AD-002 | modernc/sqlite (CGO-free) | Cross-compile friendly, no CGO toolchain needed | | AD-003 | net/http (no framework) | Stdlib suffices; avoids external router dependency | | AD-004 | os/exec with WaitDelay (Go 1.25+) | Clean process termination, native to Go | | AD-005 | Cobra for CLI | Industry standard, familiar to operators | | AD-006 | slog for logging | Native to Go 1.21+, no external dependency | | AD-007 | HCL for job specs | Familiar to Nomad/HashiCorp users | | AD-008 | Single-node scheduling (v0.1) | Multi-node scheduling deferred to v0.2+ | | AD-009 | Internal CA, no external PKI (v0.2) | Self-contained, no operational PKI requirement | | AD-010 | Roll-our-own CA in `crypto/x509` (v0.2) | step-ca/cfssl/vault-pki too heavyweight for Orca's footprint | | AD-011 | Operator-mediated CA cert distribution (v0.2) | No secret distribution over the wire; matches offline-first | | AD-012 | TLS 1.3 only, AEAD allowlist (v0.2) | Modern crypto only; no downgrade risk | | AD-013 | Eager mTLS at `node join` (v0.2) | Fail fast; don't defer handshake to first request | | AD-014 | `orca.v1.Dispatch` via stdlib h2c (v0.2) | ConnectRPC not in go.mod; stdlib suffices for a single-RPC service | | AD-015 | Best-fit bin-packing (v0.2) | Simple, deterministic, optimal for small fleets | | AD-016 | iter.Seq for streaming lists (v0.2) | Go 1.25+ native, context-aware, pull semantics | ## Anti-Patterns (Explicitly Avoided) - No controller/agent split (single binary) - No CRDs / custom resource definitions - No web UI (CLI-only) - No service mesh - No container runtime integration - No multi-tenancy - No cloud provider integrations - No auto-scaling - No admission controllers - No complex scheduling algorithms (best-fit only) - No gRPC framework dependency (stdlib net/http with h2c, Go 1.25+ native) - No external PKI / no cert transparency logs (offline-first) ## Dependency Map (minimal — v0.2 adds zero direct deps) ``` github.com/spf13/cobra # CLI framework github.com/hashicorp/hcl/v2 # HCL parser modernc.org/sqlite # SQLite (pure Go) github.com/google/uuid # UUID generation ``` Total: ~4 direct dependencies. No web framework, no ORM, no RPC framework, no PKI library. mTLS via `crypto/tls` and `crypto/x509` (stdlib). > **ConnectRPC note**: `.ciagent/config.json` lists `connectrpc` in > `frameworks`, but the actual `go.mod` does not depend on > `connectrpc.com/connect`. v0.2 falls back to plain `net/http` with > HTTP/2 cleartext (h2c) for `orca.v1.Dispatch`. The protocol is a > simple JSON-over-HTTP POST: client sends > `{"spec": "..."}` to `/orca.v1.Dispatch/Submit`; server replies > `{"job_id": "..."}`. This keeps the zero-new-dep promise and the > codebase coherent with the rest of the daemon's `http.ServeMux`. ## Deployment Model (v0.2) ``` Operator Machine Node A (CA holder) Node B (Peer) ──────────────── ───────────────── ───────────── orca CLI orca daemon orca daemon │ │ ▲ │ ▲ │ mTLS handshake │ │ mTLS │ │ │ at `node join` ────────────┼──┘ │ │ │ │ │ │ │ submit job ───POST────────▶│ POST (cross-node) ──────────▶│ │ │ mTLS only │ │ │ │ │ │ │ ◀───────jobID──────────────│ ◀─────jobID (200 OK)─────────│ │ │ │ │ orca job list --watch │ │ │ (iter.Seq stream) ◀────────│── polls local + stream events│ ``` For v0.2, one node must be the CA holder (`orca cert init` was run on it). The CA holder's `ca.crt` is copied to each peer manually by the operator; peers do not auto-fetch it. ## v0.6 Architecture Addendum — Node Bootstrap & Proxmox ### `orca init` Full Bootstrap (REQ-047, REQ-048, REQ-049) `orca init` transforms from a bare `mkdir` into a full single-node cluster bootstrap. The sequence (idempotent per D-036): ``` orca init 1. MkdirAll(certpaths.Dir(), 0o755) # namespace dir 2. store.Open(certpaths.DBPath()) # runs migrations 0001..0006 3. security.CAInit(dir, "orca-internal-ca") # idempotent fast-path 4. if !exists(server.crt): GenerateCSR("localhost", ["localhost","127.0.0.1"]) ca.SignCSR(csr) → WriteCert + WriteKey # server cert (skip if present) 5. os := detectOS() # /etc/os-release ID= 6. node := Node{kind:"localhost", os:os, name:"localhost", addr:"localhost:8443"} if GetByName("localhost") exists: UpdateLastSeenAndOS(id, os) # refresh, keep id/joined_at else: NodeRepo.Insert(node) # first-run insert 7. print summary (CA fp, server cert fp, os, node id) ``` After `orca init`, `orca doctor` MUST pass with zero FAILs. ### Node Schema Extension (REQ-049) Migration 0006 adds two nullable columns to `nodes`: ```sql ALTER TABLE nodes ADD COLUMN kind TEXT; -- localhost | linux | proxmox ALTER TABLE nodes ADD COLUMN os TEXT; -- ubuntu | debian | alpine | pve | linux ``` Existing rows get SQL NULL → mapped to `""` in Go (`sql.NullString`). `Node` struct gains `Kind string` + `OS string` fields (JSON tags `kind,omitempty` / `os,omitempty`). `NodeRepo` extends all INSERT/SELECT/scanNode calls; adds `GetByName(ctx, name)` and `UpdateLastSeenAndOS(ctx, id, os)` helpers. ### Proxmox SSH Bootstrap (REQ-050, REQ-051) ``` orca node join --type proxmox --host --user root --password │ password from --password or $ORCA_PROXMOX_PASSWORD (never persisted, D-031) ▼ internal/proxmox.BootstrapProxmox(ctx, opts) 1. GenerateOrLoadSSHKey(certpaths.Dir()) # Ed25519, ~/.orca/orca_ssh_key{,.pub} 2. SSH dial (password auth, knownhosts.New TOFU) # capture host key on first connect 3. Deploy pubkey → ~orca/.ssh/authorized_keys # via session heredoc (no SFTP dep) 4. useradd -m orca # create Linux system user (config-overridable name) 5. pveum role add OrcaOperator --privs "VM.Audit Datastore.AllocateSpace SDN.Use" (idempotent: probe pveum role list first) 6. pveum user add orca@pam -comment "Orca automation user" (idempotent: probe pveum user list first) 7. pveum acl modify / -user orca@pam -role OrcaOperator (idempotent: modify creates or updates) 8. Write /etc/sudoers.d/orca (mode 0440): orca ALL=(root) NOPASSWD: NOEXEC: /usr/bin/pct, /usr/bin/qm orca ALL=(root) NOPASSWD: /usr/bin/apt-get, /usr/bin/dpkg 9. visudo -cf /etc/sudoers.d/orca # validate; abort on error 10. NodeRepo.Insert(Node{kind:"proxmox", os:"pve", name:host, addr:host}) 11. Audit log: proxmox.bootstrap_ok (host, user, role, fp) ``` **`pvesh` excluded from sudoers** — `pvesh` can trigger the API `/nodes/{node}/execute` endpoint which spawns shell commands server-side, bypassing sudo's `NOEXEC` tag. API access is via the `OrcaOperator` PVE role + `orca@pam` user (PVE RBAC), not sudo'd `pvesh`. ### Doctor Extensions (REQ-052) - **`doctor os`**: re-runs `detectOS()` from `/etc/os-release`, compares to the stored localhost node's `os` field. Drift = WARN (OS upgraded since init? re-run `orca init` to refresh). Match = PASS. - **`doctor proxmox`**: iterates `kind=proxmox` nodes, SSH-probes each with `pveversion` (3s timeout per peer, clones `doctor.Network()` pattern). PASS = reachable + pveversion exits 0. WARN = zero proxmox nodes (single-node cluster is legitimate). FAIL = any node unreachable or pveversion fails. ### SSH Key Handling (D-037) - **Location**: `~/.orca/orca_ssh_key` (0600) + `~/.orca/orca_ssh_key.pub` (0644) - **Algorithm**: Ed25519 (smaller, faster, more secure than RSA for SSH) - **Generation**: lazy — on first `orca node join --type proxmox`, NOT at `orca init` (localhost doesn't need SSH) - **Format**: PKCS8 PEM (consistent with `ca.key`/`server.key`; `ssh.ParsePrivateKey` accepts it) - **TOFU host keys**: `~/.orca/known_hosts` (OpenSSH format via `knownhosts.New`) ### Dependency Map (v0.6 addition) ``` golang.org/x/crypto v0.54.0 # SSH (ssh + ssh/knownhosts + ed25519) └─ golang.org/x/sys v0.47.0 # indirect (bumped from v0.42.0) └─ golang.org/x/term v0.45.0 # indirect (pulled by ssh for PTY) ``` Total direct deps: 5 (was 4). One new direct dep (`x/crypto`). Matches D-030 minimal-deps rationale. No SFTP module (file upload via session heredoc). ### v0.6 Architectural Decisions (AD-017..AD-021) | ID | Decision | Rationale | |----|----------|-----------| | AD-017 | `orca init` = full bootstrap (CA + cert + db + localhost node) | Single command produces a working cluster; `orca doctor` passes post-init. Idempotent (D-036). | | AD-018 | Proxmox join via SSH (golang.org/x/crypto/ssh), not PVE REST API | SSH is the universal Proxmox management entry point; REST API would require API token bootstrap (chicken-and-egg). One new direct dep (D-030). | | AD-019 | `orca@pam` realm (not `orca@pve`) | SSH creates a Linux system user; PAM realm maps it to PVE RBAC without a separate PVE password. `@pve` requires interactive password prompt over non-PTY SSH (hangs). | | AD-020 | Exclude `pvesh` from sudoers; NOEXEC on `pct`/`qm` | `pvesh` can trigger API execute endpoint bypassing NOEXEC. `pct`/`qm` are Perl scripts via dynamically-linked perl → NOEXEC effective. `apt-get`/`dpkg` need exec for maintainer scripts → no NOEXEC. | | AD-021 | TOFU host-key via `knownhosts.New` | Avoids deprecated `ssh.InsecureIgnoreHostKey`. Capture-on-first-connect, verify-on-subsequent. Fail closed on mismatch (operator runs key-reset). |