Compare commits

...

18 Commits

Author SHA1 Message Date
Jon Chery 70c5718505 verify(P03): 4-layer verification PASS — REQ-060
---ci---
project: orca
phase: 3
milestone: v0.8
status: verify
requirements:
  covered: [REQ-060]
  partial: []
---/ci---
2026-08-04 12:18:48 +00:00
Jon Chery 46bd07c792 docs(P03): synthetic drift verification — verify-reqs passes + fails-on-drift (T03.5, REQ-060)
T03.5 synthetic drift verification (REQ-060 acceptance criterion):
1. make verify-reqs on current repo → exit 0 (60 requirements consistent)
2. flipped REQ-053 (v0.7 P1) row to Pending → make verify-reqs exit 1
   diff: REQ-053: status=Pending, expected=Complete (direction=forward)
3. reverted scratch edit → make verify-reqs exit 0

Gate passes on the real repo and correctly fails on synthetic forward
drift (ROADMAP v0.7 COMPLETE but REQUIREMENTS REQ-053 Pending). The
reverse-direction assertion is exercised by the golden-file tests
(T03.2). REQ-060 acceptance criterion met.

---ci---
project: orca
phase: 3
milestone: v0.8
status: execute
---/ci---
2026-08-04 12:17:00 +00:00
Jon Chery b6d514ee21 chore(ci): verify-reqs in validate pipeline (T03.4, REQ-060)
---ci---
project: orca
phase: 3
milestone: v0.8
status: execute
---/ci---
2026-08-04 12:16:14 +00:00
Jon Chery 7bd8e47241 chore(makefile): verify-reqs target (T03.3, REQ-060)
---ci---
project: orca
phase: 3
milestone: v0.8
status: execute
---/ci---
2026-08-04 12:15:58 +00:00
Jon Chery 016039d1a3 test(cmd/verify-reqs): golden-file tests (T03.2, REQ-060)
---ci---
project: orca
phase: 3
milestone: v0.8
status: execute
---/ci---
2026-08-04 12:15:33 +00:00
Jon Chery fc2b020423 feat(cmd/verify-reqs): requirements-hygiene gate (T03.1, REQ-060)
---ci---
project: orca
phase: 3
milestone: v0.8
status: execute
---/ci---
2026-08-04 12:11:40 +00:00
Jon Chery f4192be5d1 verify(P02): 4-layer verification PASS — REQ-058, REQ-059 + TOFU bugfix
---ci---
project: orca
phase: 2
milestone: v0.8
status: verify
requirements:
  covered: [REQ-058, REQ-059]
  partial: []
---/ci---
2026-08-04 12:07:56 +00:00
Jon Chery 11da458883 test(cli): --host-key-fingerprint non-proxmox validation (T02.11, REQ-058)
---ci---
project: orca
phase: 2
milestone: v0.8
status: execute
---/ci---
2026-08-04 12:04:28 +00:00
Jon Chery d66b3b9a0a test(proxmox,cli): end-to-end trust-surface integration tests (T02.10, REQ-058, REQ-059)
---ci---
project: orca
phase: 2
milestone: v0.8
status: execute
---/ci---
2026-08-04 12:04:24 +00:00
Jon Chery 2dcb14377a fix(doctor): TOFU capture-fix parity with bootstrap — v0.6 ship-defect (T02.9)
---ci---
project: orca
phase: 2
milestone: v0.8
status: execute
---/ci---
2026-08-04 11:56:45 +00:00
Jon Chery 13e6762f0f feat(cli): orca node key-reset <node> — local known_hosts reset (T02.8, REQ-059)
---ci---
project: orca
phase: 2
milestone: v0.8
status: execute
---/ci---
2026-08-04 11:51:47 +00:00
Jon Chery 325a5662f4 feat(proxmox): populate Result.HostKeyFingerprint (T02.7, REQ-058)
---ci---
project: orca
phase: 2
milestone: v0.8
status: execute
---/ci---
2026-08-04 11:48:00 +00:00
Jon Chery 8b0cbe10ae fix(proxmox): TOFU capture bug — v0.6 ship-defect first-connect join always failed (T02.6)
---ci---
project: orca
phase: 2
milestone: v0.8
status: execute
---/ci---
2026-08-04 11:46:38 +00:00
Jon Chery bd17e6e114 feat(proxmox): pinnedHostKeyCallback for --host-key-fingerprint (T02.5, REQ-058)
---ci---
project: orca
phase: 2
milestone: v0.8
status: execute
---/ci---
2026-08-04 11:45:53 +00:00
Jon Chery 7cb12c52ce feat(proxmox): HostKeyFingerprint field on Options (T02.4, REQ-058)
---ci---
project: orca
phase: 2
milestone: v0.8
status: execute
---/ci---
2026-08-04 11:38:39 +00:00
Jon Chery 08481d35ce feat(cli): --host-key-fingerprint flag on node join (T02.3, REQ-058)
---ci---
project: orca
phase: 2
milestone: v0.8
status: execute
---/ci---
2026-08-04 11:37:01 +00:00
Jon Chery 00869c6f5b refactor(security): export WriteAtomic (T02.2, REQ-059)
---ci---
project: orca
phase: 2
milestone: v0.8
status: execute
---/ci---
2026-08-04 11:36:42 +00:00
Jon Chery aa3462826b feat(security): SSHFingerprintSHA256 helper (T02.1, REQ-058)
---ci---
project: orca
phase: 2
milestone: v0.8
status: execute
---/ci---
2026-08-04 11:35:59 +00:00
22 changed files with 1723 additions and 60 deletions
+2 -2
View File
@@ -1,11 +1,11 @@
{
"phase": 1,
"phase": 3,
"stage": "verify",
"milestone": "v0.8",
"milestone_slug": "coverage-trust-hardening",
"phase_role": "execution",
"attempts": 0,
"updated_at": "2026-08-04T00:58:00Z",
"updated_at": "2026-08-04T01:20:00Z",
"milestone_complete": false,
"next_milestone": null
}
+55
View File
@@ -0,0 +1,55 @@
# Phase 2 Verification — v0.8 Coverage & Trust Hardening
**Phase**: P02 — SSH trust hardening
**Milestone**: v0.8
**REQs**: REQ-058, REQ-059 (+ latent TOFU bugfix closure)
**Date**: 2026-08-04
**Result**: ✅ PASS (all 4 layers)
## Layer 1 — Structural ✅
- `go build ./...` PASS
- `go vet ./...` PASS
- No TODOs/stubs in new production code
- All new exports resolve: `security.SSHFingerprintSHA256`, `security.WriteAtomic`, `proxmox.TOFUHostKeyCallback`, `proxmox.ResetHostKey`, `proxmox.pinnedHostKeyCallback`, `proxmox.Options.HostKeyFingerprint`, `cli.nodeKeyResetCmd`
- Backward compatible: existing `BootstrapProxmox` callers work (the TOFU fix changed failure→success on first connect, which is the bugfix)
## Layer 2 — Behavioral ✅
- `go test ./internal/proxmox/... ./internal/cli/... ./internal/doctor/... ./internal/security/...` PASS
- `go test -race ./internal/proxmox/... ./internal/doctor/...` PASS
- Coverage held post-P02: proxmox 86.5% (was 87.1% in P01 — marginal change from new code paths), cli 76.7% (was 76.2%), doctor 70.4% (unchanged)
- T02.10: all 7 end-to-end integration cases PASS (pinned correct/wrong, TOFU first/second/mismatch, key-reset+re-pin, pre-populated migration path)
- T02.11: `--host-key-fingerprint` non-proxmox validation PASS
## Layer 3 — Security ✅
- **REQ-058**: `--host-key-fingerprint` fails closed on mismatch (pinnedHostKeyCallback returns error on any mismatch; bootstrap aborts before any SSH session command runs). SHA256: prefix validated up front. No downgrade to TOFU when pin supplied.
- **REQ-059**: `orca node key-reset` is local-only (D-046) — only rewrites `~/.orca/known_hosts` via `security.WriteAtomic` (atomic temp+rename, AD-029); does NOT touch remote authorized_keys. Audit-logs `node.key_reset` with actor+node+host.
- **TOFU bugfix (T02.6, v0.6 ship-defect)**: first-connect now captures + writes the key (was silently failing). Mismatch detection preserved (MITM protection). The `TOFUHostKeyCallback` is shared between bootstrap (T02.6) and doctor (T02.9) — GRILL condition #2 parity satisfied.
- STRIDE: no new spoofing surface (pin is operator-supplied, fail-closed); no tampering (atomic rewrite); no repudiation (audit log); no info disclosure (fingerprint is a hash, not the key); no DoS (no network change); no elevation (local file ops only).
- No secrets in test code (fake SSH keys generated in-test).
## Layer 4 — Quality ✅
- Tests follow existing conventions (table-driven, `fakeSSHServer` fixture reused, `sshDialer`/`sessionRunner` seams injected)
- `TOFUHostKeyCallback` extracted to a shared helper (no duplication between bootstrap + doctor) — clean coupling (proxmox doesn't import doctor)
- P0 issues: none. P1+ issues: none flagged.
## Requirement Coverage
| REQ | Status | Evidence |
|-----|--------|----------|
| REQ-058 | ✅ Complete | `--host-key-fingerprint` flag (T02.3) + `pinnedHostKeyCallback` (T02.5) + `Result.HostKeyFingerprint` (T02.7) + e2e tests (T02.10) + validation (T02.11) |
| REQ-059 | ✅ Complete | `orca node key-reset <node>` (T02.8) + `proxmox.ResetHostKey` atomic rewrite + audit log + e2e test (T02.10 case 6) |
| (TOFU bugfix) | ✅ Complete | T02.6 fixes v0.6 ship-defect (first-connect `knownhosts.New` KeyError{Want:[]} treated as dial failure); T02.9 doctor parity |
## GRILL Conditions Check
- **#1 (T02.6 labeled v0.6 ship-defect)**: ✅ commit `8b0cbe1` summary "TOFU capture bug — v0.6 ship-defect first-connect join always failed"
- **#2 (T02.9 doctor parity)**: ✅ both bootstrap (`8b0cbe1`) and doctor (`2dcb143`) use the shared `proxmox.TOFUHostKeyCallback` wrapper
## Lessons
- The v0.6 TOFU bug was a latent ship-defect: `knownhosts.New` returns `KeyError{Want:[]}` on first connect without writing, and the original code treated this as a dial failure. This means first-connect Proxmox join has been broken since v0.6 shipped — a strong argument for P01's coverage uplift (the 5.1% proxmox coverage hid this). v0.8 P03's `verify-reqs` would not have caught this (it's code-vs-doc drift, not doc-vs-doc) — P04 audit is the backstop.
- Extracting `TOFUHostKeyCallback` to a shared helper was the right call for GRILL condition #2 — duplicating the wrapper in doctor would have created drift risk.
+51
View File
@@ -0,0 +1,51 @@
# Phase 3 Verification — v0.8 Coverage & Trust Hardening
**Phase**: P03 — Requirements-hygiene gate
**Milestone**: v0.8
**REQ**: REQ-060
**Date**: 2026-08-04
**Result**: ✅ PASS (all 4 layers)
## Layer 1 — Structural ✅
- `go build ./...` PASS
- `go vet ./...` PASS
- `cmd/verify-reqs/main.go` (~180 LOC, stdlib only) compiles + links
- All exports resolve: `verify(roadmapPath, reqsPath) (diff []string, count int, err error)`
- No new dependencies
## Layer 2 — Behavioral ✅
- `go test ./cmd/verify-reqs/...` PASS (7 golden-file tests: clean, multi-drift, default-args, malformed, missing-file, v0.2-substring-tolerant, real-repo regression)
- `make verify-reqs` → exit 0 on the current repo (`✓ 60 requirements consistent with roadmap`)
- T03.5 synthetic drift verification: scratch flip of REQ-053 → `make verify-reqs` exit 1 + `REQ-053: status=Pending, expected=Complete (direction=forward)`; revert → exit 0
- `go test ./...` PASS (all 16 packages)
- Forward + reverse assertions both exercised (golden test `TestVerify_drift` asserts `direction=reverse` for REQ-003)
## Layer 3 — Security ✅
- verify-reqs is a static doc-consistency checker — no network, no secrets, no input injection (markdown is parsed with `regexp` over local files only)
- `.coreci.yml` step runs in the existing `golang:1.25` container (no new image, no new permissions)
- No STRIDE surface added
## Layer 4 — Quality ✅
- Testable core (`verify()` function) + thin `main()` — follows the `cmd/orca/main.go``run()` pattern from T01.11
- Golden-file test fixtures cover the substring-tolerant regex regression (v0.2 header variant)
- GRILL condition #4 satisfied: substring-tolerant regex + reverse-direction assertion + scope note (doc-vs-doc only)
- P0 issues: none. P1+ issues: none flagged.
## Requirement Coverage
| REQ | Status | Evidence |
|-----|--------|----------|
| REQ-060 | ✅ Complete | `cmd/verify-reqs` (T03.1) + golden tests (T03.2) + `make verify-reqs` (T03.3) + `.coreci.yml` validate hook (T03.4) + synthetic drift verification (T03.5) |
## GRILL Conditions Check
- **#4 (verify-reqs regex + reverse direction)**: ✅ substring-tolerant regex matches v0.2's `**COMPLETE (merged to main via v0.3)**` header (golden test `TestVerify_v0_2_substring_tolerant`); reverse-direction assertion implemented + tested; scope note documented in the commit + the verification report.
## Lessons
- The two-regex parser (one for REQ rows, one for milestone-complete headers) with substring tolerance is the right shape — a single strict regex would have silently exempted v0.2 (the exact drift the GRILL flagged).
- Refactoring `main()` into a testable `verify()` function made golden-file testing trivial (no subprocess orchestration). This mirrors the T01.11 `main()→run()` pattern and should be the house style for all `cmd/` programs.
+7
View File
@@ -13,6 +13,8 @@ description: Orca — offline/CLI-first orchestration engine. Full release flow
# - gosec (REQ-014, REQ-040) Static analysis for Go security smells
# - govulncheck (REQ-014, REQ-027) Offline vuln scan of dependencies
# - gitleaks (REQ-039) Pre-commit-style secret scan
# v0.8 P03 added a requirements-hygiene stage:
# - verify-reqs (REQ-060) ROADMAP COMPLETE ↔ REQUIREMENTS Complete
# The `test` pipeline runs with -race (REQ-031).
# See docs/security-scanning.md for operator-facing details.
@@ -27,6 +29,11 @@ pipelines:
- gofmt -l .
- go vet ./...
- name: verify-reqs
image: golang:1.25
commands:
- make verify-reqs
- name: gosec
image: golang:1.25
commands:
+8 -1
View File
@@ -1,4 +1,4 @@
.PHONY: build test test-race lint fmt clean run release version changelog help security-scan
.PHONY: build test test-race lint fmt clean run release version changelog help security-scan verify-reqs
BINARY := bin/orca
GOFLAGS := -trimpath
@@ -30,6 +30,7 @@ help:
@echo " changelog Generate CHANGELOG.md from ---ci--- commit blocks"
@echo " release Run scripts/release.sh [VERSION] — build, tar, publish"
@echo " security-scan Run gosec+govulncheck+gitleaks (P03, REQ-014/027/039)"
@echo " verify-reqs Assert ROADMAP COMPLETE ↔ REQUIREMENTS Complete (REQ-060)"
build:
@mkdir -p bin
@@ -98,3 +99,9 @@ release:
# in a developer's local environment; CI requires all three).
security-scan:
./scripts/security_scan.sh
# verify-reqs asserts ROADMAP milestone COMPLETE ↔ REQUIREMENTS row Complete
# consistency (REQ-060). Catches doc-vs-doc drift; code-vs-doc drift is out
# of scope (P04 audit). Exits 0 on consistency, 1 with a diff on drift.
verify-reqs:
go run ./cmd/verify-reqs .ciagent/ROADMAP.md .ciagent/REQUIREMENTS.md
+183
View File
@@ -0,0 +1,183 @@
package main
import (
"bufio"
"fmt"
"os"
"regexp"
"sort"
"strings"
)
// reqRowRe captures a REQUIREMENTS.md table row's REQ-ID, Phase cell, and
// status in one pass. The leading .* is greedy so it consumes the
// Requirement and Priority cells (which may contain markdown-escaped pipes
// like `localhost\|linux\|proxmox` — see REQ-049) and backtracks to anchor
// the Phase + Status match at the END of the line, where those two columns
// always live. The status token is optionally wrapped in markdown bold
// (real rows use `**Complete**`; synthetic/future rows may use bare
// `Pending`), and may carry trailing notes (e.g. "**Complete** (P01
// shipped v0.2.1)") matched by [^|]* before the closing pipe.
var reqRowRe = regexp.MustCompile(`^\|\s*(REQ-\d+)\s*\|.*\|\s*([^|]*?)\s*\|\s*\*{0,2}(Complete|Pending)\*{0,2}[^|]*\|\s*$`)
// milestoneCompleteRe matches a ROADMAP.md milestone header that is marked
// COMPLETE. The bold span is substring-tolerant (GRILL #4): it matches
// `**COMPLETE**`, `**COMPLETE (merged to main via v0.3)**`, and any future
// variant where the word COMPLETE appears inside the bold span, possibly
// preceded or followed by non-asterisk text. The milestone version (v0.X)
// is captured.
var milestoneCompleteRe = regexp.MustCompile(`^##\s*Milestone\s+(v0\.\d+):.*—\s*\*\*[^*]*\bCOMPLETE\b[^*]*\*\*`)
// phaseRe extracts the milestone version from a REQUIREMENTS Phase cell such
// as `v0.7 P1`, `**v0.2 P01**`, `v0.2 P01P04`, or bare `v0.7`. The cell may
// contain multiple milestone refs separated by `/` or ``; each is extracted.
var phaseTokenRe = regexp.MustCompile(`v0\.\d+`)
// reqRow holds a parsed REQUIREMENTS.md row.
type reqRow struct {
id string
phase string // raw Phase cell (e.g. "v0.7 P1", "**v0.2 P01 / v0.3 P02**")
status string // "Complete" or "Pending"
}
// milestoneVersions returns the distinct v0.X milestones referenced in the
// phase cell (e.g. "v0.7 P1" → ["v0.7"]; "v0.2 P01 / v0.3 P02" →
// ["v0.2","v0.3"]).
func (r reqRow) milestoneVersions() []string {
matches := phaseTokenRe.FindAllString(r.phase, -1)
seen := map[string]bool{}
var out []string
for _, m := range matches {
if !seen[m] {
seen[m] = true
out = append(out, m)
}
}
return out
}
func main() {
roadmapPath := ".ciagent/ROADMAP.md"
reqsPath := ".ciagent/REQUIREMENTS.md"
if len(os.Args) > 1 {
roadmapPath = os.Args[1]
}
if len(os.Args) > 2 {
reqsPath = os.Args[2]
}
diff, count, err := verify(roadmapPath, reqsPath)
if err != nil {
fmt.Fprintf(os.Stderr, "verify-reqs: %v\n", err)
os.Exit(2)
}
if len(diff) > 0 {
fmt.Fprintf(os.Stderr, "requirements drift detected (%d):\n", len(diff))
for _, line := range diff {
fmt.Fprintln(os.Stderr, line)
}
os.Exit(1)
}
fmt.Printf("✓ %d requirements consistent with roadmap\n", count)
}
// verify parses the ROADMAP and REQUIREMENTS markdown and returns a diff
// listing of any drift. On success diff is nil and count is the number of
// consistent REQ rows. A non-nil error signals a parse/read failure (not
// drift); drift is reported via the diff slice.
func verify(roadmapPath, reqsPath string) (diff []string, count int, err error) {
completeMilestones, err := parseRoadmap(roadmapPath)
if err != nil {
return nil, 0, fmt.Errorf("parse roadmap %q: %w", roadmapPath, err)
}
rows, err := parseRequirements(reqsPath)
if err != nil {
return nil, 0, fmt.Errorf("parse requirements %q: %w", reqsPath, err)
}
if len(rows) == 0 {
return nil, 0, fmt.Errorf("no REQ rows found in %s", reqsPath)
}
type driftEntry struct {
id string
current string
want string
dir string // "forward" or "reverse"
}
var drifts []driftEntry
for _, r := range rows {
milestones := r.milestoneVersions()
anyComplete := false
for _, m := range milestones {
if completeMilestones[m] {
anyComplete = true
break
}
}
// Forward assertion: a REQ whose milestone is COMPLETE in ROADMAP
// must be marked Complete in REQUIREMENTS.
if anyComplete && r.status != "Complete" {
drifts = append(drifts, driftEntry{r.id, r.status, "Complete", "forward"})
}
// Reverse assertion (GRILL #4): a REQ marked Complete in
// REQUIREMENTS must reference at least one milestone ROADMAP marks
// COMPLETE. If all referenced milestones are NOT complete (or no
// milestone is referenced), that is premature-Complete drift.
if r.status == "Complete" && !anyComplete {
drifts = append(drifts, driftEntry{r.id, r.status, "Pending (milestone not COMPLETE in ROADMAP)", "reverse"})
}
}
sort.Slice(drifts, func(i, j int) bool { return drifts[i].id < drifts[j].id })
for _, d := range drifts {
diff = append(diff, fmt.Sprintf(" %s: status=%s, expected=%s (direction=%s)", d.id, d.current, d.want, d.dir))
}
consistent := len(rows) - len(drifts)
return diff, consistent, nil
}
func parseRoadmap(path string) (map[string]bool, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
complete := map[string]bool{}
sc := bufio.NewScanner(f)
sc.Buffer(make([]byte, 1024*1024), 1024*1024)
for sc.Scan() {
line := sc.Text()
m := milestoneCompleteRe.FindStringSubmatch(line)
if m != nil {
complete[m[1]] = true
}
}
if err := sc.Err(); err != nil {
return nil, err
}
return complete, nil
}
func parseRequirements(path string) ([]reqRow, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
var rows []reqRow
sc := bufio.NewScanner(f)
sc.Buffer(make([]byte, 1024*1024), 1024*1024)
for sc.Scan() {
line := sc.Text()
m := reqRowRe.FindStringSubmatch(line)
if m == nil {
continue
}
rows = append(rows, reqRow{id: m[1], phase: strings.TrimSpace(m[2]), status: m[3]})
}
if err := sc.Err(); err != nil {
return nil, err
}
return rows, nil
}
+170
View File
@@ -0,0 +1,170 @@
package main
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
// Tests run with cwd = package dir (cmd/verify-reqs), so `testdata/...`
// paths resolve relative to the package. The real-repo tests use a
// repoRoot helper to locate `.ciagent/...`.
func repoRoot(t *testing.T) string {
t.Helper()
wd, err := os.Getwd()
if err != nil {
t.Fatalf("getwd: %v", err)
}
return filepath.Join(wd, "..", "..")
}
// case (1): clean pair → no drift.
func TestVerify_clean(t *testing.T) {
diff, count, err := verify(
filepath.Join("testdata", "roadmap_clean.md"),
filepath.Join("testdata", "requirements_clean.md"),
)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(diff) != 0 {
t.Fatalf("expected no drift, got:\n%s", strings.Join(diff, "\n"))
}
if count != 5 {
t.Fatalf("expected 5 consistent rows, got %d", count)
}
}
// case (2)+(3): drift pair → all drifts reported, both directions.
func TestVerify_drift(t *testing.T) {
diff, _, err := verify(
filepath.Join("testdata", "roadmap_drift.md"),
filepath.Join("testdata", "requirements_drift.md"),
)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(diff) != 4 {
t.Fatalf("expected 4 drifts (REQ-002 forward, REQ-003 reverse, REQ-004 forward, REQ-005 forward), got %d:\n%s",
len(diff), strings.Join(diff, "\n"))
}
joined := strings.Join(diff, "\n")
for _, want := range []string{"REQ-002", "REQ-003", "REQ-004", "REQ-005"} {
if !strings.Contains(joined, want) {
t.Errorf("diff missing %s:\n%s", want, joined)
}
}
if !strings.Contains(joined, "direction=reverse") {
t.Errorf("expected a reverse-direction drift, got:\n%s", joined)
}
if !strings.Contains(joined, "direction=forward") {
t.Errorf("expected a forward-direction drift, got:\n%s", joined)
}
}
// case (4): missing args → defaults resolve to the repo's .ciagent/ files.
// Runs the program as a subprocess from the repo root.
func TestVerify_defaultArgs(t *testing.T) {
if testing.Short() {
t.Skip("subprocess test skipped in -short mode")
}
root := repoRoot(t)
cmd := exec.Command("go", "run", "./cmd/verify-reqs")
cmd.Dir = root
out := &strings.Builder{}
cmd.Stdout = out
cmd.Stderr = out
if err := cmd.Run(); err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
t.Fatalf("verify-reqs on real repo exited %d (should be 0): %s",
exitErr.ExitCode(), out.String())
}
t.Fatalf("go run failed: %v", err)
}
}
// case (5): malformed markdown (no REQ rows) → clear error, not silent pass.
func TestVerify_malformed(t *testing.T) {
_, _, err := verify(
filepath.Join("testdata", "roadmap_clean.md"),
filepath.Join("testdata", "requirements_malformed.md"),
)
if err == nil {
t.Fatal("expected error on malformed (no REQ rows) requirements, got nil")
}
if !strings.Contains(err.Error(), "no REQ rows") {
t.Errorf("expected 'no REQ rows' error, got: %v", err)
}
}
// case (6): missing file → clear error, not silent pass.
func TestVerify_missingFile(t *testing.T) {
_, _, err := verify(
filepath.Join("testdata", "roadmap_clean.md"),
filepath.Join("testdata", "does_not_exist.md"),
)
if err == nil {
t.Fatal("expected error on missing file, got nil")
}
if !strings.Contains(err.Error(), "does_not_exist.md") {
t.Errorf("expected error to mention the missing file, got: %v", err)
}
}
// GRILL #4 golden test: the v0.2-style
// `**COMPLETE (merged to main via v0.3)**` header MUST be recognized as a
// complete milestone by the substring-tolerant regex. The drift fixture's
// roadmap carries exactly this header on its v0.2 line, and the drift
// fixture's REQ-002 (v0.2 P1, Pending) would be silently skipped if the
// regex regressed to the exact `\*\*COMPLETE\*\*` form. TestVerify_drift
// already asserts REQ-002 is flagged forward-drift; this test makes the
// intent explicit and guards against a regex regression.
func TestVerify_v02SubstringTolerantHeader(t *testing.T) {
diff, _, err := verify(
filepath.Join("testdata", "roadmap_drift.md"),
filepath.Join("testdata", "requirements_drift.md"),
)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// REQ-002 references milestone v0.2, whose header uses the
// `**COMPLETE (merged to main via v0.3)**` variant. If the regex
// regressed, v0.2 would not be marked complete and REQ-002 (Pending)
// would NOT be flagged as forward drift.
found := false
for _, d := range diff {
if strings.Contains(d, "REQ-002") && strings.Contains(d, "direction=forward") {
found = true
break
}
}
if !found {
t.Fatalf("REQ-002 forward drift not reported — substring-tolerant regex may have regressed; diff:\n%s",
strings.Join(diff, "\n"))
}
}
// Verify the actual repo passes (regression guard for the real docs).
func TestVerify_realRepo(t *testing.T) {
if testing.Short() {
t.Skip("real-repo test skipped in -short mode")
}
root := repoRoot(t)
diff, count, err := verify(
filepath.Join(root, ".ciagent", "ROADMAP.md"),
filepath.Join(root, ".ciagent", "REQUIREMENTS.md"),
)
if err != nil {
t.Fatalf("verify on real repo errored: %v", err)
}
if len(diff) != 0 {
t.Fatalf("real repo has requirements drift (should be clean after SPECIFY):\n%s",
strings.Join(diff, "\n"))
}
if count <= 0 {
t.Fatalf("real repo reported %d consistent rows (expected > 0)", count)
}
}
+9
View File
@@ -0,0 +1,9 @@
# Requirements: Clean Fixture
| ID | Requirement | Priority | Phase | Status |
|----|-------------|----------|-------|--------|
| REQ-001 | Foundation req | High | v0.1 P1 | **Complete** |
| REQ-002 | Multi-node with escaped pipes (kind\|os) | Medium | **v0.2 P1** | **Complete** (shipped v0.2.1) |
| REQ-003 | Scheduling req | Low | v0.3 P1 | **Complete** |
| REQ-004 | Future req spanning phases | Low | v0.2 P1 / v0.3 P2 | **Complete** (multi-phase) |
| REQ-005 | Pending future req | Low | v0.9 P1 | Pending |
+9
View File
@@ -0,0 +1,9 @@
# Requirements: Drift Fixture
| ID | Requirement | Priority | Phase | Status |
|----|-------------|----------|-------|--------|
| REQ-001 | Foundation req (clean) | High | v0.1 P1 | **Complete** |
| REQ-002 | Multi-node req (forward drift: milestone COMPLETE but row Pending) | Medium | **v0.2 P1** | Pending |
| REQ-003 | Scheduling req (reverse drift: row Complete but milestone NOT complete) | Low | v0.9 P1 | **Complete** |
| REQ-004 | Multi-phase with range (forward drift: spans COMPLETE v0.2 + non-COMPLETE v0.9) | Low | v0.2 P1P2 | Pending |
| REQ-005 | Second forward drift (v0.3 COMPLETE, row Pending) | Low | v0.3 P1 | Pending |
+8
View File
@@ -0,0 +1,8 @@
# Requirements: Malformed Fixture
This file has no valid REQ rows, just prose and a broken table.
| ID | Requirement | Priority | Phase | Status |
|----|-------------|----------|-------|--------|
| NOT-A-REQ | broken row | High | v0.1 | **Complete** |
| REQ-999 | missing status cell | High | v0.1 |
+20
View File
@@ -0,0 +1,20 @@
# Roadmap: Clean Fixture
## Milestone v0.1: Foundation — **COMPLETE**
- [x] Phase 1
## Milestone v0.2: Networking — **COMPLETE (merged to main via v0.3)**
- [x] Phase 8
- [x] Phase 9
## Milestone v0.3: Scheduling — **COMPLETE**
- [x] Phase 1
## Milestone v0.9: Future Work
Not yet shipped.
- [ ] Phase 1
+20
View File
@@ -0,0 +1,20 @@
# Roadmap: Drift Fixture
## Milestone v0.1: Foundation — **COMPLETE**
- [x] Phase 1
## Milestone v0.2: Networking — **COMPLETE (merged to main via v0.3)**
- [x] Phase 8
- [x] Phase 9
## Milestone v0.3: Scheduling — **COMPLETE**
- [x] Phase 1
## Milestone v0.9: Future Work
Not yet shipped.
- [ ] Phase 1
+84 -19
View File
@@ -45,18 +45,19 @@ func nodeRegistry() (*engine.NodeRegistry, func() error, error) {
}
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
joinName string
joinAddr string
joinCAFinger string
joinType string
joinHost string
joinSSHUser string
joinPassword string
joinSSHPort int
joinHostKeyFP string
proxmoxUser string
proxmoxRole string
leaveID string
nodeWatch bool
)
var nodeCmd = &cobra.Command{
@@ -76,6 +77,9 @@ Node types (via --type):
(deploys orca pubkey, creates orca user + PVE role +
sudoers allowlist; requires --host + --password)`,
RunE: func(cmd *cobra.Command, args []string) error {
if joinHostKeyFP != "" && joinType != "proxmox" {
return fmt.Errorf("--host-key-fingerprint requires --type proxmox today")
}
if joinType == "proxmox" {
return joinProxmox(cmd)
}
@@ -156,13 +160,14 @@ func joinProxmox(cmd *cobra.Command) error {
defer cancel()
result, err := proxmox.BootstrapProxmox(ctx, proxmox.Options{
Host: joinHost,
SSHUser: joinSSHUser,
Password: password,
ProxmoxUser: proxmoxUser,
ProxmoxRole: proxmoxRole,
SSHPort: joinSSHPort,
Logger: newLogger(),
Host: joinHost,
SSHUser: joinSSHUser,
Password: password,
ProxmoxUser: proxmoxUser,
ProxmoxRole: proxmoxRole,
SSHPort: joinSSHPort,
HostKeyFingerprint: joinHostKeyFP,
Logger: newLogger(),
})
if err != nil {
return fmt.Errorf("proxmox bootstrap: %w", err)
@@ -341,6 +346,64 @@ func renderNodeTable(nodes []*model.Node) string {
return out
}
var nodeKeyResetCmd = &cobra.Command{
Use: "key-reset <node>",
Short: "Reset the SSH known_hosts entry for a node",
Long: `Remove the pinned SSH host key for <node> from the local known_hosts
file. The next connect re-pins the key via TOFU or --host-key-fingerprint.
LOCAL ONLY (D-046): does not touch the remote host's authorized_keys.
<node> is the node name (for proxmox nodes, this is the host address).`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
nodeArg := args[0]
registry, closer, err := nodeRegistry()
if err != nil {
return err
}
defer closer()
ctx, cancel := context.WithTimeout(cmd.Context(), 5*time.Second)
defer cancel()
nodes, err := registry.List(ctx)
if err != nil {
return fmt.Errorf("list nodes: %w", err)
}
var node *model.Node
for _, n := range nodes {
if n.Name == nodeArg || n.ID == nodeArg {
node = n
break
}
}
if node == nil {
return fmt.Errorf("node %q not found in the registry", nodeArg)
}
host := node.Name
if err := proxmox.ResetHostKey(host); err != nil {
return fmt.Errorf("reset host key: %w", err)
}
// Audit-log the reset (REQ-059): actor=cli, action=node.key_reset.
db, dbCloser, dbErr := openDB()
if dbErr == nil {
defer dbCloser()
audit := engine.NewAudit(store.NewAuditRepo(db), newLogger())
audit.Record(ctx, "cli", "node.key_reset", node.ID, "success", nil, map[string]any{
"node": node.Name,
"host": host,
})
}
fmt.Fprintf(cmd.OutOrStdout(), "✓ Host key reset for %s (next connect will re-pin via TOFU or --host-key-fingerprint)\n", node.Name)
return nil
},
}
func init() {
nodeJoinCmd.Flags().StringVar(&joinName, "name", "", "node name (required for --type localhost)")
nodeJoinCmd.Flags().StringVar(&joinAddr, "addr", "", "node address (default localhost:8443)")
@@ -352,11 +415,13 @@ func init() {
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)")
nodeJoinCmd.Flags().StringVar(&joinHostKeyFP, "host-key-fingerprint", "", "SSH host key SHA256:base64 fingerprint (pre-pin; supersedes TOFU for --type proxmox)")
nodeLeaveCmd.Flags().StringVar(&leaveID, "id", "", "node id")
nodeListCmd.Flags().BoolVar(&nodeWatch, "watch", false, "stream nodes until Ctrl-C (table refresh or --json per-event)")
nodeCmd.AddCommand(nodeJoinCmd)
nodeCmd.AddCommand(nodeLeaveCmd)
nodeCmd.AddCommand(nodeListCmd)
nodeCmd.AddCommand(nodeKeyResetCmd)
rootCmd.AddCommand(nodeCmd)
}
+176
View File
@@ -13,6 +13,8 @@ import (
"bytes"
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
@@ -318,3 +320,177 @@ func padHex(n int) string {
}
return string(b)
}
// TestNodeKeyReset removes the target node's known_hosts lines, leaves
// other hosts' lines intact, and inserts an audit row (T02.8, REQ-059).
func TestNodeKeyReset(t *testing.T) {
_, cleanup := initTestEnv(t)
defer cleanup()
// Seed a proxmox node whose Name is the host address (matches the
// key-reset RunE, which uses node.Name as the known_hosts match key).
seedProxmoxNode(t, "10.0.0.1", "10.0.0.1:8443")
// Pre-populate known_hosts: 2 lines for the target + 1 for another host.
knownHosts := certpaths.KnownHostsPath()
if err := os.MkdirAll(filepath.Dir(knownHosts), 0o755); err != nil {
t.Fatalf("mkdir known_hosts dir: %v", err)
}
original := []byte("[10.0.0.1]:22 ssh-ed25519 AAAAKEY1 host1\n" +
"10.0.0.1 ssh-ed25519 AAAAKEY1ALT host1-alt\n" +
"[10.0.0.2]:22 ssh-ed25519 AAAAKEY2 host2\n")
if err := os.WriteFile(knownHosts, original, 0o600); err != nil {
t.Fatalf("write known_hosts: %v", err)
}
resetRootFlags(t)
var buf bytes.Buffer
rootCmd.SetOut(&buf)
rootCmd.SetErr(&buf)
rootCmd.SetArgs([]string{"node", "key-reset", "10.0.0.1"})
if err := rootCmd.Execute(); err != nil {
t.Fatalf("node key-reset: %v", err)
}
out := buf.String()
if !strings.Contains(out, "Host key reset for 10.0.0.1") {
t.Errorf("output missing reset confirmation: %s", out)
}
// known_hosts: target's 2 lines removed, other host's line intact.
data, err := os.ReadFile(knownHosts)
if err != nil {
t.Fatalf("read known_hosts: %v", err)
}
result := string(data)
if strings.Contains(result, "AAAAKEY1") {
t.Errorf("target key line 1 not removed: %s", result)
}
if strings.Contains(result, "AAAAKEY1ALT") {
t.Errorf("target key line 2 not removed: %s", result)
}
if !strings.Contains(result, "AAAAKEY2") {
t.Errorf("other host's line was removed (should be intact): %s", result)
}
// Audit row inserted with action=node.key_reset.
db, err := store.Open(certpaths.DBPath())
if err != nil {
t.Fatalf("open db: %v", err)
}
defer db.Close()
entries, err := store.NewAuditRepo(db).List(context.Background(), 50)
if err != nil {
t.Fatalf("list audit: %v", err)
}
found := false
for _, e := range entries {
if e.Action == "node.key_reset" && strings.Contains(e.Resource, "10.0.0.1") {
found = true
break
}
}
if !found {
t.Errorf("audit row for node.key_reset not inserted: %+v", entries)
}
}
// TestNodeKeyReset_NodeNotFound verifies key-reset errors when the
// node is not in the registry (T02.8).
func TestNodeKeyReset_NodeNotFound(t *testing.T) {
_, cleanup := initTestEnv(t)
defer cleanup()
resetRootFlags(t)
var buf bytes.Buffer
rootCmd.SetOut(&buf)
rootCmd.SetErr(&buf)
rootCmd.SetArgs([]string{"node", "key-reset", "no.such.host"})
err := rootCmd.Execute()
if err == nil {
t.Fatal("expected error for unknown node, got nil")
}
if !strings.Contains(err.Error(), "not found") {
t.Errorf("error should mention not found, got: %v", err)
}
}
func seedProxmoxNode(t *testing.T, name, addr string) string {
t.Helper()
db, err := store.Open(certpaths.DBPath())
if err != nil {
t.Fatalf("open db: %v", err)
}
defer db.Close()
repo := store.NewNodeRepo(db)
ctx := context.Background()
n := &model.Node{
ID: "node-" + name,
Name: name,
Address: addr,
State: model.NodeStateReady,
JoinedAt: time.Now().UTC(),
LastSeen: time.Now().UTC(),
Kind: string(model.NodeKindProxmox),
OS: "pve",
}
if err := repo.Insert(ctx, n); err != nil {
t.Fatalf("insert proxmox node: %v", err)
}
return n.ID
}
// TestNodeJoinHostKeyFingerprintRequiresProxmox verifies T02.11:
// `orca node join --type linux --host-key-fingerprint SHA256:...`
// fails with a clear error from the D-044 RunE check. Exercises the
// cobra Execute() error path end-to-end.
func TestNodeJoinHostKeyFingerprintRequiresProxmox(t *testing.T) {
_, cleanup := initTestEnv(t)
defer cleanup()
resetRootFlags(t)
var buf bytes.Buffer
rootCmd.SetOut(&buf)
rootCmd.SetErr(&buf)
rootCmd.SetArgs([]string{
"node", "join",
"--type", "linux",
"--name", "linux-node",
"--host-key-fingerprint", "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
})
err := rootCmd.Execute()
if err == nil {
t.Fatal("expected error for --host-key-fingerprint without --type proxmox, got nil")
}
if !strings.Contains(err.Error(), "--host-key-fingerprint requires --type proxmox") {
t.Errorf("error should mention the --host-key-fingerprint/--type proxmox requirement, got: %v", err)
}
}
// TestNodeJoinHostKeyFingerprintProxmoxAccepted verifies that
// --host-key-fingerprint IS accepted for --type proxmox (the RunE check
// does not reject a proxmox-type join that pins the host key). This is
// the negative-space companion to TestNodeJoinHostKeyFingerprintRequiresProxmox
// (T02.11): the validation must only reject non-proxmox types.
//
// We can't run the full bootstrap without a real SSH server, so we
// assert that the RunE check passes (no "requires --type proxmox"
// error) and the failure — if any — comes from a later stage (missing
// --host / password), not the D-044 guard.
func TestNodeJoinHostKeyFingerprintProxmoxAccepted(t *testing.T) {
_, cleanup := initTestEnv(t)
defer cleanup()
resetRootFlags(t)
var buf bytes.Buffer
rootCmd.SetOut(&buf)
rootCmd.SetErr(&buf)
rootCmd.SetArgs([]string{
"node", "join",
"--type", "proxmox",
"--host-key-fingerprint", "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
})
err := rootCmd.Execute()
if err == nil {
t.Fatal("expected a later-stage error (missing --host), got nil")
}
if strings.Contains(err.Error(), "requires --type proxmox") {
t.Errorf("D-044 guard wrongly rejected proxmox type: %v", err)
}
}
+14 -10
View File
@@ -26,11 +26,11 @@ import (
"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/model"
"git.cloudinit.dev/coreci/orca/internal/osdetect"
"git.cloudinit.dev/coreci/orca/internal/proxmox"
"git.cloudinit.dev/coreci/orca/internal/security"
"git.cloudinit.dev/coreci/orca/internal/store"
"git.cloudinit.dev/coreci/orca/internal/transport"
@@ -409,7 +409,19 @@ func probeProxmoxPVEVersion(ctx context.Context, host string) error {
return fmt.Errorf("parse SSH key: %w", err)
}
hostKeyCallback, err := knownhosts.New(certpaths.KnownHostsPath())
// Extract host from the node address (orca stores host:8443;
// SSH needs host:22). We dial the SSH port, not the orca daemon port.
sshHost := host
if strings.Contains(host, ":") {
sshHost = strings.SplitN(host, ":", 2)[0]
}
sshAddr := sshHost + ":22"
// Use the shared TOFU capture-fix wrapper (T02.9 — GRILL condition
// #2: doctor parity with bootstrap). Without this, a first-connect
// proxmox node (entry missing from known_hosts) fails the doctor
// probe even though it joined fine — the v0.6 ship-defect.
hostKeyCallback, err := proxmox.TOFUHostKeyCallback(sshAddr, nil)
if err != nil {
return fmt.Errorf("known_hosts: %w", err)
}
@@ -421,14 +433,6 @@ func probeProxmoxPVEVersion(ctx context.Context, host string) error {
Timeout: 3 * time.Second,
}
// Extract host from the node address (orca stores host:8443;
// SSH needs host:22). We dial the SSH port, not the orca daemon port.
sshHost := host
if strings.Contains(host, ":") {
sshHost = strings.SplitN(host, ":", 2)[0]
}
sshAddr := sshHost + ":22"
dialer := &netDialer{}
conn, err := dialer.DialContext(ctx, "tcp", sshAddr, config)
if err != nil {
+94
View File
@@ -2,14 +2,21 @@ package doctor
import (
"context"
"crypto/ed25519"
"crypto/rand"
"net"
"os"
"path/filepath"
"strings"
"testing"
"time"
"golang.org/x/crypto/ssh"
"git.cloudinit.dev/coreci/orca/internal/certpaths"
"git.cloudinit.dev/coreci/orca/internal/model"
"git.cloudinit.dev/coreci/orca/internal/osdetect"
"git.cloudinit.dev/coreci/orca/internal/proxmox"
"git.cloudinit.dev/coreci/orca/internal/security"
"git.cloudinit.dev/coreci/orca/internal/store"
)
@@ -396,3 +403,90 @@ func init() {
// Suppress slog noise during tests.
_ = os.Setenv("ORCA_LOG_LEVEL", "error")
}
// TestProxmoxCheck_FirstConnectCapturesKey verifies that the doctor
// proxmox probe uses the shared TOFU capture-fix wrapper
// (proxmox.TOFUHostKeyCallback), which captures the host key on first
// connect instead of failing with KeyError{Want:[]} (T02.9 — GRILL
// condition #2: doctor parity with bootstrap). Before T02.9, the bare
// knownhosts.New callback returned KeyError{Want:[]} on a missing
// entry and the doctor probe reported FAIL even though the node had
// joined successfully — the v0.6 ship-defect.
//
// We exercise the exact wrapper doctor.go calls against a real SSH
// server on an ephemeral port (the probe hardcodes :22, which we
// cannot bind in CI). This proves the doctor's chosen callback captures
// on first connect rather than failing — the parity guarantee.
func TestProxmoxCheck_FirstConnectCapturesKey(t *testing.T) {
dir := t.TempDir()
t.Setenv("ORCA_HOME", dir)
// Empty known_hosts (first-connect scenario).
if err := os.WriteFile(certpaths.KnownHostsPath(), []byte{}, 0o600); err != nil {
t.Fatalf("create known_hosts: %v", err)
}
// Start a fake SSH server on an ephemeral port whose host key is
// NOT yet in known_hosts.
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
defer ln.Close()
_, srvPriv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("ed25519 gen: %v", err)
}
hostSigner, err := ssh.NewSignerFromKey(srvPriv)
if err != nil {
t.Fatalf("ssh signer: %v", err)
}
srvConfig := &ssh.ServerConfig{NoClientAuth: true}
srvConfig.AddHostKey(hostSigner)
go func() {
for {
nconn, err := ln.Accept()
if err != nil {
return
}
go func(c net.Conn) {
defer c.Close()
_, chans, reqs, err := ssh.NewServerConn(c, srvConfig)
if err != nil {
return
}
go ssh.DiscardRequests(reqs)
for nc := range chans {
nc.Reject(ssh.UnknownChannelType, "none")
}
}(nconn)
}
}()
sshAddr := ln.Addr().String()
host, _, _ := net.SplitHostPort(sshAddr)
// The doctor probe now builds its HostKeyCallback via
// proxmox.TOFUHostKeyCallback(sshAddr, nil). On first connect
// (empty known_hosts) this must capture + write the key and return
// nil, NOT a KeyError — the v0.6 ship-defect fix.
cb, err := proxmox.TOFUHostKeyCallback(sshAddr, nil)
if err != nil {
t.Fatalf("TOFUHostKeyCallback: %v", err)
}
if err := cb(sshAddr, &net.TCPAddr{IP: net.ParseIP(host), Port: 22}, hostSigner.PublicKey()); err != nil {
t.Fatalf("first-connect doctor callback should capture (not fail): %v", err)
}
// The captured key must now be in known_hosts.
data, err := os.ReadFile(certpaths.KnownHostsPath())
if err != nil {
t.Fatalf("read known_hosts: %v", err)
}
if len(data) == 0 {
t.Error("known_hosts is empty — doctor capture-fix did not write the key (T02.9)")
}
if !strings.Contains(string(data), hostSigner.PublicKey().Type()) {
t.Errorf("known_hosts missing the captured host key type: %s", data)
}
}
+168 -9
View File
@@ -22,9 +22,13 @@
package proxmox
import (
"bytes"
"context"
"errors"
"fmt"
"log/slog"
"net"
"os"
"strings"
"time"
@@ -68,6 +72,11 @@ type Options struct {
ProxmoxRole string
// SSHPort is the SSH port (default 22).
SSHPort int
// HostKeyFingerprint is the operator-pinned SSH host key fingerprint
// in `SHA256:base64` form (REQ-058, D-044). When non-empty, the
// bootstrap dialer uses a pinned-host-key callback instead of the
// TOFU known_hosts capture path. Empty falls back to TOFU.
HostKeyFingerprint string
// Logger receives audit-log entries. If nil, slog.Default() is used.
Logger *slog.Logger
}
@@ -119,15 +128,30 @@ func BootstrapProxmox(ctx context.Context, opts Options) (*Result, error) {
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)
// Step 2: SSH dial with password auth + host-key verification (D-035,
// REQ-058). When opts.HostKeyFingerprint is set (D-044), use a pinned
// callback that fails closed on mismatch (AD-028); otherwise use the
// TOFU known_hosts capture callback (D-035). The TOFU wrapper fixes
// the v0.6 ship-defect where knownhosts.New returned KeyError{Want:[]}
// on first connect WITHOUT writing the captured key, so the first
// `orca node join --type proxmox` always failed.
sshAddr := fmt.Sprintf("%s:%d", opts.Host, opts.SSHPort)
var capturedHostKey ssh.PublicKey
var hostKeyCallback ssh.HostKeyCallback
if opts.HostKeyFingerprint != "" {
cb, err := pinnedHostKeyCallback(opts.HostKeyFingerprint, &capturedHostKey)
if err != nil {
return nil, fmt.Errorf("host-key fingerprint: %w", err)
}
hostKeyCallback = cb
} else {
cb, err := TOFUHostKeyCallback(sshAddr, &capturedHostKey)
if err != nil {
return nil, fmt.Errorf("tofu host-key callback: %w", err)
}
hostKeyCallback = cb
}
sshAddr := fmt.Sprintf("%s:%d", opts.Host, opts.SSHPort)
sshConfig := &ssh.ClientConfig{
User: opts.SSHUser,
Auth: []ssh.AuthMethod{ssh.Password(opts.Password)},
@@ -147,10 +171,16 @@ func BootstrapProxmox(ctx context.Context, opts Options) (*Result, error) {
sessionRunner = &sshSessionRunner{client: conn}
}
hostKeyFP := ""
if capturedHostKey != nil {
hostKeyFP = security.SSHFingerprintSHA256(capturedHostKey)
}
log.Info("proxmox.ssh_connected",
slog.String("event", "proxmox.ssh_connected"),
slog.String("host", opts.Host),
slog.String("ssh_user", opts.SSHUser),
slog.String("host_key_fingerprint", hostKeyFP),
)
// Step 3: Deploy orca pubkey to ~orca/.ssh/authorized_keys (idempotent).
@@ -197,8 +227,9 @@ func BootstrapProxmox(ctx context.Context, opts Options) (*Result, error) {
)
return &Result{
NodeName: opts.Host,
NodeAddress: opts.Host + ":8443",
NodeName: opts.Host,
NodeAddress: opts.Host + ":8443",
HostKeyFingerprint: hostKeyFP,
}, nil
}
@@ -206,6 +237,78 @@ func BootstrapProxmox(ctx context.Context, opts Options) (*Result, error) {
// variable so tests can override it with a fake SSH server.
var sshDialer sshDialerType = defaultSSHDialer{}
// pinnedHostKeyCallback returns an ssh.HostKeyCallback that pins the
// server's host key to the operator-supplied SHA256:base64 fingerprint
// (REQ-058, AD-028). It validates the `SHA256:` prefix up front (D-045)
// and fails closed on any mismatch. The capturedKey out-param records
// the verified server key so the caller can populate Result.
func pinnedHostKeyCallback(expectedSHA256Base64 string, capturedKey *ssh.PublicKey) (ssh.HostKeyCallback, error) {
if !strings.HasPrefix(expectedSHA256Base64, "SHA256:") {
return nil, fmt.Errorf("pinnedHostKeyCallback: fingerprint must be SHA256:-prefixed (D-045), got %q", expectedSHA256Base64)
}
return func(_ string, _ net.Addr, key ssh.PublicKey) error {
got := security.SSHFingerprintSHA256(key)
if got != expectedSHA256Base64 {
return fmt.Errorf("REQ-058 host-key fingerprint mismatch: pinned=%s server=%s", expectedSHA256Base64, got)
}
if capturedKey != nil {
*capturedKey = key
}
return nil
}, nil
}
// TOFUHostKeyCallback returns an ssh.HostKeyCallback that wraps the
// standard knownhosts.New verifier with TOFU first-connect capture
// (D-035). On a host-unknown KeyError{Want:[]} it writes the
// server-presented key to certpaths.KnownHostsPath() atomically
// (security.WriteAtomic, AD-029) and allows the dial to proceed; on a
// mismatch (Want non-empty) it fails closed (MITM detection). The
// capturedKey out-param records the verified/captured server key so
// the caller can populate Result. This fixes the v0.6 ship-defect
// where knownhosts.New returned KeyError{Want:[]} on first connect
// WITHOUT writing the captured key, so the first
// `orca node join --type proxmox` always failed.
//
// Exported so the doctor proxmox probe (T02.9) can reuse the same
// capture-fix wrapper for parity (GRILL condition #2).
func TOFUHostKeyCallback(addr string, capturedKey *ssh.PublicKey) (ssh.HostKeyCallback, error) {
cb, err := knownhosts.New(certpaths.KnownHostsPath())
if err != nil {
return nil, err
}
return func(hostname string, remote net.Addr, key ssh.PublicKey) error {
err := cb(hostname, remote, key)
if err == nil {
if capturedKey != nil {
*capturedKey = key
}
return nil
}
var keyErr *knownhosts.KeyError
if errors.As(err, &keyErr) && len(keyErr.Want) == 0 {
line := knownhosts.Line([]string{knownhosts.Normalize(addr)}, key)
path := certpaths.KnownHostsPath()
existing, readErr := os.ReadFile(path)
if readErr != nil && !os.IsNotExist(readErr) {
return fmt.Errorf("tofu read known_hosts: %w", readErr)
}
if len(existing) > 0 && !bytes.HasSuffix(existing, []byte("\n")) {
existing = append(existing, '\n')
}
updated := append(existing, []byte(line)...)
if writeErr := security.WriteAtomic(path, 0o600, updated); writeErr != nil {
return fmt.Errorf("tofu write known_hosts: %w", writeErr)
}
if capturedKey != nil {
*capturedKey = key
}
return nil
}
return err
}, nil
}
type sshDialerType interface {
DialContext(ctx context.Context, network, addr string, config *ssh.ClientConfig) (*ssh.Client, error)
}
@@ -362,3 +465,59 @@ func validateSudoers() error {
}
return nil
}
// ResetHostKey removes all known_hosts entries for the given host from
// certpaths.KnownHostsPath() (REQ-059, D-046, AD-029). It rewrites the
// file atomically via security.WriteAtomic. LOCAL ONLY — it does NOT
// touch the remote host's authorized_keys (D-046). The next connect
// re-pins the host key via TOFU (T02.6) or the --host-key-fingerprint
// pinned path (T02.5).
//
// A line matches when its first whitespace-delimited field (the host
// pattern, normalized via knownhosts.Normalize) equals the normalized
// target host. Comment/blank lines are preserved.
func ResetHostKey(host string) error {
if host == "" {
return fmt.Errorf("ResetHostKey: host is required")
}
path := certpaths.KnownHostsPath()
existing, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil // nothing to reset
}
return fmt.Errorf("ResetHostKey: read known_hosts: %w", err)
}
target := knownhosts.Normalize(host)
var kept []byte
removed := 0
for _, line := range strings.Split(string(existing), "\n") {
trimmed := strings.TrimSpace(line)
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
kept = append(kept, []byte(line+"\n")...)
continue
}
fields := strings.Fields(trimmed)
if len(fields) == 0 {
kept = append(kept, []byte(line+"\n")...)
continue
}
if knownhosts.Normalize(fields[0]) == target {
removed++
continue
}
kept = append(kept, []byte(line+"\n")...)
}
if removed == 0 {
return nil
}
// Ensure the kept buffer ends with exactly one trailing newline.
kept = bytes.TrimRight(kept, "\n")
if len(kept) > 0 {
kept = append(kept, '\n')
}
if err := security.WriteAtomic(path, 0o600, kept); err != nil {
return fmt.Errorf("ResetHostKey: rewrite known_hosts: %w", err)
}
return nil
}
+558
View File
@@ -3,16 +3,22 @@ package proxmox
import (
"bytes"
"context"
"crypto/ed25519"
"crypto/rand"
"errors"
"log/slog"
"net"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"golang.org/x/crypto/ssh"
"golang.org/x/crypto/ssh/knownhosts"
"git.cloudinit.dev/coreci/orca/internal/security"
)
func TestSudoersContent(t *testing.T) {
@@ -488,3 +494,555 @@ func TestSSHSessionRunner_CombinedOutput_NewSessionError(t *testing.T) {
t.Errorf("error should mention new session, got: %v", err)
}
}
// TestPinnedHostKeyCallback_Match verifies the pinned callback returns
// nil when the server-presented key matches the operator-supplied
// fingerprint (T02.5, REQ-058).
func TestPinnedHostKeyCallback_Match(t *testing.T) {
srv := newFakeSSHServer(t)
defer srv.close()
host, port, _ := net.SplitHostPort(srv.addr())
hostKey := srv.hostPublicKey()
if hostKey == nil {
t.Fatal("server host key is nil")
}
expectedFP := security.SSHFingerprintSHA256(hostKey)
var captured ssh.PublicKey
cb, err := pinnedHostKeyCallback(expectedFP, &captured)
if err != nil {
t.Fatalf("pinnedHostKeyCallback: %v", err)
}
if err := cb(host+":"+port, &net.TCPAddr{IP: net.ParseIP(host), Port: 22}, hostKey); err != nil {
t.Errorf("match callback returned error: %v", err)
}
if !bytes.Equal(captured.Marshal(), hostKey.Marshal()) {
t.Error("captured key does not match server host key")
}
}
// TestPinnedHostKeyCallback_Mismatch verifies the pinned callback fails
// closed on mismatch (T02.5, REQ-058).
func TestPinnedHostKeyCallback_Mismatch(t *testing.T) {
srv := newFakeSSHServer(t)
defer srv.close()
host, _, _ := net.SplitHostPort(srv.addr())
hostKey := srv.hostPublicKey()
if hostKey == nil {
t.Fatal("server host key is nil")
}
cb, err := pinnedHostKeyCallback("SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", nil)
if err != nil {
t.Fatalf("pinnedHostKeyCallback: %v", err)
}
err = cb(host+":22", &net.TCPAddr{IP: net.ParseIP(host), Port: 22}, hostKey)
if err == nil {
t.Fatal("expected mismatch error, got nil")
}
if !strings.Contains(err.Error(), "REQ-058") {
t.Errorf("mismatch error should mention REQ-058, got: %v", err)
}
}
// TestPinnedHostKeyCallback_RejectsRawHex verifies the constructor
// rejects a non-SHA256:-prefixed fingerprint (T02.5, D-045).
func TestPinnedHostKeyCallback_RejectsRawHex(t *testing.T) {
_, err := pinnedHostKeyCallback("abcdef0123456789", nil)
if err == nil {
t.Fatal("expected error for raw hex fingerprint, got nil")
}
if !strings.Contains(err.Error(), "SHA256:") {
t.Errorf("error should mention SHA256: prefix requirement, got: %v", err)
}
}
// TestTOFUHostKeyCallback_FirstConnectCapturesKey verifies that on
// first connect (empty known_hosts) the TOFU callback captures the
// server key, writes it to known_hosts, and allows the dial (T02.6 —
// v0.6 ship-defect fix).
func TestTOFUHostKeyCallback_FirstConnectCapturesKey(t *testing.T) {
home := setupORCAHome(t) // empty known_hosts
srv := newFakeSSHServer(t)
defer srv.close()
host, port, _ := net.SplitHostPort(srv.addr())
addr := host + ":" + port
hostKey := srv.hostPublicKey()
if hostKey == nil {
t.Fatal("server host key is nil")
}
cb, err := TOFUHostKeyCallback(addr, nil)
if err != nil {
t.Fatalf("TOFUHostKeyCallback: %v", err)
}
if err := cb(addr, &net.TCPAddr{IP: net.ParseIP(host), Port: 22}, hostKey); err != nil {
t.Fatalf("first-connect callback returned error: %v", err)
}
data, err := os.ReadFile(filepath.Join(home, "known_hosts"))
if err != nil {
t.Fatalf("read known_hosts: %v", err)
}
if len(data) == 0 {
t.Fatal("known_hosts is empty — TOFU capture did not write the key (v0.6 ship-defect not fixed)")
}
if !strings.Contains(string(data), knownhosts.Normalize(addr)) {
t.Errorf("known_hosts missing the normalized addr %q: %s", knownhosts.Normalize(addr), data)
}
if !strings.Contains(string(data), hostKey.Type()) {
t.Errorf("known_hosts missing the host key type %q: %s", hostKey.Type(), data)
}
}
// TestTOFUHostKeyCallback_SecondConnectMatches verifies that on a
// second connect (known_hosts already has the key) the TOFU callback
// matches and returns nil (T02.6).
func TestTOFUHostKeyCallback_SecondConnectMatches(t *testing.T) {
setupORCAHome(t)
srv := newFakeSSHServer(t)
defer srv.close()
host, port, _ := net.SplitHostPort(srv.addr())
addr := host + ":" + port
hostKey := srv.hostPublicKey()
if hostKey == nil {
t.Fatal("server host key is nil")
}
// First connect: capture + write.
cb1, err := TOFUHostKeyCallback(addr, nil)
if err != nil {
t.Fatalf("TOFUHostKeyCallback #1: %v", err)
}
if err := cb1(addr, &net.TCPAddr{IP: net.ParseIP(host), Port: 22}, hostKey); err != nil {
t.Fatalf("first connect: %v", err)
}
// Second connect: the fresh knownhosts.New reads the written key.
cb2, err := TOFUHostKeyCallback(addr, nil)
if err != nil {
t.Fatalf("TOFUHostKeyCallback #2: %v", err)
}
if err := cb2(addr, &net.TCPAddr{IP: net.ParseIP(host), Port: 22}, hostKey); err != nil {
t.Fatalf("second connect should match, got: %v", err)
}
}
// TestTOFUHostKeyCallback_MismatchFails verifies that on a mismatch
// (known_hosts has a different key) the TOFU callback fails closed
// (MITM detection) (T02.6).
func TestTOFUHostKeyCallback_MismatchFails(t *testing.T) {
setupORCAHome(t)
srv := newFakeSSHServer(t)
defer srv.close()
host, port, _ := net.SplitHostPort(srv.addr())
addr := host + ":" + port
hostKey := srv.hostPublicKey()
if hostKey == nil {
t.Fatal("server host key is nil")
}
// Capture the real key first so known_hosts is populated.
cb1, err := TOFUHostKeyCallback(addr, nil)
if err != nil {
t.Fatalf("TOFUHostKeyCallback #1: %v", err)
}
if err := cb1(addr, &net.TCPAddr{IP: net.ParseIP(host), Port: 22}, hostKey); err != nil {
t.Fatalf("first connect: %v", err)
}
// Generate a different key + present it: callback must fail.
pub, _, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("ed25519 gen: %v", err)
}
altKey, err := ssh.NewPublicKey(pub)
if err != nil {
t.Fatalf("new pub: %v", err)
}
cb2, err := TOFUHostKeyCallback(addr, nil)
if err != nil {
t.Fatalf("TOFUHostKeyCallback #2: %v", err)
}
err = cb2(addr, &net.TCPAddr{IP: net.ParseIP(host), Port: 22}, altKey)
if err == nil {
t.Fatal("expected mismatch error, got nil")
}
}
// TestBootstrapProxmox_PopulatesHostKeyFingerprint verifies that after
// a successful bootstrap via TOFU, Result.HostKeyFingerprint is
// non-empty and SHA256:-prefixed (T02.7).
func TestBootstrapProxmox_PopulatesHostKeyFingerprint(t *testing.T) {
srv := newFakeSSHServer(t)
defer srv.close()
home := t.TempDir()
t.Setenv("ORCA_HOME", home)
if err := os.WriteFile(filepath.Join(home, "known_hosts"), []byte{}, 0o600); err != nil {
t.Fatalf("create known_hosts: %v", err)
}
orig := sshDialer
defer func() { sshDialer = orig }()
origRunner := sessionRunner
defer func() { sessionRunner = origRunner }()
sessionRunner = nil
// Use the real dialer so the TOFU HostKeyCallback actually runs
// against the fake server (a static dialer with an insecure client
// would bypass the callback and leave HostKeyFingerprint empty).
sshDialer = defaultSSHDialer{}
host, port, _ := net.SplitHostPort(srv.addr())
portNum, _ := strconv.Atoi(port)
result, err := BootstrapProxmox(t.Context(), Options{
Host: host,
Password: "pw",
SSHPort: portNum,
})
if err != nil {
t.Fatalf("BootstrapProxmox: %v", err)
}
if result.HostKeyFingerprint == "" {
t.Fatal("Result.HostKeyFingerprint is empty")
}
if !strings.HasPrefix(result.HostKeyFingerprint, "SHA256:") {
t.Errorf("Result.HostKeyFingerprint = %q, want SHA256: prefix", result.HostKeyFingerprint)
}
}
// TestResetHostKey_RemovesTargetLines verifies that ResetHostKey
// removes all known_hosts lines for the target host while leaving
// other hosts' lines intact (T02.8, REQ-059, D-046).
func TestResetHostKey_RemovesTargetLines(t *testing.T) {
home := setupORCAHome(t)
path := filepath.Join(home, "known_hosts")
original := []byte("[10.0.0.1]:22 ssh-ed25519 AAAAKEY1 host1\n" +
"10.0.0.1 ssh-ed25519 AAAAKEY1ALT host1-alt\n" +
"[10.0.0.2]:22 ssh-ed25519 AAAAKEY2 host2\n")
if err := os.WriteFile(path, original, 0o600); err != nil {
t.Fatalf("write known_hosts: %v", err)
}
if err := ResetHostKey("10.0.0.1"); err != nil {
t.Fatalf("ResetHostKey: %v", err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read known_hosts: %v", err)
}
result := string(data)
if strings.Contains(result, "AAAAKEY1") {
t.Errorf("target host key line not removed: %s", result)
}
if strings.Contains(result, "AAAAKEY1ALT") {
t.Errorf("target host alt key line not removed: %s", result)
}
if !strings.Contains(result, "AAAAKEY2") {
t.Errorf("other host's line was removed (should be intact): %s", result)
}
}
// TestResetHostKey_NoMatchingLinesIsNoop verifies that ResetHostKey is
// a no-op when no lines match (T02.8).
func TestResetHostKey_NoMatchingLinesIsNoop(t *testing.T) {
home := setupORCAHome(t)
path := filepath.Join(home, "known_hosts")
original := []byte("[10.0.0.2]:22 ssh-ed25519 AAAAKEY2 host2\n")
if err := os.WriteFile(path, original, 0o600); err != nil {
t.Fatalf("write known_hosts: %v", err)
}
if err := ResetHostKey("10.0.0.99"); err != nil {
t.Fatalf("ResetHostKey: %v", err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read known_hosts: %v", err)
}
if string(data) != string(original) {
t.Errorf("known_hosts changed on no-match: got %q, want %q", data, original)
}
}
// TestResetHostKey_MissingFileIsNoop verifies ResetHostKey returns nil
// when known_hosts does not exist (T02.8).
func TestResetHostKey_MissingFileIsNoop(t *testing.T) {
setupORCAHome(t)
if err := ResetHostKey("10.0.0.1"); err != nil {
t.Errorf("ResetHostKey on missing file should be no-op, got: %v", err)
}
}
// TestResetHostKey_EmptyHostErrors verifies ResetHostKey rejects an
// empty host (T02.8).
func TestResetHostKey_EmptyHostErrors(t *testing.T) {
if err := ResetHostKey(""); err == nil {
t.Error("expected error for empty host, got nil")
}
}
// bootstrapE2ESetup wires the real dialer against a fake SSH server so
// the full HostKeyCallback path (pinned or TOFU) runs end-to-end through
// BootstrapProxmox. Returns the host, port, and server (for fingerprint
// computation). The known_hosts file is created empty in the temp
// ORCA_HOME.
func bootstrapE2ESetup(t *testing.T) (srv *fakeSSHServer, host, port string) {
t.Helper()
srv = newFakeSSHServer(t)
t.Cleanup(srv.close)
home := t.TempDir()
t.Setenv("ORCA_HOME", home)
if err := os.WriteFile(filepath.Join(home, "known_hosts"), []byte{}, 0o600); err != nil {
t.Fatalf("create known_hosts: %v", err)
}
orig := sshDialer
t.Cleanup(func() { sshDialer = orig })
origRunner := sessionRunner
t.Cleanup(func() { sessionRunner = origRunner })
sessionRunner = nil
sshDialer = defaultSSHDialer{}
host, port, _ = net.SplitHostPort(srv.addr())
return srv, host, port
}
// TestBootstrapE2E_PinnedFingerprintCorrect verifies that
// --host-key-fingerprint with the correct pin (T02.10 case 1) succeeds
// end-to-end and Result.HostKeyFingerprint equals the pinned value.
func TestBootstrapE2E_PinnedFingerprintCorrect(t *testing.T) {
srv, host, port := bootstrapE2ESetup(t)
hostKey := srv.hostPublicKey()
if hostKey == nil {
t.Fatal("server host key is nil")
}
pin := security.SSHFingerprintSHA256(hostKey)
portNum, _ := strconv.Atoi(port)
result, err := BootstrapProxmox(t.Context(), Options{
Host: host,
Password: "pw",
SSHPort: portNum,
HostKeyFingerprint: pin,
})
if err != nil {
t.Fatalf("BootstrapProxmox with correct pin: %v", err)
}
if result.HostKeyFingerprint != pin {
t.Errorf("Result.HostKeyFingerprint = %q, want %q (pinned value)",
result.HostKeyFingerprint, pin)
}
}
// TestBootstrapE2E_PinnedFingerprintWrong verifies that
// --host-key-fingerprint with a wrong pin (T02.10 case 2) fails fast
// with the REQ-058 mismatch error, before any SSH session commands run.
func TestBootstrapE2E_PinnedFingerprintWrong(t *testing.T) {
_, host, port := bootstrapE2ESetup(t)
portNum, _ := strconv.Atoi(port)
wrong := "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
_, err := BootstrapProxmox(t.Context(), Options{
Host: host,
Password: "pw",
SSHPort: portNum,
HostKeyFingerprint: wrong,
})
if err == nil {
t.Fatal("expected error for wrong pin, got nil")
}
if !strings.Contains(err.Error(), "REQ-058") {
t.Errorf("error should mention REQ-058, got: %v", err)
}
}
// TestBootstrapE2E_TOFUFirstConnectCapturesKey verifies that with no
// --host-key-fingerprint on a first connect (empty known_hosts) (T02.10
// case 3) the TOFU callback captures the key, writes known_hosts, and
// bootstrap succeeds — exercised end-to-end through BootstrapProxmox.
func TestBootstrapE2E_TOFUFirstConnectCapturesKey(t *testing.T) {
srv, host, port := bootstrapE2ESetup(t)
hostKey := srv.hostPublicKey()
if hostKey == nil {
t.Fatal("server host key is nil")
}
portNum, _ := strconv.Atoi(port)
home := os.Getenv("ORCA_HOME")
knownHostsPath := filepath.Join(home, "known_hosts")
before, _ := os.ReadFile(knownHostsPath)
if len(before) != 0 {
t.Fatalf("precondition: known_hosts not empty: %q", before)
}
result, err := BootstrapProxmox(t.Context(), Options{
Host: host,
Password: "pw",
SSHPort: portNum,
})
if err != nil {
t.Fatalf("BootstrapProxmox first connect: %v", err)
}
data, err := os.ReadFile(knownHostsPath)
if err != nil {
t.Fatalf("read known_hosts: %v", err)
}
if len(data) == 0 {
t.Fatal("known_hosts empty — TOFU did not capture the key end-to-end")
}
expectedFP := security.SSHFingerprintSHA256(hostKey)
if result.HostKeyFingerprint != expectedFP {
t.Errorf("Result.HostKeyFingerprint = %q, want %q", result.HostKeyFingerprint, expectedFP)
}
}
// TestBootstrapE2E_TOFUSecondConnectMatches verifies that a second
// connect (known_hosts already has the key from the first connect)
// (T02.10 case 4) matches and succeeds end-to-end.
func TestBootstrapE2E_TOFUSecondConnectMatches(t *testing.T) {
srv, host, port := bootstrapE2ESetup(t)
portNum, _ := strconv.Atoi(port)
for i := 0; i < 2; i++ {
sessionRunner = nil
if _, err := BootstrapProxmox(t.Context(), Options{
Host: host,
Password: "pw",
SSHPort: portNum,
}); err != nil {
t.Fatalf("bootstrap run %d: %v", i+1, err)
}
}
_ = srv
}
// TestBootstrapE2E_TOFUMismatchFails verifies that when known_hosts has
// a different key (T02.10 case 5) the second connect fails with a
// mismatch (MITM detection) — end-to-end through BootstrapProxmox.
func TestBootstrapE2E_TOFUMismatchFails(t *testing.T) {
srv, host, port := bootstrapE2ESetup(t)
hostKey := srv.hostPublicKey()
if hostKey == nil {
t.Fatal("server host key is nil")
}
portNum, _ := strconv.Atoi(port)
home := os.Getenv("ORCA_HOME")
knownHostsPath := filepath.Join(home, "known_hosts")
altPub, _, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("ed25519 gen: %v", err)
}
altKey, err := ssh.NewPublicKey(altPub)
if err != nil {
t.Fatalf("new pub: %v", err)
}
addr := host + ":" + port
altLine := knownhosts.Line([]string{knownhosts.Normalize(addr)}, altKey)
if err := os.WriteFile(knownHostsPath, []byte(altLine+"\n"), 0o600); err != nil {
t.Fatalf("write known_hosts: %v", err)
}
_, err = BootstrapProxmox(t.Context(), Options{
Host: host,
Password: "pw",
SSHPort: portNum,
})
if err == nil {
t.Fatal("expected MITM/mismatch error, got nil")
}
if !strings.Contains(err.Error(), "ssh dial") {
t.Errorf("error should mention ssh dial, got: %v", err)
}
}
// TestBootstrapE2E_PrePopulatedKnownHostsMatches verifies the v0.6→v0.8
// migration path (T02.10 case 7): a known_hosts entry written by a prior
// join (simulating a v0.6 install) is matched on second-connect without
// re-capture, end-to-end through BootstrapProxmox.
func TestBootstrapE2E_PrePopulatedKnownHostsMatches(t *testing.T) {
srv, host, port := bootstrapE2ESetup(t)
hostKey := srv.hostPublicKey()
if hostKey == nil {
t.Fatal("server host key is nil")
}
portNum, _ := strconv.Atoi(port)
home := os.Getenv("ORCA_HOME")
knownHostsPath := filepath.Join(home, "known_hosts")
addr := host + ":" + port
preLine := knownhosts.Line([]string{knownhosts.Normalize(addr)}, hostKey)
if err := os.WriteFile(knownHostsPath, []byte(preLine+"\n"), 0o600); err != nil {
t.Fatalf("write known_hosts: %v", err)
}
result, err := BootstrapProxmox(t.Context(), Options{
Host: host,
Password: "pw",
SSHPort: portNum,
})
if err != nil {
t.Fatalf("BootstrapProxmox on pre-populated known_hosts: %v", err)
}
expectedFP := security.SSHFingerprintSHA256(hostKey)
if result.HostKeyFingerprint != expectedFP {
t.Errorf("Result.HostKeyFingerprint = %q, want %q", result.HostKeyFingerprint, expectedFP)
}
}
// TestBootstrapE2E_KeyResetThenRePin verifies T02.10 case 6: after
// ResetHostKey removes the known_hosts entry, the next BootstrapProxmox
// connect re-pins the key via TOFU and succeeds end-to-end. The reset
// target is the known_hosts entry key (host:port, normalized), which
// matches how the cli resolves the host from a proxmox node's address
// for non-default ports.
func TestBootstrapE2E_KeyResetThenRePin(t *testing.T) {
srv, host, port := bootstrapE2ESetup(t)
portNum, _ := strconv.Atoi(port)
home := os.Getenv("ORCA_HOME")
knownHostsPath := filepath.Join(home, "known_hosts")
addr := host + ":" + port
// First connect: TOFU captures + writes known_hosts.
sessionRunner = nil
if _, err := BootstrapProxmox(t.Context(), Options{
Host: host,
Password: "pw",
SSHPort: portNum,
}); err != nil {
t.Fatalf("first bootstrap: %v", err)
}
before, _ := os.ReadFile(knownHostsPath)
if len(before) == 0 {
t.Fatal("precondition: known_hosts empty after first connect")
}
// Reset: known_hosts entry removed. Pass the full addr (host:port)
// so Normalize produces the same bracketed form the TOFU callback
// wrote for a non-default port.
if err := ResetHostKey(addr); err != nil {
t.Fatalf("ResetHostKey: %v", err)
}
after, _ := os.ReadFile(knownHostsPath)
if strings.Contains(string(after), knownhosts.Normalize(addr)) {
t.Fatalf("known_hosts still contains host after reset: %q", after)
}
// Next connect re-pins via TOFU + succeeds.
sessionRunner = nil
if _, err := BootstrapProxmox(t.Context(), Options{
Host: host,
Password: "pw",
SSHPort: portNum,
}); err != nil {
t.Fatalf("re-pin bootstrap after reset: %v", err)
}
rePinned, _ := os.ReadFile(knownHostsPath)
if !strings.Contains(string(rePinned), knownhosts.Normalize(addr)) {
t.Fatalf("known_hosts not re-populated on next connect: %q", rePinned)
}
_ = srv
}
+17 -5
View File
@@ -27,6 +27,7 @@ type fakeSSHServer struct {
state map[string]string
authDir string
forceSudoersInvalid bool
hostSigner ssh.Signer
}
func newFakeSSHServer(t *testing.T) *fakeSSHServer {
@@ -54,11 +55,12 @@ func newFakeSSHServer(t *testing.T) *fakeSSHServer {
t.Fatalf("listen: %v", err)
}
srv := &fakeSSHServer{
listener: ln,
config: config,
done: make(chan struct{}),
state: make(map[string]string),
authDir: t.TempDir(),
listener: ln,
config: config,
done: make(chan struct{}),
state: make(map[string]string),
authDir: t.TempDir(),
hostSigner: hostSigner,
}
go srv.serve()
return srv
@@ -66,6 +68,16 @@ func newFakeSSHServer(t *testing.T) *fakeSSHServer {
func (s *fakeSSHServer) addr() string { return s.listener.Addr().String() }
// hostPublicKey returns the server's SSH host public key. Used by
// callback tests to compute the pinned fingerprint the operator would
// supply, and to feed the callback the exact key the server presents.
func (s *fakeSSHServer) hostPublicKey() ssh.PublicKey {
if s.hostSigner == nil {
return nil
}
return s.hostSigner.PublicKey()
}
func (s *fakeSSHServer) serve() {
for {
conn, err := s.listener.Accept()
+15 -12
View File
@@ -121,10 +121,10 @@ func CAInit(dir, commonName string) (*CA, error) {
// Atomic write: temp file + rename. This avoids leaving a half-written
// ca.key on disk if the process crashes mid-write.
if err := writeAtomic(certPath, CACPEMMode, certPEM); err != nil {
if err := WriteAtomic(certPath, CACPEMMode, certPEM); err != nil {
return nil, err
}
if err := writeAtomic(keyPath, CAMode, keyPEM); err != nil {
if err := WriteAtomic(keyPath, CAMode, keyPEM); err != nil {
return nil, err
}
@@ -290,23 +290,26 @@ func bothExist(paths ...string) (bool, error) {
// WriteCert writes a cert PEM blob to path with mode 0644 atomically.
// REQ-033 requires cert files to be 0644; this helper enforces that.
func WriteCert(path string, pemBytes []byte) error {
return writeAtomic(path, CACPEMMode, pemBytes)
return WriteAtomic(path, CACPEMMode, pemBytes)
}
// WriteKey writes a private-key PEM blob to path with mode 0600
// atomically. REQ-033 requires key files to be 0600; this helper
// enforces that.
func WriteKey(path string, pemBytes []byte) error {
return writeAtomic(path, CAMode, pemBytes)
return WriteAtomic(path, CAMode, pemBytes)
}
// writeAtomic writes data to a temp file in dir and renames. Sets the
// WriteAtomic writes data to a temp file in dir and renames. Sets the
// requested perm before the rename so the file lands at the right mode.
func writeAtomic(path string, mode os.FileMode, data []byte) error {
// Exported (AD-029) so the key-reset / known_hosts atomic rewrite path
// in proxmox (T02.6/T02.7) can reuse it instead of duplicating the
// ~20-LOC pattern (RESEARCH §5 pitfall #10).
func WriteAtomic(path string, mode os.FileMode, data []byte) error {
dir := filepath.Dir(path)
tmp, err := os.CreateTemp(dir, ".tmp-*")
if err != nil {
return fmt.Errorf("writeAtomic: create temp: %w", err)
return fmt.Errorf("WriteAtomic: create temp: %w", err)
}
tmpName := tmp.Name()
// Best-effort cleanup if we fail before rename.
@@ -315,21 +318,21 @@ func writeAtomic(path string, mode os.FileMode, data []byte) error {
}()
if _, err := tmp.Write(data); err != nil {
_ = tmp.Close()
return fmt.Errorf("writeAtomic: write: %w", err)
return fmt.Errorf("WriteAtomic: write: %w", err)
}
if err := tmp.Chmod(mode); err != nil {
_ = tmp.Close()
return fmt.Errorf("writeAtomic: chmod: %w", err)
return fmt.Errorf("WriteAtomic: chmod: %w", err)
}
if err := tmp.Sync(); err != nil {
_ = tmp.Close()
return fmt.Errorf("writeAtomic: sync: %w", err)
return fmt.Errorf("WriteAtomic: sync: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("writeAtomic: close: %w", err)
return fmt.Errorf("WriteAtomic: close: %w", err)
}
if err := os.Rename(tmpName, path); err != nil {
return fmt.Errorf("writeAtomic: rename: %w", err)
return fmt.Errorf("WriteAtomic: rename: %w", err)
}
return nil
}
+13 -2
View File
@@ -26,6 +26,17 @@ const (
sshPubFile = "orca_ssh_key.pub"
)
// SSHFingerprintSHA256 returns the canonical SSH public-key fingerprint
// in the form `SHA256:base64` (no trailing padding), as produced by
// `ssh-keygen -lf` and OpenSSH's host-key verification prompts. This is
// a thin wrapper over ssh.FingerprintSHA256 (AD-027) for use by the
// proxmox bootstrap pinned-host-key callback (REQ-058) and any other
// SSH-domain identity checks. Do NOT reuse security.Fingerprint — that
// returns an X.509 DER hex digest (different domain; RESEARCH §2.2).
func SSHFingerprintSHA256(pubKey ssh.PublicKey) string {
return ssh.FingerprintSHA256(pubKey)
}
// 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
@@ -84,10 +95,10 @@ func GenerateOrLoadSSHKey(dir string) (keyPEM, pubLine []byte, err error) {
pubLine = ssh.MarshalAuthorizedKey(sshPub)
// Persist with correct modes (atomic write + chmod).
if err := writeAtomic(keyPath, SSHKeyMode, keyPEM); err != nil {
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 {
if err := WriteAtomic(pubPath, SSHPubMode, pubLine); err != nil {
return nil, nil, fmt.Errorf("write SSH pub: %w", err)
}
+42
View File
@@ -1,6 +1,8 @@
package security
import (
"crypto/ed25519"
"crypto/rand"
"os"
"path/filepath"
"strings"
@@ -91,3 +93,43 @@ func TestGenerateOrLoadSSHKey_CreatesDir(t *testing.T) {
t.Errorf("nested dir not created: %v", err)
}
}
func TestSSHFingerprintSHA256_Ed25519(t *testing.T) {
pub, _, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("ed25519 gen: %v", err)
}
sshPub, err := ssh.NewPublicKey(pub)
if err != nil {
t.Fatalf("new pubkey: %v", err)
}
got := SSHFingerprintSHA256(sshPub)
// Canonical form: SHA256: followed by unpadded base64.
if !strings.HasPrefix(got, "SHA256:") {
t.Fatalf("fingerprint = %q, want SHA256: prefix", got)
}
// Must match the reference implementation exactly.
want := ssh.FingerprintSHA256(sshPub)
if got != want {
t.Errorf("SSHFingerprintSHA256 = %q, want %q", got, want)
}
}
func TestSSHFingerprintSHA256_StableAcrossCalls(t *testing.T) {
pub, _, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("ed25519 gen: %v", err)
}
sshPub, err := ssh.NewPublicKey(pub)
if err != nil {
t.Fatalf("new pubkey: %v", err)
}
a := SSHFingerprintSHA256(sshPub)
b := SSHFingerprintSHA256(sshPub)
if a != b {
t.Errorf("fingerprint not stable: %q vs %q", a, b)
}
}