bd0d241b
feat: serial console, teardown observability, quotas, and the SSH-CA jump gate
a73x 2026-07-25 10:37
Commit message
.gitignore
| Old | New | ||
|---|---|---|---|
| @@ -17,3 +17,7 @@ web/build/ | |||
| 17 | # Claude Code worktrees/session dirs | 17 | # Claude Code worktrees/session dirs |
| 18 | .claude/ | 18 | .claude/ |
| 19 | /bin/ | 19 | /bin/ |
| 20 | .worktrees/ | ||
| 21 | |||
| 22 | # Site-specific deploy config (may hold secrets); see scripts/deploy.env.example | ||
| 23 | deploy.env | ||
.golangci.yml
| Old | New | ||
|---|---|---|---|
| @@ -57,8 +57,10 @@ linters: | |||
| 57 | - error | 57 | - error |
| 58 | - empty | 58 | - empty |
| 59 | - stdlib | 59 | - stdlib |
| 60 | # documented consumer-side seams that intentionally return interfaces: | 60 | # ssh.Signer is the idiomatic return across the x/crypto/ssh API (keys and |
| 61 | - github.com/a73x/eitri/internal/agent/overlay.Overlay | 61 | # CAs ARE signers); returning the concrete key type would be worse. A |
| 62 | # documented seam, per this tier's policy. | ||
| 63 | - golang.org/x/crypto/ssh.Signer | ||
| 62 | depguard: | 64 | depguard: |
| 63 | rules: | 65 | rules: |
| 64 | # R1 cross-plane bans apply to production code only; integration tests | 66 | # R1 cross-plane bans apply to production code only; integration tests |
Makefile
| Old | New | ||
|---|---|---|---|
| @@ -11,7 +11,8 @@ DEADCODE_VERSION := v0.48.0 | |||
| 11 | LINT_WARN := errcheck,revive,gocyclo,funlen,gocritic,misspell,unconvert,nakedret | 11 | LINT_WARN := errcheck,revive,gocyclo,funlen,gocritic,misspell,unconvert,nakedret |
| 12 | 12 | ||
| 13 | .PHONY: build build-go web test vet proto smoke smoke-go devstack sandbox clean \ | 13 | .PHONY: build build-go web test vet proto smoke smoke-go devstack sandbox clean \ |
| 14 | lint lint-extra arch cover tidy-check proto-check shape shape-check api api-check ci deadcode | 14 | lint lint-extra arch cover tidy-check proto-check shape shape-check api api-check ci deadcode \ |
| 15 | deploy | ||
| 15 | 16 | ||
| 16 | # Build the SvelteKit SPA and stage it into the Go embed dir. Requires Node. | 17 | # Build the SvelteKit SPA and stage it into the Go embed dir. Requires Node. |
| 17 | # `go build` works without this (the server serves a "UI not built" notice until | 18 | # `go build` works without this (the server serves a "UI not built" notice until |
| @@ -65,6 +66,12 @@ smoke-go: build | |||
| 65 | sudo -v | 66 | sudo -v |
| 66 | go test -tags=smoke -timeout=20m -count=1 ./internal/integration -run TestSmoke -v | 67 | go test -tags=smoke -timeout=20m -count=1 ./internal/integration -run TestSmoke -v |
| 67 | 68 | ||
| 69 | # Roll freshly-built HEAD to the live fleet: local eitri-server + every remote | ||
| 70 | # eitri-agent (restart-based; running VMs survive the agent bounce). Config from | ||
| 71 | # $$EITRI_DEPLOY_ENV (default ~/eitri-deploy/deploy.env); see scripts/deploy.env.example. | ||
| 72 | deploy: | ||
| 73 | ./scripts/deploy.sh | ||
| 74 | |||
| 68 | # Bring up the real stack interactively for feature development; Ctrl-C to stop. | 75 | # Bring up the real stack interactively for feature development; Ctrl-C to stop. |
| 69 | devstack: build | 76 | devstack: build |
| 70 | sudo -v | 77 | sudo -v |
| @@ -131,18 +138,16 @@ shape-check: | |||
| 131 | # entrypoint — every main() in cmd/. Rooting at the binaries (NOT -test) is what | 138 | # entrypoint — every main() in cmd/. Rooting at the binaries (NOT -test) is what |
| 132 | # catches production code kept alive only by its own tests; the fix is to remove | 139 | # catches production code kept alive only by its own tests; the fix is to remove |
| 133 | # it, wire it into a real path, or move it into a _test.go. The smoke/sandbox tags | 140 | # it, wire it into a real path, or move it into a _test.go. The smoke/sandbox tags |
| 134 | # compile the tag-gated code so it is analysed too. Four sanctioned exceptions, | 141 | # compile the tag-gated code so it is analysed too. Three sanctioned exceptions, |
| 135 | # all production code that only a CROSS-package test can reach (so none can be | 142 | # all production code that only a CROSS-package test can reach (so none can be |
| 136 | # a _test.go): internal/integration (the e2e/harness tree), reconcile.Engine.Stop | 143 | # a _test.go): internal/integration (the e2e/harness tree), reconcile.Engine.Stop |
| 137 | # (terminal teardown that must not run in production — it would report every VM as | 144 | # (terminal teardown that must not run in production — it would report every VM as |
| 138 | # vanished — used only by an integration test's cleanup), store.Store.Close / | 145 | # vanished — used only by an integration test's cleanup), and |
| 139 | # store.Store.Epoch (reachable only via the store's tests until the serial console | ||
| 140 | # stream lands and flows the store through a Close()-bearing interface), and | ||
| 141 | # store.Store.AllocatedByHost (the single-tx Snapshot path now serves GET /hosts; | 146 | # store.Store.AllocatedByHost (the single-tx Snapshot path now serves GET /hosts; |
| 142 | # the standalone accessor is exercised only by the store's own allocation tests). | 147 | # the standalone accessor is exercised only by the store's own allocation tests). |
| 143 | deadcode: | 148 | deadcode: |
| 144 | @out=$$(go run golang.org/x/tools/cmd/deadcode@$(DEADCODE_VERSION) -tags=smoke,sandbox ./... \ | 149 | @out=$$(go run golang.org/x/tools/cmd/deadcode@$(DEADCODE_VERSION) -tags=smoke,sandbox ./... \ |
| 145 | | { grep -vE '^internal/integration/|unreachable func: Engine\.Stop$$|unreachable func: Store\.Close$$|unreachable func: Store\.Epoch$$|unreachable func: Store\.AllocatedByHost$$' || true; }); \ | 150 | | { grep -vE '^internal/integration/|unreachable func: Engine\.Stop$$|unreachable func: Store\.AllocatedByHost$$' || true; }); \ |
| 146 | if [ -n "$$out" ]; then \ | 151 | if [ -n "$$out" ]; then \ |
| 147 | echo "deadcode: unreachable from any cmd/ entrypoint (remove it, wire it in, or move it to a _test.go):"; \ | 152 | echo "deadcode: unreachable from any cmd/ entrypoint (remove it, wire it in, or move it to a _test.go):"; \ |
| 148 | echo "$$out"; exit 1; \ | 153 | echo "$$out"; exit 1; \ |
cmd/eitri-agent/main.go
| Old | New | ||
|---|---|---|---|
| @@ -3,48 +3,62 @@ | |||
| 3 | package main | 3 | package main |
| 4 | 4 | ||
| 5 | import ( | 5 | import ( |
| 6 | "bytes" | ||
| 7 | "context" | 6 | "context" |
| 8 | "encoding/json" | ||
| 9 | "errors" | 7 | "errors" |
| 10 | "flag" | 8 | "flag" |
| 11 | "fmt" | 9 | "fmt" |
| 12 | "io" | ||
| 13 | "log/slog" | 10 | "log/slog" |
| 14 | "net/http" | ||
| 15 | "os" | 11 | "os" |
| 16 | "os/exec" | 12 | "os/exec" |
| 17 | "os/signal" | 13 | "os/signal" |
| 14 | "path/filepath" | ||
| 18 | "runtime" | 15 | "runtime" |
| 19 | "strings" | ||
| 20 | "syscall" | 16 | "syscall" |
| 21 | "time" | 17 | "time" |
| 22 | 18 | ||
| 23 | "github.com/a73x/eitri/internal/agent/cloudhv" | 19 | "github.com/a73x/eitri/internal/agent/cloudhv" |
| 20 | "github.com/a73x/eitri/internal/agent/enrollclient" | ||
| 24 | "github.com/a73x/eitri/internal/agent/imagecache" | 21 | "github.com/a73x/eitri/internal/agent/imagecache" |
| 25 | "github.com/a73x/eitri/internal/agent/netenv" | 22 | "github.com/a73x/eitri/internal/agent/netenv" |
| 26 | "github.com/a73x/eitri/internal/agent/overlay" | ||
| 27 | "github.com/a73x/eitri/internal/agent/reconcile" | 23 | "github.com/a73x/eitri/internal/agent/reconcile" |
| 28 | "github.com/a73x/eitri/internal/agent/seed" | 24 | "github.com/a73x/eitri/internal/agent/seed" |
| 25 | "github.com/a73x/eitri/internal/agent/serialpump" | ||
| 29 | "github.com/a73x/eitri/internal/agent/state" | 26 | "github.com/a73x/eitri/internal/agent/state" |
| 30 | "github.com/a73x/eitri/internal/agent/syncclient" | 27 | "github.com/a73x/eitri/internal/agent/syncclient" |
| 31 | "github.com/a73x/eitri/internal/joinblob" | 28 | "github.com/a73x/eitri/internal/joinblob" |
| 32 | ) | 29 | ) |
| 33 | 30 | ||
| 31 | // agentConfig carries runAgent's wiring, replacing a long positional list. | ||
| 32 | type agentConfig struct { | ||
| 33 | StateDir, CHBin, Firmware string | ||
| 34 | TombstoneGrace, VanishGrace time.Duration | ||
| 35 | StepTimeout time.Duration | ||
| 36 | ImageCacheMaxGB int64 | ||
| 37 | // MaxVCPUs, MaxMemMB, MaxDiskGB cap the host resources this agent offers the | ||
| 38 | // fleet (0 = unlimited): advertised to the server AND enforced at VM boot. | ||
| 39 | MaxVCPUs, MaxMemMB, MaxDiskGB int64 | ||
| 40 | } | ||
| 41 | |||
| 34 | func main() { | 42 | func main() { |
| 35 | stateDir := flag.String("state-dir", "/var/lib/eitri-agent", "agent state directory") | 43 | stateDir := flag.String("state-dir", "/var/lib/eitri-agent", "agent state directory") |
| 36 | chBin := flag.String("ch-bin", "cloud-hypervisor", "path to cloud-hypervisor binary") | 44 | chBin := flag.String("ch-bin", "cloud-hypervisor", "path to cloud-hypervisor binary") |
| 37 | firmware := flag.String("firmware", "/usr/share/eitri/hypervisor-fw", "path to hypervisor-fw") | 45 | firmware := flag.String("firmware", "/usr/share/eitri/hypervisor-fw", "path to hypervisor-fw") |
| 38 | overlayKind := flag.String("overlay", "tailscale", "overlay kind: tailscale or none") | ||
| 39 | overlayAuthkey := flag.String("overlay-authkey", "", "overlay auth key (optional, for initial enroll on dedicated hosts — requires --manage-overlay)") | ||
| 40 | manageOverlay := flag.Bool("manage-overlay", false, "allow agent to additively modify overlay route advertisement (opt-in; for dedicated hosts)") | ||
| 41 | noMasqIfaces := flag.String("no-masquerade-ifaces", "", "comma-separated interfaces to exclude from NAT masquerade (e.g. wg0)") | ||
| 42 | tombstoneGrace := flag.Duration("tombstone-grace", 5*time.Minute, "quarantine period for tombstoned VMs before destroy") | 46 | tombstoneGrace := flag.Duration("tombstone-grace", 5*time.Minute, "quarantine period for tombstoned VMs before destroy") |
| 43 | vanishGrace := flag.Duration("vanish-grace", time.Hour, "quarantine period for VMs that vanished without a tombstone") | 47 | vanishGrace := flag.Duration("vanish-grace", time.Hour, "quarantine period for VMs that vanished without a tombstone") |
| 44 | stepTimeout := flag.Duration("step-timeout", 15*time.Minute, "watchdog bound on one WHOLE reconcile step — all VMs, summed (0 disables); keep above the 10m image-download timeout") | 48 | stepTimeout := flag.Duration("step-timeout", 15*time.Minute, "watchdog bound on one WHOLE reconcile step — all VMs, summed (0 disables); keep above the 10m image-download timeout") |
| 45 | imageCacheMaxGB := flag.Int64("image-cache-max-gb", 20, "evict least-recently-used cached base images beyond this size (0 = never evict)") | 49 | imageCacheMaxGB := flag.Int64("image-cache-max-gb", 20, "evict least-recently-used cached base images beyond this size (0 = never evict)") |
| 50 | maxVCPUs := flag.Int64("max-vcpus", 0, "cap the total vCPUs this host offers the fleet (0 = unlimited; reserves headroom, advertised + enforced at boot)") | ||
| 51 | maxMemMB := flag.Int64("max-mem-mb", 0, "cap the total memory (MB) this host offers the fleet (0 = unlimited)") | ||
| 52 | maxDiskGB := flag.Int64("max-disk-gb", 0, "cap the total disk (GB) this host offers the fleet (0 = unlimited)") | ||
| 46 | flag.Parse() | 53 | flag.Parse() |
| 47 | 54 | ||
| 55 | for name, v := range map[string]int64{"max-vcpus": *maxVCPUs, "max-mem-mb": *maxMemMB, "max-disk-gb": *maxDiskGB} { | ||
| 56 | if v < 0 { | ||
| 57 | slog.Error("resource cap must be >= 0 (0 = unlimited)", "flag", "--"+name, "value", v) | ||
| 58 | os.Exit(1) | ||
| 59 | } | ||
| 60 | } | ||
| 61 | |||
| 48 | st, err := state.Open(*stateDir) | 62 | st, err := state.Open(*stateDir) |
| 49 | if err != nil { | 63 | if err != nil { |
| 50 | slog.Error("open state dir", "err", err) | 64 | slog.Error("open state dir", "err", err) |
| @@ -52,17 +66,28 @@ func main() { | |||
| 52 | } | 66 | } |
| 53 | 67 | ||
| 54 | if flag.Arg(0) == "join" { | 68 | if flag.Arg(0) == "join" { |
| 55 | runJoin(st, flag.Arg(1), *overlayKind) | 69 | runJoin(st, flag.Arg(1)) |
| 56 | return | 70 | return |
| 57 | } | 71 | } |
| 58 | 72 | ||
| 59 | runAgent(st, *stateDir, *chBin, *firmware, *overlayKind, *overlayAuthkey, *noMasqIfaces, *manageOverlay, *tombstoneGrace, *vanishGrace, *stepTimeout, *imageCacheMaxGB) | 73 | runAgent(st, agentConfig{ |
| 74 | StateDir: *stateDir, | ||
| 75 | CHBin: *chBin, | ||
| 76 | Firmware: *firmware, | ||
| 77 | TombstoneGrace: *tombstoneGrace, | ||
| 78 | VanishGrace: *vanishGrace, | ||
| 79 | StepTimeout: *stepTimeout, | ||
| 80 | ImageCacheMaxGB: *imageCacheMaxGB, | ||
| 81 | MaxVCPUs: *maxVCPUs, | ||
| 82 | MaxMemMB: *maxMemMB, | ||
| 83 | MaxDiskGB: *maxDiskGB, | ||
| 84 | }) | ||
| 60 | } | 85 | } |
| 61 | 86 | ||
| 62 | // runJoin handles the "join <blob>" subcommand: decode the join blob, enroll, | 87 | // runJoin handles the "join <blob>" subcommand: decode the join blob, enroll, |
| 63 | // and persist identity — pinning the server cert from the blob (the enroll | 88 | // and persist identity — pinning the server cert from the blob (the enroll |
| 64 | // response's fingerprint is ignored, so the blob is the sole trust root). | 89 | // response's fingerprint is ignored, so the blob is the sole trust root). |
| 65 | func runJoin(st *state.Store, blob, overlayKind string) { | 90 | func runJoin(st *state.Store, blob string) { |
| 66 | if blob == "" { | 91 | if blob == "" { |
| 67 | fmt.Fprintln(os.Stderr, "usage: eitri-agent join <join-blob>") | 92 | fmt.Fprintln(os.Stderr, "usage: eitri-agent join <join-blob>") |
| 68 | os.Exit(1) | 93 | os.Exit(1) |
| @@ -78,47 +103,20 @@ func runJoin(st *state.Store, blob, overlayKind string) { | |||
| 78 | if err != nil { | 103 | if err != nil { |
| 79 | hostname = "unknown" | 104 | hostname = "unknown" |
| 80 | } | 105 | } |
| 81 | body, err := json.Marshal(map[string]string{ | ||
| 82 | "token": f.Token, | ||
| 83 | "name": hostname, | ||
| 84 | "os": runtime.GOOS, | ||
| 85 | "arch": runtime.GOARCH, | ||
| 86 | "provisioner": "cloudhv", | ||
| 87 | "overlay": overlayKind, | ||
| 88 | }) | ||
| 89 | if err != nil { | ||
| 90 | slog.Error("marshal enroll request", "err", err) | ||
| 91 | os.Exit(1) | ||
| 92 | } | ||
| 93 | 106 | ||
| 94 | client := &http.Client{Timeout: 30 * time.Second} | 107 | result, err := enrollclient.New(f.HTTPURL).Enroll(context.Background(), enrollclient.Request{ |
| 95 | resp, err := client.Post(f.HTTPURL+"/api/v1/enroll", "application/json", bytes.NewReader(body)) | 108 | Token: f.Token, |
| 96 | if err != nil { | 109 | Name: hostname, |
| 97 | slog.Error("enroll request", "err", err) | 110 | OS: runtime.GOOS, |
| 98 | os.Exit(1) | 111 | Arch: runtime.GOARCH, |
| 99 | } | 112 | Provisioner: "cloudhv", |
| 100 | defer resp.Body.Close() | 113 | }) |
| 101 | respBody, readErr := io.ReadAll(resp.Body) | 114 | if errors.Is(err, enrollclient.ErrTokenRejected) { |
| 102 | if readErr != nil { | ||
| 103 | slog.Error("read enroll response", "err", readErr) | ||
| 104 | os.Exit(1) | ||
| 105 | } | ||
| 106 | if resp.StatusCode == http.StatusForbidden { | ||
| 107 | fmt.Fprintln(os.Stderr, "enroll rejected: token already used or expired — mint a new join token") | 115 | fmt.Fprintln(os.Stderr, "enroll rejected: token already used or expired — mint a new join token") |
| 108 | os.Exit(1) | 116 | os.Exit(1) |
| 109 | } | 117 | } |
| 110 | if resp.StatusCode != http.StatusCreated { | 118 | if err != nil { |
| 111 | fmt.Fprintf(os.Stderr, "enroll failed (HTTP %d): %s\n", resp.StatusCode, respBody) | 119 | fmt.Fprintf(os.Stderr, "enroll failed: %v\n", err) |
| 112 | os.Exit(1) | ||
| 113 | } | ||
| 114 | |||
| 115 | var result struct { | ||
| 116 | HostID string `json:"host_id"` | ||
| 117 | Credential string `json:"credential"` | ||
| 118 | BridgeCIDR string `json:"bridge_cidr"` | ||
| 119 | } | ||
| 120 | if err := json.Unmarshal(respBody, &result); err != nil { | ||
| 121 | slog.Error("parse enroll response", "err", err) | ||
| 122 | os.Exit(1) | 120 | os.Exit(1) |
| 123 | } | 121 | } |
| 124 | 122 | ||
| @@ -143,23 +141,8 @@ func realRunner(ctx context.Context, name string, args ...string) (string, error | |||
| 143 | return string(out), err | 141 | return string(out), err |
| 144 | } | 142 | } |
| 145 | 143 | ||
| 146 | // splitComma splits a comma-separated string, returning nil for empty input. | ||
| 147 | func splitComma(s string) []string { | ||
| 148 | if s == "" { | ||
| 149 | return nil | ||
| 150 | } | ||
| 151 | parts := strings.Split(s, ",") | ||
| 152 | result := make([]string, 0, len(parts)) | ||
| 153 | for _, p := range parts { | ||
| 154 | if t := strings.TrimSpace(p); t != "" { | ||
| 155 | result = append(result, t) | ||
| 156 | } | ||
| 157 | } | ||
| 158 | return result | ||
| 159 | } | ||
| 160 | |||
| 161 | // runAgent handles the normal (no subcommand) run mode. | 144 | // runAgent handles the normal (no subcommand) run mode. |
| 162 | func runAgent(st *state.Store, stateDir, chBin, firmware, overlayKind, overlayAuthkey, noMasqIfacesStr string, manageOverlay bool, tombstoneGrace, vanishGrace, stepTimeout time.Duration, imageCacheMaxGB int64) { | 145 | func runAgent(st *state.Store, cfg agentConfig) { |
| 163 | id, ok := st.Identity() | 146 | id, ok := st.Identity() |
| 164 | if !ok { | 147 | if !ok { |
| 165 | fmt.Fprintln(os.Stderr, "not enrolled — run with 'join <blob>' subcommand first") | 148 | fmt.Fprintln(os.Stderr, "not enrolled — run with 'join <blob>' subcommand first") |
| @@ -175,58 +158,36 @@ func runAgent(st *state.Store, stateDir, chBin, firmware, overlayKind, overlayAu | |||
| 175 | os.Exit(1) | 158 | os.Exit(1) |
| 176 | } | 159 | } |
| 177 | 160 | ||
| 178 | extraNoMasq := splitComma(noMasqIfacesStr) | 161 | if err := net.EnsureBridge(ctx); err != nil { |
| 179 | ov, err := overlay.New(overlayKind, id.BridgeCIDR, overlayAuthkey, realRunner, extraNoMasq) | ||
| 180 | if err != nil { | ||
| 181 | slog.Error("overlay init", "err", err) | ||
| 182 | os.Exit(1) | ||
| 183 | } | ||
| 184 | |||
| 185 | if err := net.EnsureBridge(ctx, ov.NoMasqueradeIfaces()); err != nil { | ||
| 186 | slog.Error("ensure bridge", "err", err) | 162 | slog.Error("ensure bridge", "err", err) |
| 187 | os.Exit(1) | 163 | os.Exit(1) |
| 188 | } | 164 | } |
| 189 | 165 | ||
| 190 | // EnsureRoute implements the overlay ownership model (spec: | 166 | prov := cloudhv.New(st, cfg.CHBin, cfg.Firmware, realRunner) |
| 191 | // "Networking — pluggable overlay"). | ||
| 192 | // Default (--manage-overlay=false): observe-and-instruct — never mutates | ||
| 193 | // overlay state; logs an action-required warning with the exact command | ||
| 194 | // for the operator and keeps running (VMs work locally; reachability | ||
| 195 | // pending). | ||
| 196 | // Opt-in (--manage-overlay): additive only — unions our bridge CIDR into | ||
| 197 | // the host's existing advertised routes; never replaces or removes others'. | ||
| 198 | if err := ov.EnsureRoute(ctx, manageOverlay); err != nil { | ||
| 199 | if errors.Is(err, overlay.ErrActionRequired) { | ||
| 200 | slog.Warn("overlay action required — VMs reachable locally; network reachability pending operator action", | ||
| 201 | "instruction", err.Error()) | ||
| 202 | // Non-fatal: keep running. VMs work on the bridge; network connectivity | ||
| 203 | // is blocked until the operator follows the instruction. | ||
| 204 | } else { | ||
| 205 | // Config or consent error (e.g. authkey without --manage-overlay). | ||
| 206 | slog.Error("overlay setup failed", "err", err) | ||
| 207 | os.Exit(1) | ||
| 208 | } | ||
| 209 | } | ||
| 210 | 167 | ||
| 211 | if err := ov.VerifyRoute(ctx); err != nil { | 168 | // Serial console pumps: one per running VM, started at Boot (cloudhv hook) |
| 212 | if errors.Is(err, overlay.ErrUnverifiable) { | 169 | // and reattached here for VMs that survived an agent restart (CH runs in |
| 213 | slog.Info("route verification unavailable for this overlay — unverifiable by design") | 170 | // its own process group; the pump reconnects to the still-listening |
| 214 | } else { | 171 | // serial socket). |
| 215 | slog.Warn("overlay route not yet verified — VMs may be unreachable from the network", "err", err) | 172 | pumps := serialpump.NewManager(st.SerialSocketPath, func(vmID string) string { |
| 173 | return filepath.Join(st.VMDir(vmID), "serial.log") | ||
| 174 | }) | ||
| 175 | prov.Pumps = pumps | ||
| 176 | if recs, err := st.LoadVMs(); err == nil { | ||
| 177 | for _, rec := range recs { | ||
| 178 | if prov.Running(rec.Spec.VMID) { | ||
| 179 | pumps.Ensure(rec.Spec.VMID) | ||
| 180 | } | ||
| 216 | } | 181 | } |
| 217 | // Non-fatal: the bridge still works for local routing. | 182 | } else { |
| 183 | slog.Warn("serial pump adoption skipped; consoles of surviving VMs will be silent", "err", err) | ||
| 218 | } | 184 | } |
| 219 | 185 | ||
| 220 | // Start a background goroutine that periodically re-checks both | ||
| 221 | // EnsureRoute and VerifyRoute, logging only on state transitions | ||
| 222 | // (level-triggered, not one-shot). | ||
| 223 | go overlay.Watch(ctx, ov, manageOverlay, 60*time.Second, nil) | ||
| 224 | |||
| 225 | prov := cloudhv.New(st, chBin, firmware, realRunner) | ||
| 226 | cache := imagecache.New(st.ImagesDir(), realRunner) | 186 | cache := imagecache.New(st.ImagesDir(), realRunner) |
| 227 | // Clamp before shifting: GB<<30 overflows int64 for absurd flag values — | 187 | // Clamp before shifting: GB<<30 overflows int64 for absurd flag values — |
| 228 | // same trap class cloudhv's maxDiskGB comment documents. 1 PiB is beyond | 188 | // same trap class cloudhv's maxDiskGB comment documents. 1 PiB is beyond |
| 229 | // any real cache; anything above disables eviction just like 0 would. | 189 | // any real cache; anything above disables eviction just like 0 would. |
| 190 | imageCacheMaxGB := cfg.ImageCacheMaxGB | ||
| 230 | if imageCacheMaxGB < 0 || imageCacheMaxGB > 1<<20 { | 191 | if imageCacheMaxGB < 0 || imageCacheMaxGB > 1<<20 { |
| 231 | slog.Warn("image-cache-max-gb out of range [0, 2^20]; disabling eviction", "value", imageCacheMaxGB) | 192 | slog.Warn("image-cache-max-gb out of range [0, 2^20]; disabling eviction", "value", imageCacheMaxGB) |
| 232 | imageCacheMaxGB = 0 | 193 | imageCacheMaxGB = 0 |
| @@ -241,10 +202,13 @@ func runAgent(st *state.Store, stateDir, chBin, firmware, overlayKind, overlayAu | |||
| 241 | Seed: seed.Build, | 202 | Seed: seed.Build, |
| 242 | BootID: syncclient.HostBootID, | 203 | BootID: syncclient.HostBootID, |
| 243 | Now: time.Now, | 204 | Now: time.Now, |
| 244 | TombstoneGrace: tombstoneGrace, | 205 | TombstoneGrace: cfg.TombstoneGrace, |
| 245 | VanishGrace: vanishGrace, | 206 | VanishGrace: cfg.VanishGrace, |
| 246 | MaxCreateAttempts: 3, | 207 | MaxCreateAttempts: 3, |
| 247 | StepTimeout: stepTimeout, | 208 | StepTimeout: cfg.StepTimeout, |
| 209 | MaxVCPUs: cfg.MaxVCPUs, | ||
| 210 | MaxMemMB: cfg.MaxMemMB, | ||
| 211 | MaxDiskGB: cfg.MaxDiskGB, | ||
| 248 | } | 212 | } |
| 249 | 213 | ||
| 250 | // Compile-time interface satisfaction checks. | 214 | // Compile-time interface satisfaction checks. |
| @@ -252,10 +216,14 @@ func runAgent(st *state.Store, stateDir, chBin, firmware, overlayKind, overlayAu | |||
| 252 | var _ reconcile.NetEnv = net | 216 | var _ reconcile.NetEnv = net |
| 253 | 217 | ||
| 254 | client := &syncclient.Client{ | 218 | client := &syncclient.Client{ |
| 255 | Engine: engine, | 219 | Engine: engine, |
| 256 | St: st, | 220 | St: st, |
| 257 | Identity: id, | 221 | Identity: id, |
| 258 | StateDir: stateDir, | 222 | StateDir: cfg.StateDir, |
| 223 | Console: pumps, | ||
| 224 | MaxVCPUs: cfg.MaxVCPUs, | ||
| 225 | MaxMemMB: cfg.MaxMemMB, | ||
| 226 | MaxDiskGB: cfg.MaxDiskGB, | ||
| 259 | } | 227 | } |
| 260 | 228 | ||
| 261 | slog.Info("agent started", "host_id", id.HostID, "bridge_cidr", id.BridgeCIDR) | 229 | slog.Info("agent started", "host_id", id.HostID, "bridge_cidr", id.BridgeCIDR) |
cmd/eitri-server/main.go
| Old | New | ||
|---|---|---|---|
| @@ -7,21 +7,32 @@ import ( | |||
| 7 | "encoding/json" | 7 | "encoding/json" |
| 8 | "flag" | 8 | "flag" |
| 9 | "log/slog" | 9 | "log/slog" |
| 10 | "net" | ||
| 10 | "net/http" | 11 | "net/http" |
| 11 | "os" | 12 | "os" |
| 13 | "regexp" | ||
| 12 | "time" | 14 | "time" |
| 13 | 15 | ||
| 14 | "github.com/a73x/eitri/internal/joinblob" | 16 | "github.com/a73x/eitri/internal/joinblob" |
| 15 | "github.com/a73x/eitri/internal/server/api" | 17 | "github.com/a73x/eitri/internal/server/api" |
| 18 | "github.com/a73x/eitri/internal/server/health" | ||
| 16 | "github.com/a73x/eitri/internal/server/hub" | 19 | "github.com/a73x/eitri/internal/server/hub" |
| 17 | "github.com/a73x/eitri/internal/server/registry" | 20 | "github.com/a73x/eitri/internal/server/registry" |
| 21 | "github.com/a73x/eitri/internal/server/sshca" | ||
| 22 | "github.com/a73x/eitri/internal/server/sshgate" | ||
| 18 | "github.com/a73x/eitri/internal/server/store" | 23 | "github.com/a73x/eitri/internal/server/store" |
| 19 | "github.com/a73x/eitri/internal/server/syncsvc" | 24 | "github.com/a73x/eitri/internal/server/syncsvc" |
| 20 | "github.com/a73x/eitri/internal/server/web" | 25 | "github.com/a73x/eitri/internal/server/web" |
| 21 | "github.com/a73x/eitri/internal/transport" | 26 | "github.com/a73x/eitri/internal/transport" |
| 22 | "github.com/quic-go/quic-go" | 27 | "github.com/quic-go/quic-go" |
| 28 | "golang.org/x/crypto/ssh" | ||
| 23 | ) | 29 | ) |
| 24 | 30 | ||
| 31 | // defaultImageSHARe matches a valid lowercase hex SHA-256 digest. Kept | ||
| 32 | // local (not internal/agent/imagecache.ValidSHA256): R1 forbids the | ||
| 33 | // control plane importing the data plane, even transitively. | ||
| 34 | var defaultImageSHARe = regexp.MustCompile(`^[a-f0-9]{64}$`) | ||
| 35 | |||
| 25 | type config struct { | 36 | type config struct { |
| 26 | HTTPListen string `json:"http_listen"` | 37 | HTTPListen string `json:"http_listen"` |
| 27 | QUICListen string `json:"quic_listen"` | 38 | QUICListen string `json:"quic_listen"` |
| @@ -41,6 +52,26 @@ type config struct { | |||
| 41 | // AuditRetention bounds the audit_log age (Go duration; default "2160h" = | 52 | // AuditRetention bounds the audit_log age (Go duration; default "2160h" = |
| 42 | // 90 days; "0" disables pruning). Pruned at startup and daily. | 53 | // 90 days; "0" disables pruning). Pruned at startup and daily. |
| 43 | AuditRetention string `json:"audit_retention"` | 54 | AuditRetention string `json:"audit_retention"` |
| 55 | // SSHCAKey is the path to the persistent SSH user CA private key | ||
| 56 | // (auto-created 0600 if absent, handled like AdminToken — never logged). | ||
| 57 | // The CA signs the short-lived certs the jump gate accepts. | ||
| 58 | SSHCAKey string `json:"ssh_ca_key"` | ||
| 59 | // SSHHostKey is the path to the gate's persistent SSH host key | ||
| 60 | // (auto-created 0600 if absent, never regenerated on restart so users | ||
| 61 | // don't see host-key-changed warnings). | ||
| 62 | SSHHostKey string `json:"ssh_host_key"` | ||
| 63 | // SSHListen is the jump-gate listen address. Empty ⇒ gate is OFF (no | ||
| 64 | // key material is loaded and no listener is started). | ||
| 65 | SSHListen string `json:"ssh_listen"` | ||
| 66 | // SSHGateDomain is the hostname clients dial the gate as (the principal put | ||
| 67 | // on the gate's signed HOST certificate). Empty ⇒ derived from SSHListen's | ||
| 68 | // host part; if that is also empty (e.g. ":2222") it falls back to | ||
| 69 | // "localhost". It must match the host in EITRI_GATE so `@cert-authority` | ||
| 70 | // verification accepts the presented host cert. | ||
| 71 | SSHGateDomain string `json:"ssh_gate_domain"` | ||
| 72 | // SSHCertTTL bounds minted user-cert validity (Go duration; default 10m). | ||
| 73 | // Set server-side; client-requested validity is never honored. | ||
| 74 | SSHCertTTL string `json:"ssh_cert_ttl"` | ||
| 44 | } | 75 | } |
| 45 | 76 | ||
| 46 | func main() { | 77 | func main() { |
| @@ -64,6 +95,14 @@ func main() { | |||
| 64 | slog.Error("advertise_http and advertise_quic are required (the addresses agents use to reach this server, e.g. http://192.168.0.190:8080 and 192.168.0.190:8443)") | 95 | slog.Error("advertise_http and advertise_quic are required (the addresses agents use to reach this server, e.g. http://192.168.0.190:8080 and 192.168.0.190:8443)") |
| 65 | os.Exit(1) | 96 | os.Exit(1) |
| 66 | } | 97 | } |
| 98 | // [carry-forward] Fail fast on a malformed default image digest rather | ||
| 99 | // than letting every mint silently propagate a bad hash. A local regex | ||
| 100 | // (not internal/agent/imagecache.ValidSHA256) because R1 forbids the | ||
| 101 | // control plane importing the data plane, even transitively. | ||
| 102 | if cfg.DefaultImageSHA != "" && !defaultImageSHARe.MatchString(cfg.DefaultImageSHA) { | ||
| 103 | slog.Error("default_image_sha256 malformed (want 64 lowercase hex chars)", "value", cfg.DefaultImageSHA) | ||
| 104 | os.Exit(1) | ||
| 105 | } | ||
| 67 | 106 | ||
| 68 | st, err := store.Open(cfg.DBPath, cfg.CIDRPool) | 107 | st, err := store.Open(cfg.DBPath, cfg.CIDRPool) |
| 69 | if err != nil { | 108 | if err != nil { |
| @@ -147,6 +186,41 @@ func main() { | |||
| 147 | os.Exit(1) | 186 | os.Exit(1) |
| 148 | } | 187 | } |
| 149 | 188 | ||
| 189 | // SSH jump gate (§B): OFF unless ssh_listen is set. When enabled, load or | ||
| 190 | // create the persistent user CA + gate host key (0600, never logged) and | ||
| 191 | // resolve the cert TTL now so a misconfig fails fast at startup. | ||
| 192 | var sshGate *sshca.CA | ||
| 193 | sshCertTTL := 10 * time.Minute | ||
| 194 | if cfg.SSHListen != "" { | ||
| 195 | if cfg.SSHCAKey == "" || cfg.SSHHostKey == "" { | ||
| 196 | slog.Error("ssh_ca_key and ssh_host_key are required when ssh_listen is set") | ||
| 197 | os.Exit(1) | ||
| 198 | } | ||
| 199 | if cfg.SSHCertTTL != "" { | ||
| 200 | sshCertTTL, err = time.ParseDuration(cfg.SSHCertTTL) | ||
| 201 | if err != nil { | ||
| 202 | slog.Error("ssh_cert_ttl invalid", "err", err) | ||
| 203 | os.Exit(1) | ||
| 204 | } | ||
| 205 | if sshCertTTL <= 0 { | ||
| 206 | slog.Error("ssh_cert_ttl must be > 0", "value", cfg.SSHCertTTL) | ||
| 207 | os.Exit(1) | ||
| 208 | } | ||
| 209 | } | ||
| 210 | sshGate, err = sshca.New(cfg.SSHCAKey, cfg.SSHHostKey) | ||
| 211 | if err != nil { | ||
| 212 | slog.Error("ssh ca", "err", err) | ||
| 213 | os.Exit(1) | ||
| 214 | } | ||
| 215 | // Log the CA identity operators pin in known_hosts / inject into VMs. | ||
| 216 | // Only the *public* key is ever logged (private material never is). | ||
| 217 | slog.Info("ssh jump gate configured", "listen", cfg.SSHListen, | ||
| 218 | "cert_ttl", sshCertTTL, | ||
| 219 | "user_ca", string(sshGate.UserCAAuthorizedKey())) | ||
| 220 | // The gate listener itself is started below, once syncsvc.Service (the | ||
| 221 | // tunnel dialer) exists. | ||
| 222 | } | ||
| 223 | |||
| 150 | reg := registry.New(time.Now) | 224 | reg := registry.New(time.Now) |
| 151 | h := hub.New() | 225 | h := hub.New() |
| 152 | 226 | ||
| @@ -176,7 +250,100 @@ func main() { | |||
| 176 | os.Exit(1) | 250 | os.Exit(1) |
| 177 | } | 251 | } |
| 178 | } | 252 | } |
| 253 | // SSH cert minter: when the jump gate is enabled, the API mints short-lived | ||
| 254 | // user certs signed by the persistent user CA (POST /api/v1/ssh-certs). | ||
| 255 | // Left nil when the gate is off, so the endpoint 404s. | ||
| 256 | if sshGate != nil { | ||
| 257 | a.SetCertMinter(api.NewMinter(sshGate.UserCA(), sshCertTTL)) | ||
| 258 | // Per-VM host certs: sign a persistent host key + cert at each VM create, | ||
| 259 | // so VMs present verifiable host keys (clients accept via @cert-authority). | ||
| 260 | a.SetHostCertMinter(api.NewHostMinter(sshGate.UserCA())) | ||
| 261 | // Publish the CA public key so clients can pin `@cert-authority` for host | ||
| 262 | // verification of both the gate and every VM. | ||
| 263 | a.SetSSHCAAuthorizedKey(string(sshGate.UserCAAuthorizedKey())) | ||
| 264 | } | ||
| 265 | |||
| 179 | svc := syncsvc.New(st, reg, h, []byte(cfg.HostSecret), maxCredAge) | 266 | svc := syncsvc.New(st, reg, h, []byte(cfg.HostSecret), maxCredAge) |
| 267 | // When the jump gate is enabled, advertise the user-CA public key in every | ||
| 268 | // desired-VM snapshot so guests inject it as an sshd TrustedUserCAKeys | ||
| 269 | // drop-in and trust CA-signed certs. Off (nil gate) => no injection. | ||
| 270 | if sshGate != nil { | ||
| 271 | svc.SetSSHUserCAKey(string(sshGate.UserCAAuthorizedKey())) | ||
| 272 | } | ||
| 273 | // Console broker: the API bridges browser WebSockets to agent console | ||
| 274 | // streams over the live sync connections the service tracks. | ||
| 275 | a.SetConsoleDialer(svc) | ||
| 276 | |||
| 277 | // SSH jump gate listener: when enabled, front `ssh -J gate ubuntu@<vm>` with | ||
| 278 | // the hardened bastion. It resolves VM names against the store, tunnels port | ||
| 279 | // 22 through the sync connection (svc.OpenTCP), and trusts only certs signed | ||
| 280 | // by the user CA. A failed bind is fatal (like QUIC/HTTP below): a dead gate | ||
| 281 | // must not run silently. | ||
| 282 | if sshGate != nil { | ||
| 283 | // The gate host cert's principal is the name clients dial. Prefer the | ||
| 284 | // configured domain; else the host part of ssh_listen; else "localhost". | ||
| 285 | gateDomain := cfg.SSHGateDomain | ||
| 286 | if gateDomain == "" { | ||
| 287 | if h, _, err := net.SplitHostPort(cfg.SSHListen); err == nil { | ||
| 288 | gateDomain = h | ||
| 289 | } | ||
| 290 | } | ||
| 291 | if gateDomain == "" { | ||
| 292 | gateDomain = "localhost" | ||
| 293 | } | ||
| 294 | slog.Info("ssh gate host cert", "principal", gateDomain) | ||
| 295 | // resolve maps a VM name to its host/VM IDs; unknown or tombstoned ⇒ ok=false. | ||
| 296 | resolve := func(name string) (hostID, vmID string, ok bool) { | ||
| 297 | vm, err := st.VMByName(name) | ||
| 298 | if err != nil { | ||
| 299 | return "", "", false | ||
| 300 | } | ||
| 301 | return vm.HostID, vm.ID, true | ||
| 302 | } | ||
| 303 | // v1 single-admin: any CA-signed cert reaches any VM; per-user ownership is Task/Slice per §5/§9. | ||
| 304 | authorize := func(principal, vmID string) bool { return true } | ||
| 305 | // Sign a long-lived HOST cert for the gate's own host key and present THAT | ||
| 306 | // (via a cert signer) instead of the bare key, so a client verifying with | ||
| 307 | // `@cert-authority` accepts the gate on first connect — no TOFU window. | ||
| 308 | gateCert, err := sshca.SignHostCert(sshGate.UserCA(), sshGate.HostKey().PublicKey(), | ||
| 309 | []string{gateDomain}, "eitri-gate", time.Now(), sshca.HostCertTTL) | ||
| 310 | if err != nil { | ||
| 311 | slog.Error("sign gate host cert", "err", err) | ||
| 312 | os.Exit(1) | ||
| 313 | } | ||
| 314 | gateHostSigner, err := ssh.NewCertSigner(gateCert, sshGate.HostKey()) | ||
| 315 | if err != nil { | ||
| 316 | slog.Error("gate host cert signer", "err", err) | ||
| 317 | os.Exit(1) | ||
| 318 | } | ||
| 319 | // isRevoked gates every cert auth against the revocation list. Fail-CLOSED | ||
| 320 | // for the single connection on a DB error: a store hiccup rejects THAT | ||
| 321 | // login (returns revoked=true) rather than fail-open (which would let a | ||
| 322 | // possibly-revoked cert through) or fail-the-whole-gate (which a global | ||
| 323 | // close would amount to, DoSing every login on any transient error). | ||
| 324 | isRevoked := func(serial uint64) bool { | ||
| 325 | revoked, err := st.IsSSHCertRevoked(serial) | ||
| 326 | if err != nil { | ||
| 327 | slog.Error("ssh cert revocation lookup failed; rejecting connection", "err", err) | ||
| 328 | return true | ||
| 329 | } | ||
| 330 | return revoked | ||
| 331 | } | ||
| 332 | gate := sshgate.New(gateHostSigner, sshGate.UserCA().PublicKey(), resolve, authorize, svc.OpenTCP, isRevoked) | ||
| 333 | ln, err := net.Listen("tcp", cfg.SSHListen) | ||
| 334 | if err != nil { | ||
| 335 | slog.Error("ssh gate listen", "err", err) | ||
| 336 | os.Exit(1) | ||
| 337 | } | ||
| 338 | go func() { | ||
| 339 | slog.Info("ssh jump gate listening", "addr", cfg.SSHListen) | ||
| 340 | if err := gate.Serve(ln); err != nil { | ||
| 341 | slog.Error("ssh gate serve", "err", err) | ||
| 342 | os.Exit(1) | ||
| 343 | } | ||
| 344 | }() | ||
| 345 | } | ||
| 346 | |||
| 180 | go func() { | 347 | go func() { |
| 181 | slog.Info("quic listening", "addr", cfg.QUICListen) | 348 | slog.Info("quic listening", "addr", cfg.QUICListen) |
| 182 | if err := svc.Serve(context.Background(), lis); err != nil { | 349 | if err := svc.Serve(context.Background(), lis); err != nil { |
| @@ -191,6 +358,16 @@ func main() { | |||
| 191 | // Serve the REST API + SSE under /api/ and the embedded SPA everywhere else. | 358 | // Serve the REST API + SSE under /api/ and the embedded SPA everywhere else. |
| 192 | root := http.NewServeMux() | 359 | root := http.NewServeMux() |
| 193 | root.Handle("/api/", a.Handler()) | 360 | root.Handle("/api/", a.Handler()) |
| 361 | // Unauthenticated probes (outside /api/, so a load balancer or the deploy | ||
| 362 | // script needs no token). /livez is process-up; /readyz gates on the | ||
| 363 | // dependencies the server needs to actually serve — the DB. The QUIC | ||
| 364 | // listener bind is a startup invariant: quic.ListenAddr above exits the | ||
| 365 | // process on failure and runs before this HTTP server, so a response here | ||
| 366 | // already implies QUIC bound. | ||
| 367 | root.HandleFunc("/livez", health.Live) | ||
| 368 | root.Handle("/readyz", health.Ready(3*time.Second, | ||
| 369 | health.Check{Name: "db", Probe: st.Ping}, | ||
| 370 | )) | ||
| 194 | root.Handle("/", web.Handler()) | 371 | root.Handle("/", web.Handler()) |
| 195 | 372 | ||
| 196 | slog.Info("http listening", "addr", cfg.HTTPListen) | 373 | slog.Info("http listening", "addr", cfg.HTTPListen) |
docs/architecture.md
| Old | New | ||
|---|---|---|---|
| @@ -17,9 +17,17 @@ hosts and share only a wire contract: | |||
| 17 | | **Wire contract** | `internal/pb`, `internal/transport` | The only code shared across the boundary: protobuf messages + QUIC framing/TLS. | | 17 | | **Wire contract** | `internal/pb`, `internal/transport` | The only code shared across the boundary: protobuf messages + QUIC framing/TLS. | |
| 18 | 18 | ||
| 19 | The server expresses intent as a `pb.DesiredStateSnapshot` (server → agent); the | 19 | The server expresses intent as a `pb.DesiredStateSnapshot` (server → agent); the |
| 20 | agent reports back a `pb.ActualStateReport` (agent → server). The server's only | 20 | agent reports back a `pb.ActualStateReport` (agent → server). The server never |
| 21 | "actuation" is writing desired state to its store and poking the SSE hub. All | 21 | touches a VM and never executes a process: besides writing desired state to its |
| 22 | side effects on real infrastructure live in the agent. | 22 | store and poking the SSE hub, its only real-world side effects are control-plane |
| 23 | services (the SSH jump gate and cert minting). All side effects on VMs and hosts | ||
| 24 | live in the agent. | ||
| 25 | |||
| 26 | Real SSH into a guest goes through the **SSH jump gate** | ||
| 27 | (`internal/server/sshgate` + `internal/server/sshca`): the server holds an SSH | ||
| 28 | CA, mints short-lived user certs, and tunnels TCP:22 to the VM over the existing | ||
| 29 | server↔agent sync channel. There is no user network — VMs are reachable at their | ||
| 30 | bridge IP (`assigned_ip`) via the agent. | ||
| 23 | 31 | ||
| 24 | ## Invariants | 32 | ## Invariants |
| 25 | 33 | ||
| @@ -29,7 +37,7 @@ side effects on real infrastructure live in the agent. | |||
| 29 | | **R2** | No `internal/server` package shells out — the server is pure control plane. Checked transitively: an internal wrapper around `os/exec` cannot smuggle a shell-out in. | `internal/arch` `TestServerNeverShellsOut` (transitive) + `depguard` `server-no-exec` (direct, fast in-editor). | | 37 | | **R2** | No `internal/server` package shells out — the server is pure control plane. Checked transitively: an internal wrapper around `os/exec` cannot smuggle a shell-out in. | `internal/arch` `TestServerNeverShellsOut` (transitive) + `depguard` `server-no-exec` (direct, fast in-editor). | |
| 30 | | **R3** | The wire contract (`pb`, `transport`) imports no other internal package, so a heavy dependency can't leak across the boundary into both binaries. | `internal/arch` `TestWireContractIsLeaf`. Behavior pinned by `transport` round-trip contract tests. | | 38 | | **R3** | The wire contract (`pb`, `transport`) imports no other internal package, so a heavy dependency can't leak across the boundary into both binaries. | `internal/arch` `TestWireContractIsLeaf`. Behavior pinned by `transport` round-trip contract tests. | |
| 31 | | **R4** | Pure domain packages (`agent/state`, `agent/seed`, `agent/ipalloc`, `server/registry`) don't depend on the transport stack (HTTP/QUIC/`transport`). `server/store` may use `transport` (cert helpers) but not HTTP/QUIC. | `internal/arch` `TestDomainDoesNotImportTransportStack` + `depguard` `domain-no-transport`. | | 39 | | **R4** | Pure domain packages (`agent/state`, `agent/seed`, `agent/ipalloc`, `server/registry`) don't depend on the transport stack (HTTP/QUIC/`transport`). `server/store` may use `transport` (cert helpers) but not HTTP/QUIC. | `internal/arch` `TestDomainDoesNotImportTransportStack` + `depguard` `domain-no-transport`. | |
| 32 | | **R5** | The reconcile boundary interfaces (`Provisioner`, `NetEnv`, `Overlay`) stay consumer-owned and small; the IPAM seam (`NetEnv.AllocateIP`/`GuestNetwork`) is where a future central allocator plugs in. | Convention (below) + `ireturn` allow-list keeps the seams' interface returns honest. | | 40 | | **R5** | The reconcile boundary interfaces (`Provisioner`, `NetEnv`) stay consumer-owned and small; the IPAM seam (`NetEnv.AllocateIP`/`GuestNetwork`) is where a future central allocator plugs in. | Convention (below) + `ireturn` allow-list keeps the seams' interface returns honest. | |
| 33 | | **R6** | All external process execution in the data plane funnels through `agent/exec.Runner`. The sole exception is `agent/cloudhv`, which launches the long-lived cloud-hypervisor process directly. Checked transitively (reaching `os/exec` via the sanctioned `cloudhv` is fine). | `internal/arch` `TestOnlyCloudhvImportsOsExecInDataPlane` (transitive). | | 41 | | **R6** | All external process execution in the data plane funnels through `agent/exec.Runner`. The sole exception is `agent/cloudhv`, which launches the long-lived cloud-hypervisor process directly. Checked transitively (reaching `os/exec` via the sanctioned `cloudhv` is fine). | `internal/arch` `TestOnlyCloudhvImportsOsExecInDataPlane` (transitive). | |
| 34 | 42 | ||
| 35 | > The `internal/arch` tests shell out to `go list`, so Go's test cache can't see | 43 | > The `internal/arch` tests shell out to `go list`, so Go's test cache can't see |
| @@ -56,7 +64,7 @@ why the invariants hold: | |||
| 56 | `syncsvc.New`) own all state. Immutable package vars (compiled regexps) are | 64 | `syncsvc.New`) own all state. Immutable package vars (compiled regexps) are |
| 57 | fine. | 65 | fine. |
| 58 | 4. **Mock only true external dependencies.** Tests use hand-written fakes for | 66 | 4. **Mock only true external dependencies.** Tests use hand-written fakes for |
| 59 | the boundary interfaces (`Provisioner`, `NetEnv`, `Overlay`) and the | 67 | the boundary interfaces (`Provisioner`, `NetEnv`) and the |
| 60 | `exec.Runner`. The SQLite store is used for real in tests, not mocked. Don't | 68 | `exec.Runner`. The SQLite store is used for real in tests, not mocked. Don't |
| 61 | introduce a mocking framework — hand-written fakes keep tests honest about | 69 | introduce a mocking framework — hand-written fakes keep tests honest about |
| 62 | real behavior. | 70 | real behavior. |
docs/credential-revocation.md
| Old | New | ||
|---|---|---|---|
| @@ -40,3 +40,11 @@ there is no automatic renewal channel yet, so expiry trades credential | |||
| 40 | lifetime against operator toil. Generation revocation is the primary | 40 | lifetime against operator toil. Generation revocation is the primary |
| 41 | mechanism. | 41 | mechanism. |
| 42 | 42 | ||
| 43 | ## SSH user certificates | ||
| 44 | |||
| 45 | Guest SSH access uses short-lived certificates minted by the server's SSH CA | ||
| 46 | (`POST /api/v1/ssh-certs`). A short TTL (`ssh_cert_ttl`, default 10m, set | ||
| 47 | server-side) is the first line of defense: a leaked user cert expires on its | ||
| 48 | own within minutes. Before it does, a specific cert can be revoked at the gate | ||
| 49 | by serial (`POST /api/v1/ssh-certs/revoke`, idempotent; the gate rejects | ||
| 50 | revoked serials at auth) — pass either the raw serial or the full cert line. | ||
docs/openapi.json
| Old | New | ||
|---|---|---|---|
| @@ -102,9 +102,6 @@ | |||
| 102 | "os": { | 102 | "os": { |
| 103 | "type": "string" | 103 | "type": "string" |
| 104 | }, | 104 | }, |
| 105 | "overlay": { | ||
| 106 | "type": "string" | ||
| 107 | }, | ||
| 108 | "provisioner": { | 105 | "provisioner": { |
| 109 | "type": "string" | 106 | "type": "string" |
| 110 | }, | 107 | }, |
| @@ -125,9 +122,6 @@ | |||
| 125 | "host_id": { | 122 | "host_id": { |
| 126 | "type": "string" | 123 | "type": "string" |
| 127 | }, | 124 | }, |
| 128 | "overlay": { | ||
| 129 | "type": "string" | ||
| 130 | }, | ||
| 131 | "server_cert_sha256": { | 125 | "server_cert_sha256": { |
| 132 | "type": "string" | 126 | "type": "string" |
| 133 | } | 127 | } |
| @@ -136,7 +130,6 @@ | |||
| 136 | "bridge_cidr", | 130 | "bridge_cidr", |
| 137 | "credential", | 131 | "credential", |
| 138 | "host_id", | 132 | "host_id", |
| 139 | "overlay", | ||
| 140 | "server_cert_sha256" | 133 | "server_cert_sha256" |
| 141 | ], | 134 | ], |
| 142 | "type": "object" | 135 | "type": "object" |
| @@ -186,9 +179,6 @@ | |||
| 186 | "os": { | 179 | "os": { |
| 187 | "type": "string" | 180 | "type": "string" |
| 188 | }, | 181 | }, |
| 189 | "overlay": { | ||
| 190 | "type": "string" | ||
| 191 | }, | ||
| 192 | "provisioner": { | 182 | "provisioner": { |
| 193 | "type": "string" | 183 | "type": "string" |
| 194 | }, | 184 | }, |
| @@ -206,7 +196,6 @@ | |||
| 206 | "name", | 196 | "name", |
| 207 | "online", | 197 | "online", |
| 208 | "os", | 198 | "os", |
| 209 | "overlay", | ||
| 210 | "provisioner", | 199 | "provisioner", |
| 211 | "status" | 200 | "status" |
| 212 | ], | 201 | ], |
| @@ -220,6 +209,79 @@ | |||
| 220 | }, | 209 | }, |
| 221 | "type": "object" | 210 | "type": "object" |
| 222 | }, | 211 | }, |
| 212 | "RevokeSSHCertRequest": { | ||
| 213 | "properties": { | ||
| 214 | "certificate": { | ||
| 215 | "type": "string" | ||
| 216 | }, | ||
| 217 | "reason": { | ||
| 218 | "type": "string" | ||
| 219 | }, | ||
| 220 | "serial": { | ||
| 221 | "type": [ | ||
| 222 | "integer", | ||
| 223 | "null" | ||
| 224 | ] | ||
| 225 | } | ||
| 226 | }, | ||
| 227 | "type": "object" | ||
| 228 | }, | ||
| 229 | "RevokedCert": { | ||
| 230 | "properties": { | ||
| 231 | "reason": { | ||
| 232 | "type": "string" | ||
| 233 | }, | ||
| 234 | "revoked_at": { | ||
| 235 | "format": "date-time", | ||
| 236 | "type": "string" | ||
| 237 | }, | ||
| 238 | "serial": { | ||
| 239 | "type": "string" | ||
| 240 | } | ||
| 241 | }, | ||
| 242 | "required": [ | ||
| 243 | "reason", | ||
| 244 | "revoked_at", | ||
| 245 | "serial" | ||
| 246 | ], | ||
| 247 | "type": "object" | ||
| 248 | }, | ||
| 249 | "SSHCAResponse": { | ||
| 250 | "properties": { | ||
| 251 | "ca": { | ||
| 252 | "type": "string" | ||
| 253 | } | ||
| 254 | }, | ||
| 255 | "required": [ | ||
| 256 | "ca" | ||
| 257 | ], | ||
| 258 | "type": "object" | ||
| 259 | }, | ||
| 260 | "SSHCertRequest": { | ||
| 261 | "properties": { | ||
| 262 | "principals": { | ||
| 263 | "items": { | ||
| 264 | "type": "string" | ||
| 265 | }, | ||
| 266 | "type": "array" | ||
| 267 | }, | ||
| 268 | "public_key": { | ||
| 269 | "type": "string" | ||
| 270 | } | ||
| 271 | }, | ||
| 272 | "type": "object" | ||
| 273 | }, | ||
| 274 | "SSHCertResponse": { | ||
| 275 | "properties": { | ||
| 276 | "certificate": { | ||
| 277 | "type": "string" | ||
| 278 | } | ||
| 279 | }, | ||
| 280 | "required": [ | ||
| 281 | "certificate" | ||
| 282 | ], | ||
| 283 | "type": "object" | ||
| 284 | }, | ||
| 223 | "StateSnapshot": { | 285 | "StateSnapshot": { |
| 224 | "properties": { | 286 | "properties": { |
| 225 | "hosts": { | 287 | "hosts": { |
| @@ -267,6 +329,9 @@ | |||
| 267 | "deleted": { | 329 | "deleted": { |
| 268 | "type": "boolean" | 330 | "type": "boolean" |
| 269 | }, | 331 | }, |
| 332 | "destroy_at": { | ||
| 333 | "type": "integer" | ||
| 334 | }, | ||
| 270 | "disk_gb": { | 335 | "disk_gb": { |
| 271 | "type": "integer" | 336 | "type": "integer" |
| 272 | }, | 337 | }, |
| @@ -282,6 +347,9 @@ | |||
| 282 | "last_error": { | 347 | "last_error": { |
| 283 | "type": "string" | 348 | "type": "string" |
| 284 | }, | 349 | }, |
| 350 | "lifecycle": { | ||
| 351 | "type": "string" | ||
| 352 | }, | ||
| 285 | "mem_mb": { | 353 | "mem_mb": { |
| 286 | "type": "integer" | 354 | "type": "integer" |
| 287 | }, | 355 | }, |
| @@ -309,11 +377,13 @@ | |||
| 309 | "assigned_ip", | 377 | "assigned_ip", |
| 310 | "created_at", | 378 | "created_at", |
| 311 | "deleted", | 379 | "deleted", |
| 380 | "destroy_at", | ||
| 312 | "disk_gb", | 381 | "disk_gb", |
| 313 | "host_id", | 382 | "host_id", |
| 314 | "id", | 383 | "id", |
| 315 | "image_url", | 384 | "image_url", |
| 316 | "last_error", | 385 | "last_error", |
| 386 | "lifecycle", | ||
| 317 | "mem_mb", | 387 | "mem_mb", |
| 318 | "name", | 388 | "name", |
| 319 | "persistent", | 389 | "persistent", |
| @@ -596,6 +666,145 @@ | |||
| 596 | "summary": "Revoke a host's outstanding credential by bumping its generation; the host stays dark until re-enrolled." | 666 | "summary": "Revoke a host's outstanding credential by bumping its generation; the host stays dark until re-enrolled." |
| 597 | } | 667 | } |
| 598 | }, | 668 | }, |
| 669 | "/api/v1/ssh-ca": { | ||
| 670 | "get": { | ||
| 671 | "responses": { | ||
| 672 | "200": { | ||
| 673 | "content": { | ||
| 674 | "application/json": { | ||
| 675 | "schema": { | ||
| 676 | "$ref": "#/components/schemas/SSHCAResponse" | ||
| 677 | } | ||
| 678 | } | ||
| 679 | }, | ||
| 680 | "description": "success" | ||
| 681 | }, | ||
| 682 | "default": { | ||
| 683 | "content": { | ||
| 684 | "text/plain": { | ||
| 685 | "schema": { | ||
| 686 | "type": "string" | ||
| 687 | } | ||
| 688 | } | ||
| 689 | }, | ||
| 690 | "description": "error (plain text)" | ||
| 691 | } | ||
| 692 | }, | ||
| 693 | "summary": "The eitri SSH CA public key (public material) for pinning `@cert-authority` in known_hosts. 404 when the jump gate is off." | ||
| 694 | } | ||
| 695 | }, | ||
| 696 | "/api/v1/ssh-certs": { | ||
| 697 | "post": { | ||
| 698 | "requestBody": { | ||
| 699 | "content": { | ||
| 700 | "application/json": { | ||
| 701 | "schema": { | ||
| 702 | "$ref": "#/components/schemas/SSHCertRequest" | ||
| 703 | } | ||
| 704 | } | ||
| 705 | }, | ||
| 706 | "required": true | ||
| 707 | }, | ||
| 708 | "responses": { | ||
| 709 | "200": { | ||
| 710 | "content": { | ||
| 711 | "application/json": { | ||
| 712 | "schema": { | ||
| 713 | "$ref": "#/components/schemas/SSHCertResponse" | ||
| 714 | } | ||
| 715 | } | ||
| 716 | }, | ||
| 717 | "description": "success" | ||
| 718 | }, | ||
| 719 | "default": { | ||
| 720 | "content": { | ||
| 721 | "text/plain": { | ||
| 722 | "schema": { | ||
| 723 | "type": "string" | ||
| 724 | } | ||
| 725 | } | ||
| 726 | }, | ||
| 727 | "description": "error (plain text)" | ||
| 728 | } | ||
| 729 | }, | ||
| 730 | "security": [ | ||
| 731 | { | ||
| 732 | "adminToken": [] | ||
| 733 | } | ||
| 734 | ], | ||
| 735 | "summary": "Mint a short-lived SSH user certificate for the caller's public key. 404 when the jump gate is off (no CA wired)." | ||
| 736 | } | ||
| 737 | }, | ||
| 738 | "/api/v1/ssh-certs/revoke": { | ||
| 739 | "post": { | ||
| 740 | "requestBody": { | ||
| 741 | "content": { | ||
| 742 | "application/json": { | ||
| 743 | "schema": { | ||
| 744 | "$ref": "#/components/schemas/RevokeSSHCertRequest" | ||
| 745 | } | ||
| 746 | } | ||
| 747 | }, | ||
| 748 | "required": true | ||
| 749 | }, | ||
| 750 | "responses": { | ||
| 751 | "204": { | ||
| 752 | "description": "success" | ||
| 753 | }, | ||
| 754 | "default": { | ||
| 755 | "content": { | ||
| 756 | "text/plain": { | ||
| 757 | "schema": { | ||
| 758 | "type": "string" | ||
| 759 | } | ||
| 760 | } | ||
| 761 | }, | ||
| 762 | "description": "error (plain text)" | ||
| 763 | } | ||
| 764 | }, | ||
| 765 | "security": [ | ||
| 766 | { | ||
| 767 | "adminToken": [] | ||
| 768 | } | ||
| 769 | ], | ||
| 770 | "summary": "Revoke a minted SSH user certificate by serial or certificate line; the gate rejects it before its TTL expires. Idempotent." | ||
| 771 | } | ||
| 772 | }, | ||
| 773 | "/api/v1/ssh-certs/revoked": { | ||
| 774 | "get": { | ||
| 775 | "responses": { | ||
| 776 | "200": { | ||
| 777 | "content": { | ||
| 778 | "application/json": { | ||
| 779 | "schema": { | ||
| 780 | "items": { | ||
| 781 | "$ref": "#/components/schemas/RevokedCert" | ||
| 782 | }, | ||
| 783 | "type": "array" | ||
| 784 | } | ||
| 785 | } | ||
| 786 | }, | ||
| 787 | "description": "success" | ||
| 788 | }, | ||
| 789 | "default": { | ||
| 790 | "content": { | ||
| 791 | "text/plain": { | ||
| 792 | "schema": { | ||
| 793 | "type": "string" | ||
| 794 | } | ||
| 795 | } | ||
| 796 | }, | ||
| 797 | "description": "error (plain text)" | ||
| 798 | } | ||
| 799 | }, | ||
| 800 | "security": [ | ||
| 801 | { | ||
| 802 | "adminToken": [] | ||
| 803 | } | ||
| 804 | ], | ||
| 805 | "summary": "List revoked SSH user certificate serials (with reason and time), newest first." | ||
| 806 | } | ||
| 807 | }, | ||
| 599 | "/api/v1/stream-tickets": { | 808 | "/api/v1/stream-tickets": { |
| 600 | "post": { | 809 | "post": { |
| 601 | "responses": { | 810 | "responses": { |
| @@ -625,7 +834,7 @@ | |||
| 625 | "adminToken": [] | 834 | "adminToken": [] |
| 626 | } | 835 | } |
| 627 | ], | 836 | ], |
| 628 | "summary": "Mint a one-time short-TTL ticket for the SSE stream — the only credential that ever rides in a URL." | 837 | "summary": "Mint a one-time short-TTL ticket for the SSE stream or console WebSocket — the only credential that ever rides in a URL." |
| 629 | } | 838 | } |
| 630 | }, | 839 | }, |
| 631 | "/api/v1/vms": { | 840 | "/api/v1/vms": { |
| @@ -735,7 +944,7 @@ | |||
| 735 | "adminToken": [] | 944 | "adminToken": [] |
| 736 | } | 945 | } |
| 737 | ], | 946 | ], |
| 738 | "summary": "Tombstone a VM for teardown." | 947 | "summary": "Tombstone a VM for teardown; restorable within the grace window via restore." |
| 739 | }, | 948 | }, |
| 740 | "patch": { | 949 | "patch": { |
| 741 | "parameters": [ | 950 | "parameters": [ |
| @@ -780,6 +989,134 @@ | |||
| 780 | ], | 989 | ], |
| 781 | "summary": "Set a VM's desired power state (running or stopped)." | 990 | "summary": "Set a VM's desired power state (running or stopped)." |
| 782 | } | 991 | } |
| 992 | }, | ||
| 993 | "/api/v1/vms/{id}/console/ws": { | ||
| 994 | "get": { | ||
| 995 | "parameters": [ | ||
| 996 | { | ||
| 997 | "in": "path", | ||
| 998 | "name": "id", | ||
| 999 | "required": true, | ||
| 1000 | "schema": { | ||
| 1001 | "type": "string" | ||
| 1002 | } | ||
| 1003 | }, | ||
| 1004 | { | ||
| 1005 | "description": "one-time stream ticket", | ||
| 1006 | "in": "query", | ||
| 1007 | "name": "ticket", | ||
| 1008 | "required": false, | ||
| 1009 | "schema": { | ||
| 1010 | "type": "string" | ||
| 1011 | } | ||
| 1012 | } | ||
| 1013 | ], | ||
| 1014 | "responses": { | ||
| 1015 | "101": { | ||
| 1016 | "description": "switching protocols (WebSocket)" | ||
| 1017 | }, | ||
| 1018 | "default": { | ||
| 1019 | "content": { | ||
| 1020 | "text/plain": { | ||
| 1021 | "schema": { | ||
| 1022 | "type": "string" | ||
| 1023 | } | ||
| 1024 | } | ||
| 1025 | }, | ||
| 1026 | "description": "error (plain text)" | ||
| 1027 | } | ||
| 1028 | }, | ||
| 1029 | "summary": "Serial-console WebSocket: raw byte pipe to the VM's serial console." | ||
| 1030 | } | ||
| 1031 | }, | ||
| 1032 | "/api/v1/vms/{id}/events": { | ||
| 1033 | "get": { | ||
| 1034 | "parameters": [ | ||
| 1035 | { | ||
| 1036 | "in": "path", | ||
| 1037 | "name": "id", | ||
| 1038 | "required": true, | ||
| 1039 | "schema": { | ||
| 1040 | "type": "string" | ||
| 1041 | } | ||
| 1042 | }, | ||
| 1043 | { | ||
| 1044 | "description": "max rows to return (default 100, cap 1000)", | ||
| 1045 | "in": "query", | ||
| 1046 | "name": "limit", | ||
| 1047 | "required": false, | ||
| 1048 | "schema": { | ||
| 1049 | "type": "string" | ||
| 1050 | } | ||
| 1051 | } | ||
| 1052 | ], | ||
| 1053 | "responses": { | ||
| 1054 | "200": { | ||
| 1055 | "content": { | ||
| 1056 | "application/json": { | ||
| 1057 | "schema": { | ||
| 1058 | "items": { | ||
| 1059 | "$ref": "#/components/schemas/AuditEvent" | ||
| 1060 | }, | ||
| 1061 | "type": "array" | ||
| 1062 | } | ||
| 1063 | } | ||
| 1064 | }, | ||
| 1065 | "description": "success" | ||
| 1066 | }, | ||
| 1067 | "default": { | ||
| 1068 | "content": { | ||
| 1069 | "text/plain": { | ||
| 1070 | "schema": { | ||
| 1071 | "type": "string" | ||
| 1072 | } | ||
| 1073 | } | ||
| 1074 | }, | ||
| 1075 | "description": "error (plain text)" | ||
| 1076 | } | ||
| 1077 | }, | ||
| 1078 | "security": [ | ||
| 1079 | { | ||
| 1080 | "adminToken": [] | ||
| 1081 | } | ||
| 1082 | ], | ||
| 1083 | "summary": "One VM's lifecycle timeline (audit rows carrying its vm_id), newest first; survives the VM row being reaped." | ||
| 1084 | } | ||
| 1085 | }, | ||
| 1086 | "/api/v1/vms/{id}/restore": { | ||
| 1087 | "post": { | ||
| 1088 | "parameters": [ | ||
| 1089 | { | ||
| 1090 | "in": "path", | ||
| 1091 | "name": "id", | ||
| 1092 | "required": true, | ||
| 1093 | "schema": { | ||
| 1094 | "type": "string" | ||
| 1095 | } | ||
| 1096 | } | ||
| 1097 | ], | ||
| 1098 | "responses": { | ||
| 1099 | "204": { | ||
| 1100 | "description": "success" | ||
| 1101 | }, | ||
| 1102 | "default": { | ||
| 1103 | "content": { | ||
| 1104 | "text/plain": { | ||
| 1105 | "schema": { | ||
| 1106 | "type": "string" | ||
| 1107 | } | ||
| 1108 | } | ||
| 1109 | }, | ||
| 1110 | "description": "error (plain text)" | ||
| 1111 | } | ||
| 1112 | }, | ||
| 1113 | "security": [ | ||
| 1114 | { | ||
| 1115 | "adminToken": [] | ||
| 1116 | } | ||
| 1117 | ], | ||
| 1118 | "summary": "Un-tombstone a VM still within the teardown grace window; the agent re-adopts the guest." | ||
| 1119 | } | ||
| 783 | } | 1120 | } |
| 784 | } | 1121 | } |
| 785 | } | 1122 | } |
docs/shape.html
| Old | New | ||
|---|---|---|---|
| @@ -58,11 +58,12 @@ | |||
| 58 | "synopsis": "eitri-agent: BYO-hardware agent.", | 58 | "synopsis": "eitri-agent: BYO-hardware agent.", |
| 59 | "imports": [ | 59 | "imports": [ |
| 60 | "internal/agent/cloudhv", | 60 | "internal/agent/cloudhv", |
| 61 | "internal/agent/enrollclient", | ||
| 61 | "internal/agent/imagecache", | 62 | "internal/agent/imagecache", |
| 62 | "internal/agent/netenv", | 63 | "internal/agent/netenv", |
| 63 | "internal/agent/overlay", | ||
| 64 | "internal/agent/reconcile", | 64 | "internal/agent/reconcile", |
| 65 | "internal/agent/seed", | 65 | "internal/agent/seed", |
| 66 | "internal/agent/serialpump", | ||
| 66 | "internal/agent/state", | 67 | "internal/agent/state", |
| 67 | "internal/agent/syncclient", | 68 | "internal/agent/syncclient", |
| 68 | "internal/joinblob" | 69 | "internal/joinblob" |
| @@ -83,8 +84,11 @@ | |||
| 83 | "imports": [ | 84 | "imports": [ |
| 84 | "internal/joinblob", | 85 | "internal/joinblob", |
| 85 | "internal/server/api", | 86 | "internal/server/api", |
| 87 | "internal/server/health", | ||
| 86 | "internal/server/hub", | 88 | "internal/server/hub", |
| 87 | "internal/server/registry", | 89 | "internal/server/registry", |
| 90 | "internal/server/sshca", | ||
| 91 | "internal/server/sshgate", | ||
| 88 | "internal/server/store", | 92 | "internal/server/store", |
| 89 | "internal/server/syncsvc", | 93 | "internal/server/syncsvc", |
| 90 | "internal/server/web", | 94 | "internal/server/web", |
| @@ -109,15 +113,21 @@ | |||
| 109 | ] | 113 | ] |
| 110 | }, | 114 | }, |
| 111 | { | 115 | { |
| 116 | "importPath": "internal/agent/enrollclient", | ||
| 117 | "plane": "data", | ||
| 118 | "synopsis": "Package enrollclient speaks the control plane's enrollment endpoint.", | ||
| 119 | "imports": [] | ||
| 120 | }, | ||
| 121 | { | ||
| 112 | "importPath": "internal/agent/exec", | 122 | "importPath": "internal/agent/exec", |
| 113 | "plane": "data", | 123 | "plane": "data", |
| 114 | "synopsis": "Package exec defines the single command-runner type shared by the host-touching agent packages (cloudhv, imagecache, netenv, overlay).", | 124 | "synopsis": "Package exec defines the single command-runner type shared by the host-touching agent packages (cloudhv, imagecache, netenv).", |
| 115 | "imports": [] | 125 | "imports": [] |
| 116 | }, | 126 | }, |
| 117 | { | 127 | { |
| 118 | "importPath": "internal/agent/imagecache", | 128 | "importPath": "internal/agent/imagecache", |
| 119 | "plane": "data", | 129 | "plane": "data", |
| 120 | "synopsis": "Package imagecache downloads, verifies, and raw-converts base images.", | 130 | "synopsis": "Package imagecache downloads and verifies content-addressed base images (raw-converted via qemu-img, LRU-evicted beyond MaxBytes).", |
| 121 | "imports": [ | 131 | "imports": [ |
| 122 | "internal/agent/exec" | 132 | "internal/agent/exec" |
| 123 | ] | 133 | ] |
| @@ -138,14 +148,6 @@ | |||
| 138 | ] | 148 | ] |
| 139 | }, | 149 | }, |
| 140 | { | 150 | { |
| 141 | "importPath": "internal/agent/overlay", | ||
| 142 | "plane": "data", | ||
| 143 | "synopsis": "Package overlay abstracts how a host's bridge CIDR becomes reachable from the user's network.", | ||
| 144 | "imports": [ | ||
| 145 | "internal/agent/exec" | ||
| 146 | ] | ||
| 147 | }, | ||
| 148 | { | ||
| 149 | "importPath": "internal/agent/reconcile", | 151 | "importPath": "internal/agent/reconcile", |
| 150 | "plane": "data", | 152 | "plane": "data", |
| 151 | "synopsis": "Package reconcile implements the agent's level-triggered reconcile loop.", | 153 | "synopsis": "Package reconcile implements the agent's level-triggered reconcile loop.", |
| @@ -162,6 +164,12 @@ | |||
| 162 | "imports": [] | 164 | "imports": [] |
| 163 | }, | 165 | }, |
| 164 | { | 166 | { |
| 167 | "importPath": "internal/agent/serialpump", | ||
| 168 | "plane": "data", | ||
| 169 | "synopsis": "Package serialpump owns the durability of VM serial consoles.", | ||
| 170 | "imports": [] | ||
| 171 | }, | ||
| 172 | { | ||
| 165 | "importPath": "internal/agent/state", | 173 | "importPath": "internal/agent/state", |
| 166 | "plane": "data", | 174 | "plane": "data", |
| 167 | "synopsis": "Package state is the agent's durable state directory (default /var/lib/eitri-agent).", | 175 | "synopsis": "Package state is the agent's durable state directory (default /var/lib/eitri-agent).", |
| @@ -185,12 +193,24 @@ | |||
| 185 | "imports": [] | 193 | "imports": [] |
| 186 | }, | 194 | }, |
| 187 | { | 195 | { |
| 196 | "importPath": "internal/cloudinit", | ||
| 197 | "plane": "wire", | ||
| 198 | "synopsis": "Package cloudinit merges eitri's structured VM inputs into user-supplied cloud-init user-data.", | ||
| 199 | "imports": [] | ||
| 200 | }, | ||
| 201 | { | ||
| 188 | "importPath": "internal/joinblob", | 202 | "importPath": "internal/joinblob", |
| 189 | "plane": "wire", | 203 | "plane": "wire", |
| 190 | "synopsis": "Package joinblob encodes and decodes the single-paste enrollment token (\"join blob\") an agent uses to enroll: it carries the server's HTTP base URL, its QUIC address, a one-shot enrollment token, and the server's TLS cert fingerprint for out-of-band pinning.", | 204 | "synopsis": "Package joinblob encodes and decodes the single-paste enrollment token (\"join blob\") an agent uses to enroll: it carries the server's HTTP base URL, its QUIC address, a one-shot enrollment token, and the server's TLS cert fingerprint for out-of-band pinning.", |
| 191 | "imports": [] | 205 | "imports": [] |
| 192 | }, | 206 | }, |
| 193 | { | 207 | { |
| 208 | "importPath": "internal/names", | ||
| 209 | "plane": "wire", | ||
| 210 | "synopsis": "Package names validates the DNS-label shape shared across planes: a VM's name doubles as its guest hostname, so it must be a valid RFC-1123 label.", | ||
| 211 | "imports": [] | ||
| 212 | }, | ||
| 213 | { | ||
| 194 | "importPath": "internal/pb", | 214 | "importPath": "internal/pb", |
| 195 | "plane": "wire", | 215 | "plane": "wire", |
| 196 | "synopsis": "", | 216 | "synopsis": "", |
| @@ -201,11 +221,14 @@ | |||
| 201 | "plane": "control", | 221 | "plane": "control", |
| 202 | "synopsis": "Package api implements the admin REST API and the unauthenticated enrollment endpoint.", | 222 | "synopsis": "Package api implements the admin REST API and the unauthenticated enrollment endpoint.", |
| 203 | "imports": [ | 223 | "imports": [ |
| 224 | "internal/cloudinit", | ||
| 204 | "internal/joinblob", | 225 | "internal/joinblob", |
| 226 | "internal/names", | ||
| 205 | "internal/server/api/types", | 227 | "internal/server/api/types", |
| 206 | "internal/server/hosttoken", | 228 | "internal/server/hosttoken", |
| 207 | "internal/server/hub", | 229 | "internal/server/hub", |
| 208 | "internal/server/registry", | 230 | "internal/server/registry", |
| 231 | "internal/server/sshca", | ||
| 209 | "internal/server/store" | 232 | "internal/server/store" |
| 210 | ] | 233 | ] |
| 211 | }, | 234 | }, |
| @@ -225,6 +248,12 @@ | |||
| 225 | "imports": [] | 248 | "imports": [] |
| 226 | }, | 249 | }, |
| 227 | { | 250 | { |
| 251 | "importPath": "internal/server/health", | ||
| 252 | "plane": "control", | ||
| 253 | "synopsis": "Package health serves the eitri-server liveness and readiness probes.", | ||
| 254 | "imports": [] | ||
| 255 | }, | ||
| 256 | { | ||
| 228 | "importPath": "internal/server/hosttoken", | 257 | "importPath": "internal/server/hosttoken", |
| 229 | "plane": "control", | 258 | "plane": "control", |
| 230 | "synopsis": "Package hosttoken mints and verifies generation-versioned host credentials.", | 259 | "synopsis": "Package hosttoken mints and verifies generation-versioned host credentials.", |
| @@ -243,6 +272,18 @@ | |||
| 243 | "imports": [] | 272 | "imports": [] |
| 244 | }, | 273 | }, |
| 245 | { | 274 | { |
| 275 | "importPath": "internal/server/sshca", | ||
| 276 | "plane": "control", | ||
| 277 | "synopsis": "Package sshca manages eitri's SSH key material: a persistent user CA (whose short-lived certs authenticate admins to the jump gate and VMs) and a persistent gate host key.", | ||
| 278 | "imports": [] | ||
| 279 | }, | ||
| 280 | { | ||
| 281 | "importPath": "internal/server/sshgate", | ||
| 282 | "plane": "control", | ||
| 283 | "synopsis": "Package sshgate is eitri's hardened SSH jump gate: a bastion front-end that admins reach with `ssh -J gate ubuntu@\u003cvm\u003e`.", | ||
| 284 | "imports": [] | ||
| 285 | }, | ||
| 286 | { | ||
| 246 | "importPath": "internal/server/store", | 287 | "importPath": "internal/server/store", |
| 247 | "plane": "control", | 288 | "plane": "control", |
| 248 | "synopsis": "Package store is the server's durable control-plane state, backed by SQLite: the host registry, enrollment tokens, desired VM specs, and freed CIDRs.", | 289 | "synopsis": "Package store is the server's durable control-plane state, backed by SQLite: the host registry, enrollment tokens, desired VM specs, and freed CIDRs.", |
| @@ -289,7 +330,8 @@ | |||
| 289 | const PLANES = [ | 330 | const PLANES = [ |
| 290 | ["control", "control", "#4571c4"], ["data", "data", "#d04a4a"], | 331 | ["control", "control", "#4571c4"], ["data", "data", "#d04a4a"], |
| 291 | ["wire", "wire", "#2fa85a"], ["binaries", "binaries", "#8a4fd0"], | 332 | ["wire", "wire", "#2fa85a"], ["binaries", "binaries", "#8a4fd0"], |
| 292 | ["tooling", "tooling", "#7a7a7a"], ["unclassified", "unclassified", "#d4a017"], | 333 | ["tooling", "tooling", "#7a7a7a"], ["mesh", "mesh", "#17a2b8"], |
| 334 | ["unclassified", "unclassified", "#d4a017"], | ||
| 293 | ]; | 335 | ]; |
| 294 | const COLOR = Object.fromEntries(PLANES.map(([k, , c]) => [k, c])); | 336 | const COLOR = Object.fromEntries(PLANES.map(([k, , c]) => [k, c])); |
| 295 | const esc = s => String(s).replace(/[&<>]/g, c => ({ "&": "&", "<": "<", ">": ">" }[c])); | 337 | const esc = s => String(s).replace(/[&<>]/g, c => ({ "&": "&", "<": "<", ">": ">" }[c])); |
docs/shape.json
| Old | New | ||
|---|---|---|---|
| @@ -7,11 +7,12 @@ | |||
| 7 | "synopsis": "eitri-agent: BYO-hardware agent.", | 7 | "synopsis": "eitri-agent: BYO-hardware agent.", |
| 8 | "imports": [ | 8 | "imports": [ |
| 9 | "internal/agent/cloudhv", | 9 | "internal/agent/cloudhv", |
| 10 | "internal/agent/enrollclient", | ||
| 10 | "internal/agent/imagecache", | 11 | "internal/agent/imagecache", |
| 11 | "internal/agent/netenv", | 12 | "internal/agent/netenv", |
| 12 | "internal/agent/overlay", | ||
| 13 | "internal/agent/reconcile", | 13 | "internal/agent/reconcile", |
| 14 | "internal/agent/seed", | 14 | "internal/agent/seed", |
| 15 | "internal/agent/serialpump", | ||
| 15 | "internal/agent/state", | 16 | "internal/agent/state", |
| 16 | "internal/agent/syncclient", | 17 | "internal/agent/syncclient", |
| 17 | "internal/joinblob" | 18 | "internal/joinblob" |
| @@ -32,8 +33,11 @@ | |||
| 32 | "imports": [ | 33 | "imports": [ |
| 33 | "internal/joinblob", | 34 | "internal/joinblob", |
| 34 | "internal/server/api", | 35 | "internal/server/api", |
| 36 | "internal/server/health", | ||
| 35 | "internal/server/hub", | 37 | "internal/server/hub", |
| 36 | "internal/server/registry", | 38 | "internal/server/registry", |
| 39 | "internal/server/sshca", | ||
| 40 | "internal/server/sshgate", | ||
| 37 | "internal/server/store", | 41 | "internal/server/store", |
| 38 | "internal/server/syncsvc", | 42 | "internal/server/syncsvc", |
| 39 | "internal/server/web", | 43 | "internal/server/web", |
| @@ -58,15 +62,21 @@ | |||
| 58 | ] | 62 | ] |
| 59 | }, | 63 | }, |
| 60 | { | 64 | { |
| 65 | "importPath": "internal/agent/enrollclient", | ||
| 66 | "plane": "data", | ||
| 67 | "synopsis": "Package enrollclient speaks the control plane's enrollment endpoint.", | ||
| 68 | "imports": [] | ||
| 69 | }, | ||
| 70 | { | ||
| 61 | "importPath": "internal/agent/exec", | 71 | "importPath": "internal/agent/exec", |
| 62 | "plane": "data", | 72 | "plane": "data", |
| 63 | "synopsis": "Package exec defines the single command-runner type shared by the host-touching agent packages (cloudhv, imagecache, netenv, overlay).", | 73 | "synopsis": "Package exec defines the single command-runner type shared by the host-touching agent packages (cloudhv, imagecache, netenv).", |
| 64 | "imports": [] | 74 | "imports": [] |
| 65 | }, | 75 | }, |
| 66 | { | 76 | { |
| 67 | "importPath": "internal/agent/imagecache", | 77 | "importPath": "internal/agent/imagecache", |
| 68 | "plane": "data", | 78 | "plane": "data", |
| 69 | "synopsis": "Package imagecache downloads, verifies, and raw-converts base images.", | 79 | "synopsis": "Package imagecache downloads and verifies content-addressed base images (raw-converted via qemu-img, LRU-evicted beyond MaxBytes).", |
| 70 | "imports": [ | 80 | "imports": [ |
| 71 | "internal/agent/exec" | 81 | "internal/agent/exec" |
| 72 | ] | 82 | ] |
| @@ -87,14 +97,6 @@ | |||
| 87 | ] | 97 | ] |
| 88 | }, | 98 | }, |
| 89 | { | 99 | { |
| 90 | "importPath": "internal/agent/overlay", | ||
| 91 | "plane": "data", | ||
| 92 | "synopsis": "Package overlay abstracts how a host's bridge CIDR becomes reachable from the user's network.", | ||
| 93 | "imports": [ | ||
| 94 | "internal/agent/exec" | ||
| 95 | ] | ||
| 96 | }, | ||
| 97 | { | ||
| 98 | "importPath": "internal/agent/reconcile", | 100 | "importPath": "internal/agent/reconcile", |
| 99 | "plane": "data", | 101 | "plane": "data", |
| 100 | "synopsis": "Package reconcile implements the agent's level-triggered reconcile loop.", | 102 | "synopsis": "Package reconcile implements the agent's level-triggered reconcile loop.", |
| @@ -111,6 +113,12 @@ | |||
| 111 | "imports": [] | 113 | "imports": [] |
| 112 | }, | 114 | }, |
| 113 | { | 115 | { |
| 116 | "importPath": "internal/agent/serialpump", | ||
| 117 | "plane": "data", | ||
| 118 | "synopsis": "Package serialpump owns the durability of VM serial consoles.", | ||
| 119 | "imports": [] | ||
| 120 | }, | ||
| 121 | { | ||
| 114 | "importPath": "internal/agent/state", | 122 | "importPath": "internal/agent/state", |
| 115 | "plane": "data", | 123 | "plane": "data", |
| 116 | "synopsis": "Package state is the agent's durable state directory (default /var/lib/eitri-agent).", | 124 | "synopsis": "Package state is the agent's durable state directory (default /var/lib/eitri-agent).", |
| @@ -134,12 +142,24 @@ | |||
| 134 | "imports": [] | 142 | "imports": [] |
| 135 | }, | 143 | }, |
| 136 | { | 144 | { |
| 145 | "importPath": "internal/cloudinit", | ||
| 146 | "plane": "wire", | ||
| 147 | "synopsis": "Package cloudinit merges eitri's structured VM inputs into user-supplied cloud-init user-data.", | ||
| 148 | "imports": [] | ||
| 149 | }, | ||
| 150 | { | ||
| 137 | "importPath": "internal/joinblob", | 151 | "importPath": "internal/joinblob", |
| 138 | "plane": "wire", | 152 | "plane": "wire", |
| 139 | "synopsis": "Package joinblob encodes and decodes the single-paste enrollment token (\"join blob\") an agent uses to enroll: it carries the server's HTTP base URL, its QUIC address, a one-shot enrollment token, and the server's TLS cert fingerprint for out-of-band pinning.", | 153 | "synopsis": "Package joinblob encodes and decodes the single-paste enrollment token (\"join blob\") an agent uses to enroll: it carries the server's HTTP base URL, its QUIC address, a one-shot enrollment token, and the server's TLS cert fingerprint for out-of-band pinning.", |
| 140 | "imports": [] | 154 | "imports": [] |
| 141 | }, | 155 | }, |
| 142 | { | 156 | { |
| 157 | "importPath": "internal/names", | ||
| 158 | "plane": "wire", | ||
| 159 | "synopsis": "Package names validates the DNS-label shape shared across planes: a VM's name doubles as its guest hostname, so it must be a valid RFC-1123 label.", | ||
| 160 | "imports": [] | ||
| 161 | }, | ||
| 162 | { | ||
| 143 | "importPath": "internal/pb", | 163 | "importPath": "internal/pb", |
| 144 | "plane": "wire", | 164 | "plane": "wire", |
| 145 | "synopsis": "", | 165 | "synopsis": "", |
| @@ -150,11 +170,14 @@ | |||
| 150 | "plane": "control", | 170 | "plane": "control", |
| 151 | "synopsis": "Package api implements the admin REST API and the unauthenticated enrollment endpoint.", | 171 | "synopsis": "Package api implements the admin REST API and the unauthenticated enrollment endpoint.", |
| 152 | "imports": [ | 172 | "imports": [ |
| 173 | "internal/cloudinit", | ||
| 153 | "internal/joinblob", | 174 | "internal/joinblob", |
| 175 | "internal/names", | ||
| 154 | "internal/server/api/types", | 176 | "internal/server/api/types", |
| 155 | "internal/server/hosttoken", | 177 | "internal/server/hosttoken", |
| 156 | "internal/server/hub", | 178 | "internal/server/hub", |
| 157 | "internal/server/registry", | 179 | "internal/server/registry", |
| 180 | "internal/server/sshca", | ||
| 158 | "internal/server/store" | 181 | "internal/server/store" |
| 159 | ] | 182 | ] |
| 160 | }, | 183 | }, |
| @@ -174,6 +197,12 @@ | |||
| 174 | "imports": [] | 197 | "imports": [] |
| 175 | }, | 198 | }, |
| 176 | { | 199 | { |
| 200 | "importPath": "internal/server/health", | ||
| 201 | "plane": "control", | ||
| 202 | "synopsis": "Package health serves the eitri-server liveness and readiness probes.", | ||
| 203 | "imports": [] | ||
| 204 | }, | ||
| 205 | { | ||
| 177 | "importPath": "internal/server/hosttoken", | 206 | "importPath": "internal/server/hosttoken", |
| 178 | "plane": "control", | 207 | "plane": "control", |
| 179 | "synopsis": "Package hosttoken mints and verifies generation-versioned host credentials.", | 208 | "synopsis": "Package hosttoken mints and verifies generation-versioned host credentials.", |
| @@ -192,6 +221,18 @@ | |||
| 192 | "imports": [] | 221 | "imports": [] |
| 193 | }, | 222 | }, |
| 194 | { | 223 | { |
| 224 | "importPath": "internal/server/sshca", | ||
| 225 | "plane": "control", | ||
| 226 | "synopsis": "Package sshca manages eitri's SSH key material: a persistent user CA (whose short-lived certs authenticate admins to the jump gate and VMs) and a persistent gate host key.", | ||
| 227 | "imports": [] | ||
| 228 | }, | ||
| 229 | { | ||
| 230 | "importPath": "internal/server/sshgate", | ||
| 231 | "plane": "control", | ||
| 232 | "synopsis": "Package sshgate is eitri's hardened SSH jump gate: a bastion front-end that admins reach with `ssh -J gate ubuntu@\u003cvm\u003e`.", | ||
| 233 | "imports": [] | ||
| 234 | }, | ||
| 235 | { | ||
| 195 | "importPath": "internal/server/store", | 236 | "importPath": "internal/server/store", |
| 196 | "plane": "control", | 237 | "plane": "control", |
| 197 | "synopsis": "Package store is the server's durable control-plane state, backed by SQLite: the host registry, enrollment tokens, desired VM specs, and freed CIDRs.", | 238 | "synopsis": "Package store is the server's durable control-plane state, backed by SQLite: the host registry, enrollment tokens, desired VM specs, and freed CIDRs.", |
docs/ssh-access.md
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,112 @@ | |||
| 1 | # SSH access via the eitri jump gate | ||
| 2 | |||
| 3 | eitri runs an SSH **jump gate**: a bastion that accepts an `ssh -J` hop and | ||
| 4 | forwards you to a VM's SSHd. You authenticate to the gate with a **short-lived | ||
| 5 | SSH user certificate** minted by the eitri server and signed by eitri's user CA. | ||
| 6 | Every VM built from the eitri seed already trusts that CA, so no per-VM key | ||
| 7 | management is needed. The cert carries the principal `ubuntu`, which is the login | ||
| 8 | user on the VM. | ||
| 9 | |||
| 10 | Verification runs **both ways**. Just as the VM trusts your user cert, you | ||
| 11 | verify what you connect to: the gate and every VM present a **host certificate** | ||
| 12 | signed by the same eitri CA. You pin the CA once (`@cert-authority`) and both | ||
| 13 | hops are then verified by certificate — no blind trust-on-first-use, and no | ||
| 14 | host-key-changed warnings when VM names or IPs are recycled. | ||
| 15 | |||
| 16 | ## One-liner | ||
| 17 | |||
| 18 | ```sh | ||
| 19 | export EITRI_URL=https://eitri.example.com | ||
| 20 | export EITRI_TOKEN=<admin-bearer-token> | ||
| 21 | export EITRI_GATE=eitri.example.com:2222 # the gate's ssh_listen address | ||
| 22 | |||
| 23 | hack/eitri-ssh <vm-name> # opens a shell on the VM | ||
| 24 | hack/eitri-ssh <vm-name> uptime # runs a command and exits | ||
| 25 | ``` | ||
| 26 | |||
| 27 | Environment variables: | ||
| 28 | |||
| 29 | | Var | Meaning | | ||
| 30 | | ------------- | --------------------------------------------------------- | | ||
| 31 | | `EITRI_URL` | Base URL of the eitri server | | ||
| 32 | | `EITRI_TOKEN` | Admin bearer token used to mint the cert | | ||
| 33 | | `EITRI_GATE` | Jump gate address for `ssh -J` (host:port, `ssh_listen`) | | ||
| 34 | | `EITRI_KEY` | SSH private key path (default `~/.ssh/id_ed25519`) | | ||
| 35 | | `EITRI_KNOWN_HOSTS` | eitri-managed known_hosts for the CA pin (default `~/.ssh/eitri_known_hosts`) | | ||
| 36 | |||
| 37 | The helper generates `~/.ssh/id_ed25519` if it is missing, mints a cert, writes | ||
| 38 | it to `<key>-cert.pub`, fetches the eitri CA and pins it as `@cert-authority *` | ||
| 39 | in a dedicated known_hosts file, and execs `ssh`. | ||
| 40 | |||
| 41 | > The host `EITRI_GATE` points at **must match** the gate's host-cert principal, | ||
| 42 | > i.e. the server's `ssh_gate_domain` (which defaults to the host part of | ||
| 43 | > `ssh_listen`). A mismatch is a hard host-verification failure, by design. | ||
| 44 | |||
| 45 | ## Manual flow | ||
| 46 | |||
| 47 | The helper is a thin wrapper over three steps you can run by hand: | ||
| 48 | |||
| 49 | 1. **Mint a cert** for your public key (admin-authed): | ||
| 50 | |||
| 51 | ```sh | ||
| 52 | curl -sS \ | ||
| 53 | -H "Authorization: Bearer $EITRI_TOKEN" \ | ||
| 54 | -H 'Content-Type: application/json' \ | ||
| 55 | -d "{\"public_key\":\"$(cat ~/.ssh/id_ed25519.pub)\"}" \ | ||
| 56 | "$EITRI_URL/api/v1/ssh-certs" | jq -r .certificate > ~/.ssh/id_ed25519-cert.pub | ||
| 57 | ``` | ||
| 58 | |||
| 59 | 2. **Place the cert beside the key.** OpenSSH auto-offers a cert named | ||
| 60 | `<key>-cert.pub` next to `<key>`, so the write above is all that's needed — | ||
| 61 | no `ssh-add` required. | ||
| 62 | |||
| 63 | 3. **Hop through the gate** to `ubuntu@<vm>`: | ||
| 64 | |||
| 65 | ```sh | ||
| 66 | ssh -J "$EITRI_GATE" ubuntu@<vm-name> | ||
| 67 | ``` | ||
| 68 | |||
| 69 | The inner user must be `ubuntu` (the cert principal). The outer gate hop | ||
| 70 | accepts any username. | ||
| 71 | |||
| 72 | ## Certs are short-lived | ||
| 73 | |||
| 74 | Minted certs have a short TTL. When one expires, ssh will simply be rejected — | ||
| 75 | re-run `hack/eitri-ssh` (or the mint step) to refresh. Nothing to revoke. | ||
| 76 | |||
| 77 | ## Host verification (via the CA) | ||
| 78 | |||
| 79 | The gate and every VM present a **host certificate** signed by the eitri CA | ||
| 80 | (the same CA that signs your user certs — it doubles as the host CA). You verify | ||
| 81 | them by pinning the CA once as a `@cert-authority` entry, rather than | ||
| 82 | trust-on-first-use. | ||
| 83 | |||
| 84 | Fetch the CA (public material, no token needed) and pin it in a **dedicated** | ||
| 85 | known_hosts file — never your main `~/.ssh/known_hosts`, where a `*` wildcard | ||
| 86 | CA would be trusted for *every* host you ssh to: | ||
| 87 | |||
| 88 | ```sh | ||
| 89 | curl -sS "$EITRI_URL/api/v1/ssh-ca" | jq -r .ca \ | ||
| 90 | | sed 's/^/@cert-authority * /' > ~/.ssh/eitri_known_hosts | ||
| 91 | ``` | ||
| 92 | |||
| 93 | Then both hops are verified against the CA with `StrictHostKeyChecking=yes`. A | ||
| 94 | command-line `-o` reaches only the *final* hop, so thread the same options to the | ||
| 95 | jump hop with an explicit `ProxyCommand` instead of `-J`: | ||
| 96 | |||
| 97 | ```sh | ||
| 98 | GATE_HOST=${EITRI_GATE%%:*}; GATE_PORT=${EITRI_GATE##*:} | ||
| 99 | [ "$GATE_PORT" = "$EITRI_GATE" ] && GATE_PORT=22 | ||
| 100 | KH=~/.ssh/eitri_known_hosts | ||
| 101 | ssh \ | ||
| 102 | -o "ProxyCommand=ssh -W %h:%p -o StrictHostKeyChecking=yes -o UserKnownHostsFile=$KH -p $GATE_PORT ubuntu@$GATE_HOST" \ | ||
| 103 | -o StrictHostKeyChecking=yes \ | ||
| 104 | -o "UserKnownHostsFile=$KH" \ | ||
| 105 | ubuntu@<vm-name> | ||
| 106 | ``` | ||
| 107 | |||
| 108 | The gate's cert principal is `ssh_gate_domain` (so `$GATE_HOST` must match it), | ||
| 109 | and each VM's cert principal is its VM name (so the inner `ubuntu@<vm-name>` host | ||
| 110 | must match). Because verification is by CA, recycling a VM name or IP never | ||
| 111 | produces a host-key-changed warning — the new VM simply presents a fresh | ||
| 112 | CA-signed cert for that name. `hack/eitri-ssh` does all of this for you. | ||
go.mod
| Old | New | ||
|---|---|---|---|
| @@ -3,10 +3,13 @@ module github.com/a73x/eitri | |||
| 3 | go 1.26.4 | 3 | go 1.26.4 |
| 4 | 4 | ||
| 5 | require ( | 5 | require ( |
| 6 | github.com/coder/websocket v1.8.15 | ||
| 6 | github.com/diskfs/go-diskfs v1.9.3 | 7 | github.com/diskfs/go-diskfs v1.9.3 |
| 7 | github.com/quic-go/quic-go v0.48.2 | 8 | github.com/quic-go/quic-go v0.48.2 |
| 8 | github.com/stretchr/testify v1.11.1 | 9 | github.com/stretchr/testify v1.11.1 |
| 10 | golang.org/x/crypto v0.48.0 | ||
| 9 | google.golang.org/protobuf v1.36.11 | 11 | google.golang.org/protobuf v1.36.11 |
| 12 | gopkg.in/yaml.v3 v3.0.1 | ||
| 10 | modernc.org/sqlite v1.52.0 | 13 | modernc.org/sqlite v1.52.0 |
| 11 | ) | 14 | ) |
| 12 | 15 | ||
| @@ -32,14 +35,12 @@ require ( | |||
| 32 | github.com/sirupsen/logrus v1.9.4 // indirect | 35 | github.com/sirupsen/logrus v1.9.4 // indirect |
| 33 | github.com/ulikunitz/xz v0.5.15 // indirect | 36 | github.com/ulikunitz/xz v0.5.15 // indirect |
| 34 | go.uber.org/mock v0.4.0 // indirect | 37 | go.uber.org/mock v0.4.0 // indirect |
| 35 | golang.org/x/crypto v0.48.0 // indirect | ||
| 36 | golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 // indirect | 38 | golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 // indirect |
| 37 | golang.org/x/mod v0.33.0 // indirect | 39 | golang.org/x/mod v0.33.0 // indirect |
| 38 | golang.org/x/net v0.51.0 // indirect | 40 | golang.org/x/net v0.51.0 // indirect |
| 39 | golang.org/x/sync v0.20.0 // indirect | 41 | golang.org/x/sync v0.20.0 // indirect |
| 40 | golang.org/x/sys v0.43.0 // indirect | 42 | golang.org/x/sys v0.43.0 // indirect |
| 41 | golang.org/x/tools v0.42.0 // indirect | 43 | golang.org/x/tools v0.42.0 // indirect |
| 42 | gopkg.in/yaml.v3 v3.0.1 // indirect | ||
| 43 | modernc.org/libc v1.72.3 // indirect | 44 | modernc.org/libc v1.72.3 // indirect |
| 44 | modernc.org/mathutil v1.7.1 // indirect | 45 | modernc.org/mathutil v1.7.1 // indirect |
| 45 | modernc.org/memory v1.11.0 // indirect | 46 | modernc.org/memory v1.11.0 // indirect |
go.sum
| Old | New | ||
|---|---|---|---|
| @@ -1,5 +1,7 @@ | |||
| 1 | github.com/anchore/go-lzo v0.1.0 h1:NgAacnzqPeGH49Ky19QKLBZEuFRqtTG9cdaucc3Vncs= | 1 | github.com/anchore/go-lzo v0.1.0 h1:NgAacnzqPeGH49Ky19QKLBZEuFRqtTG9cdaucc3Vncs= |
| 2 | github.com/anchore/go-lzo v0.1.0/go.mod h1:3kLx0bve2oN1iDwgM1U5zGku1Tfbdb0No5qp1eL1fIk= | 2 | github.com/anchore/go-lzo v0.1.0/go.mod h1:3kLx0bve2oN1iDwgM1U5zGku1Tfbdb0No5qp1eL1fIk= |
| 3 | github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= | ||
| 4 | github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= | ||
| 3 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= | 5 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= |
| 4 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= | 6 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= |
| 5 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= | 7 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= |
| @@ -72,6 +74,8 @@ golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBc | |||
| 72 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | 74 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= |
| 73 | golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= | 75 | golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= |
| 74 | golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= | 76 | golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= |
| 77 | golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= | ||
| 78 | golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= | ||
| 75 | golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= | 79 | golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= |
| 76 | golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= | 80 | golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= |
| 77 | golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= | 81 | golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= |
hack/eitri-ssh
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,138 @@ | |||
| 1 | #!/usr/bin/env bash | ||
| 2 | # | ||
| 3 | # eitri-ssh — mint a short-lived SSH user cert from the eitri server and SSH into | ||
| 4 | # a VM through the eitri jump gate in one shot. | ||
| 5 | # | ||
| 6 | # Usage: | ||
| 7 | # eitri-ssh <vm-name> [extra ssh args/command...] | ||
| 8 | # eitri-ssh --help | ||
| 9 | # | ||
| 10 | # Environment: | ||
| 11 | # EITRI_URL Base URL of the eitri server (e.g. https://eitri.example.com) | ||
| 12 | # EITRI_TOKEN Admin bearer token used to mint the cert | ||
| 13 | # EITRI_GATE Jump gate address for `ssh -J` (e.g. eitri.example.com:2222) | ||
| 14 | # EITRI_KEY Optional path to the SSH private key (default ~/.ssh/id_ed25519) | ||
| 15 | # EITRI_KNOWN_HOSTS Optional eitri-managed known_hosts file | ||
| 16 | # (default ~/.ssh/eitri_known_hosts) | ||
| 17 | # | ||
| 18 | # The minted cert is written beside the key as "<key>-cert.pub", which OpenSSH | ||
| 19 | # auto-offers. The inner login user is always "ubuntu" (the cert principal); the | ||
| 20 | # outer gate hop accepts any username. Certs are short-lived — just re-run to | ||
| 21 | # refresh. | ||
| 22 | # | ||
| 23 | # Host verification is by CERTIFICATE, not TOFU: the helper fetches eitri's CA | ||
| 24 | # public key and pins it as a `@cert-authority *` entry in a DEDICATED | ||
| 25 | # known_hosts file (never your main ~/.ssh/known_hosts — a wildcard cert | ||
| 26 | # authority there would trust eitri's CA for every host you ssh to). Both the | ||
| 27 | # gate hop and the VM hop are then verified against that CA with | ||
| 28 | # StrictHostKeyChecking=yes. See docs/ssh-access.md. | ||
| 29 | |||
| 30 | set -eu | ||
| 31 | |||
| 32 | usage() { | ||
| 33 | sed -n '3,20p' "$0" | sed 's/^# \{0,1\}//' | ||
| 34 | exit "${1:-0}" | ||
| 35 | } | ||
| 36 | |||
| 37 | case "${1:-}" in | ||
| 38 | -h | --help | "") usage 0 ;; | ||
| 39 | esac | ||
| 40 | |||
| 41 | VM=$1 | ||
| 42 | shift | ||
| 43 | |||
| 44 | : "${EITRI_URL:?set EITRI_URL to the eitri server base URL}" | ||
| 45 | : "${EITRI_TOKEN:?set EITRI_TOKEN to an admin bearer token}" | ||
| 46 | : "${EITRI_GATE:?set EITRI_GATE to the jump gate host:port}" | ||
| 47 | KEY=${EITRI_KEY:-$HOME/.ssh/id_ed25519} | ||
| 48 | # A DEDICATED known_hosts for the `@cert-authority *` pin — deliberately NOT the | ||
| 49 | # user's main known_hosts, where a wildcard CA would apply to every ssh target. | ||
| 50 | KNOWN_HOSTS=${EITRI_KNOWN_HOSTS:-$HOME/.ssh/eitri_known_hosts} | ||
| 51 | |||
| 52 | # 1. Ensure a keypair exists. | ||
| 53 | if [ ! -f "$KEY" ]; then | ||
| 54 | echo "eitri-ssh: generating SSH key at $KEY" >&2 | ||
| 55 | ssh-keygen -t ed25519 -N '' -f "$KEY" >/dev/null | ||
| 56 | fi | ||
| 57 | |||
| 58 | # 2. Mint a cert for our public key. Capture body + HTTP status separately so a | ||
| 59 | # non-200 prints the server's error and fails loudly. | ||
| 60 | PUB=$(cat "$KEY.pub") | ||
| 61 | resp=$(curl -sS -w '\n%{http_code}' \ | ||
| 62 | -H "Authorization: Bearer $EITRI_TOKEN" \ | ||
| 63 | -H 'Content-Type: application/json' \ | ||
| 64 | -d "{\"public_key\":\"$PUB\"}" \ | ||
| 65 | "$EITRI_URL/api/v1/ssh-certs") | ||
| 66 | code=${resp##*$'\n'} | ||
| 67 | body=${resp%$'\n'*} | ||
| 68 | |||
| 69 | if [ "$code" != "200" ]; then | ||
| 70 | echo "eitri-ssh: mint failed (HTTP $code): $body" >&2 | ||
| 71 | exit 1 | ||
| 72 | fi | ||
| 73 | |||
| 74 | # 3. Extract .certificate (jq preferred; sed fallback for the flat string field). | ||
| 75 | if command -v jq >/dev/null 2>&1; then | ||
| 76 | cert=$(printf '%s' "$body" | jq -r '.certificate') | ||
| 77 | else | ||
| 78 | cert=$(printf '%s' "$body" | sed -n 's/.*"certificate"[[:space:]]*:[[:space:]]*"\(.*\)".*/\1/p' | sed 's/\\n/\n/g') | ||
| 79 | fi | ||
| 80 | if [ -z "$cert" ] || [ "$cert" = "null" ]; then | ||
| 81 | echo "eitri-ssh: could not extract certificate from response: $body" >&2 | ||
| 82 | exit 1 | ||
| 83 | fi | ||
| 84 | |||
| 85 | # 4. Write it beside the key so ssh auto-offers it. | ||
| 86 | printf '%s\n' "$cert" >"$KEY-cert.pub" | ||
| 87 | |||
| 88 | # 5. Fetch the eitri CA public key and pin it as a `@cert-authority *` entry so | ||
| 89 | # BOTH hops are verified by certificate (no TOFU). The CA endpoint is public | ||
| 90 | # (no token). We overwrite the dedicated known_hosts each run so it always | ||
| 91 | # reflects the current CA — this file holds nothing but the eitri pin. | ||
| 92 | ca_resp=$(curl -sS -w '\n%{http_code}' "$EITRI_URL/api/v1/ssh-ca") | ||
| 93 | ca_code=${ca_resp##*$'\n'} | ||
| 94 | ca_body=${ca_resp%$'\n'*} | ||
| 95 | if [ "$ca_code" != "200" ]; then | ||
| 96 | echo "eitri-ssh: fetch CA failed (HTTP $ca_code): $ca_body" >&2 | ||
| 97 | exit 1 | ||
| 98 | fi | ||
| 99 | if command -v jq >/dev/null 2>&1; then | ||
| 100 | ca=$(printf '%s' "$ca_body" | jq -r '.ca') | ||
| 101 | else | ||
| 102 | ca=$(printf '%s' "$ca_body" | sed -n 's/.*"ca"[[:space:]]*:[[:space:]]*"\(.*\)".*/\1/p' | sed 's/\\n/\n/g') | ||
| 103 | fi | ||
| 104 | if [ -z "$ca" ] || [ "$ca" = "null" ]; then | ||
| 105 | echo "eitri-ssh: could not extract CA key from response: $ca_body" >&2 | ||
| 106 | exit 1 | ||
| 107 | fi | ||
| 108 | mkdir -p "$(dirname "$KNOWN_HOSTS")" | ||
| 109 | # Trim any trailing newline the CA line carries, then write the single pin. | ||
| 110 | printf '@cert-authority * %s\n' "$(printf '%s' "$ca" | tr -d '\r\n')" >"$KNOWN_HOSTS" | ||
| 111 | |||
| 112 | # 6. Hop through the gate to ubuntu@<vm>. Pass through any extra args/command. | ||
| 113 | # | ||
| 114 | # We do NOT use `ssh -J`: command-line `-o` options (host-key checking, | ||
| 115 | # known_hosts, key) reach ONLY the final hop, so on a machine with no tty the | ||
| 116 | # jump hop would fall back to the default policy. Instead we build an explicit | ||
| 117 | # ProxyCommand for the jump hop that carries the SAME host-key options as the | ||
| 118 | # final hop, so BOTH hops verify the presented host cert against the eitri CA | ||
| 119 | # with StrictHostKeyChecking=yes. | ||
| 120 | # | ||
| 121 | # The gate host cert's principal must match $GATE_HOST (eitri's | ||
| 122 | # ssh_gate_domain); each VM's host cert principal is the VM name. A mismatch | ||
| 123 | # is a hard failure, not a prompt — that is the point. | ||
| 124 | GATE_HOST=${EITRI_GATE%%:*} | ||
| 125 | GATE_PORT=${EITRI_GATE##*:} | ||
| 126 | [ "$GATE_PORT" = "$EITRI_GATE" ] && GATE_PORT=22 | ||
| 127 | |||
| 128 | PROXY="ssh -W %h:%p \ | ||
| 129 | -o StrictHostKeyChecking=yes \ | ||
| 130 | -o UserKnownHostsFile=$KNOWN_HOSTS \ | ||
| 131 | -i $KEY -p $GATE_PORT ubuntu@$GATE_HOST" | ||
| 132 | |||
| 133 | exec ssh \ | ||
| 134 | -o "ProxyCommand=$PROXY" \ | ||
| 135 | -o StrictHostKeyChecking=yes \ | ||
| 136 | -o "UserKnownHostsFile=$KNOWN_HOSTS" \ | ||
| 137 | -i "$KEY" \ | ||
| 138 | "ubuntu@$VM" "$@" | ||
internal/agent/cloudhv/cloudhv.go
| Old | New | ||
|---|---|---|---|
| @@ -33,12 +33,23 @@ func MAC(vmID string) string { | |||
| 33 | return fmt.Sprintf("52:54:00:%02x:%02x:%02x", h[0], h[1], h[2]) | 33 | return fmt.Sprintf("52:54:00:%02x:%02x:%02x", h[0], h[1], h[2]) |
| 34 | } | 34 | } |
| 35 | 35 | ||
| 36 | // PumpHooks is the serial-console pump lifecycle the provisioner drives | ||
| 37 | // (consumer-owned; the concrete implementation is *serialpump.Manager, wired | ||
| 38 | // by main — cloudhv must not import serialpump). nil disables the hooks. | ||
| 39 | type PumpHooks interface { | ||
| 40 | Ensure(vmID string) | ||
| 41 | Stop(vmID string) | ||
| 42 | } | ||
| 43 | |||
| 36 | // Provisioner manages cloud-hypervisor processes for all VMs on this host. | 44 | // Provisioner manages cloud-hypervisor processes for all VMs on this host. |
| 37 | type Provisioner struct { | 45 | type Provisioner struct { |
| 38 | st *state.Store | 46 | st *state.Store |
| 39 | chBin string // path to cloud-hypervisor binary | 47 | chBin string // path to cloud-hypervisor binary |
| 40 | firmware string // path to hypervisor-fw (EFI firmware) | 48 | firmware string // path to hypervisor-fw (EFI firmware) |
| 41 | run agentexec.Runner | 49 | run agentexec.Runner |
| 50 | |||
| 51 | // Pumps receives serial-pump lifecycle calls at Boot/Kill. nil = no-op. | ||
| 52 | Pumps PumpHooks | ||
| 42 | } | 53 | } |
| 43 | 54 | ||
| 44 | // New constructs a Provisioner. run may be nil when only pure methods | 55 | // New constructs a Provisioner. run may be nil when only pure methods |
| @@ -54,7 +65,6 @@ func (p *Provisioner) buildArgs(spec state.VMSpec) []string { | |||
| 54 | vmID := spec.VMID | 65 | vmID := spec.VMID |
| 55 | tap := state.TapName(vmID) | 66 | tap := state.TapName(vmID) |
| 56 | mac := MAC(vmID) | 67 | mac := MAC(vmID) |
| 57 | serialLog := filepath.Join(p.st.VMDir(vmID), "serial.log") | ||
| 58 | 68 | ||
| 59 | return []string{ | 69 | return []string{ |
| 60 | "--api-socket", p.st.SocketPath(vmID), | 70 | "--api-socket", p.st.SocketPath(vmID), |
| @@ -65,7 +75,7 @@ func (p *Provisioner) buildArgs(spec state.VMSpec) []string { | |||
| 65 | fmt.Sprintf("path=%s", p.st.DiskPath(vmID)), | 75 | fmt.Sprintf("path=%s", p.st.DiskPath(vmID)), |
| 66 | fmt.Sprintf("path=%s,readonly=on", p.st.SeedPath(vmID)), | 76 | fmt.Sprintf("path=%s,readonly=on", p.st.SeedPath(vmID)), |
| 67 | "--net", fmt.Sprintf("tap=%s,mac=%s", tap, mac), | 77 | "--net", fmt.Sprintf("tap=%s,mac=%s", tap, mac), |
| 68 | "--serial", fmt.Sprintf("file=%s", serialLog), | 78 | "--serial", fmt.Sprintf("socket=%s", p.st.SerialSocketPath(vmID)), |
| 69 | "--console", "off", | 79 | "--console", "off", |
| 70 | } | 80 | } |
| 71 | } | 81 | } |
| @@ -140,14 +150,19 @@ func (p *Provisioner) pidPath(vmID string) string { | |||
| 140 | // agent stop). Stopping a VM is exclusively the job of Shutdown/Kill, driven | 150 | // agent stop). Stopping a VM is exclusively the job of Shutdown/Kill, driven |
| 141 | // by the reconcile loop. Pinned by TestBootedVMSurvivesCtxCancellation. | 151 | // by the reconcile loop. Pinned by TestBootedVMSurvivesCtxCancellation. |
| 142 | func (p *Provisioner) Boot(_ context.Context, vmID string, spec state.VMSpec) error { | 152 | func (p *Provisioner) Boot(_ context.Context, vmID string, spec state.VMSpec) error { |
| 143 | // Remove stale socket from a previous run. | 153 | // Remove stale sockets from a previous run. CH does NOT unlink a |
| 154 | // pre-existing socket path before binding (it removes it only on clean | ||
| 155 | // exit), so after a CH crash, SIGKILL, or host reboot a stale socket | ||
| 156 | // would make the next boot fail to bind. | ||
| 144 | _ = os.Remove(p.st.SocketPath(vmID)) | 157 | _ = os.Remove(p.st.SocketPath(vmID)) |
| 158 | _ = os.Remove(p.st.SerialSocketPath(vmID)) | ||
| 145 | 159 | ||
| 146 | args := p.buildArgs(spec) | 160 | args := p.buildArgs(spec) |
| 147 | cmd := exec.Command(p.chBin, args...) | 161 | cmd := exec.Command(p.chBin, args...) |
| 148 | cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} | 162 | cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} |
| 149 | 163 | ||
| 150 | // Guest console output (serial) goes to serial.log (via --serial file=…). | 164 | // Guest console output (serial) goes to a unix socket (--serial socket=…) |
| 165 | // that the serialpump drains into serial.log. | ||
| 151 | // CH's own diagnostic output (startup errors, API logs) goes to ch.log. | 166 | // CH's own diagnostic output (startup errors, API logs) goes to ch.log. |
| 152 | chLogPath := filepath.Join(p.st.VMDir(vmID), "ch.log") | 167 | chLogPath := filepath.Join(p.st.VMDir(vmID), "ch.log") |
| 153 | chLog, err := os.OpenFile(chLogPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, chLogMode) | 168 | chLog, err := os.OpenFile(chLogPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, chLogMode) |
| @@ -175,6 +190,14 @@ func (p *Provisioner) Boot(_ context.Context, vmID string, spec state.VMSpec) er | |||
| 175 | // Reap child asynchronously; ignore exit error (VM may be killed intentionally). | 190 | // Reap child asynchronously; ignore exit error (VM may be killed intentionally). |
| 176 | go func() { _ = cmd.Wait() }() | 191 | go func() { _ = cmd.Wait() }() |
| 177 | 192 | ||
| 193 | // Attach the serial pump NOW: the pump must be the socket's one client | ||
| 194 | // from as close to power-on as possible so the boot log lands in the | ||
| 195 | // ring/log (older CH drops unconsumed serial output entirely; current CH | ||
| 196 | // only buffers a bounded amount). | ||
| 197 | if p.Pumps != nil { | ||
| 198 | p.Pumps.Ensure(vmID) | ||
| 199 | } | ||
| 200 | |||
| 178 | return nil | 201 | return nil |
| 179 | } | 202 | } |
| 180 | 203 | ||
| @@ -255,15 +278,25 @@ func (p *Provisioner) sigterm(vmID string) error { | |||
| 255 | return nil | 278 | return nil |
| 256 | } | 279 | } |
| 257 | 280 | ||
| 258 | // Kill sends SIGKILL to the cloud-hypervisor process for vmID and removes | 281 | // Kill sends SIGKILL to the cloud-hypervisor process for vmID, stops its |
| 259 | // the PID file. | 282 | // serial pump, and removes the PID file and serial socket. |
| 260 | func (p *Provisioner) Kill(ctx context.Context, vmID string) error { | 283 | func (p *Provisioner) Kill(ctx context.Context, vmID string) error { |
| 284 | // Pump teardown + serial-socket removal come BEFORE the SIGKILL error | ||
| 285 | // return: a pump leaked past a failed Kill would dial a deleted path | ||
| 286 | // forever. ch.sock is deliberately left in place — the next Boot clears | ||
| 287 | // a stale API socket itself, and reap's DeleteVM removes the whole VM dir. | ||
| 288 | if p.Pumps != nil { | ||
| 289 | p.Pumps.Stop(vmID) | ||
| 290 | } | ||
| 291 | _ = os.Remove(p.st.SerialSocketPath(vmID)) | ||
| 261 | pid := p.readPID(vmID) | 292 | pid := p.readPID(vmID) |
| 262 | if pid != 0 { | 293 | if pid != 0 { |
| 263 | if err := syscall.Kill(pid, syscall.SIGKILL); err != nil && err != syscall.ESRCH { | 294 | if err := syscall.Kill(pid, syscall.SIGKILL); err != nil && err != syscall.ESRCH { |
| 264 | return fmt.Errorf("SIGKILL %s (pid %d): %w", vmID, pid, err) | 295 | return fmt.Errorf("SIGKILL %s (pid %d): %w", vmID, pid, err) |
| 265 | } | 296 | } |
| 266 | } | 297 | } |
| 298 | // pidfile removal stays LAST: a failed SIGKILL must leave the pidfile so | ||
| 299 | // a Kill retry can find the process again. | ||
| 267 | _ = os.Remove(p.pidPath(vmID)) | 300 | _ = os.Remove(p.pidPath(vmID)) |
| 268 | return nil | 301 | return nil |
| 269 | } | 302 | } |
internal/agent/cloudhv/cloudhv_test.go
| Old | New | ||
|---|---|---|---|
| @@ -39,6 +39,69 @@ func TestBuildArgs(t *testing.T) { | |||
| 39 | assert.Contains(t, joined, "tap=eit-vm1,mac="+MAC("vm1")) | 39 | assert.Contains(t, joined, "tap=eit-vm1,mac="+MAC("vm1")) |
| 40 | } | 40 | } |
| 41 | 41 | ||
| 42 | func TestBuildArgsUsesSerialSocket(t *testing.T) { | ||
| 43 | st, err := state.Open(t.TempDir()) | ||
| 44 | require.NoError(t, err) | ||
| 45 | p := New(st, "ch", "fw", nil) // chBin/firmware placeholders fine: args only | ||
| 46 | args := p.buildArgs(state.VMSpec{VMID: "vm1", VCPUs: 1, MemMB: 512, DiskGB: 5}) | ||
| 47 | joined := strings.Join(args, " ") | ||
| 48 | assert.Contains(t, joined, "--serial socket="+st.SerialSocketPath("vm1")) | ||
| 49 | assert.NotContains(t, joined, "--serial file=", "serial must be a socket now — the pump owns the log") | ||
| 50 | } | ||
| 51 | |||
| 52 | // pumpRecorder records Ensure/Stop calls through the consumer-owned hook. | ||
| 53 | type pumpRecorder struct{ ensured, stopped []string } | ||
| 54 | |||
| 55 | func (r *pumpRecorder) Ensure(vmID string) { r.ensured = append(r.ensured, vmID) } | ||
| 56 | func (r *pumpRecorder) Stop(vmID string) { r.stopped = append(r.stopped, vmID) } | ||
| 57 | |||
| 58 | func TestKillStopsPumpAndRemovesSerialSocket(t *testing.T) { | ||
| 59 | st, err := state.Open(t.TempDir()) | ||
| 60 | require.NoError(t, err) | ||
| 61 | rec := &pumpRecorder{} | ||
| 62 | p := New(st, "ch", "fw", nil) | ||
| 63 | p.Pumps = rec | ||
| 64 | // No CH process running: Kill on an unknown VM must still be clean — | ||
| 65 | // and must still stop the pump + remove the socket path. | ||
| 66 | require.NoError(t, os.MkdirAll(st.VMDir("vm1"), 0o755)) | ||
| 67 | require.NoError(t, os.WriteFile(st.SerialSocketPath("vm1"), nil, 0o644)) | ||
| 68 | _ = p.Kill(context.Background(), "vm1") | ||
| 69 | assert.Equal(t, []string{"vm1"}, rec.stopped) | ||
| 70 | _, statErr := os.Stat(st.SerialSocketPath("vm1")) | ||
| 71 | assert.True(t, os.IsNotExist(statErr), "stale serial socket must be removed") | ||
| 72 | } | ||
| 73 | |||
| 74 | // TestBootEnsuresPump pins the most load-bearing pump hook: Boot must Ensure | ||
| 75 | // the serial pump right after CH starts (a regression here = silently dead | ||
| 76 | // consoles fleet-wide, since reconcile tests fake the whole Provisioner). It | ||
| 77 | // also pins the deliberate asymmetry: Shutdown must NOT stop the pump — the | ||
| 78 | // pump survives VM stop/start (it reconnects to the fresh socket); only Kill | ||
| 79 | // (VM destroyed) tears it down. | ||
| 80 | func TestBootEnsuresPump(t *testing.T) { | ||
| 81 | st, err := state.Open(t.TempDir()) | ||
| 82 | require.NoError(t, err) | ||
| 83 | |||
| 84 | vmID := "vm-pump-hook" | ||
| 85 | require.NoError(t, st.SaveVM(state.Record{Spec: state.VMSpec{VMID: vmID}})) | ||
| 86 | |||
| 87 | // Fake cloud-hypervisor: ignores its CLI args and sleeps (same pattern as | ||
| 88 | // TestBootedVMSurvivesCtxCancellation). | ||
| 89 | fakeCH := filepath.Join(t.TempDir(), "fake-ch") | ||
| 90 | require.NoError(t, os.WriteFile(fakeCH, []byte("#!/bin/sh\nexec sleep 60\n"), 0o755)) | ||
| 91 | |||
| 92 | rec := &pumpRecorder{} | ||
| 93 | p := New(st, fakeCH, "fw", nil) | ||
| 94 | p.Pumps = rec | ||
| 95 | require.NoError(t, p.Boot(context.Background(), vmID, state.VMSpec{VMID: vmID, VCPUs: 1, MemMB: 128})) | ||
| 96 | t.Cleanup(func() { _ = p.Kill(context.Background(), vmID) }) | ||
| 97 | assert.Equal(t, []string{vmID}, rec.ensured, "Boot must attach the serial pump") | ||
| 98 | |||
| 99 | // No API socket is listening, so Shutdown falls back to SIGTERM — either | ||
| 100 | // path must leave the pump alone. | ||
| 101 | require.NoError(t, p.Shutdown(context.Background(), vmID)) | ||
| 102 | assert.Empty(t, rec.stopped, "Shutdown must NOT stop the pump — it survives VM restarts") | ||
| 103 | } | ||
| 104 | |||
| 42 | // sparseFile creates a sparse file of the given size and returns its path. | 105 | // sparseFile creates a sparse file of the given size and returns its path. |
| 43 | // Sparse: no real disk space is consumed regardless of the nominal size. | 106 | // Sparse: no real disk space is consumed regardless of the nominal size. |
| 44 | func sparseFile(t *testing.T, size int64) string { | 107 | func sparseFile(t *testing.T, size int64) string { |
internal/agent/enrollclient/enrollclient.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,95 @@ | |||
| 1 | // Package enrollclient speaks the control plane's enrollment endpoint. It owns | ||
| 2 | // the single HTTP exchange an agent makes before it has an identity: POST the | ||
| 3 | // join facts, receive the host credential. The request and response shapes are | ||
| 4 | // typed here — mirroring the server's contract in internal/server/api — so the | ||
| 5 | // wire format lives in one checkable place on this side of the trust boundary | ||
| 6 | // rather than as a hand-built map at the call site. | ||
| 7 | package enrollclient | ||
| 8 | |||
| 9 | import ( | ||
| 10 | "bytes" | ||
| 11 | "context" | ||
| 12 | "encoding/json" | ||
| 13 | "errors" | ||
| 14 | "fmt" | ||
| 15 | "io" | ||
| 16 | "net/http" | ||
| 17 | "time" | ||
| 18 | ) | ||
| 19 | |||
| 20 | // Request is the enrollment payload. Its JSON tags match the server's | ||
| 21 | // enrollRequest; the agent is the only producer of this shape. | ||
| 22 | type Request struct { | ||
| 23 | Token string `json:"token"` | ||
| 24 | Name string `json:"name"` | ||
| 25 | OS string `json:"os"` | ||
| 26 | Arch string `json:"arch"` | ||
| 27 | Provisioner string `json:"provisioner"` | ||
| 28 | } | ||
| 29 | |||
| 30 | // Response carries the fields the agent consumes from a successful enroll. The | ||
| 31 | // server also returns server_cert_sha256, which the agent deliberately ignores: | ||
| 32 | // the fingerprint pinned in the join blob is the sole trust root, so it is | ||
| 33 | // omitted here rather than decoded and discarded. | ||
| 34 | type Response struct { | ||
| 35 | HostID string `json:"host_id"` | ||
| 36 | Credential string `json:"credential"` | ||
| 37 | BridgeCIDR string `json:"bridge_cidr"` | ||
| 38 | } | ||
| 39 | |||
| 40 | // ErrTokenRejected reports that the control plane refused the token — it was | ||
| 41 | // already redeemed or has expired (HTTP 403). It is a distinct sentinel so the | ||
| 42 | // caller can surface the one actionable recovery ("mint a new join token") | ||
| 43 | // separately from transport or server faults. | ||
| 44 | var ErrTokenRejected = errors.New("enroll token rejected: already used or expired") | ||
| 45 | |||
| 46 | // Client posts enrollment requests to a control-plane HTTP origin. | ||
| 47 | type Client struct { | ||
| 48 | baseURL string | ||
| 49 | http *http.Client | ||
| 50 | } | ||
| 51 | |||
| 52 | // New returns a Client targeting baseURL (the control plane's HTTP origin, e.g. | ||
| 53 | // https://host:port). Its timeout bounds the one enroll call so a wrong or dead | ||
| 54 | // address fails fast instead of hanging the join. | ||
| 55 | func New(baseURL string) *Client { | ||
| 56 | return &Client{baseURL: baseURL, http: &http.Client{Timeout: 30 * time.Second}} | ||
| 57 | } | ||
| 58 | |||
| 59 | // Enroll redeems req against POST {baseURL}/api/v1/enroll. It returns | ||
| 60 | // ErrTokenRejected on 403, a descriptive error on any other non-201 status or on | ||
| 61 | // a transport/decode failure, and the decoded credential on success. | ||
| 62 | func (c *Client) Enroll(ctx context.Context, req Request) (Response, error) { | ||
| 63 | body, err := json.Marshal(req) | ||
| 64 | if err != nil { | ||
| 65 | return Response{}, fmt.Errorf("marshal enroll request: %w", err) | ||
| 66 | } | ||
| 67 | httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/api/v1/enroll", bytes.NewReader(body)) | ||
| 68 | if err != nil { | ||
| 69 | return Response{}, fmt.Errorf("build enroll request: %w", err) | ||
| 70 | } | ||
| 71 | httpReq.Header.Set("Content-Type", "application/json") | ||
| 72 | |||
| 73 | resp, err := c.http.Do(httpReq) | ||
| 74 | if err != nil { | ||
| 75 | return Response{}, fmt.Errorf("enroll request: %w", err) | ||
| 76 | } | ||
| 77 | defer resp.Body.Close() | ||
| 78 | respBody, err := io.ReadAll(resp.Body) | ||
| 79 | if err != nil { | ||
| 80 | return Response{}, fmt.Errorf("read enroll response: %w", err) | ||
| 81 | } | ||
| 82 | |||
| 83 | switch resp.StatusCode { | ||
| 84 | case http.StatusCreated: | ||
| 85 | var out Response | ||
| 86 | if err := json.Unmarshal(respBody, &out); err != nil { | ||
| 87 | return Response{}, fmt.Errorf("parse enroll response: %w", err) | ||
| 88 | } | ||
| 89 | return out, nil | ||
| 90 | case http.StatusForbidden: | ||
| 91 | return Response{}, ErrTokenRejected | ||
| 92 | default: | ||
| 93 | return Response{}, fmt.Errorf("enroll failed (HTTP %d): %s", resp.StatusCode, respBody) | ||
| 94 | } | ||
| 95 | } | ||
internal/agent/enrollclient/enrollclient_test.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,105 @@ | |||
| 1 | package enrollclient | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "context" | ||
| 5 | "encoding/json" | ||
| 6 | "errors" | ||
| 7 | "io" | ||
| 8 | "net/http" | ||
| 9 | "net/http/httptest" | ||
| 10 | "testing" | ||
| 11 | ) | ||
| 12 | |||
| 13 | // startServer stands up an /api/v1/enroll handler and returns a Client aimed at | ||
| 14 | // it. handler receives the decoded request and returns the status + response | ||
| 15 | // body to send back. | ||
| 16 | func startServer(t *testing.T, handler func(Request) (int, any)) *Client { | ||
| 17 | t.Helper() | ||
| 18 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| 19 | if r.URL.Path != "/api/v1/enroll" { | ||
| 20 | t.Errorf("enroll hit wrong path %q", r.URL.Path) | ||
| 21 | } | ||
| 22 | if ct := r.Header.Get("Content-Type"); ct != "application/json" { | ||
| 23 | t.Errorf("content-type = %q, want application/json", ct) | ||
| 24 | } | ||
| 25 | var req Request | ||
| 26 | body, _ := io.ReadAll(r.Body) | ||
| 27 | if err := json.Unmarshal(body, &req); err != nil { | ||
| 28 | t.Errorf("server could not decode request body %q: %v", body, err) | ||
| 29 | } | ||
| 30 | status, resp := handler(req) | ||
| 31 | w.WriteHeader(status) | ||
| 32 | if resp != nil { | ||
| 33 | _ = json.NewEncoder(w).Encode(resp) | ||
| 34 | } | ||
| 35 | })) | ||
| 36 | t.Cleanup(ts.Close) | ||
| 37 | return New(ts.URL) | ||
| 38 | } | ||
| 39 | |||
| 40 | func TestEnrollReturnsCredentialOn201(t *testing.T) { | ||
| 41 | var got Request | ||
| 42 | c := startServer(t, func(req Request) (int, any) { | ||
| 43 | got = req | ||
| 44 | return http.StatusCreated, map[string]string{ | ||
| 45 | "host_id": "h-123", | ||
| 46 | "credential": "cred-abc", | ||
| 47 | "bridge_cidr": "10.77.1.0/24", | ||
| 48 | "server_cert_sha256": "ignored-by-agent", | ||
| 49 | } | ||
| 50 | }) | ||
| 51 | |||
| 52 | resp, err := c.Enroll(context.Background(), Request{ | ||
| 53 | Token: "tok", Name: "host-1", OS: "linux", Arch: "amd64", Provisioner: "cloudhv", | ||
| 54 | }) | ||
| 55 | if err != nil { | ||
| 56 | t.Fatalf("Enroll returned error: %v", err) | ||
| 57 | } | ||
| 58 | |||
| 59 | // The request reached the server with every field intact. | ||
| 60 | if got != (Request{Token: "tok", Name: "host-1", OS: "linux", Arch: "amd64", Provisioner: "cloudhv"}) { | ||
| 61 | t.Errorf("server received %+v, want the posted request verbatim", got) | ||
| 62 | } | ||
| 63 | // The response decoded into the typed fields the agent consumes. | ||
| 64 | if resp.HostID != "h-123" || resp.Credential != "cred-abc" || resp.BridgeCIDR != "10.77.1.0/24" { | ||
| 65 | t.Errorf("decoded response = %+v, want host_id/credential/bridge_cidr populated", resp) | ||
| 66 | } | ||
| 67 | } | ||
| 68 | |||
| 69 | func TestEnrollMapsForbiddenToErrTokenRejected(t *testing.T) { | ||
| 70 | c := startServer(t, func(Request) (int, any) { | ||
| 71 | return http.StatusForbidden, nil | ||
| 72 | }) | ||
| 73 | |||
| 74 | _, err := c.Enroll(context.Background(), Request{Token: "used"}) | ||
| 75 | if !errors.Is(err, ErrTokenRejected) { | ||
| 76 | t.Fatalf("403 gave err %v, want ErrTokenRejected", err) | ||
| 77 | } | ||
| 78 | } | ||
| 79 | |||
| 80 | func TestEnrollReportsOtherStatusCodes(t *testing.T) { | ||
| 81 | c := startServer(t, func(Request) (int, any) { | ||
| 82 | return http.StatusInternalServerError, nil | ||
| 83 | }) | ||
| 84 | |||
| 85 | _, err := c.Enroll(context.Background(), Request{Token: "tok"}) | ||
| 86 | if err == nil { | ||
| 87 | t.Fatal("500 returned nil error") | ||
| 88 | } | ||
| 89 | if errors.Is(err, ErrTokenRejected) { | ||
| 90 | t.Errorf("500 must not map to ErrTokenRejected, got %v", err) | ||
| 91 | } | ||
| 92 | } | ||
| 93 | |||
| 94 | func TestEnrollFailsOnUnreachableServer(t *testing.T) { | ||
| 95 | // A syntactically valid but dead origin: the transport error must surface, | ||
| 96 | // not a panic or a false success. | ||
| 97 | c := New("http://127.0.0.1:1") | ||
| 98 | _, err := c.Enroll(context.Background(), Request{Token: "tok"}) | ||
| 99 | if err == nil { | ||
| 100 | t.Fatal("unreachable server returned nil error") | ||
| 101 | } | ||
| 102 | if errors.Is(err, ErrTokenRejected) { | ||
| 103 | t.Errorf("transport failure must not map to ErrTokenRejected, got %v", err) | ||
| 104 | } | ||
| 105 | } | ||
internal/agent/exec/exec.go
| Old | New | ||
|---|---|---|---|
| @@ -1,5 +1,5 @@ | |||
| 1 | // Package exec defines the single command-runner type shared by the host-touching | 1 | // Package exec defines the single command-runner type shared by the host-touching |
| 2 | // agent packages (cloudhv, imagecache, netenv, overlay). Injecting a Runner keeps | 2 | // agent packages (cloudhv, imagecache, netenv). Injecting a Runner keeps |
| 3 | // those packages testable without touching the kernel. | 3 | // those packages testable without touching the kernel. |
| 4 | package exec | 4 | package exec |
| 5 | 5 | ||
internal/agent/imagecache/imagecache.go
| Old | New | ||
|---|---|---|---|
| @@ -1,9 +1,10 @@ | |||
| 1 | // Package imagecache downloads, verifies, and raw-converts base images. | 1 | // Package imagecache downloads and verifies content-addressed base images |
| 2 | // Layout: <dir>/<sha256>.raw — keyed by checksum (spec). The cache is | 2 | // (raw-converted via qemu-img, LRU-evicted beyond MaxBytes). Layout: |
| 3 | // size-capped LRU: a hit refreshes the file's mtime, and after every | 3 | // <dir>/<sha256>.raw — keyed by checksum (spec). LRU: a hit refreshes the |
| 4 | // successful Ensure the oldest images beyond MaxBytes are evicted (never the | 4 | // file's mtime, and after every successful Ensure the oldest .raw images |
| 5 | // one just ensured). Eviction is safe for running VMs: PrepareDisk copies | 5 | // beyond MaxBytes are evicted (never the one just ensured). Eviction is safe |
| 6 | // (reflink) the base, so nothing references it after create. | 6 | // for running VMs: PrepareDisk copies (reflink) the base, so nothing |
| 7 | // references it after create. | ||
| 7 | package imagecache | 8 | package imagecache |
| 8 | 9 | ||
| 9 | import ( | 10 | import ( |
| @@ -56,20 +57,9 @@ func New(dir string, run exec.Runner) *Cache { | |||
| 56 | return &Cache{dir: dir, run: run, http: &http.Client{Timeout: 10 * time.Minute}} | 57 | return &Cache{dir: dir, run: run, http: &http.Client{Timeout: 10 * time.Minute}} |
| 57 | } | 58 | } |
| 58 | 59 | ||
| 59 | func (c *Cache) Ensure(ctx context.Context, url, sha string) (string, error) { | 60 | // fetch downloads url into a temp file in the cache dir and verifies its |
| 60 | // Guard path traversal: sha becomes part of the cache file path. | 61 | // sha256. Returns the temp path; the caller owns renaming or removing it. |
| 61 | if !sha256Re.MatchString(sha) { | 62 | func (c *Cache) fetch(ctx context.Context, url, sha string) (string, error) { |
| 62 | return "", fmt.Errorf("invalid sha256: %q", sha) | ||
| 63 | } | ||
| 64 | |||
| 65 | final := filepath.Join(c.dir, sha+".raw") | ||
| 66 | if _, err := os.Stat(final); err == nil { | ||
| 67 | // Hit: refresh recency so frequently-used images sort as recent. | ||
| 68 | now := time.Now() | ||
| 69 | _ = os.Chtimes(final, now, now) | ||
| 70 | c.evict(final) | ||
| 71 | return final, nil | ||
| 72 | } | ||
| 73 | req, err := http.NewRequestWithContext(ctx, "GET", url, nil) | 63 | req, err := http.NewRequestWithContext(ctx, "GET", url, nil) |
| 74 | if err != nil { | 64 | if err != nil { |
| 75 | return "", err | 65 | return "", err |
| @@ -86,20 +76,46 @@ func (c *Cache) Ensure(ctx context.Context, url, sha string) (string, error) { | |||
| 86 | if err != nil { | 76 | if err != nil { |
| 87 | return "", err | 77 | return "", err |
| 88 | } | 78 | } |
| 89 | defer os.Remove(tmp.Name()) | ||
| 90 | h := sha256.New() | 79 | h := sha256.New() |
| 91 | if _, err := io.Copy(io.MultiWriter(tmp, h), resp.Body); err != nil { | 80 | if _, err := io.Copy(io.MultiWriter(tmp, h), resp.Body); err != nil { |
| 92 | tmp.Close() | 81 | tmp.Close() |
| 82 | os.Remove(tmp.Name()) | ||
| 83 | return "", err | ||
| 84 | } | ||
| 85 | if err := tmp.Close(); err != nil { | ||
| 86 | os.Remove(tmp.Name()) | ||
| 93 | return "", err | 87 | return "", err |
| 94 | } | 88 | } |
| 95 | tmp.Close() | ||
| 96 | if got := hex.EncodeToString(h.Sum(nil)); got != sha { | 89 | if got := hex.EncodeToString(h.Sum(nil)); got != sha { |
| 90 | os.Remove(tmp.Name()) | ||
| 97 | return "", fmt.Errorf("checksum mismatch for %s: got %s want %s", url, got, sha) | 91 | return "", fmt.Errorf("checksum mismatch for %s: got %s want %s", url, got, sha) |
| 98 | } | 92 | } |
| 93 | return tmp.Name(), nil | ||
| 94 | } | ||
| 95 | |||
| 96 | func (c *Cache) Ensure(ctx context.Context, url, sha string) (string, error) { | ||
| 97 | // Guard path traversal: sha becomes part of the cache file path. | ||
| 98 | if !sha256Re.MatchString(sha) { | ||
| 99 | return "", fmt.Errorf("invalid sha256: %q", sha) | ||
| 100 | } | ||
| 101 | |||
| 102 | final := filepath.Join(c.dir, sha+".raw") | ||
| 103 | if _, err := os.Stat(final); err == nil { | ||
| 104 | // Hit: refresh recency so frequently-used images sort as recent. | ||
| 105 | now := time.Now() | ||
| 106 | _ = os.Chtimes(final, now, now) | ||
| 107 | c.evict(final) | ||
| 108 | return final, nil | ||
| 109 | } | ||
| 110 | tmp, err := c.fetch(ctx, url, sha) | ||
| 111 | if err != nil { | ||
| 112 | return "", err | ||
| 113 | } | ||
| 114 | defer os.Remove(tmp) | ||
| 99 | // Convert to a temp file first; rename onto final atomically so a crash | 115 | // Convert to a temp file first; rename onto final atomically so a crash |
| 100 | // mid-convert cannot leave a corrupt file at the final path. | 116 | // mid-convert cannot leave a corrupt file at the final path. |
| 101 | converting := final + ".converting" | 117 | converting := final + ".converting" |
| 102 | if _, err := c.run(ctx, "qemu-img", "convert", "-O", "raw", tmp.Name(), converting); err != nil { | 118 | if _, err := c.run(ctx, "qemu-img", "convert", "-O", "raw", tmp, converting); err != nil { |
| 103 | os.Remove(converting) | 119 | os.Remove(converting) |
| 104 | return "", fmt.Errorf("qemu-img convert: %w", err) | 120 | return "", fmt.Errorf("qemu-img convert: %w", err) |
| 105 | } | 121 | } |
internal/agent/netenv/netenv.go
| Old | New | ||
|---|---|---|---|
| @@ -1,6 +1,7 @@ | |||
| 1 | // Package netenv manages the host side of VM networking: bridge eitri0 with | 1 | // Package netenv manages the host side of VM networking: bridge eitri0 with |
| 2 | // the host as .1 gateway, per-VM taps, and NAT for outbound internet. | 2 | // the host as .1 gateway, per-VM taps, and NAT for outbound internet. The |
| 3 | // Overlay-specific logic (Tailscale, none, etc.) lives in package overlay. | 3 | // bridge is a pure masqueraded underlay; mesh connectivity (per-VM identity |
| 4 | // and routing) is the guest's own concern via the rayfish (ray) binary. | ||
| 4 | package netenv | 5 | package netenv |
| 5 | 6 | ||
| 6 | import ( | 7 | import ( |
| @@ -113,17 +114,13 @@ func (n *Net) best(ctx context.Context, name string, args ...string) (string, er | |||
| 113 | } | 114 | } |
| 114 | 115 | ||
| 115 | // EnsureBridge creates and configures the eitri0 Linux bridge, enables IP | 116 | // EnsureBridge creates and configures the eitri0 Linux bridge, enables IP |
| 116 | // forwarding, and installs a scoped nftables NAT rule that masquerades VM | 117 | // forwarding, and installs an nftables NAT rule that masquerades VM outbound |
| 117 | // outbound traffic on every interface except those in noMasqIfaces. | 118 | // traffic on every interface. |
| 118 | // | ||
| 119 | // noMasqIfaces is the combined list from Overlay.NoMasqueradeIfaces() — e.g. | ||
| 120 | // ["tailscale0"] for the tailscale overlay, or ["wg0"] for overlay=none with | ||
| 121 | // --no-masquerade-ifaces=wg0. Empty list → plain masquerade on all interfaces. | ||
| 122 | // | 119 | // |
| 123 | // The function is safe to call on every agent restart: the nft chain is | 120 | // The function is safe to call on every agent restart: the nft chain is |
| 124 | // flushed before the masquerade rule is added, so rules never accumulate | 121 | // flushed before the masquerade rule is added, so rules never accumulate |
| 125 | // across restarts. | 122 | // across restarts. |
| 126 | func (n *Net) EnsureBridge(ctx context.Context, noMasqIfaces []string) error { | 123 | func (n *Net) EnsureBridge(ctx context.Context) error { |
| 127 | gw := n.Gateway() | 124 | gw := n.Gateway() |
| 128 | bits := n.cidr.Bits() | 125 | bits := n.cidr.Bits() |
| 129 | cidrStr := n.cidr.Masked().String() | 126 | cidrStr := n.cidr.Masked().String() |
| @@ -178,15 +175,9 @@ func (n *Net) EnsureBridge(ctx context.Context, noMasqIfaces []string) error { | |||
| 178 | return fmt.Errorf("nft flush chain ip eitri postrouting: %w", err) | 175 | return fmt.Errorf("nft flush chain ip eitri postrouting: %w", err) |
| 179 | } | 176 | } |
| 180 | 177 | ||
| 181 | // Build the masquerade rule. For each interface in noMasqIfaces add an | 178 | // Plain masquerade rule: no interface exclusions. |
| 182 | // oifname != "<iface>" condition. All conditions are in a single nft rule. | ||
| 183 | // Empty noMasqIfaces → plain masquerade (no exclusions). | ||
| 184 | ruleArgs := []string{"add", "rule", "ip", "eitri", "postrouting", | 179 | ruleArgs := []string{"add", "rule", "ip", "eitri", "postrouting", |
| 185 | "ip", "saddr", cidrStr} | 180 | "ip", "saddr", cidrStr, "masquerade"} |
| 186 | for _, iface := range noMasqIfaces { | ||
| 187 | ruleArgs = append(ruleArgs, "oifname", "!=", `"`+iface+`"`) | ||
| 188 | } | ||
| 189 | ruleArgs = append(ruleArgs, "masquerade") | ||
| 190 | 181 | ||
| 191 | if _, err := n.run(ctx, "nft", ruleArgs...); err != nil { | 182 | if _, err := n.run(ctx, "nft", ruleArgs...); err != nil { |
| 192 | return fmt.Errorf("nft add rule: %w", err) | 183 | return fmt.Errorf("nft add rule: %w", err) |
internal/agent/netenv/netenv_test.go
| Old | New | ||
|---|---|---|---|
| @@ -39,7 +39,7 @@ func joinCalls(calls *[]call) string { | |||
| 39 | return sb.String() | 39 | return sb.String() |
| 40 | } | 40 | } |
| 41 | 41 | ||
| 42 | func TestEnsureBridgeSetsUpGatewayForwardingAndScopedNAT(t *testing.T) { | 42 | func TestEnsureBridgeSetsUpGatewayForwardingAndNAT(t *testing.T) { |
| 43 | // Fresh host: `ip link show` errors (bridge absent) so link-add runs. | 43 | // Fresh host: `ip link show` errors (bridge absent) so link-add runs. |
| 44 | errs := map[string]error{ | 44 | errs := map[string]error{ |
| 45 | "ip link show dev eitri0": errors.New("Device \"eitri0\" does not exist."), | 45 | "ip link show dev eitri0": errors.New("Device \"eitri0\" does not exist."), |
| @@ -50,7 +50,7 @@ func TestEnsureBridgeSetsUpGatewayForwardingAndScopedNAT(t *testing.T) { | |||
| 50 | run, calls := recorder(out, errs) | 50 | run, calls := recorder(out, errs) |
| 51 | n, err := New(run, "10.77.1.0/24") | 51 | n, err := New(run, "10.77.1.0/24") |
| 52 | require.NoError(t, err) | 52 | require.NoError(t, err) |
| 53 | require.NoError(t, n.EnsureBridge(context.Background(), []string{"tailscale0"})) | 53 | require.NoError(t, n.EnsureBridge(context.Background())) |
| 54 | 54 | ||
| 55 | all := joinCalls(calls) | 55 | all := joinCalls(calls) |
| 56 | assert.Contains(t, all, "ip link add eitri0 type bridge") | 56 | assert.Contains(t, all, "ip link add eitri0 type bridge") |
| @@ -58,8 +58,7 @@ func TestEnsureBridgeSetsUpGatewayForwardingAndScopedNAT(t *testing.T) { | |||
| 58 | assert.Contains(t, all, "ip addr replace 10.77.1.1/24 dev eitri0") | 58 | assert.Contains(t, all, "ip addr replace 10.77.1.1/24 dev eitri0") |
| 59 | assert.NotContains(t, all, "ip addr add") | 59 | assert.NotContains(t, all, "ip addr add") |
| 60 | assert.Contains(t, all, "net.ipv4.ip_forward=1") | 60 | assert.Contains(t, all, "net.ipv4.ip_forward=1") |
| 61 | // NAT must NOT masquerade tailnet-bound traffic (spec): scoped by oifname. | 61 | assert.Contains(t, all, "masquerade") |
| 62 | assert.Contains(t, all, `oifname != "tailscale0"`) | ||
| 63 | assert.Contains(t, all, "10.77.1.0/24") | 62 | assert.Contains(t, all, "10.77.1.0/24") |
| 64 | } | 63 | } |
| 65 | 64 | ||
| @@ -81,7 +80,7 @@ func TestEnsureBridgeIdempotentOnRestart(t *testing.T) { | |||
| 81 | run, calls := recorder(out, errs) | 80 | run, calls := recorder(out, errs) |
| 82 | n, err := New(run, "10.77.1.0/24") | 81 | n, err := New(run, "10.77.1.0/24") |
| 83 | require.NoError(t, err) | 82 | require.NoError(t, err) |
| 84 | require.NoError(t, n.EnsureBridge(context.Background(), []string{"tailscale0"}), | 83 | require.NoError(t, n.EnsureBridge(context.Background()), |
| 85 | "restart with existing bridge+address must succeed") | 84 | "restart with existing bridge+address must succeed") |
| 86 | 85 | ||
| 87 | all := joinCalls(calls) | 86 | all := joinCalls(calls) |
| @@ -117,8 +116,8 @@ func TestEnsureBridgeFlushPrecedesRuleAdd(t *testing.T) { | |||
| 117 | require.NoError(t, err) | 116 | require.NoError(t, err) |
| 118 | 117 | ||
| 119 | // Call EnsureBridge twice — simulating two agent restarts. | 118 | // Call EnsureBridge twice — simulating two agent restarts. |
| 120 | require.NoError(t, n.EnsureBridge(context.Background(), []string{"tailscale0"})) | 119 | require.NoError(t, n.EnsureBridge(context.Background())) |
| 121 | require.NoError(t, n.EnsureBridge(context.Background(), []string{"tailscale0"})) | 120 | require.NoError(t, n.EnsureBridge(context.Background())) |
| 122 | 121 | ||
| 123 | all := joinCalls(calls) | 122 | all := joinCalls(calls) |
| 124 | 123 | ||
| @@ -156,13 +155,13 @@ func TestEnsureBridgeGatewayConflictDetected(t *testing.T) { | |||
| 156 | n, err := New(run, "10.77.1.0/24") | 155 | n, err := New(run, "10.77.1.0/24") |
| 157 | require.NoError(t, err) | 156 | require.NoError(t, err) |
| 158 | 157 | ||
| 159 | err = n.EnsureBridge(context.Background(), []string{"tailscale0"}) | 158 | err = n.EnsureBridge(context.Background()) |
| 160 | require.Error(t, err) | 159 | require.Error(t, err) |
| 161 | assert.Contains(t, err.Error(), "gateway IP 10.77.1.1 not present on eitri0") | 160 | assert.Contains(t, err.Error(), "gateway IP 10.77.1.1 not present on eitri0") |
| 162 | } | 161 | } |
| 163 | 162 | ||
| 164 | // TestEnsureBridgePlainMasquerade: empty noMasqIfaces → masquerade without any | 163 | // TestEnsureBridgePlainMasquerade: the masquerade rule has no interface |
| 165 | // oifname exclusions (e.g. overlay=none without --no-masquerade-ifaces). | 164 | // exclusions — the bridge is a pure masqueraded underlay. |
| 166 | func TestEnsureBridgePlainMasquerade(t *testing.T) { | 165 | func TestEnsureBridgePlainMasquerade(t *testing.T) { |
| 167 | out := map[string]string{ | 166 | out := map[string]string{ |
| 168 | "ip -o addr show dev eitri0": "2: eitri0 inet 10.77.1.1/24 brd 10.77.1.255 scope global eitri0", | 167 | "ip -o addr show dev eitri0": "2: eitri0 inet 10.77.1.1/24 brd 10.77.1.255 scope global eitri0", |
| @@ -170,36 +169,11 @@ func TestEnsureBridgePlainMasquerade(t *testing.T) { | |||
| 170 | run, calls := recorder(out, nil) | 169 | run, calls := recorder(out, nil) |
| 171 | n, err := New(run, "10.77.1.0/24") | 170 | n, err := New(run, "10.77.1.0/24") |
| 172 | require.NoError(t, err) | 171 | require.NoError(t, err) |
| 173 | require.NoError(t, n.EnsureBridge(context.Background(), nil)) | 172 | require.NoError(t, n.EnsureBridge(context.Background())) |
| 174 | 173 | ||
| 175 | all := joinCalls(calls) | 174 | all := joinCalls(calls) |
| 176 | assert.Contains(t, all, "masquerade", "masquerade rule must still be added") | 175 | assert.Contains(t, all, "masquerade", "masquerade rule must still be added") |
| 177 | assert.NotContains(t, all, "oifname", | 176 | assert.NotContains(t, all, "oifname", "no interface exclusions in the rule") |
| 178 | "no noMasqIfaces → no oifname conditions in the rule") | ||
| 179 | } | ||
| 180 | |||
| 181 | // TestEnsureBridgeTwoNoMasqIfaces: two ifaces in noMasqIfaces → both appear | ||
| 182 | // as oifname != conditions in a single nft add rule call. | ||
| 183 | func TestEnsureBridgeTwoNoMasqIfaces(t *testing.T) { | ||
| 184 | out := map[string]string{ | ||
| 185 | "ip -o addr show dev eitri0": "2: eitri0 inet 10.77.1.1/24 brd 10.77.1.255 scope global eitri0", | ||
| 186 | } | ||
| 187 | run, calls := recorder(out, nil) | ||
| 188 | n, err := New(run, "10.77.1.0/24") | ||
| 189 | require.NoError(t, err) | ||
| 190 | require.NoError(t, n.EnsureBridge(context.Background(), []string{"tailscale0", "wg0"})) | ||
| 191 | |||
| 192 | // Find the nft add rule call and verify both exclusions are present. | ||
| 193 | var ruleCall string | ||
| 194 | for _, c := range *calls { | ||
| 195 | if c.name == "nft" && strings.HasPrefix(c.args, "add rule ip eitri postrouting") { | ||
| 196 | ruleCall = c.args | ||
| 197 | break | ||
| 198 | } | ||
| 199 | } | ||
| 200 | require.NotEmpty(t, ruleCall, "must find nft add rule call") | ||
| 201 | assert.Contains(t, ruleCall, `oifname != "tailscale0"`) | ||
| 202 | assert.Contains(t, ruleCall, `oifname != "wg0"`) | ||
| 203 | } | 177 | } |
| 204 | 178 | ||
| 205 | // CreateTap must tolerate "File exists" / "already exists" so it is idempotent. | 179 | // CreateTap must tolerate "File exists" / "already exists" so it is idempotent. |
internal/agent/overlay/overlay.go
| Old | New | ||
|---|---|---|---|
| @@ -1,274 +0,0 @@ | |||
| 1 | // Package overlay abstracts how a host's bridge CIDR becomes reachable from | ||
| 2 | // the user's network. Implementations honor the ownership model: the host's | ||
| 3 | // networking belongs to the user; observe-and-instruct by default, | ||
| 4 | // additive-only with manage=true. | ||
| 5 | package overlay | ||
| 6 | |||
| 7 | import ( | ||
| 8 | "context" | ||
| 9 | "encoding/json" | ||
| 10 | "errors" | ||
| 11 | "fmt" | ||
| 12 | "net/netip" | ||
| 13 | "strings" | ||
| 14 | |||
| 15 | "github.com/a73x/eitri/internal/agent/exec" | ||
| 16 | ) | ||
| 17 | |||
| 18 | // ErrActionRequired is returned by EnsureRoute (manage=false, or overlay=none) | ||
| 19 | // when the bridge CIDR is not reachable and the agent must not mutate state. | ||
| 20 | // The error message contains the exact operator command or instruction. | ||
| 21 | // Use errors.Is to detect it. | ||
| 22 | var ErrActionRequired = errors.New("action required") | ||
| 23 | |||
| 24 | // ErrUnverifiable is returned by VerifyRoute for overlay implementations that | ||
| 25 | // have no mechanism to verify reachability (e.g. overlay=none). Callers must | ||
| 26 | // treat it as "unknown by design", never as success. | ||
| 27 | var ErrUnverifiable = errors.New("route verification not supported by this overlay") | ||
| 28 | |||
| 29 | // Overlay abstracts the mechanism that makes the bridge CIDR reachable from | ||
| 30 | // the user's network. | ||
| 31 | type Overlay interface { | ||
| 32 | // EnsureRoute makes the bridge CIDR reachable over the user's network, | ||
| 33 | // honoring the ownership model: observe-and-instruct unless manage=true, | ||
| 34 | // and additive-only even then. | ||
| 35 | EnsureRoute(ctx context.Context, manage bool) error | ||
| 36 | // VerifyRoute reports whether the CIDR is actually announced/approved. | ||
| 37 | VerifyRoute(ctx context.Context) error | ||
| 38 | // NoMasqueradeIfaces returns egress interfaces excluded from NAT, so VM | ||
| 39 | // source IPs survive the overlay path. | ||
| 40 | NoMasqueradeIfaces() []string | ||
| 41 | } | ||
| 42 | |||
| 43 | // New constructs an Overlay. kind must be "tailscale" or "none". | ||
| 44 | // cidr is the bridge CIDR (e.g. "10.77.1.0/24"). | ||
| 45 | // authkey is optional; non-empty with kind="none" is an error. | ||
| 46 | // extraNoMasq are additional interfaces to exclude from NAT beyond the | ||
| 47 | // overlay's own default. | ||
| 48 | func New(kind, cidr, authkey string, run exec.Runner, extraNoMasq []string) (Overlay, error) { | ||
| 49 | p, err := netip.ParsePrefix(cidr) | ||
| 50 | if err != nil { | ||
| 51 | return nil, fmt.Errorf("invalid CIDR %q: %w", cidr, err) | ||
| 52 | } | ||
| 53 | if !p.Addr().Is4() { | ||
| 54 | return nil, fmt.Errorf("bridge CIDR must be IPv4, got %s", cidr) | ||
| 55 | } | ||
| 56 | |||
| 57 | switch kind { | ||
| 58 | case "tailscale": | ||
| 59 | return &tailscaleOverlay{ | ||
| 60 | run: run, | ||
| 61 | cidr: p, | ||
| 62 | authkey: authkey, | ||
| 63 | extraNoMasq: extraNoMasq, | ||
| 64 | }, nil | ||
| 65 | case "none": | ||
| 66 | if authkey != "" { | ||
| 67 | return nil, fmt.Errorf("authkey is meaningless with overlay=none") | ||
| 68 | } | ||
| 69 | return &noneOverlay{ | ||
| 70 | cidr: p, | ||
| 71 | extraNoMasq: extraNoMasq, | ||
| 72 | }, nil | ||
| 73 | default: | ||
| 74 | return nil, fmt.Errorf("unknown overlay kind %q (supported: tailscale, none)", kind) | ||
| 75 | } | ||
| 76 | } | ||
| 77 | |||
| 78 | // ── tailscale overlay ───────────────────────────────────────────────────────── | ||
| 79 | |||
| 80 | type tailscaleOverlay struct { | ||
| 81 | run exec.Runner | ||
| 82 | cidr netip.Prefix | ||
| 83 | authkey string | ||
| 84 | extraNoMasq []string | ||
| 85 | } | ||
| 86 | |||
| 87 | // tsDebugPrefs is the subset of `tailscale debug prefs` JSON we care about. | ||
| 88 | type tsDebugPrefs struct { | ||
| 89 | AdvertiseRoutes []string `json:"AdvertiseRoutes"` | ||
| 90 | } | ||
| 91 | |||
| 92 | // advertisedRoutes returns the host's currently configured advertise-routes | ||
| 93 | // by parsing `tailscale debug prefs`. Returns nil slice (not error) when | ||
| 94 | // AdvertiseRoutes is JSON null or an empty array. | ||
| 95 | func (o *tailscaleOverlay) advertisedRoutes(ctx context.Context) ([]string, error) { | ||
| 96 | out, err := o.run(ctx, "tailscale", "debug", "prefs") | ||
| 97 | if err != nil { | ||
| 98 | return nil, fmt.Errorf("tailscale debug prefs: %w", err) | ||
| 99 | } | ||
| 100 | var prefs tsDebugPrefs | ||
| 101 | if err := json.Unmarshal([]byte(out), &prefs); err != nil { | ||
| 102 | return nil, fmt.Errorf("tailscale debug prefs parse: %w", err) | ||
| 103 | } | ||
| 104 | return prefs.AdvertiseRoutes, nil | ||
| 105 | } | ||
| 106 | |||
| 107 | // unionRoutes returns a new slice containing all routes from existing, with | ||
| 108 | // our CIDR appended if it is not already present. | ||
| 109 | func (o *tailscaleOverlay) unionRoutes(existing []string) []string { | ||
| 110 | want := o.cidr.Masked().String() | ||
| 111 | for _, r := range existing { | ||
| 112 | if r == want { | ||
| 113 | return existing | ||
| 114 | } | ||
| 115 | } | ||
| 116 | result := make([]string, len(existing), len(existing)+1) | ||
| 117 | copy(result, existing) | ||
| 118 | return append(result, want) | ||
| 119 | } | ||
| 120 | |||
| 121 | // EnsureRoute implements the spec Tailscale ownership model. | ||
| 122 | // | ||
| 123 | // manage=false (observe-and-instruct): NEVER mutates Tailscale state. | ||
| 124 | // If the bridge CIDR is missing, returns ErrActionRequired with the exact | ||
| 125 | // operator command (existing routes preserved in the suggestion). If reads | ||
| 126 | // fail, degrades gracefully: ErrActionRequired with just our CIDR. | ||
| 127 | // | ||
| 128 | // manage=true (additive): reads current AdvertiseRoutes, unions in our bridge | ||
| 129 | // CIDR, applies via `tailscale set --advertise-routes=<union>`. If authkey is | ||
| 130 | // non-empty, uses `tailscale up --advertise-routes=<union> --authkey=<key>`. | ||
| 131 | // If reads fail in manage=true, returns the read error (does NOT blind-write). | ||
| 132 | // | ||
| 133 | // Both modes: if the CIDR is already advertised → nil (no commands beyond read). | ||
| 134 | // Passing authkey with manage=false is a configuration error. | ||
| 135 | func (o *tailscaleOverlay) EnsureRoute(ctx context.Context, manage bool) error { | ||
| 136 | // Consent check: authkey implies joining a new tailnet — requires manage. | ||
| 137 | if !manage && o.authkey != "" { | ||
| 138 | return fmt.Errorf("authkey requires --manage-overlay") | ||
| 139 | } | ||
| 140 | |||
| 141 | want := o.cidr.Masked().String() | ||
| 142 | |||
| 143 | existing, err := o.advertisedRoutes(ctx) | ||
| 144 | if err != nil { | ||
| 145 | if !manage { | ||
| 146 | // Observe mode: degrade gracefully. | ||
| 147 | cmd := fmt.Sprintf("sudo tailscale set --advertise-routes=%s", want) | ||
| 148 | return fmt.Errorf("%w: run: %s (could not read existing routes; preserve them manually): %v", | ||
| 149 | ErrActionRequired, cmd, err) | ||
| 150 | } | ||
| 151 | // Manage mode: do NOT blind-write when read fails. | ||
| 152 | return err | ||
| 153 | } | ||
| 154 | |||
| 155 | // Check whether our CIDR is already present — fast-path for both modes. | ||
| 156 | for _, r := range existing { | ||
| 157 | if r == want { | ||
| 158 | return nil | ||
| 159 | } | ||
| 160 | } | ||
| 161 | |||
| 162 | // Our CIDR is not present. Compute the unioned route list. | ||
| 163 | unioned := o.unionRoutes(existing) | ||
| 164 | routeArg := "--advertise-routes=" + strings.Join(unioned, ",") | ||
| 165 | |||
| 166 | if !manage { | ||
| 167 | // Observe-and-instruct: return ErrActionRequired with the exact command. | ||
| 168 | cmd := fmt.Sprintf("sudo tailscale set %s", routeArg) | ||
| 169 | return fmt.Errorf("%w: run: %s", ErrActionRequired, cmd) | ||
| 170 | } | ||
| 171 | |||
| 172 | // Manage mode: apply additively. | ||
| 173 | // When an auth key is present, join the tailnet first with `tailscale login` | ||
| 174 | // (which does NOT clobber existing prefs), then apply routes additively via | ||
| 175 | // `tailscale set`. Never use `tailscale up` — it is an absolute command that | ||
| 176 | // replaces all prefs and errors on dedicated hosts with prior state. | ||
| 177 | if o.authkey != "" { | ||
| 178 | if _, err = o.run(ctx, "tailscale", "login", fmt.Sprintf("--auth-key=%s", o.authkey)); err != nil { | ||
| 179 | return fmt.Errorf("tailscale login: %w", err) | ||
| 180 | } | ||
| 181 | } | ||
| 182 | _, err = o.run(ctx, "tailscale", "set", routeArg) | ||
| 183 | if err != nil { | ||
| 184 | return fmt.Errorf("tailscale set: %w", err) | ||
| 185 | } | ||
| 186 | return nil | ||
| 187 | } | ||
| 188 | |||
| 189 | // tsStatus is the subset of `tailscale status --json` we care about. | ||
| 190 | type tsStatus struct { | ||
| 191 | Self struct { | ||
| 192 | PrimaryRoutes []string `json:"PrimaryRoutes"` | ||
| 193 | } `json:"Self"` | ||
| 194 | } | ||
| 195 | |||
| 196 | // VerifyRoute checks that the bridge CIDR is listed in Tailscale's | ||
| 197 | // PrimaryRoutes (i.e. the route has been approved in the tailnet ACL). | ||
| 198 | func (o *tailscaleOverlay) VerifyRoute(ctx context.Context) error { | ||
| 199 | out, err := o.run(ctx, "tailscale", "status", "--json") | ||
| 200 | if err != nil { | ||
| 201 | return fmt.Errorf("tailscale status: %w", err) | ||
| 202 | } | ||
| 203 | |||
| 204 | var st tsStatus | ||
| 205 | if err := json.Unmarshal([]byte(out), &st); err != nil { | ||
| 206 | return fmt.Errorf("tailscale status parse: %w", err) | ||
| 207 | } | ||
| 208 | |||
| 209 | want := o.cidr.Masked().String() | ||
| 210 | for _, r := range st.Self.PrimaryRoutes { | ||
| 211 | if r == want { | ||
| 212 | return nil | ||
| 213 | } | ||
| 214 | } | ||
| 215 | |||
| 216 | return fmt.Errorf( | ||
| 217 | "route %s pending approval in tailnet ACL — "+ | ||
| 218 | "add an autoApprovers entry or approve manually in the Tailscale admin console", | ||
| 219 | want, | ||
| 220 | ) | ||
| 221 | } | ||
| 222 | |||
| 223 | // NoMasqueradeIfaces returns ["tailscale0"] plus any extra interfaces. | ||
| 224 | func (o *tailscaleOverlay) NoMasqueradeIfaces() []string { | ||
| 225 | result := make([]string, 0, 1+len(o.extraNoMasq)) | ||
| 226 | result = append(result, "tailscale0") | ||
| 227 | result = append(result, o.extraNoMasq...) | ||
| 228 | return result | ||
| 229 | } | ||
| 230 | |||
| 231 | // ── none overlay ────────────────────────────────────────────────────────────── | ||
| 232 | |||
| 233 | type noneOverlay struct { | ||
| 234 | cidr netip.Prefix | ||
| 235 | extraNoMasq []string | ||
| 236 | } | ||
| 237 | |||
| 238 | // EnsureRoute for overlay=none: | ||
| 239 | // - manage=true is a hard error (there is nothing to manage; the user should | ||
| 240 | // drop --manage-overlay). This is NOT ErrActionRequired. | ||
| 241 | // - manage=false returns ErrActionRequired with overlay-neutral text naming | ||
| 242 | // BOTH operator duties: make the CIDR reachable AND pass | ||
| 243 | // --no-masquerade-ifaces so VM source IPs are preserved over the tunnel. | ||
| 244 | func (o *noneOverlay) EnsureRoute(_ context.Context, manage bool) error { | ||
| 245 | if manage { | ||
| 246 | return fmt.Errorf("overlay=none has nothing to manage; drop --manage-overlay") | ||
| 247 | } | ||
| 248 | want := o.cidr.Masked().String() | ||
| 249 | return fmt.Errorf( | ||
| 250 | "%w: make %s reachable from your network "+ | ||
| 251 | "(e.g. WireGuard AllowedIPs includes the bridge CIDR); "+ | ||
| 252 | "also pass --no-masquerade-ifaces=<egress-iface> so VM source IPs "+ | ||
| 253 | "are preserved (without it, VM-originated flows are SNATted to the host address); "+ | ||
| 254 | "Eitri will not configure this", | ||
| 255 | ErrActionRequired, want, | ||
| 256 | ) | ||
| 257 | } | ||
| 258 | |||
| 259 | // VerifyRoute returns ErrUnverifiable for overlay=none — there is nothing to | ||
| 260 | // check; callers must treat this as "unknown by design", never as success. | ||
| 261 | func (o *noneOverlay) VerifyRoute(_ context.Context) error { | ||
| 262 | want := o.cidr.Masked().String() | ||
| 263 | return fmt.Errorf("%w: %s", ErrUnverifiable, want) | ||
| 264 | } | ||
| 265 | |||
| 266 | // NoMasqueradeIfaces returns only the extra interfaces supplied at construction. | ||
| 267 | func (o *noneOverlay) NoMasqueradeIfaces() []string { | ||
| 268 | if len(o.extraNoMasq) == 0 { | ||
| 269 | return nil | ||
| 270 | } | ||
| 271 | result := make([]string, len(o.extraNoMasq)) | ||
| 272 | copy(result, o.extraNoMasq) | ||
| 273 | return result | ||
| 274 | } | ||
internal/agent/overlay/overlay_test.go
| Old | New | ||
|---|---|---|---|
| @@ -1,367 +0,0 @@ | |||
| 1 | package overlay | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "context" | ||
| 5 | "errors" | ||
| 6 | "strings" | ||
| 7 | "testing" | ||
| 8 | |||
| 9 | "github.com/a73x/eitri/internal/agent/exec" | ||
| 10 | "github.com/stretchr/testify/assert" | ||
| 11 | "github.com/stretchr/testify/require" | ||
| 12 | ) | ||
| 13 | |||
| 14 | // ── test helpers ────────────────────────────────────────────────────────────── | ||
| 15 | |||
| 16 | type call struct { | ||
| 17 | name string | ||
| 18 | args string | ||
| 19 | } | ||
| 20 | |||
| 21 | func recorder(out map[string]string, errs map[string]error) (exec.Runner, *[]call) { | ||
| 22 | var calls []call | ||
| 23 | return func(ctx context.Context, name string, args ...string) (string, error) { | ||
| 24 | joined := strings.Join(args, " ") | ||
| 25 | key := name + " " + joined | ||
| 26 | calls = append(calls, call{name, joined}) | ||
| 27 | return out[key], errs[key] | ||
| 28 | }, &calls | ||
| 29 | } | ||
| 30 | |||
| 31 | func joinCalls(calls *[]call) string { | ||
| 32 | var sb strings.Builder | ||
| 33 | for _, c := range *calls { | ||
| 34 | sb.WriteString(c.name + " " + c.args + "\n") | ||
| 35 | } | ||
| 36 | return sb.String() | ||
| 37 | } | ||
| 38 | |||
| 39 | func hasMutatingCall(calls *[]call) bool { | ||
| 40 | for _, c := range *calls { | ||
| 41 | if c.name == "tailscale" && (strings.HasPrefix(c.args, "set ") || strings.HasPrefix(c.args, "up ")) { | ||
| 42 | return true | ||
| 43 | } | ||
| 44 | } | ||
| 45 | return false | ||
| 46 | } | ||
| 47 | |||
| 48 | func debugPrefsJSON(routes []string) string { | ||
| 49 | if routes == nil { | ||
| 50 | return `{"AdvertiseRoutes": null}` | ||
| 51 | } | ||
| 52 | if len(routes) == 0 { | ||
| 53 | return `{"AdvertiseRoutes": []}` | ||
| 54 | } | ||
| 55 | quoted := make([]string, len(routes)) | ||
| 56 | for i, r := range routes { | ||
| 57 | quoted[i] = `"` + r + `"` | ||
| 58 | } | ||
| 59 | return `{"AdvertiseRoutes": [` + strings.Join(quoted, ",") + `]}` | ||
| 60 | } | ||
| 61 | |||
| 62 | // ── New() constructor tests ─────────────────────────────────────────────────── | ||
| 63 | |||
| 64 | func TestNew_InvalidKindReturnsError(t *testing.T) { | ||
| 65 | run, _ := recorder(nil, nil) | ||
| 66 | _, err := New("wireguard", "10.77.1.0/24", "", run, nil) | ||
| 67 | require.Error(t, err) | ||
| 68 | assert.Contains(t, err.Error(), "wireguard") | ||
| 69 | } | ||
| 70 | |||
| 71 | func TestNew_InvalidCIDRReturnsError(t *testing.T) { | ||
| 72 | run, _ := recorder(nil, nil) | ||
| 73 | _, err := New("tailscale", "not-a-cidr", "", run, nil) | ||
| 74 | require.Error(t, err) | ||
| 75 | } | ||
| 76 | |||
| 77 | func TestNew_IPv6CIDRReturnsError(t *testing.T) { | ||
| 78 | run, _ := recorder(nil, nil) | ||
| 79 | _, err := New("tailscale", "fd00::/48", "", run, nil) | ||
| 80 | require.Error(t, err) | ||
| 81 | assert.Contains(t, err.Error(), "IPv4") | ||
| 82 | } | ||
| 83 | |||
| 84 | func TestNew_NoneWithAuthkeyReturnsError(t *testing.T) { | ||
| 85 | run, _ := recorder(nil, nil) | ||
| 86 | _, err := New("none", "10.77.1.0/24", "tskey-abc", run, nil) | ||
| 87 | require.Error(t, err) | ||
| 88 | assert.Contains(t, err.Error(), "authkey is meaningless with overlay=none") | ||
| 89 | } | ||
| 90 | |||
| 91 | // ── tailscale: EnsureRoute ──────────────────────────────────────────────────── | ||
| 92 | |||
| 93 | func TestTailscale_EnsureRoute_ObserveMode_CIDRMissing(t *testing.T) { | ||
| 94 | existing := []string{"192.168.1.0/24"} | ||
| 95 | out := map[string]string{ | ||
| 96 | "tailscale debug prefs": debugPrefsJSON(existing), | ||
| 97 | } | ||
| 98 | run, calls := recorder(out, nil) | ||
| 99 | ov, err := New("tailscale", "10.77.1.0/24", "", run, nil) | ||
| 100 | require.NoError(t, err) | ||
| 101 | |||
| 102 | err = ov.EnsureRoute(context.Background(), false) | ||
| 103 | require.Error(t, err) | ||
| 104 | |||
| 105 | assert.True(t, errors.Is(err, ErrActionRequired), | ||
| 106 | "error must wrap ErrActionRequired, got: %v", err) | ||
| 107 | assert.Contains(t, err.Error(), "tailscale set --advertise-routes=192.168.1.0/24,10.77.1.0/24", | ||
| 108 | "suggest command must union existing routes with our CIDR") | ||
| 109 | assert.False(t, hasMutatingCall(calls), | ||
| 110 | "manage=false must NEVER execute tailscale set or tailscale up") | ||
| 111 | } | ||
| 112 | |||
| 113 | func TestTailscale_EnsureRoute_ObserveMode_CIDRAlreadyPresent(t *testing.T) { | ||
| 114 | existing := []string{"192.168.1.0/24", "10.77.1.0/24"} | ||
| 115 | out := map[string]string{ | ||
| 116 | "tailscale debug prefs": debugPrefsJSON(existing), | ||
| 117 | } | ||
| 118 | run, calls := recorder(out, nil) | ||
| 119 | ov, err := New("tailscale", "10.77.1.0/24", "", run, nil) | ||
| 120 | require.NoError(t, err) | ||
| 121 | |||
| 122 | err = ov.EnsureRoute(context.Background(), false) | ||
| 123 | assert.NoError(t, err, "CIDR already present: nothing to do → nil") | ||
| 124 | assert.False(t, hasMutatingCall(calls)) | ||
| 125 | } | ||
| 126 | |||
| 127 | func TestTailscale_EnsureRoute_ManageMode_UnionsExistingRoutes(t *testing.T) { | ||
| 128 | existing := []string{"192.168.1.0/24"} | ||
| 129 | out := map[string]string{ | ||
| 130 | "tailscale debug prefs": debugPrefsJSON(existing), | ||
| 131 | } | ||
| 132 | run, calls := recorder(out, nil) | ||
| 133 | ov, err := New("tailscale", "10.77.1.0/24", "", run, nil) | ||
| 134 | require.NoError(t, err) | ||
| 135 | |||
| 136 | err = ov.EnsureRoute(context.Background(), true) | ||
| 137 | require.NoError(t, err) | ||
| 138 | |||
| 139 | all := joinCalls(calls) | ||
| 140 | assert.Contains(t, all, "tailscale set --advertise-routes=192.168.1.0/24,10.77.1.0/24") | ||
| 141 | assert.NotContains(t, all, "tailscale up", | ||
| 142 | "manage=true without authkey must use `set`, never `up`") | ||
| 143 | } | ||
| 144 | |||
| 145 | func TestTailscale_EnsureRoute_ManageMode_EmptyRoutes(t *testing.T) { | ||
| 146 | out := map[string]string{ | ||
| 147 | "tailscale debug prefs": debugPrefsJSON(nil), | ||
| 148 | } | ||
| 149 | run, calls := recorder(out, nil) | ||
| 150 | ov, err := New("tailscale", "10.77.1.0/24", "", run, nil) | ||
| 151 | require.NoError(t, err) | ||
| 152 | |||
| 153 | err = ov.EnsureRoute(context.Background(), true) | ||
| 154 | require.NoError(t, err) | ||
| 155 | |||
| 156 | all := joinCalls(calls) | ||
| 157 | assert.Contains(t, all, "tailscale set --advertise-routes=10.77.1.0/24") | ||
| 158 | assert.NotContains(t, all, "tailscale up") | ||
| 159 | } | ||
| 160 | |||
| 161 | |||
| 162 | func TestTailscale_EnsureRoute_ObserveMode_WithAuthkey_IsError(t *testing.T) { | ||
| 163 | // Authkey stored at construction time is irrelevant here; the constructor | ||
| 164 | // allows it (authkey only forbidden on none). The consent check is at | ||
| 165 | // EnsureRoute time: manage=false + non-empty authkey → config error. | ||
| 166 | // We need to test this through the struct directly since New with tailscale | ||
| 167 | // allows authkey. Build the overlay and call EnsureRoute(manage=false). | ||
| 168 | run, calls := recorder(nil, nil) | ||
| 169 | ov, err := New("tailscale", "10.77.1.0/24", "tskey-abc", run, nil) | ||
| 170 | require.NoError(t, err) | ||
| 171 | |||
| 172 | err = ov.EnsureRoute(context.Background(), false) | ||
| 173 | require.Error(t, err, "authkey without --manage-overlay must be an error") | ||
| 174 | assert.False(t, errors.Is(err, ErrActionRequired), | ||
| 175 | "config/consent error, not action-required") | ||
| 176 | assert.False(t, hasMutatingCall(calls)) | ||
| 177 | } | ||
| 178 | |||
| 179 | func TestTailscale_EnsureRoute_ManageMode_CIDRAlreadyPresent(t *testing.T) { | ||
| 180 | existing := []string{"192.168.1.0/24", "10.77.1.0/24"} | ||
| 181 | out := map[string]string{ | ||
| 182 | "tailscale debug prefs": debugPrefsJSON(existing), | ||
| 183 | } | ||
| 184 | run, calls := recorder(out, nil) | ||
| 185 | ov, err := New("tailscale", "10.77.1.0/24", "", run, nil) | ||
| 186 | require.NoError(t, err) | ||
| 187 | |||
| 188 | err = ov.EnsureRoute(context.Background(), true) | ||
| 189 | assert.NoError(t, err, "manage=true with CIDR already present: nothing to do → nil") | ||
| 190 | assert.False(t, hasMutatingCall(calls)) | ||
| 191 | } | ||
| 192 | |||
| 193 | func TestTailscale_EnsureRoute_ObserveMode_ReadFails(t *testing.T) { | ||
| 194 | errs := map[string]error{ | ||
| 195 | "tailscale debug prefs": errors.New("tailscaled not running"), | ||
| 196 | } | ||
| 197 | run, calls := recorder(nil, errs) | ||
| 198 | ov, err := New("tailscale", "10.77.1.0/24", "", run, nil) | ||
| 199 | require.NoError(t, err) | ||
| 200 | |||
| 201 | err = ov.EnsureRoute(context.Background(), false) | ||
| 202 | require.Error(t, err) | ||
| 203 | assert.True(t, errors.Is(err, ErrActionRequired), | ||
| 204 | "read failure in observe mode degrades to ErrActionRequired") | ||
| 205 | assert.Contains(t, err.Error(), "10.77.1.0/24") | ||
| 206 | assert.False(t, hasMutatingCall(calls)) | ||
| 207 | } | ||
| 208 | |||
| 209 | func TestTailscale_EnsureRoute_ManageMode_ReadFails(t *testing.T) { | ||
| 210 | errs := map[string]error{ | ||
| 211 | "tailscale debug prefs": errors.New("tailscaled not running"), | ||
| 212 | } | ||
| 213 | run, calls := recorder(nil, errs) | ||
| 214 | ov, err := New("tailscale", "10.77.1.0/24", "", run, nil) | ||
| 215 | require.NoError(t, err) | ||
| 216 | |||
| 217 | err = ov.EnsureRoute(context.Background(), true) | ||
| 218 | require.Error(t, err, "manage=true read failure must return the error") | ||
| 219 | assert.False(t, errors.Is(err, ErrActionRequired), | ||
| 220 | "manage=true read failure is NOT action-required; it's a real error") | ||
| 221 | assert.False(t, hasMutatingCall(calls)) | ||
| 222 | } | ||
| 223 | |||
| 224 | // ── tailscale: VerifyRoute ──────────────────────────────────────────────────── | ||
| 225 | |||
| 226 | func TestTailscale_VerifyRoute_ApprovedRoute(t *testing.T) { | ||
| 227 | good := `{"Self":{"PrimaryRoutes":["10.77.1.0/24"]}}` | ||
| 228 | run, _ := recorder(map[string]string{"tailscale status --json": good}, nil) | ||
| 229 | ov, err := New("tailscale", "10.77.1.0/24", "", run, nil) | ||
| 230 | require.NoError(t, err) | ||
| 231 | assert.NoError(t, ov.VerifyRoute(context.Background())) | ||
| 232 | } | ||
| 233 | |||
| 234 | func TestTailscale_VerifyRoute_PendingApproval(t *testing.T) { | ||
| 235 | bad := `{"Self":{"PrimaryRoutes":[]}}` | ||
| 236 | run, _ := recorder(map[string]string{"tailscale status --json": bad}, nil) | ||
| 237 | ov, err := New("tailscale", "10.77.1.0/24", "", run, nil) | ||
| 238 | require.NoError(t, err) | ||
| 239 | err = ov.VerifyRoute(context.Background()) | ||
| 240 | require.Error(t, err) | ||
| 241 | assert.Contains(t, err.Error(), "pending approval", | ||
| 242 | "actionable error: fix autoApprovers in the tailnet ACL") | ||
| 243 | } | ||
| 244 | |||
| 245 | // ── tailscale: NoMasqueradeIfaces ───────────────────────────────────────────── | ||
| 246 | |||
| 247 | func TestTailscale_NoMasqueradeIfaces_DefaultOnly(t *testing.T) { | ||
| 248 | run, _ := recorder(nil, nil) | ||
| 249 | ov, err := New("tailscale", "10.77.1.0/24", "", run, nil) | ||
| 250 | require.NoError(t, err) | ||
| 251 | ifaces := ov.NoMasqueradeIfaces() | ||
| 252 | assert.Equal(t, []string{"tailscale0"}, ifaces) | ||
| 253 | } | ||
| 254 | |||
| 255 | func TestTailscale_NoMasqueradeIfaces_WithExtras(t *testing.T) { | ||
| 256 | run, _ := recorder(nil, nil) | ||
| 257 | ov, err := New("tailscale", "10.77.1.0/24", "", run, []string{"wg0"}) | ||
| 258 | require.NoError(t, err) | ||
| 259 | ifaces := ov.NoMasqueradeIfaces() | ||
| 260 | assert.Equal(t, []string{"tailscale0", "wg0"}, ifaces) | ||
| 261 | } | ||
| 262 | |||
| 263 | // ── tailscale: EnsureRoute with authkey uses login+set, never tailscale up ─── | ||
| 264 | |||
| 265 | func TestTailscale_EnsureRoute_ManageMode_WithAuthkey_UsesLoginThenSet(t *testing.T) { | ||
| 266 | // Fix 1: authkey path must run `tailscale login --auth-key=<key>` then | ||
| 267 | // `tailscale set --advertise-routes=<union>`, NEVER `tailscale up`. | ||
| 268 | existing := []string{"192.168.1.0/24"} | ||
| 269 | out := map[string]string{ | ||
| 270 | "tailscale debug prefs": debugPrefsJSON(existing), | ||
| 271 | // login produces no meaningful output | ||
| 272 | "tailscale login --auth-key=tskey-abc": "", | ||
| 273 | // set produces no meaningful output | ||
| 274 | "tailscale set --advertise-routes=192.168.1.0/24,10.77.1.0/24": "", | ||
| 275 | } | ||
| 276 | run, calls := recorder(out, nil) | ||
| 277 | ov, err := New("tailscale", "10.77.1.0/24", "tskey-abc", run, nil) | ||
| 278 | require.NoError(t, err) | ||
| 279 | |||
| 280 | err = ov.EnsureRoute(context.Background(), true) | ||
| 281 | require.NoError(t, err) | ||
| 282 | |||
| 283 | all := joinCalls(calls) | ||
| 284 | // Must see `tailscale login --auth-key=tskey-abc` then `tailscale set --advertise-routes=…` | ||
| 285 | assert.Contains(t, all, "tailscale login --auth-key=tskey-abc", | ||
| 286 | "authkey path must run tailscale login first") | ||
| 287 | assert.Contains(t, all, "tailscale set --advertise-routes=192.168.1.0/24,10.77.1.0/24", | ||
| 288 | "authkey path must run tailscale set after login") | ||
| 289 | assert.NotContains(t, all, "tailscale up", | ||
| 290 | "tailscale up must NEVER be used — including the authkey join path") | ||
| 291 | |||
| 292 | // Assert ordering: login comes before set. | ||
| 293 | var loginIdx, setIdx int = -1, -1 | ||
| 294 | for i, c := range *calls { | ||
| 295 | if c.name == "tailscale" && strings.HasPrefix(c.args, "login ") { | ||
| 296 | loginIdx = i | ||
| 297 | } | ||
| 298 | if c.name == "tailscale" && strings.HasPrefix(c.args, "set ") { | ||
| 299 | setIdx = i | ||
| 300 | } | ||
| 301 | } | ||
| 302 | assert.GreaterOrEqual(t, loginIdx, 0, "login call must be present") | ||
| 303 | assert.GreaterOrEqual(t, setIdx, 0, "set call must be present") | ||
| 304 | assert.Less(t, loginIdx, setIdx, "login must precede set") | ||
| 305 | } | ||
| 306 | |||
| 307 | // ── none overlay tests ──────────────────────────────────────────────────────── | ||
| 308 | |||
| 309 | func TestNone_EnsureRoute_AlwaysReturnsErrActionRequired(t *testing.T) { | ||
| 310 | run, calls := recorder(nil, nil) | ||
| 311 | ov, err := New("none", "10.77.3.0/24", "", run, nil) | ||
| 312 | require.NoError(t, err) | ||
| 313 | |||
| 314 | // manage=false | ||
| 315 | err = ov.EnsureRoute(context.Background(), false) | ||
| 316 | require.Error(t, err) | ||
| 317 | assert.True(t, errors.Is(err, ErrActionRequired), "none always returns ErrActionRequired") | ||
| 318 | assert.Contains(t, err.Error(), "10.77.3.0/24", "message must contain the CIDR") | ||
| 319 | assert.Contains(t, err.Error(), "WireGuard AllowedIPs", | ||
| 320 | "Fix 2: message must name WireGuard AllowedIPs as example overlay") | ||
| 321 | assert.Contains(t, err.Error(), "--no-masquerade-ifaces", | ||
| 322 | "Fix 2: message must mention --no-masquerade-ifaces so VM source IPs are preserved") | ||
| 323 | assert.Empty(t, *calls, "none EnsureRoute must run ZERO commands") | ||
| 324 | } | ||
| 325 | |||
| 326 | func TestNone_EnsureRoute_ManageTrue_IsHardError(t *testing.T) { | ||
| 327 | // Fix 3: manage=true with overlay=none must be a hard error (not ErrActionRequired). | ||
| 328 | run, calls := recorder(nil, nil) | ||
| 329 | ov, err := New("none", "10.77.3.0/24", "", run, nil) | ||
| 330 | require.NoError(t, err) | ||
| 331 | |||
| 332 | err = ov.EnsureRoute(context.Background(), true) | ||
| 333 | require.Error(t, err) | ||
| 334 | assert.False(t, errors.Is(err, ErrActionRequired), | ||
| 335 | "Fix 3: manage=true on overlay=none must be a hard error, NOT ErrActionRequired") | ||
| 336 | assert.Contains(t, err.Error(), "nothing to manage", | ||
| 337 | "Fix 3: error must say there is nothing to manage") | ||
| 338 | assert.Empty(t, *calls, "none EnsureRoute must run ZERO commands even with manage=true") | ||
| 339 | } | ||
| 340 | |||
| 341 | func TestNone_VerifyRoute_ReturnsErrUnverifiable(t *testing.T) { | ||
| 342 | // Fix 4: VerifyRoute for overlay=none must return ErrUnverifiable (not nil). | ||
| 343 | run, _ := recorder(nil, nil) | ||
| 344 | ov, err := New("none", "10.77.3.0/24", "", run, nil) | ||
| 345 | require.NoError(t, err) | ||
| 346 | |||
| 347 | err = ov.VerifyRoute(context.Background()) | ||
| 348 | require.Error(t, err, "Fix 4: none VerifyRoute must return an error (ErrUnverifiable)") | ||
| 349 | assert.True(t, errors.Is(err, ErrUnverifiable), | ||
| 350 | "Fix 4: error must wrap ErrUnverifiable, got: %v", err) | ||
| 351 | assert.Contains(t, err.Error(), "10.77.3.0/24", | ||
| 352 | "Fix 4: ErrUnverifiable must include the CIDR") | ||
| 353 | } | ||
| 354 | |||
| 355 | func TestNone_NoMasqueradeIfaces_EmptyWithoutExtras(t *testing.T) { | ||
| 356 | run, _ := recorder(nil, nil) | ||
| 357 | ov, err := New("none", "10.77.3.0/24", "", run, nil) | ||
| 358 | require.NoError(t, err) | ||
| 359 | assert.Nil(t, ov.NoMasqueradeIfaces()) | ||
| 360 | } | ||
| 361 | |||
| 362 | func TestNone_NoMasqueradeIfaces_ReflectsExtras(t *testing.T) { | ||
| 363 | run, _ := recorder(nil, nil) | ||
| 364 | ov, err := New("none", "10.77.3.0/24", "", run, []string{"wg0", "eth1"}) | ||
| 365 | require.NoError(t, err) | ||
| 366 | assert.Equal(t, []string{"wg0", "eth1"}, ov.NoMasqueradeIfaces()) | ||
| 367 | } | ||
internal/agent/overlay/watch.go
| Old | New | ||
|---|---|---|---|
| @@ -1,79 +0,0 @@ | |||
| 1 | package overlay | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "context" | ||
| 5 | "errors" | ||
| 6 | "log/slog" | ||
| 7 | "time" | ||
| 8 | ) | ||
| 9 | |||
| 10 | // Watch runs a level-triggered reconcile loop: every interval it calls | ||
| 11 | // EnsureRoute and VerifyRoute on ov and logs only on state transitions. | ||
| 12 | // It returns when ctx is cancelled. | ||
| 13 | // | ||
| 14 | // Logging policy: | ||
| 15 | // - ErrActionRequired / non-nil error → warning on first occurrence and on | ||
| 16 | // every transition from a different status. | ||
| 17 | // - nil after non-nil (recovery) → info "overlay route restored". | ||
| 18 | // - ErrUnverifiable → info "route verification unavailable for this overlay | ||
| 19 | // — unverifiable by design" on first occurrence and on each re-occurrence | ||
| 20 | // only if the previous status was different. | ||
| 21 | // - Steady-state repetitions (same status string) are silently dropped. | ||
| 22 | func Watch(ctx context.Context, ov Overlay, manage bool, interval time.Duration, logger *slog.Logger) { | ||
| 23 | if logger == nil { | ||
| 24 | logger = slog.Default() | ||
| 25 | } | ||
| 26 | var lastStatus string | ||
| 27 | tick := time.NewTicker(interval) | ||
| 28 | defer tick.Stop() | ||
| 29 | for { | ||
| 30 | select { | ||
| 31 | case <-ctx.Done(): | ||
| 32 | return | ||
| 33 | case <-tick.C: | ||
| 34 | res := probeStatus(ctx, ov, manage) | ||
| 35 | if res.status != lastStatus { | ||
| 36 | logTransition(logger, lastStatus, res) | ||
| 37 | lastStatus = res.status | ||
| 38 | } | ||
| 39 | } | ||
| 40 | } | ||
| 41 | } | ||
| 42 | |||
| 43 | // probeResult is the outcome of one probe. status is a canonical string used | ||
| 44 | // for change detection (empty means fully healthy); err is the original error, | ||
| 45 | // if any, preserved so callers can classify it with errors.Is. | ||
| 46 | type probeResult struct { | ||
| 47 | status string | ||
| 48 | err error | ||
| 49 | } | ||
| 50 | |||
| 51 | // probeStatus runs EnsureRoute then VerifyRoute and returns the probe result. | ||
| 52 | func probeStatus(ctx context.Context, ov Overlay, manage bool) probeResult { | ||
| 53 | if err := ov.EnsureRoute(ctx, manage); err != nil { | ||
| 54 | return probeResult{status: "ensure:" + err.Error(), err: err} | ||
| 55 | } | ||
| 56 | if err := ov.VerifyRoute(ctx); err != nil { | ||
| 57 | if errors.Is(err, ErrUnverifiable) { | ||
| 58 | return probeResult{status: "unverifiable", err: err} | ||
| 59 | } | ||
| 60 | return probeResult{status: "verify:" + err.Error(), err: err} | ||
| 61 | } | ||
| 62 | return probeResult{} | ||
| 63 | } | ||
| 64 | |||
| 65 | // logTransition emits one log line on status change. | ||
| 66 | func logTransition(logger *slog.Logger, prev string, curr probeResult) { | ||
| 67 | switch { | ||
| 68 | case curr.status == "": | ||
| 69 | // Recovered from a prior error. | ||
| 70 | logger.Info("overlay route restored") | ||
| 71 | case curr.status == "unverifiable": | ||
| 72 | logger.Info("route verification unavailable for this overlay — unverifiable by design") | ||
| 73 | case errors.Is(curr.err, ErrActionRequired): | ||
| 74 | logger.Warn("overlay action required — VMs reachable locally; network reachability pending operator action", | ||
| 75 | "status", curr.status) | ||
| 76 | default: | ||
| 77 | logger.Warn("overlay route check failed", "status", curr.status, "prev", prev) | ||
| 78 | } | ||
| 79 | } | ||
internal/agent/overlay/watch_test.go
| Old | New | ||
|---|---|---|---|
| @@ -1,203 +0,0 @@ | |||
| 1 | package overlay | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "bytes" | ||
| 5 | "context" | ||
| 6 | "errors" | ||
| 7 | "fmt" | ||
| 8 | "log/slog" | ||
| 9 | "sync" | ||
| 10 | "testing" | ||
| 11 | "time" | ||
| 12 | |||
| 13 | "github.com/stretchr/testify/assert" | ||
| 14 | "github.com/stretchr/testify/require" | ||
| 15 | ) | ||
| 16 | |||
| 17 | // fakeOverlay is a minimal Overlay stub that returns pre-programmed errors. | ||
| 18 | // A mutex guards ensureErr and verifyErr so the test can change them safely | ||
| 19 | // from the main goroutine while Watch reads them concurrently. | ||
| 20 | type fakeOverlay struct { | ||
| 21 | mu sync.Mutex | ||
| 22 | ensureErr error | ||
| 23 | verifyErr error | ||
| 24 | } | ||
| 25 | |||
| 26 | func (f *fakeOverlay) setEnsureErr(err error) { | ||
| 27 | f.mu.Lock() | ||
| 28 | defer f.mu.Unlock() | ||
| 29 | f.ensureErr = err | ||
| 30 | } | ||
| 31 | |||
| 32 | func (f *fakeOverlay) EnsureRoute(_ context.Context, _ bool) error { | ||
| 33 | f.mu.Lock() | ||
| 34 | defer f.mu.Unlock() | ||
| 35 | return f.ensureErr | ||
| 36 | } | ||
| 37 | |||
| 38 | func (f *fakeOverlay) VerifyRoute(_ context.Context) error { | ||
| 39 | f.mu.Lock() | ||
| 40 | defer f.mu.Unlock() | ||
| 41 | return f.verifyErr | ||
| 42 | } | ||
| 43 | |||
| 44 | func (f *fakeOverlay) RemoveRoute(_ context.Context) error { return nil } | ||
| 45 | func (f *fakeOverlay) NoMasqueradeIfaces() []string { return nil } | ||
| 46 | |||
| 47 | // safeBuffer wraps bytes.Buffer with a mutex so the Watch goroutine can write | ||
| 48 | // concurrently while the test goroutine reads. | ||
| 49 | type safeBuffer struct { | ||
| 50 | mu sync.Mutex | ||
| 51 | buf bytes.Buffer | ||
| 52 | } | ||
| 53 | |||
| 54 | func (b *safeBuffer) Write(p []byte) (int, error) { | ||
| 55 | b.mu.Lock() | ||
| 56 | defer b.mu.Unlock() | ||
| 57 | return b.buf.Write(p) | ||
| 58 | } | ||
| 59 | |||
| 60 | func (b *safeBuffer) String() string { | ||
| 61 | b.mu.Lock() | ||
| 62 | defer b.mu.Unlock() | ||
| 63 | return b.buf.String() | ||
| 64 | } | ||
| 65 | |||
| 66 | func (b *safeBuffer) Len() int { | ||
| 67 | b.mu.Lock() | ||
| 68 | defer b.mu.Unlock() | ||
| 69 | return b.buf.Len() | ||
| 70 | } | ||
| 71 | |||
| 72 | func newLogger(buf *safeBuffer) *slog.Logger { | ||
| 73 | return slog.New(slog.NewTextHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug})) | ||
| 74 | } | ||
| 75 | |||
| 76 | // TestWatch_RecoveryLoggedExactlyOnce tests Fix 6: | ||
| 77 | // a transition from ErrActionRequired → nil logs recovery exactly once, | ||
| 78 | // and steady-state OK repetitions stay silent. | ||
| 79 | func TestWatch_RecoveryLoggedExactlyOnce(t *testing.T) { | ||
| 80 | logBuf := &safeBuffer{} | ||
| 81 | logger := newLogger(logBuf) | ||
| 82 | |||
| 83 | ov := &fakeOverlay{} | ||
| 84 | ov.setEnsureErr(fmt.Errorf("%w: run: sudo tailscale set --advertise-routes=10.77.1.0/24", ErrActionRequired)) | ||
| 85 | |||
| 86 | ctx, cancel := context.WithCancel(context.Background()) | ||
| 87 | interval := 10 * time.Millisecond | ||
| 88 | |||
| 89 | done := make(chan struct{}) | ||
| 90 | go func() { | ||
| 91 | defer close(done) | ||
| 92 | Watch(ctx, ov, false, interval, logger) | ||
| 93 | }() | ||
| 94 | |||
| 95 | // Let at least 3 ticks fire with the error. | ||
| 96 | time.Sleep(60 * time.Millisecond) | ||
| 97 | |||
| 98 | logSnapshot1 := logBuf.String() | ||
| 99 | // Should have exactly one warning (first occurrence), not three. | ||
| 100 | count := countOccurrences(logSnapshot1, "overlay action required") | ||
| 101 | assert.Equal(t, 1, count, "ErrActionRequired logged once on first occurrence, not on repeats") | ||
| 102 | |||
| 103 | // Now clear the error — simulate recovery. | ||
| 104 | ov.setEnsureErr(nil) | ||
| 105 | time.Sleep(60 * time.Millisecond) | ||
| 106 | |||
| 107 | logSnapshot2 := logBuf.String() | ||
| 108 | assert.Contains(t, logSnapshot2, "overlay route restored", | ||
| 109 | "recovery must be logged on transition from error to ok") | ||
| 110 | |||
| 111 | // Let a few more ticks pass — steady-state OK must stay silent. | ||
| 112 | logLenAfterRecovery := logBuf.Len() | ||
| 113 | time.Sleep(60 * time.Millisecond) | ||
| 114 | assert.Equal(t, logLenAfterRecovery, logBuf.Len(), | ||
| 115 | "steady-state OK ticks must produce no additional log lines") | ||
| 116 | |||
| 117 | cancel() | ||
| 118 | select { | ||
| 119 | case <-done: | ||
| 120 | case <-time.After(time.Second): | ||
| 121 | t.Fatal("Watch did not exit after context cancel") | ||
| 122 | } | ||
| 123 | } | ||
| 124 | |||
| 125 | // TestWatch_ErrUnverifiable_LoggedAsInfo verifies that ErrUnverifiable triggers | ||
| 126 | // an info-level log (not a warning) and is silent on steady repetition. | ||
| 127 | func TestWatch_ErrUnverifiable_LoggedAsInfo(t *testing.T) { | ||
| 128 | logBuf := &safeBuffer{} | ||
| 129 | logger := newLogger(logBuf) | ||
| 130 | |||
| 131 | ov := &fakeOverlay{} | ||
| 132 | ov.verifyErr = fmt.Errorf("%w: 10.77.1.0/24", ErrUnverifiable) | ||
| 133 | |||
| 134 | ctx, cancel := context.WithCancel(context.Background()) | ||
| 135 | interval := 10 * time.Millisecond | ||
| 136 | done := make(chan struct{}) | ||
| 137 | go func() { | ||
| 138 | defer close(done) | ||
| 139 | Watch(ctx, ov, false, interval, logger) | ||
| 140 | }() | ||
| 141 | |||
| 142 | time.Sleep(80 * time.Millisecond) | ||
| 143 | cancel() | ||
| 144 | <-done | ||
| 145 | |||
| 146 | log := logBuf.String() | ||
| 147 | assert.Contains(t, log, "unverifiable by design", | ||
| 148 | "ErrUnverifiable must log the unverifiable message") | ||
| 149 | count := countOccurrences(log, "unverifiable by design") | ||
| 150 | assert.Equal(t, 1, count, | ||
| 151 | "ErrUnverifiable in steady state must be logged only once, not on every tick") | ||
| 152 | } | ||
| 153 | |||
| 154 | // TestProbeStatus_ReturnsEmptyOnSuccess verifies that probeStatus returns an | ||
| 155 | // empty status when both EnsureRoute and VerifyRoute succeed. | ||
| 156 | func TestProbeStatus_ReturnsEmptyOnSuccess(t *testing.T) { | ||
| 157 | ov := &fakeOverlay{} | ||
| 158 | res := probeStatus(context.Background(), ov, false) | ||
| 159 | assert.Equal(t, "", res.status, "healthy overlay must return empty status") | ||
| 160 | assert.NoError(t, res.err) | ||
| 161 | } | ||
| 162 | |||
| 163 | // TestProbeStatus_ErrActionRequired verifies the original error is preserved on | ||
| 164 | // EnsureRoute failure so it can be classified with errors.Is. | ||
| 165 | func TestProbeStatus_ErrActionRequired(t *testing.T) { | ||
| 166 | errMsg := fmt.Errorf("%w: run: sudo tailscale set --advertise-routes=10.77.1.0/24", ErrActionRequired) | ||
| 167 | ov := &fakeOverlay{ensureErr: errMsg} | ||
| 168 | res := probeStatus(context.Background(), ov, false) | ||
| 169 | require.NotEmpty(t, res.status) | ||
| 170 | assert.True(t, errors.Is(res.err, ErrActionRequired), | ||
| 171 | "ErrActionRequired must be detectable from the preserved error, got: %s", res.status) | ||
| 172 | } | ||
| 173 | |||
| 174 | // TestProbeStatus_Unverifiable verifies the canonical "unverifiable" status. | ||
| 175 | func TestProbeStatus_Unverifiable(t *testing.T) { | ||
| 176 | ov := &fakeOverlay{} | ||
| 177 | ov.verifyErr = fmt.Errorf("%w: 10.77.1.0/24", ErrUnverifiable) | ||
| 178 | res := probeStatus(context.Background(), ov, false) | ||
| 179 | assert.Equal(t, "unverifiable", res.status) | ||
| 180 | } | ||
| 181 | |||
| 182 | // countOccurrences counts non-overlapping occurrences of sub in s. | ||
| 183 | func countOccurrences(s, sub string) int { | ||
| 184 | n, start := 0, 0 | ||
| 185 | for { | ||
| 186 | idx := indexOf(s[start:], sub) | ||
| 187 | if idx < 0 { | ||
| 188 | break | ||
| 189 | } | ||
| 190 | n++ | ||
| 191 | start += idx + len(sub) | ||
| 192 | } | ||
| 193 | return n | ||
| 194 | } | ||
| 195 | |||
| 196 | func indexOf(s, sub string) int { | ||
| 197 | for i := 0; i <= len(s)-len(sub); i++ { | ||
| 198 | if s[i:i+len(sub)] == sub { | ||
| 199 | return i | ||
| 200 | } | ||
| 201 | } | ||
| 202 | return -1 | ||
| 203 | } | ||
internal/agent/reconcile/quota_test.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,95 @@ | |||
| 1 | package reconcile | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "context" | ||
| 5 | "testing" | ||
| 6 | |||
| 7 | "github.com/a73x/eitri/internal/pb" | ||
| 8 | "github.com/stretchr/testify/assert" | ||
| 9 | "github.com/stretchr/testify/require" | ||
| 10 | ) | ||
| 11 | |||
| 12 | // withRes overrides a desired VM's resource request. | ||
| 13 | func withRes(vcpus, memMB, diskGB int64) func(*pb.VMDesired) { | ||
| 14 | return func(v *pb.VMDesired) { v.Vcpus = vcpus; v.MemMb = memMB; v.DiskGb = diskGB } | ||
| 15 | } | ||
| 16 | |||
| 17 | func TestQuotaUnderCapBoots(t *testing.T) { | ||
| 18 | f := setup(t) | ||
| 19 | f.eng.MaxVCPUs = 4 | ||
| 20 | rep := f.eng.Step(context.Background(), snap(1, vm("vm1", withRes(2, 512, 5)))) | ||
| 21 | assert.Equal(t, []string{"vm1"}, f.prov.booted) | ||
| 22 | assert.Equal(t, "ready", findVM(rep, "vm1").Phase) | ||
| 23 | } | ||
| 24 | |||
| 25 | func TestQuotaOverCapRefusedNamesDimension(t *testing.T) { | ||
| 26 | f := setup(t) | ||
| 27 | f.eng.MaxVCPUs = 2 | ||
| 28 | rep := f.eng.Step(context.Background(), snap(1, vm("vm1", withRes(4, 512, 5)))) | ||
| 29 | assert.Empty(t, f.prov.booted, "an over-cap VM must not boot") | ||
| 30 | assert.Empty(t, f.prov.prepared, "an over-cap VM must not even prepare a disk") | ||
| 31 | av := findVM(rep, "vm1") | ||
| 32 | require.NotNil(t, av) | ||
| 33 | assert.Equal(t, "failed", av.Phase) | ||
| 34 | assert.Contains(t, av.GetLastError(), "capacity limit") | ||
| 35 | assert.Contains(t, av.GetLastError(), "vcpus", "error names the binding dimension") | ||
| 36 | } | ||
| 37 | |||
| 38 | func TestQuotaEnforcesMemAndDiskIndependently(t *testing.T) { | ||
| 39 | f := setup(t) | ||
| 40 | f.eng.MaxMemMB = 1024 | ||
| 41 | rep := f.eng.Step(context.Background(), snap(1, vm("vm1", withRes(1, 4096, 5)))) | ||
| 42 | assert.Empty(t, f.prov.booted) | ||
| 43 | assert.Contains(t, findVM(rep, "vm1").GetLastError(), "memory") | ||
| 44 | |||
| 45 | g := setup(t) | ||
| 46 | g.eng.MaxDiskGB = 10 | ||
| 47 | rep = g.eng.Step(context.Background(), snap(1, vm("vm1", withRes(1, 512, 50)))) | ||
| 48 | assert.Empty(t, g.prov.booted) | ||
| 49 | assert.Contains(t, findVM(rep, "vm1").GetLastError(), "disk") | ||
| 50 | } | ||
| 51 | |||
| 52 | func TestQuotaZeroMeansUnlimited(t *testing.T) { | ||
| 53 | f := setup(t) // caps default to 0 | ||
| 54 | rep := f.eng.Step(context.Background(), snap(1, vm("vm1", withRes(999, 999999, 99999)))) | ||
| 55 | assert.Equal(t, []string{"vm1"}, f.prov.booted, "no caps configured = unlimited") | ||
| 56 | assert.Equal(t, "ready", findVM(rep, "vm1").Phase) | ||
| 57 | } | ||
| 58 | |||
| 59 | func TestQuotaRefusalIsNonTerminal(t *testing.T) { | ||
| 60 | f := setup(t) | ||
| 61 | f.eng.MaxVCPUs = 2 | ||
| 62 | // vm1 (2 vcpus) fills the cap exactly and boots. | ||
| 63 | f.eng.Step(context.Background(), snap(1, vm("vm1", withRes(2, 512, 5)))) | ||
| 64 | require.Equal(t, []string{"vm1"}, f.prov.booted) | ||
| 65 | |||
| 66 | // vm2 (2 vcpus) would push the host to 4 > 2 — blocked. Repeat well past | ||
| 67 | // MaxCreateAttempts (3): quota refusal must NOT consume the retry budget. | ||
| 68 | for i := 0; i < 5; i++ { | ||
| 69 | f.eng.Step(context.Background(), snap(2, vm("vm1", withRes(2, 512, 5)), vm("vm2", withRes(2, 512, 5)))) | ||
| 70 | } | ||
| 71 | assert.Equal(t, []string{"vm1"}, f.prov.booted, "vm2 still blocked, vm1 untouched") | ||
| 72 | |||
| 73 | // Room frees (operator raises the cap). A blocked-forever VM whose budget | ||
| 74 | // had been burned would be terminal-failed; a non-terminal one boots now. | ||
| 75 | f.eng.MaxVCPUs = 10 | ||
| 76 | f.eng.Step(context.Background(), snap(3, vm("vm1", withRes(2, 512, 5)), vm("vm2", withRes(2, 512, 5)))) | ||
| 77 | assert.Equal(t, []string{"vm1", "vm2"}, f.prov.booted, "vm2 boots once room frees") | ||
| 78 | } | ||
| 79 | |||
| 80 | func TestQuotaExcludesQuarantinedFromLiveSum(t *testing.T) { | ||
| 81 | f := setup(t) | ||
| 82 | f.eng.MaxVCPUs = 3 | ||
| 83 | // vm1 (2 vcpus) boots. | ||
| 84 | f.eng.Step(context.Background(), snap(1, vm("vm1", withRes(2, 512, 5)))) | ||
| 85 | require.Equal(t, []string{"vm1"}, f.prov.booted) | ||
| 86 | |||
| 87 | // Tombstone vm1 (→ quarantined, stopped, awaiting destroy) and desire vm2 | ||
| 88 | // (2 vcpus). Counting vm1 would give 4 > 3 and block vm2; excluding the | ||
| 89 | // quarantined record leaves 2 <= 3, so vm2 boots. | ||
| 90 | rep := f.eng.Step(context.Background(), snap(2, | ||
| 91 | tombstoned(vm("vm1", withRes(2, 512, 5))), | ||
| 92 | vm("vm2", withRes(2, 512, 5)))) | ||
| 93 | assert.Contains(t, f.prov.booted, "vm2", "quarantined vm1 must not count against the cap") | ||
| 94 | assert.Equal(t, "ready", findVM(rep, "vm2").Phase) | ||
| 95 | } | ||
internal/agent/reconcile/reconcile.go
| Old | New | ||
|---|---|---|---|
| @@ -20,6 +20,7 @@ import ( | |||
| 20 | "context" | 20 | "context" |
| 21 | "encoding/json" | 21 | "encoding/json" |
| 22 | "errors" | 22 | "errors" |
| 23 | "fmt" | ||
| 23 | "time" | 24 | "time" |
| 24 | 25 | ||
| 25 | "github.com/a73x/eitri/internal/agent/seed" | 26 | "github.com/a73x/eitri/internal/agent/seed" |
| @@ -79,6 +80,14 @@ type Engine struct { | |||
| 79 | // MaxCreateAttempts is the maximum number of create attempts before terminal failed. | 80 | // MaxCreateAttempts is the maximum number of create attempts before terminal failed. |
| 80 | MaxCreateAttempts int | 81 | MaxCreateAttempts int |
| 81 | 82 | ||
| 83 | // MaxVCPUs, MaxMemMB, MaxDiskGB cap the host resources this agent will | ||
| 84 | // commit to live VMs (0 = unlimited). A create whose resources would push | ||
| 85 | // the running total past a cap is refused — see quotaBlock. This is the | ||
| 86 | // enforced half of agent-side quotas; syncclient advertises the same caps. | ||
| 87 | MaxVCPUs int64 | ||
| 88 | MaxMemMB int64 | ||
| 89 | MaxDiskGB int64 | ||
| 90 | |||
| 82 | // StepTimeout bounds one WHOLE Step call — the sum of all operations for | 91 | // StepTimeout bounds one WHOLE Step call — the sum of all operations for |
| 83 | // all VMs in that step, not each operation. Zero disables the watchdog. | 92 | // all VMs in that step, not each operation. Zero disables the watchdog. |
| 84 | // A wedged operation (disk prep, seed build, image fetch) then fails with | 93 | // A wedged operation (disk prep, seed build, image fetch) then fails with |
| @@ -251,6 +260,38 @@ func (e *Engine) Step(ctx context.Context, snap *pb.DesiredStateSnapshot) *pb.Ac | |||
| 251 | return rep | 260 | return rep |
| 252 | } | 261 | } |
| 253 | 262 | ||
| 263 | // quotaBlock returns a non-empty reason when booting d would exceed a configured | ||
| 264 | // host resource cap, else "". It sums the resources of the currently-committed | ||
| 265 | // local VMs — excluding quarantined records (being torn down; their guests are | ||
| 266 | // already stopped) and d's own record (so a retry or spec-edit of an existing | ||
| 267 | // VM does not double-count itself) — and adds d's request. The binding | ||
| 268 | // dimension is named in the message so the operator sees which cap was hit. | ||
| 269 | func (e *Engine) quotaBlock(d *pb.VMDesired) string { | ||
| 270 | if e.MaxVCPUs == 0 && e.MaxMemMB == 0 && e.MaxDiskGB == 0 { | ||
| 271 | return "" // no caps configured — unlimited | ||
| 272 | } | ||
| 273 | spec := specFromDesired(d) | ||
| 274 | vcpus, mem, disk := spec.VCPUs, spec.MemMB, spec.DiskGB | ||
| 275 | recs, _ := e.St.LoadVMs() | ||
| 276 | for _, r := range recs { | ||
| 277 | if r.QuarantinedAt != nil || r.Spec.VMID == d.VmId { | ||
| 278 | continue | ||
| 279 | } | ||
| 280 | vcpus += r.Spec.VCPUs | ||
| 281 | mem += r.Spec.MemMB | ||
| 282 | disk += r.Spec.DiskGB | ||
| 283 | } | ||
| 284 | switch { | ||
| 285 | case e.MaxVCPUs > 0 && vcpus > e.MaxVCPUs: | ||
| 286 | return fmt.Sprintf("host capacity limit reached: needs %d vcpus, host cap %d", vcpus, e.MaxVCPUs) | ||
| 287 | case e.MaxMemMB > 0 && mem > e.MaxMemMB: | ||
| 288 | return fmt.Sprintf("host capacity limit reached: needs %d MB memory, host cap %d", mem, e.MaxMemMB) | ||
| 289 | case e.MaxDiskGB > 0 && disk > e.MaxDiskGB: | ||
| 290 | return fmt.Sprintf("host capacity limit reached: needs %d GB disk, host cap %d", disk, e.MaxDiskGB) | ||
| 291 | } | ||
| 292 | return "" | ||
| 293 | } | ||
| 294 | |||
| 254 | // create attempts to create a new VM from desired state d. | 295 | // create attempts to create a new VM from desired state d. |
| 255 | // rec is the existing (potentially stale) record, ok indicates whether one exists. | 296 | // rec is the existing (potentially stale) record, ok indicates whether one exists. |
| 256 | func (e *Engine) create(ctx context.Context, d *pb.VMDesired, rec state.Record, ok bool, rep *pb.ActualStateReport) { | 297 | func (e *Engine) create(ctx context.Context, d *pb.VMDesired, rec state.Record, ok bool, rep *pb.ActualStateReport) { |
| @@ -278,6 +319,16 @@ func (e *Engine) create(ctx context.Context, d *pb.VMDesired, rec state.Record, | |||
| 278 | return | 319 | return |
| 279 | } | 320 | } |
| 280 | 321 | ||
| 322 | // Quota gate: refuse to boot a VM that would push this host's committed | ||
| 323 | // resources past a configured cap. NON-TERMINAL by design — it returns | ||
| 324 | // before touching CreateAttempts or any state, so once a running VM is | ||
| 325 | // removed and room frees, the next level-triggered tick retries and boots. | ||
| 326 | // A newly-lowered cap never kills a running guest; only new boots are gated. | ||
| 327 | if msg := e.quotaBlock(d); msg != "" { | ||
| 328 | addReport(rep, d.VmId, rec.IP, "stopped", "failed", msg) | ||
| 329 | return | ||
| 330 | } | ||
| 331 | |||
| 281 | // Build the spec from desired. | 332 | // Build the spec from desired. |
| 282 | rec.Spec = specFromDesired(d) | 333 | rec.Spec = specFromDesired(d) |
| 283 | rec.CreateAttempts++ | 334 | rec.CreateAttempts++ |
| @@ -330,13 +381,16 @@ func (e *Engine) create(ctx context.Context, d *pb.VMDesired, rec state.Record, | |||
| 330 | // not derived from a CIDR here — the core holds no network-shaped state. | 381 | // not derived from a CIDR here — the core holds no network-shaped state. |
| 331 | gateway, prefixLen := e.Net.GuestNetwork() | 382 | gateway, prefixLen := e.Net.GuestNetwork() |
| 332 | if err := e.Seed(e.St.SeedPath(d.VmId), seed.Params{ | 383 | if err := e.Seed(e.St.SeedPath(d.VmId), seed.Params{ |
| 333 | Hostname: d.Name, | 384 | Hostname: d.Name, |
| 334 | InstanceID: d.VmId, | 385 | InstanceID: d.VmId, |
| 335 | IP: rec.IP, | 386 | IP: rec.IP, |
| 336 | PrefixLen: prefixLen, | 387 | PrefixLen: prefixLen, |
| 337 | Gateway: gateway, | 388 | Gateway: gateway, |
| 338 | SSHAuthorizedKey: d.SshAuthorizedKey, | 389 | SSHAuthorizedKey: d.SshAuthorizedKey, |
| 339 | UserData: d.CloudInit, | 390 | UserData: d.CloudInit, |
| 391 | SSHUserCAAuthorizedKey: d.SshUserCaAuthorizedKey, | ||
| 392 | SSHHostKeyPEM: d.SshHostKeyPem, | ||
| 393 | SSHHostCert: d.SshHostCert, | ||
| 340 | }); err != nil { | 394 | }); err != nil { |
| 341 | e.failCreate(ctx, rec, err, rep) | 395 | e.failCreate(ctx, rec, err, rep) |
| 342 | return | 396 | return |
internal/agent/seed/seed.go
| Old | New | ||
|---|---|---|---|
| @@ -21,6 +21,29 @@ type Params struct { | |||
| 21 | SSHAuthorizedKey string | 21 | SSHAuthorizedKey string |
| 22 | UserData string // verbatim if set; default generated otherwise | 22 | UserData string // verbatim if set; default generated otherwise |
| 23 | InstanceID string // used as cloud-init instance-id; falls back to Hostname when empty | 23 | InstanceID string // used as cloud-init instance-id; falls back to Hostname when empty |
| 24 | // SSHUserCAAuthorizedKey, when non-empty, is the eitri user-CA public key in | ||
| 25 | // authorized_keys form (from sshca.CA.UserCAAuthorizedKey). Its presence | ||
| 26 | // makes the seed inject an sshd drop-in (TrustedUserCAKeys) so the guest | ||
| 27 | // trusts CA-signed user certs minted by the jump gate. Empty = no injection. | ||
| 28 | // Exempt from the newline check: it is embedded as a YAML block scalar (like | ||
| 29 | // UserData), and MarshalAuthorizedKey output carries a trailing newline. | ||
| 30 | SSHUserCAAuthorizedKey string | ||
| 31 | // SSHHostKeyPEM / SSHHostCert, when non-empty, are the VM's persistent | ||
| 32 | // ed25519 host private key (OpenSSH PEM) and its eitri-CA-signed host cert | ||
| 33 | // (authorized_keys form). Their presence makes the seed hand them to | ||
| 34 | // cloud-init via its native ssh_keys map (ed25519_private / ed25519_certificate), | ||
| 35 | // which installs them at /etc/ssh/ssh_host_ed25519_key(+-cert.pub); the drop-in | ||
| 36 | // then points sshd at the cert (HostCertificate) so clients can verify the VM | ||
| 37 | // via `@cert-authority`. Providing ssh_keys makes cloud-init install ONLY the | ||
| 38 | // ed25519 host key; with ssh_deletekeys at its default (true) the image's baked | ||
| 39 | // rsa/ecdsa host keys are deleted and NOT regenerated — so each VM ends up with | ||
| 40 | // a single per-VM, CA-certified ed25519 host key and no shared image keys. | ||
| 41 | // (Add ssh_genkeytypes if rsa/ecdsa host keys are ever needed too.) Empty = no host-cert | ||
| 42 | // injection (TOFU as before). Both are embedded as YAML block scalars (PEM is | ||
| 43 | // multi-line), so they are exempt from the newline check like UserData / the | ||
| 44 | // CA key. | ||
| 45 | SSHHostKeyPEM string | ||
| 46 | SSHHostCert string | ||
| 24 | } | 47 | } |
| 25 | 48 | ||
| 26 | // validateParams checks that fields embedded into YAML do not contain newlines | 49 | // validateParams checks that fields embedded into YAML do not contain newlines |
| @@ -43,6 +66,97 @@ func validateParams(p Params) error { | |||
| 43 | return nil | 66 | return nil |
| 44 | } | 67 | } |
| 45 | 68 | ||
| 69 | // Guest paths the seed installs the eitri SSH material into. cloud-init's | ||
| 70 | // ssh_keys map installs the eitri-CA-signed ed25519 host key at the default | ||
| 71 | // path so the VM presents it; sshd is pointed at the cert via the drop-in. | ||
| 72 | const ( | ||
| 73 | userCAPath = "/etc/ssh/eitri_user_ca.pub" | ||
| 74 | hostKeyPath = "/etc/ssh/ssh_host_ed25519_key" | ||
| 75 | hostCertPath = "/etc/ssh/ssh_host_ed25519_key-cert.pub" | ||
| 76 | dropInPath = "/etc/ssh/sshd_config.d/eitri-ca.conf" | ||
| 77 | ) | ||
| 78 | |||
| 79 | // sshdDropIn builds the eitri sshd drop-in content: a TrustedUserCAKeys line | ||
| 80 | // when the user CA is injected (so the guest trusts CA-signed USER certs), and | ||
| 81 | // HostKey + HostCertificate lines when a per-VM HOST cert is injected (so the | ||
| 82 | // guest presents a CA-signed host key clients verify via `@cert-authority`). | ||
| 83 | // Either or both may be present. A DROP-IN under sshd_config.d — sshd_config | ||
| 84 | // itself is never edited in place. Trailing newlines keep it a well-formed conf. | ||
| 85 | func sshdDropIn(p Params) string { | ||
| 86 | var b strings.Builder | ||
| 87 | if p.SSHUserCAAuthorizedKey != "" { | ||
| 88 | b.WriteString("TrustedUserCAKeys " + userCAPath + "\n") | ||
| 89 | } | ||
| 90 | if p.SSHHostKeyPEM != "" { | ||
| 91 | b.WriteString("HostKey " + hostKeyPath + "\n") | ||
| 92 | b.WriteString("HostCertificate " + hostCertPath + "\n") | ||
| 93 | } | ||
| 94 | return b.String() | ||
| 95 | } | ||
| 96 | |||
| 97 | // vendorDataDoc renders the vendor-data #cloud-config for a seed: the eitri | ||
| 98 | // user-CA trust drop-in when the CA key is set, and the per-VM host key + cert | ||
| 99 | // when those are set. Returns "" when neither is set, meaning no vendor-data | ||
| 100 | // file is written (unchanged TOFU behaviour). | ||
| 101 | // | ||
| 102 | // The keys, cert, and drop-in conf are embedded as YAML block scalars (`|`), so | ||
| 103 | // their bytes appear verbatim in the guest files. Block scalars are | ||
| 104 | // injection-safe: every indented line is literal content. The runcmd gates the | ||
| 105 | // reload behind `sshd -t` so a malformed config can never lock anyone out. | ||
| 106 | func vendorDataDoc(p Params) string { | ||
| 107 | if p.SSHUserCAAuthorizedKey == "" && p.SSHHostKeyPEM == "" { | ||
| 108 | return "" | ||
| 109 | } | ||
| 110 | var b strings.Builder | ||
| 111 | b.WriteString("#cloud-config\n") | ||
| 112 | if p.SSHHostKeyPEM != "" { | ||
| 113 | // Hand the ed25519 host key + cert to cloud-init's native ssh_keys map. | ||
| 114 | // cc_ssh installs ed25519_private/ed25519_certificate at the default paths | ||
| 115 | // (/etc/ssh/ssh_host_ed25519_key(+-cert.pub)). With ssh_deletekeys at its | ||
| 116 | // default (true), cc_ssh deletes the image's baked host keys and, because a | ||
| 117 | // ssh_keys map is present, installs ONLY our ed25519 key — no rsa/ecdsa are | ||
| 118 | // regenerated. Result: one per-VM CA-certified ed25519 host key, no shared | ||
| 119 | // image keys (the intended tidy-up). This suits eitri's cert-only access. | ||
| 120 | // ssh_keys is a top-level key, only emitted when we inject a host key. | ||
| 121 | b.WriteString("ssh_keys:\n") | ||
| 122 | writeBlockScalar(&b, " ", "ed25519_private", p.SSHHostKeyPEM) | ||
| 123 | writeBlockScalar(&b, " ", "ed25519_certificate", p.SSHHostCert) | ||
| 124 | } | ||
| 125 | b.WriteString("write_files:\n") | ||
| 126 | if p.SSHUserCAAuthorizedKey != "" { | ||
| 127 | writeFileBlock(&b, userCAPath, p.SSHUserCAAuthorizedKey) | ||
| 128 | } | ||
| 129 | writeFileBlock(&b, dropInPath, sshdDropIn(p)) | ||
| 130 | b.WriteString("runcmd:\n") | ||
| 131 | // -t gates the reload: a bad config won't reload, so no lockout. | ||
| 132 | b.WriteString(" - [\"sh\", \"-c\", \"sshd -t && systemctl reload sshd\"]\n") | ||
| 133 | return b.String() | ||
| 134 | } | ||
| 135 | |||
| 136 | // writeBlockScalar appends a `key: |` mapping entry (at the given indent) whose | ||
| 137 | // value is a YAML literal block scalar, so multi-line content (e.g. a PEM) lands | ||
| 138 | // verbatim. Value lines are indented two spaces past the key. Used for the | ||
| 139 | // cloud-init ssh_keys map (ed25519_private / ed25519_certificate). | ||
| 140 | func writeBlockScalar(b *strings.Builder, indent, key, content string) { | ||
| 141 | b.WriteString(indent + key + ": |\n") | ||
| 142 | for _, line := range strings.Split(strings.TrimRight(content, "\n"), "\n") { | ||
| 143 | b.WriteString(indent + " " + line + "\n") | ||
| 144 | } | ||
| 145 | } | ||
| 146 | |||
| 147 | // writeFileBlock appends a write_files entry (mode 0644) whose content is the | ||
| 148 | // given bytes, rendered as a YAML literal block scalar so the bytes land in the | ||
| 149 | // guest file verbatim. Used for the public user-CA key and the sshd drop-in — | ||
| 150 | // both public trust material, so 0644 is correct. | ||
| 151 | func writeFileBlock(b *strings.Builder, path, content string) { | ||
| 152 | b.WriteString(" - path: " + path + "\n") | ||
| 153 | b.WriteString(" permissions: '0644'\n") | ||
| 154 | b.WriteString(" content: |\n") | ||
| 155 | for _, line := range strings.Split(strings.TrimRight(content, "\n"), "\n") { | ||
| 156 | b.WriteString(" " + line + "\n") | ||
| 157 | } | ||
| 158 | } | ||
| 159 | |||
| 46 | // userData returns the cloud-config string. If p.UserData is non-empty it is | 160 | // userData returns the cloud-config string. If p.UserData is non-empty it is |
| 47 | // returned verbatim (advanced users own their user-data). Otherwise a sensible | 161 | // returned verbatim (advanced users own their user-data). Otherwise a sensible |
| 48 | // default is generated with an SSH key, growpart, and a default ubuntu user. | 162 | // default is generated with an SSH key, growpart, and a default ubuntu user. |
| @@ -50,6 +164,14 @@ func userData(p Params) string { | |||
| 50 | if p.UserData != "" { | 164 | if p.UserData != "" { |
| 51 | return p.UserData | 165 | return p.UserData |
| 52 | } | 166 | } |
| 167 | // Render ssh_authorized_keys only when a key is supplied. Emitting the key | ||
| 168 | // with no value produces a null list item ("- ") that cloud-init rejects | ||
| 169 | // (users.0.ssh_authorized_keys.0: None is not of type 'string') and runs | ||
| 170 | // degraded, so omit the whole mapping when SSHAuthorizedKey is empty. | ||
| 171 | sshKeys := "" | ||
| 172 | if p.SSHAuthorizedKey != "" { | ||
| 173 | sshKeys = fmt.Sprintf("\n ssh_authorized_keys:\n - %s", p.SSHAuthorizedKey) | ||
| 174 | } | ||
| 53 | return fmt.Sprintf(`#cloud-config | 175 | return fmt.Sprintf(`#cloud-config |
| 54 | hostname: %s | 176 | hostname: %s |
| 55 | disk_setup: | 177 | disk_setup: |
| @@ -63,10 +185,8 @@ growpart: | |||
| 63 | users: | 185 | users: |
| 64 | - name: ubuntu | 186 | - name: ubuntu |
| 65 | sudo: ALL=(ALL) NOPASSWD:ALL | 187 | sudo: ALL=(ALL) NOPASSWD:ALL |
| 66 | shell: /bin/bash | 188 | shell: /bin/bash%s |
| 67 | ssh_authorized_keys: | 189 | `, p.Hostname, sshKeys) |
| 68 | - %s | ||
| 69 | `, p.Hostname, p.SSHAuthorizedKey) | ||
| 70 | } | 190 | } |
| 71 | 191 | ||
| 72 | // metaData returns the cloud-init meta-data content. | 192 | // metaData returns the cloud-init meta-data content. |
| @@ -105,7 +225,8 @@ func Build(outPath string, p Params) error { | |||
| 105 | return err | 225 | return err |
| 106 | } | 226 | } |
| 107 | 227 | ||
| 108 | const isoSize = 1 * 1024 * 1024 // 1 MiB | 228 | // 1 MiB covers the text files plus slack for ISO9660 structures. |
| 229 | isoSize := int64(1 * 1024 * 1024) | ||
| 109 | // ISO9660 requires 2048-byte logical block size; diskfs.SectorSize512 (default) would fail. | 230 | // ISO9660 requires 2048-byte logical block size; diskfs.SectorSize512 (default) would fail. |
| 110 | const isoSectorSize diskfs.SectorSize = 2048 | 231 | const isoSectorSize diskfs.SectorSize = 2048 |
| 111 | 232 | ||
| @@ -141,6 +262,10 @@ func Build(outPath string, p Params) error { | |||
| 141 | "/meta-data": metaData(p), | 262 | "/meta-data": metaData(p), |
| 142 | "/network-config": networkConfig(p), | 263 | "/network-config": networkConfig(p), |
| 143 | } | 264 | } |
| 265 | // Vendor-data carries the eitri user-CA sshd drop-in (empty doc => no file). | ||
| 266 | if vd := vendorDataDoc(p); vd != "" { | ||
| 267 | files["/vendor-data"] = vd | ||
| 268 | } | ||
| 144 | for name, content := range files { | 269 | for name, content := range files { |
| 145 | f, err := fs.OpenFile(name, os.O_CREATE|os.O_RDWR) | 270 | f, err := fs.OpenFile(name, os.O_CREATE|os.O_RDWR) |
| 146 | if err != nil { | 271 | if err != nil { |
internal/agent/seed/seed_test.go
| Old | New | ||
|---|---|---|---|
| @@ -1,6 +1,7 @@ | |||
| 1 | package seed | 1 | package seed |
| 2 | 2 | ||
| 3 | import ( | 3 | import ( |
| 4 | "io" | ||
| 4 | "os" | 5 | "os" |
| 5 | "strings" | 6 | "strings" |
| 6 | "testing" | 7 | "testing" |
| @@ -52,6 +53,25 @@ func TestUserDataDefaultInjectsKeyAndGrowpart(t *testing.T) { | |||
| 52 | assert.Contains(t, ud, "growpart") // disk_gb resize completes in-guest (spec) | 53 | assert.Contains(t, ud, "growpart") // disk_gb resize completes in-guest (spec) |
| 53 | } | 54 | } |
| 54 | 55 | ||
| 56 | func TestUserDataOmitsSSHAuthorizedKeysWhenNoKey(t *testing.T) { | ||
| 57 | // No key ⇒ no ssh_authorized_keys mapping at all. A null list item | ||
| 58 | // ("- " with no value) makes cloud-init log an error and run degraded. | ||
| 59 | ud := userData(Params{Hostname: "h"}) | ||
| 60 | assert.NotContains(t, ud, "ssh_authorized_keys", | ||
| 61 | "ssh_authorized_keys must be omitted entirely when no key is supplied") | ||
| 62 | assert.NotContains(t, ud, "- \n", "no null list item may be rendered") | ||
| 63 | // The rest of the default user-data is intact. | ||
| 64 | assert.Contains(t, ud, "hostname: h") | ||
| 65 | assert.Contains(t, ud, "growpart") | ||
| 66 | assert.Contains(t, ud, "name: ubuntu") | ||
| 67 | } | ||
| 68 | |||
| 69 | func TestUserDataIncludesSSHAuthorizedKeysWhenKeyPresent(t *testing.T) { | ||
| 70 | ud := userData(Params{Hostname: "h", SSHAuthorizedKey: "ssh-ed25519 KEY"}) | ||
| 71 | assert.Contains(t, ud, "ssh_authorized_keys:") | ||
| 72 | assert.Contains(t, ud, "- ssh-ed25519 KEY") | ||
| 73 | } | ||
| 74 | |||
| 55 | func TestUserDataCustomPassthrough(t *testing.T) { | 75 | func TestUserDataCustomPassthrough(t *testing.T) { |
| 56 | ud := userData(Params{Hostname: "h", UserData: "#cloud-config\npackages: [htop]"}) | 76 | ud := userData(Params{Hostname: "h", UserData: "#cloud-config\npackages: [htop]"}) |
| 57 | assert.Equal(t, "#cloud-config\npackages: [htop]", ud, | 77 | assert.Equal(t, "#cloud-config\npackages: [htop]", ud, |
| @@ -121,3 +141,173 @@ func TestMetaDataFallsBackToHostnameWhenInstanceIDEmpty(t *testing.T) { | |||
| 121 | assert.Contains(t, md, "instance-id: myhostname") | 141 | assert.Contains(t, md, "instance-id: myhostname") |
| 122 | assert.Contains(t, md, "local-hostname: myhostname") | 142 | assert.Contains(t, md, "local-hostname: myhostname") |
| 123 | } | 143 | } |
| 144 | |||
| 145 | // readISOFile returns the content of one file from a finished seed ISO. | ||
| 146 | func readISOFile(t *testing.T, isoPath, name string) []byte { | ||
| 147 | t.Helper() | ||
| 148 | d, err := diskfs.Open(isoPath, diskfs.WithSectorSize(2048)) | ||
| 149 | require.NoError(t, err) | ||
| 150 | defer d.Close() | ||
| 151 | fsi, err := d.GetFilesystem(0) | ||
| 152 | require.NoError(t, err) | ||
| 153 | f, err := fsi.OpenFile(name, os.O_RDONLY) | ||
| 154 | require.NoError(t, err) | ||
| 155 | b, err := io.ReadAll(f) | ||
| 156 | require.NoError(t, err) | ||
| 157 | return b | ||
| 158 | } | ||
| 159 | |||
| 160 | func TestBuildWithoutCAKeepsThreeFileLayout(t *testing.T) { | ||
| 161 | out := t.TempDir() + "/seed.iso" | ||
| 162 | require.NoError(t, Build(out, Params{ | ||
| 163 | Hostname: "plain", IP: "10.77.1.2", PrefixLen: 24, Gateway: "10.77.1.1", | ||
| 164 | SSHAuthorizedKey: "ssh-ed25519 AAAA", | ||
| 165 | })) | ||
| 166 | d, err := diskfs.Open(out, diskfs.WithSectorSize(2048)) | ||
| 167 | require.NoError(t, err) | ||
| 168 | defer d.Close() | ||
| 169 | fsi, err := d.GetFilesystem(0) | ||
| 170 | require.NoError(t, err) | ||
| 171 | entries, err := fsi.ReadDir(".") | ||
| 172 | require.NoError(t, err) | ||
| 173 | names := map[string]bool{} | ||
| 174 | for _, e := range entries { | ||
| 175 | names[strings.ToLower(e.Name())] = true | ||
| 176 | } | ||
| 177 | assert.False(t, names["vendor-data"], "seeds with no CA key must not carry vendor-data") | ||
| 178 | st, err := os.Stat(out) | ||
| 179 | require.NoError(t, err) | ||
| 180 | assert.Equal(t, int64(1*1024*1024), st.Size(), "ISO size unchanged (1 MiB)") | ||
| 181 | } | ||
| 182 | |||
| 183 | // --- eitri user-CA trust injection (sshd drop-in) --- | ||
| 184 | |||
| 185 | const testUserCAKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITESTCAKEYaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa eitri-user-ca\n" | ||
| 186 | |||
| 187 | func TestVendorDataInjectsUserCATrust(t *testing.T) { | ||
| 188 | // A VM with a CA key gets a vendor-data carrying the sshd drop-in. | ||
| 189 | out := t.TempDir() + "/seed.iso" | ||
| 190 | require.NoError(t, Build(out, Params{ | ||
| 191 | Hostname: "plain", IP: "10.77.1.2", PrefixLen: 24, Gateway: "10.77.1.1", | ||
| 192 | SSHAuthorizedKey: "ssh-ed25519 AAAA user@host", | ||
| 193 | SSHUserCAAuthorizedKey: testUserCAKey, | ||
| 194 | })) | ||
| 195 | |||
| 196 | vd := string(readISOFile(t, out, "/vendor-data")) | ||
| 197 | assert.True(t, strings.HasPrefix(vd, "#cloud-config\n")) | ||
| 198 | assert.Contains(t, vd, "/etc/ssh/eitri_user_ca.pub") | ||
| 199 | assert.Contains(t, vd, "/etc/ssh/sshd_config.d/eitri-ca.conf") | ||
| 200 | assert.Contains(t, vd, "TrustedUserCAKeys /etc/ssh/eitri_user_ca.pub") | ||
| 201 | // The CA public key bytes must be embedded verbatim (not base64). | ||
| 202 | assert.Contains(t, vd, strings.TrimSpace(testUserCAKey)) | ||
| 203 | // A bad config must NOT lock anyone out: -t gates the reload. | ||
| 204 | assert.Contains(t, vd, "sshd -t && systemctl reload sshd") | ||
| 205 | |||
| 206 | // user-data is never touched by CA injection. | ||
| 207 | ud := string(readISOFile(t, out, "/user-data")) | ||
| 208 | assert.Contains(t, ud, "hostname: plain") | ||
| 209 | assert.NotContains(t, ud, "eitri-ca.conf") | ||
| 210 | } | ||
| 211 | |||
| 212 | const ( | ||
| 213 | testHostKeyPEM = "-----BEGIN OPENSSH PRIVATE KEY-----\nAAAAtesthostkeyline1\nAAAAtesthostkeyline2\n-----END OPENSSH PRIVATE KEY-----\n" | ||
| 214 | testHostCert = "ssh-ed25519-cert-v01@openssh.com AAAATESTHOSTCERTdata with-hostcert\n" | ||
| 215 | ) | ||
| 216 | |||
| 217 | func TestVendorDataInjectsHostKeyAndCert(t *testing.T) { | ||
| 218 | // A VM with a host key + cert gets them installed and sshd pointed at them. | ||
| 219 | out := t.TempDir() + "/seed.iso" | ||
| 220 | require.NoError(t, Build(out, Params{ | ||
| 221 | Hostname: "with-hostcert", IP: "10.77.1.2", PrefixLen: 24, Gateway: "10.77.1.1", | ||
| 222 | SSHAuthorizedKey: "ssh-ed25519 AAAA user@host", | ||
| 223 | SSHUserCAAuthorizedKey: testUserCAKey, | ||
| 224 | SSHHostKeyPEM: testHostKeyPEM, | ||
| 225 | SSHHostCert: testHostCert, | ||
| 226 | })) | ||
| 227 | |||
| 228 | vd := string(readISOFile(t, out, "/vendor-data")) | ||
| 229 | // The host key + cert are handed to cloud-init via its native ssh_keys map, | ||
| 230 | // and sshd's HostCertificate directive is asserted via the drop-in. | ||
| 231 | assert.Contains(t, vd, "ssh_keys:") | ||
| 232 | assert.Contains(t, vd, "ed25519_private: |") | ||
| 233 | assert.Contains(t, vd, "ed25519_certificate: |") | ||
| 234 | assert.Contains(t, vd, "HostCertificate /etc/ssh/ssh_host_ed25519_key-cert.pub") | ||
| 235 | // The key + cert bytes are embedded verbatim (multi-line PEM in a block scalar). | ||
| 236 | assert.Contains(t, vd, "-----BEGIN OPENSSH PRIVATE KEY-----") | ||
| 237 | assert.Contains(t, vd, "AAAAtesthostkeyline2") | ||
| 238 | assert.Contains(t, vd, strings.TrimSpace(testHostCert)) | ||
| 239 | // The user-CA trust still coexists in the same drop-in. | ||
| 240 | assert.Contains(t, vd, "TrustedUserCAKeys /etc/ssh/eitri_user_ca.pub") | ||
| 241 | assert.Contains(t, vd, "sshd -t && systemctl reload sshd") | ||
| 242 | } | ||
| 243 | |||
| 244 | func TestVendorDataInjectsHostKeyViaSSHKeys(t *testing.T) { | ||
| 245 | // The per-VM ed25519 host key + cert are handed to cloud-init via its native | ||
| 246 | // ssh_keys map — NOT write_files — and we do NOT set ssh_deletekeys: false. | ||
| 247 | // So cloud-init (default ssh_deletekeys: true) deletes the image's baked host | ||
| 248 | // keys, installs OUR ed25519 key+cert, and regenerates rsa/ecdsa fresh per VM. | ||
| 249 | vd := vendorDataDoc(Params{ | ||
| 250 | SSHHostKeyPEM: testHostKeyPEM, | ||
| 251 | SSHHostCert: testHostCert, | ||
| 252 | }) | ||
| 253 | assert.Contains(t, vd, "ssh_keys:") | ||
| 254 | assert.Contains(t, vd, "ed25519_private: |") | ||
| 255 | assert.Contains(t, vd, "ed25519_certificate: |") | ||
| 256 | // The PEM (multi-line) and cert bytes land verbatim in the block scalars. | ||
| 257 | assert.Contains(t, vd, "-----BEGIN OPENSSH PRIVATE KEY-----") | ||
| 258 | assert.Contains(t, vd, "AAAAtesthostkeyline2") | ||
| 259 | assert.Contains(t, vd, strings.TrimSpace(testHostCert)) | ||
| 260 | // The ssh_deletekeys hack is gone, and the host key is no longer write_files'd. | ||
| 261 | assert.NotContains(t, vd, "ssh_deletekeys") | ||
| 262 | assert.NotContains(t, vd, "path: /etc/ssh/ssh_host_ed25519_key\n", | ||
| 263 | "host key must not be injected via write_files") | ||
| 264 | } | ||
| 265 | |||
| 266 | func TestVendorDataOmitsSSHKeysWithoutHostCert(t *testing.T) { | ||
| 267 | // No host-cert injection ⇒ no ssh_keys map (and never the ssh_deletekeys hack). | ||
| 268 | vd := vendorDataDoc(Params{SSHUserCAAuthorizedKey: testUserCAKey}) | ||
| 269 | assert.NotContains(t, vd, "ssh_keys:", | ||
| 270 | "ssh_keys must only appear when injecting a host key") | ||
| 271 | assert.NotContains(t, vd, "ssh_deletekeys") | ||
| 272 | } | ||
| 273 | |||
| 274 | func TestVendorDataHostCertWithoutCAKey(t *testing.T) { | ||
| 275 | // Host cert injection is independent of user-CA trust: a seed with only a | ||
| 276 | // host key still produces vendor-data with the host directives (and no | ||
| 277 | // TrustedUserCAKeys line). | ||
| 278 | vd := vendorDataDoc(Params{ | ||
| 279 | SSHHostKeyPEM: testHostKeyPEM, | ||
| 280 | SSHHostCert: testHostCert, | ||
| 281 | }) | ||
| 282 | assert.True(t, strings.HasPrefix(vd, "#cloud-config\n")) | ||
| 283 | assert.Contains(t, vd, "HostCertificate /etc/ssh/ssh_host_ed25519_key-cert.pub") | ||
| 284 | assert.NotContains(t, vd, "TrustedUserCAKeys") | ||
| 285 | } | ||
| 286 | |||
| 287 | func TestNoVendorDataWhenHostAndCAEmpty(t *testing.T) { | ||
| 288 | // Neither CA key nor host key ⇒ no vendor-data at all. | ||
| 289 | assert.Equal(t, "", vendorDataDoc(Params{})) | ||
| 290 | } | ||
| 291 | |||
| 292 | func TestNoVendorDataWhenCAKeyEmpty(t *testing.T) { | ||
| 293 | // Empty CA key: no drop-in, no vendor-data at all. | ||
| 294 | out := t.TempDir() + "/seed.iso" | ||
| 295 | require.NoError(t, Build(out, Params{ | ||
| 296 | Hostname: "plain", IP: "10.77.1.2", PrefixLen: 24, Gateway: "10.77.1.1", | ||
| 297 | SSHAuthorizedKey: "ssh-ed25519 AAAA user@host", | ||
| 298 | })) | ||
| 299 | d, err := diskfs.Open(out, diskfs.WithSectorSize(2048)) | ||
| 300 | require.NoError(t, err) | ||
| 301 | defer d.Close() | ||
| 302 | fsi, err := d.GetFilesystem(0) | ||
| 303 | require.NoError(t, err) | ||
| 304 | entries, err := fsi.ReadDir(".") | ||
| 305 | require.NoError(t, err) | ||
| 306 | for _, e := range entries { | ||
| 307 | assert.NotEqual(t, "vendor-data", strings.ToLower(e.Name()), | ||
| 308 | "no CA key must not produce vendor-data") | ||
| 309 | } | ||
| 310 | // user-data untouched (default form). | ||
| 311 | ud := string(readISOFile(t, out, "/user-data")) | ||
| 312 | assert.Contains(t, ud, "hostname: plain") | ||
| 313 | } | ||
internal/agent/serialpump/serialpump.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,405 @@ | |||
| 1 | // Package serialpump owns the durability of VM serial consoles. With | ||
| 2 | // cloud-hypervisor's --serial socket= mode the serial line is a unix socket | ||
| 3 | // serving ONE client at a time (a new connection kicks the old one), and | ||
| 4 | // depending on CH version, output written while no client is connected is | ||
| 5 | // dropped (older) or buffered in a bounded replay ring (current). The pump is | ||
| 6 | // that one client, always: a supervised goroutine per running VM dials the | ||
| 7 | // socket, drains continuously into a bounded in-memory ring (backlog for new | ||
| 8 | // viewers) and a capped on-disk serial.log (survives agent restart, unbounded | ||
| 9 | // history), fans live bytes out to attached console viewers, and forwards | ||
| 10 | // viewer input back to the socket. A slow viewer is dropped, never allowed to | ||
| 11 | // stall the drain. Do NOT connect other clients (socat etc.) to the serial | ||
| 12 | // socket — they would steal the line from the pump. | ||
| 13 | package serialpump | ||
| 14 | |||
| 15 | import ( | ||
| 16 | "context" | ||
| 17 | "errors" | ||
| 18 | "fmt" | ||
| 19 | "io" | ||
| 20 | "log/slog" | ||
| 21 | "net" | ||
| 22 | "os" | ||
| 23 | "sync" | ||
| 24 | "time" | ||
| 25 | ) | ||
| 26 | |||
| 27 | const ( | ||
| 28 | defaultRingMax = 256 << 10 // 256 KiB — enough for a boot log | ||
| 29 | defaultLogMax = 4 << 20 // 4 MiB on disk, then rotate once to .old | ||
| 30 | viewerDepth = 64 // live-tail channel depth before a viewer is dropped | ||
| 31 | ) | ||
| 32 | |||
| 33 | // Manager runs one Pump per VM. Paths are injected so the package stays a leaf | ||
| 34 | // (no dependency on the agent's state store). | ||
| 35 | type Manager struct { | ||
| 36 | socketPath func(vmID string) string | ||
| 37 | logPath func(vmID string) string | ||
| 38 | ringMax int | ||
| 39 | logMax int64 | ||
| 40 | |||
| 41 | mu sync.Mutex | ||
| 42 | pumps map[string]*pump | ||
| 43 | } | ||
| 44 | |||
| 45 | // NewManager returns a Manager resolving each VM's serial socket and on-disk | ||
| 46 | // log through the given path funcs. | ||
| 47 | func NewManager(socketPath, logPath func(vmID string) string) *Manager { | ||
| 48 | return &Manager{ | ||
| 49 | socketPath: socketPath, | ||
| 50 | logPath: logPath, | ||
| 51 | ringMax: defaultRingMax, | ||
| 52 | logMax: defaultLogMax, | ||
| 53 | pumps: map[string]*pump{}, | ||
| 54 | } | ||
| 55 | } | ||
| 56 | |||
| 57 | // Ensure starts the VM's pump if it is not already running. Idempotent. | ||
| 58 | func (m *Manager) Ensure(vmID string) { | ||
| 59 | m.mu.Lock() | ||
| 60 | defer m.mu.Unlock() | ||
| 61 | if p, ok := m.pumps[vmID]; ok { | ||
| 62 | // Pump already running — but it may be parked deep in dial backoff | ||
| 63 | // (up to 30s): Shutdown deliberately leaves the pump alive across VM | ||
| 64 | // stop/start, so on restart the VM's fresh socket must not wait out a | ||
| 65 | // stale backoff (unconsumed early boot output is dropped or truncated | ||
| 66 | // by CH). Poke the dial loop to retry now. | ||
| 67 | select { | ||
| 68 | case p.poke <- struct{}{}: | ||
| 69 | default: // a poke is already pending | ||
| 70 | } | ||
| 71 | return | ||
| 72 | } | ||
| 73 | p := &pump{ | ||
| 74 | socket: m.socketPath(vmID), | ||
| 75 | logPath: m.logPath(vmID), | ||
| 76 | ringMax: m.ringMax, | ||
| 77 | logMax: m.logMax, | ||
| 78 | viewers: map[int]chan []byte{}, | ||
| 79 | done: make(chan struct{}), | ||
| 80 | poke: make(chan struct{}, 1), | ||
| 81 | } | ||
| 82 | m.pumps[vmID] = p | ||
| 83 | go p.run() | ||
| 84 | } | ||
| 85 | |||
| 86 | // Stop tears down the VM's pump (VM destroyed). No-op for unknown VMs. | ||
| 87 | func (m *Manager) Stop(vmID string) { | ||
| 88 | m.mu.Lock() | ||
| 89 | p := m.pumps[vmID] | ||
| 90 | delete(m.pumps, vmID) | ||
| 91 | m.mu.Unlock() | ||
| 92 | if p != nil { | ||
| 93 | p.stop() | ||
| 94 | } | ||
| 95 | } | ||
| 96 | |||
| 97 | // StopAll tears down every pump (agent shutdown, tests). | ||
| 98 | func (m *Manager) StopAll() { | ||
| 99 | m.mu.Lock() | ||
| 100 | pumps := m.pumps | ||
| 101 | m.pumps = map[string]*pump{} | ||
| 102 | m.mu.Unlock() | ||
| 103 | for _, p := range pumps { | ||
| 104 | p.stop() | ||
| 105 | } | ||
| 106 | } | ||
| 107 | |||
| 108 | // Attach bridges rw to the VM's console: onReady (may be nil) fires after | ||
| 109 | // validation and before any bytes — the caller uses it to send its protocol | ||
| 110 | // reply — then the ring backlog is replayed, then live output flows; bytes | ||
| 111 | // read from rw are forwarded to the guest as input. Blocks until ctx ends, | ||
| 112 | // rw errors, or the viewer is dropped as too slow. Returns an error | ||
| 113 | // immediately (before onReady) when the VM has no pump. The caller must close | ||
| 114 | // rw (unblocking its Read) once Attach returns, or the input-forwarding | ||
| 115 | // goroutine leaks parked in rw.Read. | ||
| 116 | func (m *Manager) Attach(ctx context.Context, vmID string, rw io.ReadWriter, onReady func() error) error { | ||
| 117 | m.mu.Lock() | ||
| 118 | p := m.pumps[vmID] | ||
| 119 | m.mu.Unlock() | ||
| 120 | if p == nil { | ||
| 121 | return fmt.Errorf("no console for vm %q (not running on this host?)", vmID) | ||
| 122 | } | ||
| 123 | if onReady != nil { | ||
| 124 | if err := onReady(); err != nil { | ||
| 125 | return err | ||
| 126 | } | ||
| 127 | } | ||
| 128 | return p.attach(ctx, rw) | ||
| 129 | } | ||
| 130 | |||
| 131 | // pump drains one VM's serial socket. It reconnects forever (CH restarts on VM | ||
| 132 | // stop/start; the socket may not exist yet at boot) until stop() is called. | ||
| 133 | type pump struct { | ||
| 134 | socket string | ||
| 135 | logPath string | ||
| 136 | ringMax int | ||
| 137 | logMax int64 | ||
| 138 | |||
| 139 | mu sync.Mutex | ||
| 140 | ring []byte | ||
| 141 | conn net.Conn // current socket conn; input writes go here | ||
| 142 | viewers map[int]chan []byte | ||
| 143 | nextID int | ||
| 144 | logF *os.File | ||
| 145 | logSize int64 | ||
| 146 | logWarned bool // one breadcrumb per pump when the log path fails | ||
| 147 | |||
| 148 | done chan struct{} | ||
| 149 | poke chan struct{} // buffered(1); re-Ensure nudges a backed-off dial loop | ||
| 150 | stopOnce sync.Once | ||
| 151 | } | ||
| 152 | |||
| 153 | func (p *pump) stop() { | ||
| 154 | p.stopOnce.Do(func() { | ||
| 155 | close(p.done) | ||
| 156 | p.mu.Lock() | ||
| 157 | if p.conn != nil { | ||
| 158 | p.conn.Close() // unblock the drain read | ||
| 159 | } | ||
| 160 | for id, ch := range p.viewers { | ||
| 161 | close(ch) | ||
| 162 | delete(p.viewers, id) | ||
| 163 | } | ||
| 164 | if p.logF != nil { | ||
| 165 | p.logF.Close() | ||
| 166 | p.logF = nil | ||
| 167 | } | ||
| 168 | p.mu.Unlock() | ||
| 169 | }) | ||
| 170 | } | ||
| 171 | |||
| 172 | func (p *pump) run() { | ||
| 173 | backoff := 250 * time.Millisecond | ||
| 174 | for { | ||
| 175 | select { | ||
| 176 | case <-p.done: | ||
| 177 | return | ||
| 178 | default: | ||
| 179 | } | ||
| 180 | conn, err := net.Dial("unix", p.socket) | ||
| 181 | if err != nil { | ||
| 182 | select { | ||
| 183 | case <-p.done: | ||
| 184 | return | ||
| 185 | case <-time.After(backoff): | ||
| 186 | if backoff < 30*time.Second { | ||
| 187 | backoff *= 2 | ||
| 188 | } | ||
| 189 | case <-p.poke: | ||
| 190 | // Re-Ensure of a live pump (VM restarted, fresh socket): | ||
| 191 | // retry immediately with a fresh backoff instead of waiting | ||
| 192 | // out a stale one. | ||
| 193 | backoff = 250 * time.Millisecond | ||
| 194 | } | ||
| 195 | continue | ||
| 196 | } | ||
| 197 | backoff = 250 * time.Millisecond | ||
| 198 | p.mu.Lock() | ||
| 199 | select { | ||
| 200 | case <-p.done: | ||
| 201 | // stop() ran between Dial and here: it closed p.conn (nil at that | ||
| 202 | // point) but not THIS conn — do not resurrect the pump. | ||
| 203 | p.mu.Unlock() | ||
| 204 | conn.Close() | ||
| 205 | return | ||
| 206 | default: | ||
| 207 | } | ||
| 208 | p.conn = conn | ||
| 209 | p.mu.Unlock() | ||
| 210 | p.drain(conn) | ||
| 211 | p.mu.Lock() | ||
| 212 | if p.conn == conn { | ||
| 213 | p.conn = nil | ||
| 214 | } | ||
| 215 | p.mu.Unlock() | ||
| 216 | conn.Close() | ||
| 217 | } | ||
| 218 | } | ||
| 219 | |||
| 220 | func (p *pump) drain(conn net.Conn) { | ||
| 221 | buf := make([]byte, 4096) | ||
| 222 | for { | ||
| 223 | n, err := conn.Read(buf) | ||
| 224 | if n > 0 { | ||
| 225 | p.publish(buf[:n]) | ||
| 226 | } | ||
| 227 | if err != nil { | ||
| 228 | return | ||
| 229 | } | ||
| 230 | } | ||
| 231 | } | ||
| 232 | |||
| 233 | // publish appends b to the ring (trimming from the front past ringMax), the | ||
| 234 | // on-disk log (rotating once at logMax), and every viewer. A viewer whose | ||
| 235 | // channel is full is dropped (closed + removed) — it must never stall the | ||
| 236 | // drain. b is copied: callers reuse their read buffer. The disk write happens | ||
| 237 | // under p.mu deliberately: the only contenders are keystrokes and attaches, | ||
| 238 | // and drain is the sole caller — a writer goroutine would buy complexity, not | ||
| 239 | // throughput. | ||
| 240 | func (p *pump) publish(b []byte) { | ||
| 241 | cp := make([]byte, len(b)) | ||
| 242 | copy(cp, b) | ||
| 243 | |||
| 244 | p.mu.Lock() | ||
| 245 | defer p.mu.Unlock() | ||
| 246 | |||
| 247 | select { | ||
| 248 | case <-p.done: | ||
| 249 | // stop() may have run while this publish waited on the lock. It already | ||
| 250 | // closed the log file and viewer channels; touching them now would | ||
| 251 | // reopen the log fd with nothing left to close it (run() is exiting). | ||
| 252 | return | ||
| 253 | default: | ||
| 254 | } | ||
| 255 | |||
| 256 | p.ring = append(p.ring, cp...) | ||
| 257 | if over := len(p.ring) - p.ringMax; over > 0 { | ||
| 258 | p.ring = p.ring[over:] | ||
| 259 | } | ||
| 260 | p.appendLogLocked(cp) | ||
| 261 | for id, ch := range p.viewers { | ||
| 262 | select { | ||
| 263 | case ch <- cp: | ||
| 264 | default: | ||
| 265 | close(ch) // slow viewer: drop it, never block the pump | ||
| 266 | delete(p.viewers, id) | ||
| 267 | } | ||
| 268 | } | ||
| 269 | } | ||
| 270 | |||
| 271 | // appendLogLocked writes to the capped on-disk log, rotating once (.old) at | ||
| 272 | // the cap. Log failures are swallowed — the console must keep working even if | ||
| 273 | // the disk is unhappy (the ring still serves backlog) — but breadcrumbed once | ||
| 274 | // per pump, so "history survives agent restart" going false is visible. | ||
| 275 | func (p *pump) appendLogLocked(b []byte) { | ||
| 276 | if p.logF == nil { | ||
| 277 | f, err := os.OpenFile(p.logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) | ||
| 278 | if err != nil { | ||
| 279 | p.warnLogLocked("open", err) | ||
| 280 | return | ||
| 281 | } | ||
| 282 | st, err := f.Stat() | ||
| 283 | if err != nil { | ||
| 284 | f.Close() | ||
| 285 | p.warnLogLocked("stat", err) | ||
| 286 | return | ||
| 287 | } | ||
| 288 | p.logF, p.logSize = f, st.Size() | ||
| 289 | } | ||
| 290 | if p.logSize+int64(len(b)) > p.logMax { | ||
| 291 | p.logF.Close() | ||
| 292 | p.logF = nil | ||
| 293 | if err := os.Rename(p.logPath, p.logPath+".old"); err != nil { | ||
| 294 | p.warnLogLocked("rotate", err) | ||
| 295 | return | ||
| 296 | } | ||
| 297 | f, err := os.OpenFile(p.logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) | ||
| 298 | if err != nil { | ||
| 299 | p.warnLogLocked("reopen", err) | ||
| 300 | return | ||
| 301 | } | ||
| 302 | p.logF, p.logSize = f, 0 | ||
| 303 | } | ||
| 304 | n, err := p.logF.Write(b) | ||
| 305 | if err != nil { | ||
| 306 | p.warnLogLocked("write", err) | ||
| 307 | } | ||
| 308 | p.logSize += int64(n) | ||
| 309 | } | ||
| 310 | |||
| 311 | // warnLogLocked emits one warning per pump lifetime: log failures are | ||
| 312 | // tolerated, but silently losing restart-surviving history is not. | ||
| 313 | func (p *pump) warnLogLocked(op string, err error) { | ||
| 314 | if p.logWarned { | ||
| 315 | return | ||
| 316 | } | ||
| 317 | p.logWarned = true | ||
| 318 | slog.Warn("serial log persistence failing; console history will not survive agent restart", | ||
| 319 | "op", op, "path", p.logPath, "err", err) | ||
| 320 | } | ||
| 321 | |||
| 322 | // errPumpStopped reports an attach racing a Stop (VM destroyed): the viewer | ||
| 323 | // must error out, not hang on a channel nothing will ever publish to or close. | ||
| 324 | var errPumpStopped = errors.New("console pump stopped") | ||
| 325 | |||
| 326 | // subscribe atomically snapshots the ring and registers a live channel — one | ||
| 327 | // lock, so no byte can fall between backlog and live, and stop() (which also | ||
| 328 | // takes the lock to close all viewer channels) cannot interleave: either we | ||
| 329 | // see done closed here, or stop sees our channel in the map and closes it. | ||
| 330 | func (p *pump) subscribe() (backlog []byte, ch chan []byte, cancel func(), err error) { | ||
| 331 | p.mu.Lock() | ||
| 332 | defer p.mu.Unlock() | ||
| 333 | select { | ||
| 334 | case <-p.done: | ||
| 335 | return nil, nil, nil, errPumpStopped | ||
| 336 | default: | ||
| 337 | } | ||
| 338 | backlog = make([]byte, len(p.ring)) | ||
| 339 | copy(backlog, p.ring) | ||
| 340 | ch = make(chan []byte, viewerDepth) | ||
| 341 | id := p.nextID | ||
| 342 | p.nextID++ | ||
| 343 | p.viewers[id] = ch | ||
| 344 | return backlog, ch, func() { | ||
| 345 | p.mu.Lock() | ||
| 346 | defer p.mu.Unlock() | ||
| 347 | if c, ok := p.viewers[id]; ok { | ||
| 348 | close(c) | ||
| 349 | delete(p.viewers, id) | ||
| 350 | } | ||
| 351 | }, nil | ||
| 352 | } | ||
| 353 | |||
| 354 | // writeInput forwards viewer keystrokes to the guest. Dropped silently when | ||
| 355 | // the socket is not currently connected (VM stopped): the serial line simply | ||
| 356 | // isn't there, exactly like typing into an unplugged terminal. | ||
| 357 | func (p *pump) writeInput(b []byte) { | ||
| 358 | p.mu.Lock() | ||
| 359 | conn := p.conn | ||
| 360 | p.mu.Unlock() | ||
| 361 | if conn != nil { | ||
| 362 | _, _ = conn.Write(b) | ||
| 363 | } | ||
| 364 | } | ||
| 365 | |||
| 366 | func (p *pump) attach(ctx context.Context, rw io.ReadWriter) error { | ||
| 367 | backlog, ch, cancel, err := p.subscribe() | ||
| 368 | if err != nil { | ||
| 369 | return err | ||
| 370 | } | ||
| 371 | defer cancel() | ||
| 372 | if len(backlog) > 0 { | ||
| 373 | if _, err := rw.Write(backlog); err != nil { | ||
| 374 | return err | ||
| 375 | } | ||
| 376 | } | ||
| 377 | // Input pump: rw → guest. Ends when rw read errors (stream closed); | ||
| 378 | // cancel() then makes the output loop below observe the closed channel. | ||
| 379 | go func() { | ||
| 380 | buf := make([]byte, 1024) | ||
| 381 | for { | ||
| 382 | n, err := rw.Read(buf) | ||
| 383 | if n > 0 { | ||
| 384 | p.writeInput(buf[:n]) | ||
| 385 | } | ||
| 386 | if err != nil { | ||
| 387 | cancel() | ||
| 388 | return | ||
| 389 | } | ||
| 390 | } | ||
| 391 | }() | ||
| 392 | for { | ||
| 393 | select { | ||
| 394 | case <-ctx.Done(): | ||
| 395 | return ctx.Err() | ||
| 396 | case b, ok := <-ch: | ||
| 397 | if !ok { | ||
| 398 | return fmt.Errorf("console viewer detached (slow reader, stream closed, or pump stopped)") | ||
| 399 | } | ||
| 400 | if _, err := rw.Write(b); err != nil { | ||
| 401 | return err | ||
| 402 | } | ||
| 403 | } | ||
| 404 | } | ||
| 405 | } | ||
internal/agent/serialpump/serialpump_test.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,353 @@ | |||
| 1 | package serialpump | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "context" | ||
| 5 | "io" | ||
| 6 | "net" | ||
| 7 | "os" | ||
| 8 | "path/filepath" | ||
| 9 | "testing" | ||
| 10 | "time" | ||
| 11 | |||
| 12 | "github.com/stretchr/testify/assert" | ||
| 13 | "github.com/stretchr/testify/require" | ||
| 14 | ) | ||
| 15 | |||
| 16 | // fakeCH is a unix-socket listener standing in for cloud-hypervisor's | ||
| 17 | // --serial socket=. It records input written by the pump and lets tests | ||
| 18 | // emit guest output. | ||
| 19 | type fakeCH struct { | ||
| 20 | ln net.Listener | ||
| 21 | conns chan net.Conn | ||
| 22 | } | ||
| 23 | |||
| 24 | func newFakeCH(t *testing.T, sock string) *fakeCH { | ||
| 25 | t.Helper() | ||
| 26 | ln, err := net.Listen("unix", sock) | ||
| 27 | require.NoError(t, err) | ||
| 28 | f := &fakeCH{ln: ln, conns: make(chan net.Conn, 4)} | ||
| 29 | go func() { | ||
| 30 | for { | ||
| 31 | c, err := ln.Accept() | ||
| 32 | if err != nil { | ||
| 33 | return | ||
| 34 | } | ||
| 35 | f.conns <- c | ||
| 36 | } | ||
| 37 | }() | ||
| 38 | t.Cleanup(func() { ln.Close() }) | ||
| 39 | return f | ||
| 40 | } | ||
| 41 | |||
| 42 | func (f *fakeCH) conn(t *testing.T) net.Conn { | ||
| 43 | t.Helper() | ||
| 44 | select { | ||
| 45 | case c := <-f.conns: | ||
| 46 | return c | ||
| 47 | case <-time.After(5 * time.Second): | ||
| 48 | t.Fatal("pump never dialed the serial socket") | ||
| 49 | return nil | ||
| 50 | } | ||
| 51 | } | ||
| 52 | |||
| 53 | func newTestManager(t *testing.T, dir string) *Manager { | ||
| 54 | t.Helper() | ||
| 55 | m := NewManager( | ||
| 56 | func(vmID string) string { return filepath.Join(dir, vmID+".serial.sock") }, | ||
| 57 | func(vmID string) string { return filepath.Join(dir, vmID+".serial.log") }, | ||
| 58 | ) | ||
| 59 | t.Cleanup(m.StopAll) | ||
| 60 | return m | ||
| 61 | } | ||
| 62 | |||
| 63 | // pipeViewer returns an in-memory io.ReadWriter viewer plus the far ends the | ||
| 64 | // test uses to observe output and inject keystrokes. | ||
| 65 | func pipeViewer() (viewer io.ReadWriter, out io.Reader, in io.Writer) { | ||
| 66 | or, ow := io.Pipe() // pump → viewer output | ||
| 67 | ir, iw := io.Pipe() // test → viewer input | ||
| 68 | type rw struct { | ||
| 69 | io.Reader | ||
| 70 | io.Writer | ||
| 71 | } | ||
| 72 | return rw{ir, ow}, or, iw | ||
| 73 | } | ||
| 74 | |||
| 75 | func readN(t *testing.T, r io.Reader, n int) []byte { | ||
| 76 | t.Helper() | ||
| 77 | buf := make([]byte, n) | ||
| 78 | _, err := io.ReadFull(r, buf) | ||
| 79 | require.NoError(t, err) | ||
| 80 | return buf | ||
| 81 | } | ||
| 82 | |||
| 83 | func TestBacklogReplayThenLive(t *testing.T) { | ||
| 84 | dir := t.TempDir() | ||
| 85 | m := newTestManager(t, dir) | ||
| 86 | ch := newFakeCH(t, filepath.Join(dir, "vm1.serial.sock")) | ||
| 87 | m.Ensure("vm1") | ||
| 88 | guest := ch.conn(t) | ||
| 89 | |||
| 90 | _, err := guest.Write([]byte("BOOT-LOG\n")) | ||
| 91 | require.NoError(t, err) | ||
| 92 | |||
| 93 | viewer, out, _ := pipeViewer() | ||
| 94 | errc := make(chan error, 1) | ||
| 95 | ctx, cancel := context.WithCancel(context.Background()) | ||
| 96 | defer cancel() | ||
| 97 | go func() { errc <- m.Attach(ctx, "vm1", viewer, nil) }() | ||
| 98 | |||
| 99 | // Backlog written before attach is replayed first... | ||
| 100 | assert.Equal(t, "BOOT-LOG\n", string(readN(t, out, 9))) | ||
| 101 | // ...then live bytes flow. | ||
| 102 | _, err = guest.Write([]byte("LIVE\n")) | ||
| 103 | require.NoError(t, err) | ||
| 104 | assert.Equal(t, "LIVE\n", string(readN(t, out, 5))) | ||
| 105 | cancel() | ||
| 106 | assert.ErrorIs(t, <-errc, context.Canceled) | ||
| 107 | } | ||
| 108 | |||
| 109 | func TestInputForwardedToSocket(t *testing.T) { | ||
| 110 | dir := t.TempDir() | ||
| 111 | m := newTestManager(t, dir) | ||
| 112 | ch := newFakeCH(t, filepath.Join(dir, "vm1.serial.sock")) | ||
| 113 | m.Ensure("vm1") | ||
| 114 | guest := ch.conn(t) | ||
| 115 | |||
| 116 | viewer, _, in := pipeViewer() | ||
| 117 | ctx, cancel := context.WithCancel(context.Background()) | ||
| 118 | defer cancel() | ||
| 119 | go m.Attach(ctx, "vm1", viewer, nil) //nolint:errcheck | ||
| 120 | |||
| 121 | _, err := in.Write([]byte("ls\r")) | ||
| 122 | require.NoError(t, err) | ||
| 123 | assert.Equal(t, "ls\r", string(readN(t, guest, 3))) | ||
| 124 | } | ||
| 125 | |||
| 126 | func TestAttachUnknownVMErrorsBeforeOnReady(t *testing.T) { | ||
| 127 | m := newTestManager(t, t.TempDir()) | ||
| 128 | called := false | ||
| 129 | err := m.Attach(context.Background(), "nope", nil, func() error { called = true; return nil }) | ||
| 130 | assert.Error(t, err) | ||
| 131 | assert.False(t, called, "onReady must not fire when the VM has no pump") | ||
| 132 | } | ||
| 133 | |||
| 134 | func TestOnReadyFiresBeforeBacklog(t *testing.T) { | ||
| 135 | dir := t.TempDir() | ||
| 136 | m := newTestManager(t, dir) | ||
| 137 | ch := newFakeCH(t, filepath.Join(dir, "vm1.serial.sock")) | ||
| 138 | m.Ensure("vm1") | ||
| 139 | guest := ch.conn(t) | ||
| 140 | _, _ = guest.Write([]byte("X")) | ||
| 141 | |||
| 142 | viewer, out, _ := pipeViewer() | ||
| 143 | ready := make(chan struct{}) | ||
| 144 | ctx, cancel := context.WithCancel(context.Background()) | ||
| 145 | defer cancel() | ||
| 146 | go m.Attach(ctx, "vm1", viewer, func() error { close(ready); return nil }) //nolint:errcheck | ||
| 147 | <-ready // onReady before any viewer write (protocol: reply frame precedes raw bytes) | ||
| 148 | assert.Equal(t, "X", string(readN(t, out, 1))) | ||
| 149 | } | ||
| 150 | |||
| 151 | func TestRingIsBounded(t *testing.T) { | ||
| 152 | dir := t.TempDir() | ||
| 153 | m := newTestManager(t, dir) | ||
| 154 | m.ringMax = 16 // shrink for the test | ||
| 155 | ch := newFakeCH(t, filepath.Join(dir, "vm1.serial.sock")) | ||
| 156 | m.Ensure("vm1") | ||
| 157 | guest := ch.conn(t) | ||
| 158 | |||
| 159 | _, err := guest.Write([]byte("0123456789ABCDEFGHIJ")) // 20 bytes > 16 | ||
| 160 | require.NoError(t, err) | ||
| 161 | // Wait until the pump has drained all 20 bytes into the log. | ||
| 162 | log := filepath.Join(dir, "vm1.serial.log") | ||
| 163 | require.Eventually(t, func() bool { | ||
| 164 | b, _ := os.ReadFile(log) | ||
| 165 | return len(b) == 20 | ||
| 166 | }, 5*time.Second, 10*time.Millisecond) | ||
| 167 | |||
| 168 | viewer, out, _ := pipeViewer() | ||
| 169 | ctx, cancel := context.WithCancel(context.Background()) | ||
| 170 | defer cancel() | ||
| 171 | go m.Attach(ctx, "vm1", viewer, nil) //nolint:errcheck | ||
| 172 | // Backlog is only the LAST 16 bytes. | ||
| 173 | assert.Equal(t, "456789ABCDEFGHIJ", string(readN(t, out, 16))) | ||
| 174 | } | ||
| 175 | |||
| 176 | func TestOnDiskLogRotatesAtCap(t *testing.T) { | ||
| 177 | dir := t.TempDir() | ||
| 178 | m := newTestManager(t, dir) | ||
| 179 | m.logMax = 32 | ||
| 180 | ch := newFakeCH(t, filepath.Join(dir, "vm1.serial.sock")) | ||
| 181 | m.Ensure("vm1") | ||
| 182 | guest := ch.conn(t) | ||
| 183 | |||
| 184 | big := make([]byte, 40) | ||
| 185 | for i := range big { | ||
| 186 | big[i] = 'a' | ||
| 187 | } | ||
| 188 | _, err := guest.Write(big) | ||
| 189 | require.NoError(t, err) | ||
| 190 | require.Eventually(t, func() bool { | ||
| 191 | _, err := os.Stat(filepath.Join(dir, "vm1.serial.log.old")) | ||
| 192 | return err == nil | ||
| 193 | }, 5*time.Second, 10*time.Millisecond, "log must rotate to .old at the cap") | ||
| 194 | } | ||
| 195 | |||
| 196 | // slowViewer consumes output correctly but slowly: Write sleeps then succeeds. | ||
| 197 | // (A never-reading viewer would park Attach inside rw.Write, where it could | ||
| 198 | // not observe the drop — the pump drops via the CHANNEL, so the viewer must | ||
| 199 | // keep returning from Write to come back to the channel receive.) | ||
| 200 | type slowViewer struct{ done chan struct{} } | ||
| 201 | |||
| 202 | func (v slowViewer) Read(p []byte) (int, error) { <-v.done; return 0, io.EOF } | ||
| 203 | func (v slowViewer) Write(p []byte) (int, error) { | ||
| 204 | time.Sleep(3 * time.Millisecond) | ||
| 205 | return len(p), nil | ||
| 206 | } | ||
| 207 | |||
| 208 | func TestSlowViewerIsDroppedNotBlocking(t *testing.T) { | ||
| 209 | dir := t.TempDir() | ||
| 210 | m := newTestManager(t, dir) | ||
| 211 | ch := newFakeCH(t, filepath.Join(dir, "vm1.serial.sock")) | ||
| 212 | m.Ensure("vm1") | ||
| 213 | guest := ch.conn(t) | ||
| 214 | |||
| 215 | v := slowViewer{done: make(chan struct{})} | ||
| 216 | defer close(v.done) | ||
| 217 | errc := make(chan error, 1) | ||
| 218 | go func() { errc <- m.Attach(context.Background(), "vm1", v, nil) }() | ||
| 219 | |||
| 220 | // Flood: 256 chunks of 4096 bytes. The pump's read buffer is 4096, so | ||
| 221 | // coalescing cannot reduce this below 256 distinct publishes — far past | ||
| 222 | // viewerDepth (64) — while the viewer consumes only ~1 per 3ms. The | ||
| 223 | // channel overflows, the pump drops the viewer, and Attach's next channel | ||
| 224 | // receive observes the close and errors out. | ||
| 225 | junk := make([]byte, 4096) | ||
| 226 | for i := 0; i < 256; i++ { | ||
| 227 | _, err := guest.Write(junk) | ||
| 228 | require.NoError(t, err) | ||
| 229 | } | ||
| 230 | select { | ||
| 231 | case err := <-errc: | ||
| 232 | assert.Error(t, err, "slow viewer is dropped with an error") | ||
| 233 | case <-time.After(10 * time.Second): | ||
| 234 | t.Fatal("slow viewer was never dropped") | ||
| 235 | } | ||
| 236 | // Pump still healthy after the drop: fresh output still lands in the log. | ||
| 237 | log := filepath.Join(dir, "vm1.serial.log") | ||
| 238 | prev, _ := os.Stat(log) | ||
| 239 | _, err := guest.Write([]byte("still-draining")) | ||
| 240 | require.NoError(t, err) | ||
| 241 | require.Eventually(t, func() bool { | ||
| 242 | st, err := os.Stat(log) | ||
| 243 | return err == nil && (prev == nil || st.Size() > prev.Size()) | ||
| 244 | }, 5*time.Second, 10*time.Millisecond) | ||
| 245 | } | ||
| 246 | |||
| 247 | func TestFanOutToTwoViewers(t *testing.T) { | ||
| 248 | dir := t.TempDir() | ||
| 249 | m := newTestManager(t, dir) | ||
| 250 | ch := newFakeCH(t, filepath.Join(dir, "vm1.serial.sock")) | ||
| 251 | m.Ensure("vm1") | ||
| 252 | guest := ch.conn(t) | ||
| 253 | |||
| 254 | v1, out1, _ := pipeViewer() | ||
| 255 | v2, out2, _ := pipeViewer() | ||
| 256 | ctx1, cancel1 := context.WithCancel(context.Background()) | ||
| 257 | defer cancel1() | ||
| 258 | ctx2, cancel2 := context.WithCancel(context.Background()) | ||
| 259 | defer cancel2() | ||
| 260 | go m.Attach(ctx1, "vm1", v1, nil) //nolint:errcheck | ||
| 261 | go m.Attach(ctx2, "vm1", v2, nil) //nolint:errcheck | ||
| 262 | |||
| 263 | // Both viewers see the same bytes (whichever attached later gets them via | ||
| 264 | // the ring replay — same contract either way). | ||
| 265 | _, err := guest.Write([]byte("BOTH")) | ||
| 266 | require.NoError(t, err) | ||
| 267 | assert.Equal(t, "BOTH", string(readN(t, out1, 4))) | ||
| 268 | assert.Equal(t, "BOTH", string(readN(t, out2, 4))) | ||
| 269 | |||
| 270 | // One viewer detaching must not disturb the other. | ||
| 271 | cancel1() | ||
| 272 | _, err = guest.Write([]byte("SOLO")) | ||
| 273 | require.NoError(t, err) | ||
| 274 | assert.Equal(t, "SOLO", string(readN(t, out2, 4))) | ||
| 275 | } | ||
| 276 | |||
| 277 | func TestSubscribeAfterStopErrors(t *testing.T) { | ||
| 278 | // The Attach-vs-Stop race: Manager.Attach can look a pump up just before | ||
| 279 | // Stop() runs. subscribe must then refuse (not register a viewer channel | ||
| 280 | // nothing will ever close) so the console errors instead of hanging. | ||
| 281 | p := &pump{viewers: map[int]chan []byte{}, done: make(chan struct{})} | ||
| 282 | p.stop() | ||
| 283 | _, _, _, err := p.subscribe() | ||
| 284 | assert.ErrorIs(t, err, errPumpStopped) | ||
| 285 | } | ||
| 286 | |||
| 287 | func TestPumpReconnectsAfterSocketRestart(t *testing.T) { | ||
| 288 | dir := t.TempDir() | ||
| 289 | m := newTestManager(t, dir) | ||
| 290 | sock := filepath.Join(dir, "vm1.serial.sock") | ||
| 291 | ch := newFakeCH(t, sock) | ||
| 292 | m.Ensure("vm1") | ||
| 293 | guest := ch.conn(t) | ||
| 294 | _, _ = guest.Write([]byte("A")) | ||
| 295 | // Listener FIRST, then conn: closing the conn first lets the pump re-dial | ||
| 296 | // into the still-live old listener, parking it on a conn ch2 never sees. | ||
| 297 | ch.ln.Close() | ||
| 298 | guest.Close() | ||
| 299 | // Go's UnixListener unlinks its socket file on Close, so the path is | ||
| 300 | // usually gone already — this remove only guards against a leftover file. | ||
| 301 | if err := os.Remove(sock); err != nil && !os.IsNotExist(err) { | ||
| 302 | t.Fatal(err) | ||
| 303 | } | ||
| 304 | |||
| 305 | // CH restarts (VM stop/start): a new listener appears; pump must re-dial. | ||
| 306 | ch2 := newFakeCH(t, sock) | ||
| 307 | guest2 := ch2.conn(t) // blocks until the pump reconnects | ||
| 308 | _, err := guest2.Write([]byte("B")) | ||
| 309 | require.NoError(t, err) | ||
| 310 | } | ||
| 311 | |||
| 312 | // TestEnsurePokesBackedOffPump pins the re-Ensure nudge: Shutdown deliberately | ||
| 313 | // leaves the pump alive, so when a VM restarts, Ensure finds an existing pump | ||
| 314 | // possibly parked deep in dial backoff — the poke must make it re-dial | ||
| 315 | // immediately, or the fresh socket sits unconsumed for up to 30s and early | ||
| 316 | // boot output is lost. | ||
| 317 | // | ||
| 318 | // Timing schedule (every wait is a lower bound — time.After never fires | ||
| 319 | // early, so scheduling jitter only pushes dials LATER): | ||
| 320 | // | ||
| 321 | // unpoked dials with no listener land at ~0 / 0.25 / 0.75 / 1.75 / 3.75s | ||
| 322 | // (backoff 0.25 → 0.5 → 1 → 2 → 4s); after the ~3.75s failure the pump is | ||
| 323 | // parked in a 4s wait, so the next UNPOKED dial cannot happen before ~7.75s. | ||
| 324 | // | ||
| 325 | // We sleep 4.2s — the ~3.75s dial has ~450ms of jitter margin to fail before | ||
| 326 | // the listener exists — then create the listener and re-Ensure (the poke). | ||
| 327 | // Requiring a connection within 2s (~6.2s total) leaves ~1.5s of margin below | ||
| 328 | // the ~7.75s unpoked dial: only the poke can connect that early. (If the | ||
| 329 | // ~3.75s dial were somehow delayed past the sleep, the poke is buffered and | ||
| 330 | // fires the moment the pump enters its next wait — still well within bound.) | ||
| 331 | func TestEnsurePokesBackedOffPump(t *testing.T) { | ||
| 332 | dir := t.TempDir() | ||
| 333 | m := newTestManager(t, dir) | ||
| 334 | sock := filepath.Join(dir, "vm1.serial.sock") | ||
| 335 | |||
| 336 | m.Ensure("vm1") // no listener yet: pump enters its dial-backoff loop | ||
| 337 | time.Sleep(4200 * time.Millisecond) | ||
| 338 | |||
| 339 | ch := newFakeCH(t, sock) | ||
| 340 | m.Ensure("vm1") // idempotent — but must poke the backed-off dial loop | ||
| 341 | |||
| 342 | select { | ||
| 343 | case <-ch.conns: | ||
| 344 | // poked pump re-dialed immediately | ||
| 345 | case <-time.After(2 * time.Second): | ||
| 346 | t.Fatal("pump did not re-dial after Ensure poke; still waiting out a stale backoff") | ||
| 347 | } | ||
| 348 | } | ||
| 349 | |||
| 350 | func TestStopUnknownVMIsNoop(t *testing.T) { | ||
| 351 | m := newTestManager(t, t.TempDir()) | ||
| 352 | m.Stop("never-started") // must not panic | ||
| 353 | } | ||
internal/agent/state/state.go
| Old | New | ||
|---|---|---|---|
| @@ -15,7 +15,7 @@ import ( | |||
| 15 | type VMSpec struct { | 15 | type VMSpec struct { |
| 16 | VMID, Name, ImageURL, ImageSHA256, CloudInit, SSHAuthorizedKey string | 16 | VMID, Name, ImageURL, ImageSHA256, CloudInit, SSHAuthorizedKey string |
| 17 | VCPUs, MemMB, DiskGB int64 | 17 | VCPUs, MemMB, DiskGB int64 |
| 18 | Persistent bool | 18 | Persistent bool |
| 19 | } | 19 | } |
| 20 | 20 | ||
| 21 | type Record struct { | 21 | type Record struct { |
| @@ -64,6 +64,12 @@ func (s *Store) SeedPath(vmID string) string { return filepath.Join(s.VMDir(vmID | |||
| 64 | // SocketPath returns the path of the VM's cloud-hypervisor API socket. | 64 | // SocketPath returns the path of the VM's cloud-hypervisor API socket. |
| 65 | func (s *Store) SocketPath(vmID string) string { return filepath.Join(s.VMDir(vmID), "ch.sock") } | 65 | func (s *Store) SocketPath(vmID string) string { return filepath.Join(s.VMDir(vmID), "ch.sock") } |
| 66 | 66 | ||
| 67 | // SerialSocketPath returns the path of the VM's cloud-hypervisor serial | ||
| 68 | // console socket (--serial socket=…). The serialpump dials it. | ||
| 69 | func (s *Store) SerialSocketPath(vmID string) string { | ||
| 70 | return filepath.Join(s.VMDir(vmID), "serial.sock") | ||
| 71 | } | ||
| 72 | |||
| 67 | // TapName returns the TAP device name for a given vmID. The name is truncated | 73 | // TapName returns the TAP device name for a given vmID. The name is truncated |
| 68 | // to the first 8 characters of the vmID, giving "eit-XXXXXXXX" (12 chars), | 74 | // to the first 8 characters of the vmID, giving "eit-XXXXXXXX" (12 chars), |
| 69 | // which is safely below the 15-char IFNAMSIZ limit. | 75 | // which is safely below the 15-char IFNAMSIZ limit. |
| @@ -135,6 +141,21 @@ func (s *Store) LoadVMs() (map[string]Record, error) { | |||
| 135 | return out, nil | 141 | return out, nil |
| 136 | } | 142 | } |
| 137 | 143 | ||
| 144 | // Get loads a single VM's record by id, returning ok=false when this host has | ||
| 145 | // no such record (unknown VM / not running here). Like LoadVMs, an unreadable | ||
| 146 | // or unparseable record reads as absent. | ||
| 147 | func (s *Store) Get(vmID string) (Record, bool) { | ||
| 148 | raw, err := os.ReadFile(filepath.Join(s.VMDir(vmID), "record.json")) | ||
| 149 | if err != nil { | ||
| 150 | return Record{}, false | ||
| 151 | } | ||
| 152 | var rec Record | ||
| 153 | if err := json.Unmarshal(raw, &rec); err != nil { | ||
| 154 | return Record{}, false | ||
| 155 | } | ||
| 156 | return rec, true | ||
| 157 | } | ||
| 158 | |||
| 138 | // DeleteVM removes the entire VM directory (record + disk + seed + socket). | 159 | // DeleteVM removes the entire VM directory (record + disk + seed + socket). |
| 139 | func (s *Store) DeleteVM(vmID string) error { | 160 | func (s *Store) DeleteVM(vmID string) error { |
| 140 | return os.RemoveAll(s.VMDir(vmID)) | 161 | return os.RemoveAll(s.VMDir(vmID)) |
internal/agent/state/state_test.go
| Old | New | ||
|---|---|---|---|
| @@ -2,6 +2,7 @@ package state | |||
| 2 | 2 | ||
| 3 | import ( | 3 | import ( |
| 4 | "os" | 4 | "os" |
| 5 | "path/filepath" | ||
| 5 | "testing" | 6 | "testing" |
| 6 | "time" | 7 | "time" |
| 7 | 8 | ||
| @@ -55,6 +56,12 @@ func TestDeleteVMRemovesRecordAndDir(t *testing.T) { | |||
| 55 | assert.True(t, os.IsNotExist(err)) | 56 | assert.True(t, os.IsNotExist(err)) |
| 56 | } | 57 | } |
| 57 | 58 | ||
| 59 | func TestSerialSocketPath(t *testing.T) { | ||
| 60 | s := open(t) | ||
| 61 | p := s.SerialSocketPath("vm1") | ||
| 62 | assert.Equal(t, filepath.Join(s.VMDir("vm1"), "serial.sock"), p) | ||
| 63 | } | ||
| 64 | |||
| 58 | func TestDiskExistsReflectsDiskFile(t *testing.T) { | 65 | func TestDiskExistsReflectsDiskFile(t *testing.T) { |
| 59 | s := open(t) | 66 | s := open(t) |
| 60 | require.NoError(t, s.SaveVM(Record{Spec: VMSpec{VMID: "vm1"}})) | 67 | require.NoError(t, s.SaveVM(Record{Spec: VMSpec{VMID: "vm1"}})) |
internal/agent/syncclient/capacity_test.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,45 @@ | |||
| 1 | package syncclient | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "testing" | ||
| 5 | |||
| 6 | "github.com/a73x/eitri/internal/pb" | ||
| 7 | "github.com/stretchr/testify/assert" | ||
| 8 | ) | ||
| 9 | |||
| 10 | func TestClampCapacityUnsetIsPassthrough(t *testing.T) { | ||
| 11 | // caps unset (0) must reproduce today's behaviour exactly: advertise the | ||
| 12 | // raw machine totals. | ||
| 13 | raw := &pb.Capacity{Vcpus: 16, MemMb: 32000, DiskGb: 500} | ||
| 14 | got := clampCapacity(raw, 0, 0, 0) | ||
| 15 | assert.Equal(t, int64(16), got.GetVcpus()) | ||
| 16 | assert.Equal(t, int64(32000), got.GetMemMb()) | ||
| 17 | assert.Equal(t, int64(500), got.GetDiskGb()) | ||
| 18 | } | ||
| 19 | |||
| 20 | func TestClampCapacityReducesToCap(t *testing.T) { | ||
| 21 | raw := &pb.Capacity{Vcpus: 16, MemMb: 32000, DiskGb: 500} | ||
| 22 | got := clampCapacity(raw, 8, 16000, 200) | ||
| 23 | assert.Equal(t, int64(8), got.GetVcpus()) | ||
| 24 | assert.Equal(t, int64(16000), got.GetMemMb()) | ||
| 25 | assert.Equal(t, int64(200), got.GetDiskGb()) | ||
| 26 | } | ||
| 27 | |||
| 28 | func TestClampCapacityNeverInflates(t *testing.T) { | ||
| 29 | // A cap above the machine's real total is a no-op — an agent cannot | ||
| 30 | // advertise more than it has. | ||
| 31 | raw := &pb.Capacity{Vcpus: 4, MemMb: 8000, DiskGb: 100} | ||
| 32 | got := clampCapacity(raw, 64, 128000, 2000) | ||
| 33 | assert.Equal(t, int64(4), got.GetVcpus()) | ||
| 34 | assert.Equal(t, int64(8000), got.GetMemMb()) | ||
| 35 | assert.Equal(t, int64(100), got.GetDiskGb()) | ||
| 36 | } | ||
| 37 | |||
| 38 | func TestClampCapacityDimensionsIndependent(t *testing.T) { | ||
| 39 | // Capping memory must not touch vcpus or disk. | ||
| 40 | raw := &pb.Capacity{Vcpus: 16, MemMb: 32000, DiskGb: 500} | ||
| 41 | got := clampCapacity(raw, 0, 16000, 0) | ||
| 42 | assert.Equal(t, int64(16), got.GetVcpus()) | ||
| 43 | assert.Equal(t, int64(16000), got.GetMemMb()) | ||
| 44 | assert.Equal(t, int64(500), got.GetDiskGb()) | ||
| 45 | } | ||
internal/agent/syncclient/client.go
| Old | New | ||
|---|---|---|---|
| @@ -5,7 +5,9 @@ package syncclient | |||
| 5 | import ( | 5 | import ( |
| 6 | "context" | 6 | "context" |
| 7 | "errors" | 7 | "errors" |
| 8 | "io" | ||
| 8 | "log/slog" | 9 | "log/slog" |
| 10 | "net" | ||
| 9 | "os" | 11 | "os" |
| 10 | "runtime" | 12 | "runtime" |
| 11 | "strings" | 13 | "strings" |
| @@ -53,6 +55,18 @@ func capacity(stateDir string) *pb.Capacity { | |||
| 53 | } | 55 | } |
| 54 | } | 56 | } |
| 55 | 57 | ||
| 58 | // Console bridges server-opened console streams to a VM's serial pump | ||
| 59 | // (consumer-owned; the concrete implementation is *serialpump.Manager). | ||
| 60 | // onReady fires after validation and before any bytes — the accept loop uses | ||
| 61 | // it to send the ConsoleOpened ok-frame, so a refusal (unknown VM) can still | ||
| 62 | // be reported as ok=false. onReady MUST be invoked synchronously — before | ||
| 63 | // Attach returns, on the caller's goroutine — because the accept loop's | ||
| 64 | // sentReady bookkeeping depends on that ordering. nil Console refuses every | ||
| 65 | // console request. | ||
| 66 | type Console interface { | ||
| 67 | Attach(ctx context.Context, vmID string, rw io.ReadWriter, onReady func() error) error | ||
| 68 | } | ||
| 69 | |||
| 56 | // Client manages the agent's QUIC sync session. | 70 | // Client manages the agent's QUIC sync session. |
| 57 | type Client struct { | 71 | type Client struct { |
| 58 | Engine *reconcile.Engine | 72 | Engine *reconcile.Engine |
| @@ -60,6 +74,15 @@ type Client struct { | |||
| 60 | Identity state.Identity | 74 | Identity state.Identity |
| 61 | StateDir string | 75 | StateDir string |
| 62 | 76 | ||
| 77 | // Console handles server-opened console streams (nil refuses them all). | ||
| 78 | Console Console | ||
| 79 | |||
| 80 | // dialGuest connects to a VM's ssh port; overridable in tests. nil uses the | ||
| 81 | // production dialer, which pins the guest port to 22 — the wire port is | ||
| 82 | // validated but never dialed, so a compromised control plane cannot redirect | ||
| 83 | // the tunnel to an arbitrary port. | ||
| 84 | dialGuest func(ip string) (net.Conn, error) | ||
| 85 | |||
| 63 | // TickInterval is the period of the fallback ticker that drives a reconcile | 86 | // TickInterval is the period of the fallback ticker that drives a reconcile |
| 64 | // step even when no new snapshot has arrived (e.g. for periodic health | 87 | // step even when no new snapshot has arrived (e.g. for periodic health |
| 65 | // reports). Zero uses the default of 10 seconds. | 88 | // reports). Zero uses the default of 10 seconds. |
| @@ -68,6 +91,38 @@ type Client struct { | |||
| 68 | // ReconnectBackoff is the sleep between a transient session failure and the | 91 | // ReconnectBackoff is the sleep between a transient session failure and the |
| 69 | // next dial attempt. Zero uses the production default of 5 seconds. | 92 | // next dial attempt. Zero uses the production default of 5 seconds. |
| 70 | ReconnectBackoff time.Duration | 93 | ReconnectBackoff time.Duration |
| 94 | |||
| 95 | // MaxVCPUs, MaxMemMB, MaxDiskGB cap the capacity this agent advertises to | ||
| 96 | // the fleet (0 = unlimited). They let an operator reserve host headroom | ||
| 97 | // rather than donating the whole machine. Enforcement of the same caps at | ||
| 98 | // VM-boot time lives on reconcile.Engine; this is the advertised half. | ||
| 99 | MaxVCPUs int64 | ||
| 100 | MaxMemMB int64 | ||
| 101 | MaxDiskGB int64 | ||
| 102 | } | ||
| 103 | |||
| 104 | // clampCapacity reduces each advertised dimension to its configured cap | ||
| 105 | // (0 = unlimited). It never inflates: a cap above the real total is a no-op, so | ||
| 106 | // an agent cannot advertise more than the machine actually has. | ||
| 107 | func clampCapacity(cap *pb.Capacity, maxVCPUs, maxMemMB, maxDiskGB int64) *pb.Capacity { | ||
| 108 | return &pb.Capacity{ | ||
| 109 | Vcpus: clampDim(cap.GetVcpus(), maxVCPUs), | ||
| 110 | MemMb: clampDim(cap.GetMemMb(), maxMemMB), | ||
| 111 | DiskGb: clampDim(cap.GetDiskGb(), maxDiskGB), | ||
| 112 | } | ||
| 113 | } | ||
| 114 | |||
| 115 | func clampDim(actual, cap int64) int64 { | ||
| 116 | if cap > 0 && cap < actual { | ||
| 117 | return cap | ||
| 118 | } | ||
| 119 | return actual | ||
| 120 | } | ||
| 121 | |||
| 122 | // advertisedCapacity is the machine's real capacity clamped to this agent's | ||
| 123 | // configured caps — what the agent reports to the server. | ||
| 124 | func (c *Client) advertisedCapacity(stateDir string) *pb.Capacity { | ||
| 125 | return clampCapacity(capacity(stateDir), c.MaxVCPUs, c.MaxMemMB, c.MaxDiskGB) | ||
| 71 | } | 126 | } |
| 72 | 127 | ||
| 73 | // errPermanentAuth marks a credential rejection so Run() backs off long instead | 128 | // errPermanentAuth marks a credential rejection so Run() backs off long instead |
| @@ -164,7 +219,7 @@ func (c *Client) session(ctx context.Context) error { | |||
| 164 | hello := &pb.AgentMessage{Msg: &pb.AgentMessage_Hello{Hello: &pb.Hello{ | 219 | hello := &pb.AgentMessage{Msg: &pb.AgentMessage_Hello{Hello: &pb.Hello{ |
| 165 | HostId: c.Identity.HostID, Hostname: hostname, Os: runtime.GOOS, Arch: runtime.GOARCH, | 220 | HostId: c.Identity.HostID, Hostname: hostname, Os: runtime.GOOS, Arch: runtime.GOARCH, |
| 166 | Provisioner: "cloudhv", BridgeCidr: c.Identity.BridgeCIDR, | 221 | Provisioner: "cloudhv", BridgeCidr: c.Identity.BridgeCIDR, |
| 167 | LastSeenEpoch: c.St.Epoch(), Capacity: capacity(stateDir), | 222 | LastSeenEpoch: c.St.Epoch(), Capacity: c.advertisedCapacity(stateDir), |
| 168 | Credential: c.Identity.Credential, | 223 | Credential: c.Identity.Credential, |
| 169 | }}} | 224 | }}} |
| 170 | if err := transport.WriteMsg(up, hello); err != nil { | 225 | if err := transport.WriteMsg(up, hello); err != nil { |
| @@ -179,6 +234,19 @@ func (c *Client) session(ctx context.Context) error { | |||
| 179 | return classifyErr(err) | 234 | return classifyErr(err) |
| 180 | } | 235 | } |
| 181 | 236 | ||
| 237 | // Console streams: every server-initiated stream after the snapshot | ||
| 238 | // down-stream is a console request. The loop dies with the connection, | ||
| 239 | // which also ends every console session on it — the browser reconnects. | ||
| 240 | go func() { | ||
| 241 | for { | ||
| 242 | cs, err := conn.AcceptStream(ctx) | ||
| 243 | if err != nil { | ||
| 244 | return | ||
| 245 | } | ||
| 246 | go c.handleConsoleStream(ctx, cs) | ||
| 247 | } | ||
| 248 | }() | ||
| 249 | |||
| 182 | var mu sync.Mutex | 250 | var mu sync.Mutex |
| 183 | var latest *pb.DesiredStateSnapshot | 251 | var latest *pb.DesiredStateSnapshot |
| 184 | // connectedOnce is set true (under mu) once the recv goroutine reads its | 252 | // connectedOnce is set true (under mu) once the recv goroutine reads its |
| @@ -196,7 +264,7 @@ func (c *Client) session(ctx context.Context) error { | |||
| 196 | return nil | 264 | return nil |
| 197 | } | 265 | } |
| 198 | rep := c.Engine.Step(ctx, snap) | 266 | rep := c.Engine.Step(ctx, snap) |
| 199 | rep.Capacity = capacity(stateDir) | 267 | rep.Capacity = c.advertisedCapacity(stateDir) |
| 200 | return transport.WriteMsg(up, &pb.AgentMessage{Msg: &pb.AgentMessage_Report{Report: rep}}) | 268 | return transport.WriteMsg(up, &pb.AgentMessage{Msg: &pb.AgentMessage_Report{Report: rep}}) |
| 201 | } | 269 | } |
| 202 | 270 | ||
| @@ -260,6 +328,142 @@ func (c *Client) session(ctx context.Context) error { | |||
| 260 | return sessErr | 328 | return sessErr |
| 261 | } | 329 | } |
| 262 | 330 | ||
| 331 | // handleConsoleStream services one server-opened console stream: read the | ||
| 332 | // ConsoleOpen header, reply ConsoleOpened (ok=false with a reason when the VM | ||
| 333 | // has no pump or no Console is wired), then hand the raw stream to the pump. | ||
| 334 | func (c *Client) handleConsoleStream(ctx context.Context, s quic.Stream) { | ||
| 335 | defer func() { | ||
| 336 | s.CancelRead(0) | ||
| 337 | _ = s.Close() | ||
| 338 | }() | ||
| 339 | // Defense-in-depth: bound the header read so a stream that never delivers | ||
| 340 | // its first frame can't pin this goroutine forever. Only a server bug can | ||
| 341 | // trip it — the server writes ConsoleOpen immediately or closes — so 10s | ||
| 342 | // (mirroring the server's consoleHandshakeTimeout) is generous. | ||
| 343 | _ = s.SetReadDeadline(time.Now().Add(10 * time.Second)) | ||
| 344 | var first pb.ServerMessage | ||
| 345 | if err := transport.ReadMsg(s, &first, transport.DefaultMaxFrame); err != nil { | ||
| 346 | return | ||
| 347 | } | ||
| 348 | _ = s.SetReadDeadline(time.Time{}) | ||
| 349 | if tcp := first.GetTcpOpen(); tcp != nil { | ||
| 350 | // Raw TCP tunnel (e.g. the SSH jump gate), not a console. tunnelStream's | ||
| 351 | // Close tears down both directions so either copy goroutine can unblock | ||
| 352 | // the other; the deferred Close above is then a harmless second close. | ||
| 353 | c.handleTCPStream(ctx, tunnelStream{s}, tcp.GetVmId(), tcp.GetPort()) | ||
| 354 | return | ||
| 355 | } | ||
| 356 | open := first.GetConsoleOpen() | ||
| 357 | if open == nil { | ||
| 358 | return // not a console stream; drop it | ||
| 359 | } | ||
| 360 | refuse := func(msg string) { | ||
| 361 | _ = transport.WriteMsg(s, &pb.AgentMessage{Msg: &pb.AgentMessage_ConsoleOpened{ | ||
| 362 | ConsoleOpened: &pb.ConsoleOpened{Ok: false, Error: msg}}}) | ||
| 363 | } | ||
| 364 | if c.Console == nil { | ||
| 365 | refuse("console not supported by this agent") | ||
| 366 | return | ||
| 367 | } | ||
| 368 | sentReady := false | ||
| 369 | err := c.Console.Attach(ctx, open.GetVmId(), s, func() error { | ||
| 370 | if err := transport.WriteMsg(s, &pb.AgentMessage{Msg: &pb.AgentMessage_ConsoleOpened{ | ||
| 371 | ConsoleOpened: &pb.ConsoleOpened{Ok: true}}}); err != nil { | ||
| 372 | return err | ||
| 373 | } | ||
| 374 | sentReady = true | ||
| 375 | return nil | ||
| 376 | }) | ||
| 377 | if err != nil { | ||
| 378 | if !sentReady { | ||
| 379 | // Validation failed before the ok-frame: an ok=false reply is | ||
| 380 | // still legal wire protocol. | ||
| 381 | refuse(err.Error()) | ||
| 382 | } | ||
| 383 | // After sentReady the stream is RAW bytes: writing a ConsoleOpened | ||
| 384 | // frame here would inject protobuf garbage into a LIVE console (the | ||
| 385 | // peer is alive on a slow-viewer drop or VM-deleted-mid-session) — | ||
| 386 | // late errors just close the stream (the deferred Close above). | ||
| 387 | slog.Debug("console session ended", "vm", open.GetVmId(), "err", err) | ||
| 388 | } | ||
| 389 | } | ||
| 390 | |||
| 391 | // tunnelStream adapts a quic.Stream so Close tears down BOTH directions | ||
| 392 | // (Stream.Close closes only the write half). handleTCPStream needs a closing a | ||
| 393 | // blocked Read: cancelling the read half is how one copy goroutine unblocks the | ||
| 394 | // other when the peer TCP conn dies. | ||
| 395 | type tunnelStream struct{ quic.Stream } | ||
| 396 | |||
| 397 | func (t tunnelStream) Close() error { | ||
| 398 | t.CancelRead(0) | ||
| 399 | return t.Stream.Close() | ||
| 400 | } | ||
| 401 | |||
| 402 | // handleTCPStream services a server-opened raw TCP tunnel: it validates the | ||
| 403 | // request against agent state, replies TCPOpened, then splices the stream to a | ||
| 404 | // fresh dial of the VM's guest ssh port. It deliberately does NOT go through | ||
| 405 | // serialpump — the pump's replay ring and slow-consumer drop would corrupt an | ||
| 406 | // SSH byte stream. On any refusal it sends ok=false and returns. | ||
| 407 | func (c *Client) handleTCPStream(ctx context.Context, stream io.ReadWriteCloser, vmID string, port uint32) { | ||
| 408 | refuse := func(msg string) { | ||
| 409 | _ = transport.WriteMsg(stream, &pb.AgentMessage{Msg: &pb.AgentMessage_TcpOpened{ | ||
| 410 | TcpOpened: &pb.TCPOpened{Ok: false, Error: msg}}}) | ||
| 411 | } | ||
| 412 | rec, ok := c.St.Get(vmID) | ||
| 413 | if !ok { | ||
| 414 | refuse("vm not on this host") | ||
| 415 | return | ||
| 416 | } | ||
| 417 | if rec.IP == "" { | ||
| 418 | // An empty IP would make JoinHostPort produce ":22", so net.Dial would | ||
| 419 | // hit the AGENT host's own sshd — refuse instead of tunnelling to self. | ||
| 420 | refuse("vm has no address") | ||
| 421 | return | ||
| 422 | } | ||
| 423 | if port != 22 { | ||
| 424 | // Pin 22; the wire port is validated but never trusted for the dial. | ||
| 425 | refuse("port not allowed") | ||
| 426 | return | ||
| 427 | } | ||
| 428 | conn, err := c.dial(rec.IP) | ||
| 429 | if err != nil { | ||
| 430 | refuse(err.Error()) | ||
| 431 | return | ||
| 432 | } | ||
| 433 | defer conn.Close() | ||
| 434 | if err := transport.WriteMsg(stream, &pb.AgentMessage{Msg: &pb.AgentMessage_TcpOpened{ | ||
| 435 | TcpOpened: &pb.TCPOpened{Ok: true}}}); err != nil { | ||
| 436 | return | ||
| 437 | } | ||
| 438 | |||
| 439 | // Fresh dual splice: each direction, on EOF/error, closes both ends so the | ||
| 440 | // opposite goroutine unblocks and exits. Both ends are closed on return. | ||
| 441 | done := make(chan struct{}, 2) | ||
| 442 | go func() { | ||
| 443 | _, _ = io.Copy(conn, stream) | ||
| 444 | conn.Close() | ||
| 445 | stream.Close() | ||
| 446 | done <- struct{}{} | ||
| 447 | }() | ||
| 448 | go func() { | ||
| 449 | _, _ = io.Copy(stream, conn) | ||
| 450 | conn.Close() | ||
| 451 | stream.Close() | ||
| 452 | done <- struct{}{} | ||
| 453 | }() | ||
| 454 | <-done | ||
| 455 | <-done | ||
| 456 | } | ||
| 457 | |||
| 458 | // dial connects to a VM's ssh port. Production pins port 22; tests override | ||
| 459 | // via c.dialGuest. | ||
| 460 | func (c *Client) dial(ip string) (net.Conn, error) { | ||
| 461 | if c.dialGuest != nil { | ||
| 462 | return c.dialGuest(ip) | ||
| 463 | } | ||
| 464 | return net.Dial("tcp", net.JoinHostPort(ip, "22")) | ||
| 465 | } | ||
| 466 | |||
| 263 | // classifyErr distinguishes a permanent auth rejection from a transient error so | 467 | // classifyErr distinguishes a permanent auth rejection from a transient error so |
| 264 | // Run() can log loudly and avoid a tight reconnect loop on a dead credential. | 468 | // Run() can log loudly and avoid a tight reconnect loop on a dead credential. |
| 265 | func classifyErr(err error) error { | 469 | func classifyErr(err error) error { |
internal/agent/syncclient/client_test.go
| Old | New | ||
|---|---|---|---|
| @@ -1,7 +1,10 @@ | |||
| 1 | package syncclient | 1 | package syncclient |
| 2 | 2 | ||
| 3 | import ( | 3 | import ( |
| 4 | "bytes" | ||
| 4 | "context" | 5 | "context" |
| 6 | "fmt" | ||
| 7 | "io" | ||
| 5 | "sync/atomic" | 8 | "sync/atomic" |
| 6 | "testing" | 9 | "testing" |
| 7 | "time" | 10 | "time" |
| @@ -16,6 +19,7 @@ import ( | |||
| 16 | "github.com/a73x/eitri/internal/server/syncsvc" | 19 | "github.com/a73x/eitri/internal/server/syncsvc" |
| 17 | "github.com/a73x/eitri/internal/transport" | 20 | "github.com/a73x/eitri/internal/transport" |
| 18 | "github.com/quic-go/quic-go" | 21 | "github.com/quic-go/quic-go" |
| 22 | "github.com/stretchr/testify/assert" | ||
| 19 | "github.com/stretchr/testify/require" | 23 | "github.com/stretchr/testify/require" |
| 20 | ) | 24 | ) |
| 21 | 25 | ||
| @@ -52,6 +56,7 @@ type serverHarness struct { | |||
| 52 | addr string | 56 | addr string |
| 53 | cancel context.CancelFunc | 57 | cancel context.CancelFunc |
| 54 | lis *quic.Listener | 58 | lis *quic.Listener |
| 59 | svc *syncsvc.Service | ||
| 55 | } | 60 | } |
| 56 | 61 | ||
| 57 | func newServerHarness(t *testing.T) *serverHarness { | 62 | func newServerHarness(t *testing.T) *serverHarness { |
| @@ -81,8 +86,8 @@ func (h *serverHarness) start(addr string) { | |||
| 81 | h.addr = lis.Addr().String() | 86 | h.addr = lis.Addr().String() |
| 82 | ctx, cancel := context.WithCancel(context.Background()) | 87 | ctx, cancel := context.WithCancel(context.Background()) |
| 83 | h.cancel = cancel | 88 | h.cancel = cancel |
| 84 | svc := syncsvc.New(h.st, h.reg, h.hub, h.secret, 0) | 89 | h.svc = syncsvc.New(h.st, h.reg, h.hub, h.secret, 0) |
| 85 | go svc.Serve(ctx, lis) //nolint:errcheck | 90 | go h.svc.Serve(ctx, lis) //nolint:errcheck |
| 86 | } | 91 | } |
| 87 | 92 | ||
| 88 | func (h *serverHarness) stop() { | 93 | func (h *serverHarness) stop() { |
| @@ -97,7 +102,7 @@ func (h *serverHarness) stop() { | |||
| 97 | // enroll creates a host and returns a valid credential for it. | 102 | // enroll creates a host and returns a valid credential for it. |
| 98 | func (h *serverHarness) enroll() (hostID, cred string) { | 103 | func (h *serverHarness) enroll() (hostID, cred string) { |
| 99 | tok, _ := h.st.CreateEnrollmentToken() | 104 | tok, _ := h.st.CreateEnrollmentToken() |
| 100 | host, err := h.st.RedeemEnrollmentToken(tok, "h", "linux", "amd64", "cloudhv", "", "") | 105 | host, err := h.st.RedeemEnrollmentToken(tok, "h", "linux", "amd64", "cloudhv", "") |
| 101 | require.NoError(h.t, err) | 106 | require.NoError(h.t, err) |
| 102 | return host.ID, hosttoken.Mint(h.secret, host.ID, host.CredGeneration, time.Now()) | 107 | return host.ID, hosttoken.Mint(h.secret, host.ID, host.CredGeneration, time.Now()) |
| 103 | } | 108 | } |
| @@ -190,6 +195,89 @@ func TestAuthRejectedClassified(t *testing.T) { | |||
| 190 | "Run must not tight-loop on a permanently rejected credential") | 195 | "Run must not tight-loop on a permanently rejected credential") |
| 191 | } | 196 | } |
| 192 | 197 | ||
| 198 | // echoConsole implements the Console interface: replays a fixed backlog, then | ||
| 199 | // echoes input back uppercased — enough to prove both directions + ordering. | ||
| 200 | type echoConsole struct{ backlog string } | ||
| 201 | |||
| 202 | func (e *echoConsole) Attach(ctx context.Context, vmID string, rw io.ReadWriter, onReady func() error) error { | ||
| 203 | if vmID != "vm-ok" { | ||
| 204 | return fmt.Errorf("unknown vm %q", vmID) | ||
| 205 | } | ||
| 206 | if err := onReady(); err != nil { | ||
| 207 | return err | ||
| 208 | } | ||
| 209 | if _, err := io.WriteString(rw, e.backlog); err != nil { | ||
| 210 | return err | ||
| 211 | } | ||
| 212 | buf := make([]byte, 64) | ||
| 213 | for { | ||
| 214 | n, err := rw.Read(buf) | ||
| 215 | if n > 0 { | ||
| 216 | if _, werr := rw.Write(bytes.ToUpper(buf[:n])); werr != nil { | ||
| 217 | return werr | ||
| 218 | } | ||
| 219 | } | ||
| 220 | if err != nil { | ||
| 221 | return nil //nolint:nilerr // stream closed by peer = clean end | ||
| 222 | } | ||
| 223 | } | ||
| 224 | } | ||
| 225 | |||
| 226 | // startConsoleClient runs a real client (with the given Console handler, which | ||
| 227 | // may be nil) against a fresh harness and blocks until the host is connected — | ||
| 228 | // the precondition for OpenConsole to find a live connection. | ||
| 229 | func startConsoleClient(t *testing.T, console Console) (h *serverHarness, hostID string) { | ||
| 230 | t.Helper() | ||
| 231 | h = newServerHarness(t) | ||
| 232 | hostID, cred := h.enroll() | ||
| 233 | c := newClient(t, h.addr, h.fp, hostID, cred) | ||
| 234 | c.Console = console | ||
| 235 | ctx, cancel := context.WithCancel(context.Background()) | ||
| 236 | t.Cleanup(cancel) | ||
| 237 | go c.Run(ctx) | ||
| 238 | require.Eventually(t, func() bool { | ||
| 239 | _, ok := h.reg.Get(hostID) | ||
| 240 | return ok | ||
| 241 | }, 5*time.Second, 50*time.Millisecond, "client should connect and report") | ||
| 242 | return h, hostID | ||
| 243 | } | ||
| 244 | |||
| 245 | func TestConsoleStreamEndToEnd(t *testing.T) { | ||
| 246 | h, hostID := startConsoleClient(t, &echoConsole{backlog: "BOOT|"}) | ||
| 247 | |||
| 248 | stream, err := h.svc.OpenConsole(context.Background(), hostID, "vm-ok") | ||
| 249 | require.NoError(t, err) | ||
| 250 | defer stream.Close() | ||
| 251 | |||
| 252 | got := make([]byte, 5) | ||
| 253 | _, err = io.ReadFull(stream, got) | ||
| 254 | require.NoError(t, err) | ||
| 255 | assert.Equal(t, "BOOT|", string(got), "backlog replays first") | ||
| 256 | |||
| 257 | _, err = stream.Write([]byte("hi")) | ||
| 258 | require.NoError(t, err) | ||
| 259 | _, err = io.ReadFull(stream, got[:2]) | ||
| 260 | require.NoError(t, err) | ||
| 261 | assert.Equal(t, "HI", string(got[:2]), "input reaches the console and output returns") | ||
| 262 | } | ||
| 263 | |||
| 264 | func TestConsoleRefusedUnknownVM(t *testing.T) { | ||
| 265 | h, hostID := startConsoleClient(t, &echoConsole{backlog: "BOOT|"}) | ||
| 266 | |||
| 267 | // Unknown vm → agent replies ok=false, OpenConsole errors. | ||
| 268 | _, err := h.svc.OpenConsole(context.Background(), hostID, "vm-nope") | ||
| 269 | require.Error(t, err) | ||
| 270 | assert.Contains(t, err.Error(), "console refused") | ||
| 271 | } | ||
| 272 | |||
| 273 | func TestConsoleWithoutHandlerRefused(t *testing.T) { | ||
| 274 | h, hostID := startConsoleClient(t, nil) // client.Console left nil | ||
| 275 | |||
| 276 | _, err := h.svc.OpenConsole(context.Background(), hostID, "vm-ok") | ||
| 277 | require.Error(t, err) | ||
| 278 | assert.Contains(t, err.Error(), "console refused") | ||
| 279 | } | ||
| 280 | |||
| 193 | // newCountingServer is a serverHarness whose accept loop calls onAccept(1) for | 281 | // newCountingServer is a serverHarness whose accept loop calls onAccept(1) for |
| 194 | // every connection, so tests can observe reconnect attempts. | 282 | // every connection, so tests can observe reconnect attempts. |
| 195 | func newCountingServer(t *testing.T, onAccept func(int64) int64) *serverHarness { | 283 | func newCountingServer(t *testing.T, onAccept func(int64) int64) *serverHarness { |
internal/agent/syncclient/tcphandler_test.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,108 @@ | |||
| 1 | package syncclient | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "context" | ||
| 5 | "io" | ||
| 6 | "net" | ||
| 7 | "testing" | ||
| 8 | |||
| 9 | "github.com/a73x/eitri/internal/agent/state" | ||
| 10 | "github.com/a73x/eitri/internal/pb" | ||
| 11 | "github.com/a73x/eitri/internal/transport" | ||
| 12 | "github.com/stretchr/testify/assert" | ||
| 13 | "github.com/stretchr/testify/require" | ||
| 14 | ) | ||
| 15 | |||
| 16 | // tcpStore returns a real state.Store in a temp dir with recs saved — the | ||
| 17 | // smallest fake for the TCPOpen guards, which only read Record.IP by vmID. | ||
| 18 | func tcpStore(t *testing.T, recs ...state.Record) *state.Store { | ||
| 19 | t.Helper() | ||
| 20 | st, err := state.Open(t.TempDir()) | ||
| 21 | require.NoError(t, err) | ||
| 22 | for _, r := range recs { | ||
| 23 | require.NoError(t, st.SaveVM(r)) | ||
| 24 | } | ||
| 25 | return st | ||
| 26 | } | ||
| 27 | |||
| 28 | // readTCPOpened reads one framed AgentMessage from r and returns its TCPOpened. | ||
| 29 | func readTCPOpened(t *testing.T, r io.Reader) *pb.TCPOpened { | ||
| 30 | t.Helper() | ||
| 31 | var msg pb.AgentMessage | ||
| 32 | require.NoError(t, transport.ReadMsg(r, &msg, transport.DefaultMaxFrame)) | ||
| 33 | to := msg.GetTcpOpened() | ||
| 34 | require.NotNil(t, to, "reply must be a TCPOpened frame") | ||
| 35 | return to | ||
| 36 | } | ||
| 37 | |||
| 38 | func TestTCPOpenRefusedUnknownVM(t *testing.T) { | ||
| 39 | c := &Client{St: tcpStore(t)} // no record for vm1 | ||
| 40 | agentEnd, serverEnd := net.Pipe() | ||
| 41 | defer serverEnd.Close() | ||
| 42 | go c.handleTCPStream(context.Background(), agentEnd, "vm1", 22) | ||
| 43 | |||
| 44 | to := readTCPOpened(t, serverEnd) | ||
| 45 | assert.False(t, to.GetOk()) | ||
| 46 | assert.Contains(t, to.GetError(), "vm not on this host") | ||
| 47 | } | ||
| 48 | |||
| 49 | func TestTCPOpenRefusedEmptyIP(t *testing.T) { | ||
| 50 | c := &Client{St: tcpStore(t, state.Record{Spec: state.VMSpec{VMID: "vm1"}, IP: ""})} | ||
| 51 | agentEnd, serverEnd := net.Pipe() | ||
| 52 | defer serverEnd.Close() | ||
| 53 | go c.handleTCPStream(context.Background(), agentEnd, "vm1", 22) | ||
| 54 | |||
| 55 | to := readTCPOpened(t, serverEnd) | ||
| 56 | assert.False(t, to.GetOk()) | ||
| 57 | assert.Contains(t, to.GetError(), "vm has no address") | ||
| 58 | } | ||
| 59 | |||
| 60 | func TestTCPOpenRefusedPortNotAllowed(t *testing.T) { | ||
| 61 | c := &Client{St: tcpStore(t, state.Record{Spec: state.VMSpec{VMID: "vm1"}, IP: "10.0.0.5"})} | ||
| 62 | agentEnd, serverEnd := net.Pipe() | ||
| 63 | defer serverEnd.Close() | ||
| 64 | go c.handleTCPStream(context.Background(), agentEnd, "vm1", 80) | ||
| 65 | |||
| 66 | to := readTCPOpened(t, serverEnd) | ||
| 67 | assert.False(t, to.GetOk()) | ||
| 68 | assert.Contains(t, to.GetError(), "port not allowed") | ||
| 69 | } | ||
| 70 | |||
| 71 | func TestTCPOpenHappyPathRoundTrip(t *testing.T) { | ||
| 72 | // An in-process echo listener stands in for the VM's sshd on :22. | ||
| 73 | ln, err := net.Listen("tcp", "127.0.0.1:0") | ||
| 74 | require.NoError(t, err) | ||
| 75 | defer ln.Close() | ||
| 76 | go func() { | ||
| 77 | conn, err := ln.Accept() | ||
| 78 | if err != nil { | ||
| 79 | return | ||
| 80 | } | ||
| 81 | _, _ = io.Copy(conn, conn) // echo until closed | ||
| 82 | _ = conn.Close() | ||
| 83 | }() | ||
| 84 | |||
| 85 | c := &Client{ | ||
| 86 | St: tcpStore(t, state.Record{Spec: state.VMSpec{VMID: "vm1"}, IP: "10.0.0.5"}), | ||
| 87 | // Production pins :22; the test dialer models that by reaching the | ||
| 88 | // in-process echo listener regardless of the guest IP it is handed. | ||
| 89 | dialGuest: func(ip string) (net.Conn, error) { | ||
| 90 | return net.Dial("tcp", ln.Addr().String()) | ||
| 91 | }, | ||
| 92 | } | ||
| 93 | |||
| 94 | agentEnd, serverEnd := net.Pipe() | ||
| 95 | defer serverEnd.Close() | ||
| 96 | go c.handleTCPStream(context.Background(), agentEnd, "vm1", 22) | ||
| 97 | |||
| 98 | to := readTCPOpened(t, serverEnd) | ||
| 99 | require.True(t, to.GetOk(), "happy path should reply ok=true") | ||
| 100 | |||
| 101 | // Bytes written to the stream come back from the echo listener. | ||
| 102 | want := []byte("ping") | ||
| 103 | go func() { _, _ = serverEnd.Write(want) }() | ||
| 104 | got := make([]byte, len(want)) | ||
| 105 | _, err = io.ReadFull(serverEnd, got) | ||
| 106 | require.NoError(t, err) | ||
| 107 | assert.Equal(t, want, got, "tunnel round-trips bytes to the VM and back") | ||
| 108 | } | ||
internal/cloudinit/cloudinit.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,158 @@ | |||
| 1 | // Package cloudinit merges eitri's structured VM inputs into user-supplied | ||
| 2 | // cloud-init user-data. It is a neutral leaf: the control plane (server/api) | ||
| 3 | // uses it at create time so a bad merge fails fast as a 400, and it holds no | ||
| 4 | // dependency on either plane. | ||
| 5 | // | ||
| 6 | // Today it does one thing — install an SSH public key into whatever user-data | ||
| 7 | // the caller supplied — but it exists as a package because "eitri owns a bit of | ||
| 8 | // your cloud-init" is a real responsibility that deserves one home and real | ||
| 9 | // tests, not a string hack buried in a handler. | ||
| 10 | package cloudinit | ||
| 11 | |||
| 12 | import ( | ||
| 13 | "errors" | ||
| 14 | "fmt" | ||
| 15 | "io" | ||
| 16 | "strings" | ||
| 17 | |||
| 18 | "gopkg.in/yaml.v3" | ||
| 19 | ) | ||
| 20 | |||
| 21 | // ErrNotCloudConfig reports user-data that is not a `#cloud-config` document — | ||
| 22 | // a shell script, a jinja-templated config, MIME multipart, etc. There is no | ||
| 23 | // ssh_authorized_keys to merge into, so the caller must reject rather than | ||
| 24 | // silently drop the key (the exact bug this package removes). | ||
| 25 | var ErrNotCloudConfig = errors.New("user-data is not #cloud-config; cannot merge an ssh key into it") | ||
| 26 | |||
| 27 | // MergeSSHKey returns userData with key added to the top-level | ||
| 28 | // ssh_authorized_keys list — cloud-init's canonical way to add a key to the | ||
| 29 | // image's default user, robust whether or not the doc defines a `users:` block. | ||
| 30 | // (An operator who REPLACES the default user with their own named user should | ||
| 31 | // put the key in that users block themselves.) | ||
| 32 | // | ||
| 33 | // The edit is done IN PLACE on the YAML node tree, so every other value, | ||
| 34 | // comment, and key order in the user's document is preserved byte-for-byte — | ||
| 35 | // only the ssh_authorized_keys sequence is touched (created, or appended to | ||
| 36 | // with de-duplication; a scalar value is normalized to a sequence first). The | ||
| 37 | // `#cloud-config` header is re-emitted. | ||
| 38 | // | ||
| 39 | // Multi-document YAML (a second `---`) is rejected: cloud-init rejects it too, | ||
| 40 | // and accepting-then-silently-dropping the later documents would mask an error | ||
| 41 | // the guest would otherwise raise. | ||
| 42 | // | ||
| 43 | // key must already be validated single-line by the caller. Even so, the key is | ||
| 44 | // added as a YAML scalar node (not string-interpolated), so a value with | ||
| 45 | // YAML-significant characters — or a bypassed multi-line value — is emitted as | ||
| 46 | // a quoted/block scalar and cannot inject structure. | ||
| 47 | func MergeSSHKey(userData, key string) (string, error) { | ||
| 48 | if !isCloudConfig(userData) { | ||
| 49 | return "", ErrNotCloudConfig | ||
| 50 | } | ||
| 51 | |||
| 52 | dec := yaml.NewDecoder(strings.NewReader(userData)) | ||
| 53 | var doc yaml.Node | ||
| 54 | switch err := dec.Decode(&doc); { | ||
| 55 | case errors.Is(err, io.EOF): | ||
| 56 | // A #cloud-config with only comments / no body: synthesize an empty | ||
| 57 | // mapping document to hang the key on. | ||
| 58 | doc = yaml.Node{Kind: yaml.DocumentNode, Content: []*yaml.Node{{Kind: yaml.MappingNode}}} | ||
| 59 | case err != nil: | ||
| 60 | return "", fmt.Errorf("parse cloud-config: %w", err) | ||
| 61 | default: | ||
| 62 | // Reject a second document (cloud-init rejects multi-doc cloud-config). | ||
| 63 | var extra yaml.Node | ||
| 64 | if err := dec.Decode(&extra); err == nil { | ||
| 65 | return "", errors.New("multi-document cloud-config is not supported") | ||
| 66 | } else if !errors.Is(err, io.EOF) { | ||
| 67 | return "", fmt.Errorf("parse cloud-config: %w", err) | ||
| 68 | } | ||
| 69 | } | ||
| 70 | |||
| 71 | if len(doc.Content) == 0 { | ||
| 72 | doc.Content = []*yaml.Node{{Kind: yaml.MappingNode}} | ||
| 73 | } | ||
| 74 | root := doc.Content[0] | ||
| 75 | if root.Kind != yaml.MappingNode { | ||
| 76 | return "", fmt.Errorf("cloud-config root must be a mapping, not %s", kindName(root.Kind)) | ||
| 77 | } | ||
| 78 | if err := appendAuthorizedKey(root, key); err != nil { | ||
| 79 | return "", err | ||
| 80 | } | ||
| 81 | |||
| 82 | out, err := yaml.Marshal(&doc) | ||
| 83 | if err != nil { | ||
| 84 | return "", fmt.Errorf("marshal cloud-config: %w", err) | ||
| 85 | } | ||
| 86 | return "#cloud-config\n" + string(out), nil | ||
| 87 | } | ||
| 88 | |||
| 89 | // appendAuthorizedKey adds key to the mapping's ssh_authorized_keys sequence, | ||
| 90 | // creating the entry if absent, normalizing a scalar to a sequence, and | ||
| 91 | // de-duplicating. Every other node in the mapping is left untouched. | ||
| 92 | func appendAuthorizedKey(root *yaml.Node, key string) error { | ||
| 93 | keyNode := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: key} | ||
| 94 | for i := 0; i+1 < len(root.Content); i += 2 { | ||
| 95 | if root.Content[i].Value != "ssh_authorized_keys" { | ||
| 96 | continue | ||
| 97 | } | ||
| 98 | val := root.Content[i+1] | ||
| 99 | switch val.Kind { | ||
| 100 | case yaml.SequenceNode: | ||
| 101 | for _, e := range val.Content { | ||
| 102 | if e.Value == key { | ||
| 103 | return nil // already present | ||
| 104 | } | ||
| 105 | } | ||
| 106 | val.Content = append(val.Content, keyNode) | ||
| 107 | case yaml.ScalarNode: | ||
| 108 | if val.Value == key { | ||
| 109 | return nil | ||
| 110 | } | ||
| 111 | existing := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: val.Value} | ||
| 112 | *val = yaml.Node{Kind: yaml.SequenceNode, Content: []*yaml.Node{existing, keyNode}} | ||
| 113 | default: | ||
| 114 | return fmt.Errorf("ssh_authorized_keys must be a string or list, not %s", kindName(val.Kind)) | ||
| 115 | } | ||
| 116 | return nil | ||
| 117 | } | ||
| 118 | // Absent: append `ssh_authorized_keys: [key]`. | ||
| 119 | root.Content = append(root.Content, | ||
| 120 | &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "ssh_authorized_keys"}, | ||
| 121 | &yaml.Node{Kind: yaml.SequenceNode, Content: []*yaml.Node{keyNode}}) | ||
| 122 | return nil | ||
| 123 | } | ||
| 124 | |||
| 125 | // isCloudConfig reports whether s is a cloud-config document: its first | ||
| 126 | // non-blank line's first whitespace-delimited token must be exactly | ||
| 127 | // `#cloud-config` (so a trailing comment like `#cloud-config # my vm` still | ||
| 128 | // counts, but `#cloud-config-archive` — a different format — does not). A | ||
| 129 | // `## template: jinja` preamble or any other first line is rejected. | ||
| 130 | func isCloudConfig(s string) bool { | ||
| 131 | return firstMarker(s) == "#cloud-config" | ||
| 132 | } | ||
| 133 | |||
| 134 | // firstMarker returns the first whitespace-delimited token of the first | ||
| 135 | // non-blank line — cloud-init's per-format "magic" marker. | ||
| 136 | func firstMarker(s string) string { | ||
| 137 | if f := strings.Fields(firstNonBlankLine(s)); len(f) > 0 { | ||
| 138 | return f[0] | ||
| 139 | } | ||
| 140 | return "" | ||
| 141 | } | ||
| 142 | |||
| 143 | func kindName(k yaml.Kind) string { | ||
| 144 | switch k { | ||
| 145 | case yaml.DocumentNode: | ||
| 146 | return "document" | ||
| 147 | case yaml.SequenceNode: | ||
| 148 | return "list" | ||
| 149 | case yaml.MappingNode: | ||
| 150 | return "mapping" | ||
| 151 | case yaml.ScalarNode: | ||
| 152 | return "scalar" | ||
| 153 | case yaml.AliasNode: | ||
| 154 | return "alias" | ||
| 155 | default: | ||
| 156 | return "unknown" | ||
| 157 | } | ||
| 158 | } | ||
internal/cloudinit/cloudinit_test.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,141 @@ | |||
| 1 | package cloudinit | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "strings" | ||
| 5 | "testing" | ||
| 6 | |||
| 7 | "github.com/stretchr/testify/assert" | ||
| 8 | "github.com/stretchr/testify/require" | ||
| 9 | "gopkg.in/yaml.v3" | ||
| 10 | ) | ||
| 11 | |||
| 12 | const key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExampleKeyForTests user@host" | ||
| 13 | |||
| 14 | // parse re-parses merged output so assertions test SEMANTICS, not text | ||
| 15 | // formatting (which reflows through the YAML round-trip by design). | ||
| 16 | func parse(t *testing.T, s string) map[string]any { | ||
| 17 | t.Helper() | ||
| 18 | require.True(t, strings.HasPrefix(s, "#cloud-config\n"), "merged output must keep the #cloud-config header, got: %q", s[:min(20, len(s))]) | ||
| 19 | var m map[string]any | ||
| 20 | require.NoError(t, yaml.Unmarshal([]byte(s), &m)) | ||
| 21 | return m | ||
| 22 | } | ||
| 23 | |||
| 24 | func authKeys(t *testing.T, m map[string]any) []string { | ||
| 25 | t.Helper() | ||
| 26 | raw, ok := m["ssh_authorized_keys"] | ||
| 27 | require.True(t, ok, "merged doc must have top-level ssh_authorized_keys") | ||
| 28 | list, ok := raw.([]any) | ||
| 29 | require.True(t, ok, "ssh_authorized_keys must be a list, got %T", raw) | ||
| 30 | out := make([]string, len(list)) | ||
| 31 | for i, v := range list { | ||
| 32 | out[i] = v.(string) | ||
| 33 | } | ||
| 34 | return out | ||
| 35 | } | ||
| 36 | |||
| 37 | func TestMergeAddsKeyWhenAbsent(t *testing.T) { | ||
| 38 | in := "#cloud-config\npackages:\n - htop\n" | ||
| 39 | out, err := MergeSSHKey(in, key) | ||
| 40 | require.NoError(t, err) | ||
| 41 | m := parse(t, out) | ||
| 42 | assert.Equal(t, []string{key}, authKeys(t, m)) | ||
| 43 | // Unrelated content survives. | ||
| 44 | assert.Contains(t, m, "packages") | ||
| 45 | } | ||
| 46 | |||
| 47 | func TestMergeAppendsToExistingList(t *testing.T) { | ||
| 48 | existing := "ssh-rsa AAAAexisting other@host" | ||
| 49 | in := "#cloud-config\nssh_authorized_keys:\n - " + existing + "\n" | ||
| 50 | out, err := MergeSSHKey(in, key) | ||
| 51 | require.NoError(t, err) | ||
| 52 | assert.Equal(t, []string{existing, key}, authKeys(t, parse(t, out))) | ||
| 53 | } | ||
| 54 | |||
| 55 | func TestMergeIsIdempotentDedup(t *testing.T) { | ||
| 56 | in := "#cloud-config\nssh_authorized_keys:\n - " + key + "\n" | ||
| 57 | out, err := MergeSSHKey(in, key) | ||
| 58 | require.NoError(t, err) | ||
| 59 | assert.Equal(t, []string{key}, authKeys(t, parse(t, out)), "an already-present key must not be duplicated") | ||
| 60 | } | ||
| 61 | |||
| 62 | func TestMergeNormalizesScalarKey(t *testing.T) { | ||
| 63 | // cloud-init accepts ssh_authorized_keys as a single scalar; normalize to a | ||
| 64 | // list and append rather than clobbering the user's existing key. | ||
| 65 | existing := "ssh-rsa AAAAexisting other@host" | ||
| 66 | in := "#cloud-config\nssh_authorized_keys: " + existing + "\n" | ||
| 67 | out, err := MergeSSHKey(in, key) | ||
| 68 | require.NoError(t, err) | ||
| 69 | assert.Equal(t, []string{existing, key}, authKeys(t, parse(t, out))) | ||
| 70 | } | ||
| 71 | |||
| 72 | func TestMergeHeaderWithLeadingBlankLines(t *testing.T) { | ||
| 73 | in := "\n\n#cloud-config\nruncmd:\n - echo hi\n" | ||
| 74 | out, err := MergeSSHKey(in, key) | ||
| 75 | require.NoError(t, err) | ||
| 76 | assert.Equal(t, []string{key}, authKeys(t, parse(t, out))) | ||
| 77 | } | ||
| 78 | |||
| 79 | func TestMergeRejectsNonCloudConfig(t *testing.T) { | ||
| 80 | for _, in := range []string{ | ||
| 81 | "#!/bin/bash\necho hi\n", // shell script user-data | ||
| 82 | "## template: jinja\n#cloud-config\n", // jinja-templated (can't safely edit) | ||
| 83 | "just some text", // not user-data at all | ||
| 84 | } { | ||
| 85 | _, err := MergeSSHKey(in, key) | ||
| 86 | assert.Error(t, err, "must refuse to merge into non-cloud-config: %q", in) | ||
| 87 | } | ||
| 88 | } | ||
| 89 | |||
| 90 | func TestMergeRejectsMalformedYAML(t *testing.T) { | ||
| 91 | _, err := MergeSSHKey("#cloud-config\n bad: : : indent\n\t- x\n", key) | ||
| 92 | assert.Error(t, err) | ||
| 93 | } | ||
| 94 | |||
| 95 | func TestMergeRejectsSSHAuthorizedKeysWrongType(t *testing.T) { | ||
| 96 | // A mapping where we expect a scalar/sequence — don't silently drop it. | ||
| 97 | in := "#cloud-config\nssh_authorized_keys:\n nested: value\n" | ||
| 98 | _, err := MergeSSHKey(in, key) | ||
| 99 | assert.Error(t, err) | ||
| 100 | } | ||
| 101 | |||
| 102 | func TestMergePreservesScalarFidelity(t *testing.T) { | ||
| 103 | // The merge must NOT coerce unquoted scalar VALUES elsewhere in the doc: | ||
| 104 | // an in-place node edit preserves them; a map[string]any round-trip mangles | ||
| 105 | // permissions 0644->420, 1.10->1.1, dates, etc. This is the common path. | ||
| 106 | in := "#cloud-config\n" + | ||
| 107 | "write_files:\n" + | ||
| 108 | " - path: /etc/x\n" + | ||
| 109 | " permissions: 0644\n" + | ||
| 110 | "version: 1.10\n" + | ||
| 111 | "stamp: 2020-01-02\n" | ||
| 112 | out, err := MergeSSHKey(in, key) | ||
| 113 | require.NoError(t, err) | ||
| 114 | assert.Contains(t, out, "0644", "octal permissions must survive verbatim, not become 420") | ||
| 115 | assert.Contains(t, out, "1.10", "trailing zero must survive") | ||
| 116 | assert.Contains(t, out, "2020-01-02", "date must not be normalized to RFC3339") | ||
| 117 | // And the key still landed. | ||
| 118 | assert.Contains(t, out, key) | ||
| 119 | } | ||
| 120 | |||
| 121 | func TestMergePreservesComments(t *testing.T) { | ||
| 122 | in := "#cloud-config\n# keep me\npackages:\n - htop # inline\n" | ||
| 123 | out, err := MergeSSHKey(in, key) | ||
| 124 | require.NoError(t, err) | ||
| 125 | assert.Contains(t, out, "# keep me", "top-level comment must survive an in-place edit") | ||
| 126 | } | ||
| 127 | |||
| 128 | func TestMergeRejectsMultiDocYAML(t *testing.T) { | ||
| 129 | // cloud-init rejects multi-document cloud-config; accepting-and-truncating | ||
| 130 | // would mask an error the guest would raise. Reject it too. | ||
| 131 | in := "#cloud-config\npackages: [htop]\n---\nruncmd:\n - echo hi\n" | ||
| 132 | _, err := MergeSSHKey(in, key) | ||
| 133 | assert.Error(t, err) | ||
| 134 | } | ||
| 135 | |||
| 136 | func TestMergeEmptyBodyGetsKey(t *testing.T) { | ||
| 137 | // A bare "#cloud-config" with no body is valid; the merge seeds the key. | ||
| 138 | out, err := MergeSSHKey("#cloud-config\n", key) | ||
| 139 | require.NoError(t, err) | ||
| 140 | assert.Equal(t, []string{key}, authKeys(t, parse(t, out))) | ||
| 141 | } | ||
internal/cloudinit/multipart.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,260 @@ | |||
| 1 | package cloudinit | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "bytes" | ||
| 5 | "errors" | ||
| 6 | "fmt" | ||
| 7 | "io" | ||
| 8 | "mime" | ||
| 9 | "mime/multipart" | ||
| 10 | "net/mail" | ||
| 11 | "net/textproto" | ||
| 12 | "regexp" | ||
| 13 | "strings" | ||
| 14 | ) | ||
| 15 | |||
| 16 | // Format classifies cloud-init user-data. cloud-init picks a handler by the | ||
| 17 | // payload's leading "magic" line (or MIME/gzip framing), so user-data is a | ||
| 18 | // tagged union, not just YAML — detection mirrors cloud-init's own sniff. | ||
| 19 | type Format int | ||
| 20 | |||
| 21 | const ( | ||
| 22 | FormatUnknown Format = iota | ||
| 23 | FormatCloudConfig | ||
| 24 | FormatShellScript | ||
| 25 | FormatBoothook | ||
| 26 | FormatInclude | ||
| 27 | FormatPartHandler | ||
| 28 | FormatMultipart // MIME multipart/mixed archive | ||
| 29 | FormatJinja // ## template: jinja — templated; not safe to edit or wrap blind | ||
| 30 | FormatGzip // gzip-compressed payload | ||
| 31 | ) | ||
| 32 | |||
| 33 | func (f Format) String() string { | ||
| 34 | switch f { | ||
| 35 | case FormatCloudConfig: | ||
| 36 | return "cloud-config" | ||
| 37 | case FormatShellScript: | ||
| 38 | return "shell-script" | ||
| 39 | case FormatBoothook: | ||
| 40 | return "cloud-boothook" | ||
| 41 | case FormatInclude: | ||
| 42 | return "include" | ||
| 43 | case FormatPartHandler: | ||
| 44 | return "part-handler" | ||
| 45 | case FormatMultipart: | ||
| 46 | return "multipart" | ||
| 47 | case FormatJinja: | ||
| 48 | return "jinja-template" | ||
| 49 | case FormatGzip: | ||
| 50 | return "gzip" | ||
| 51 | default: | ||
| 52 | return "unknown" | ||
| 53 | } | ||
| 54 | } | ||
| 55 | |||
| 56 | // headerRe matches an RFC 5322 header field name at the start of a line — | ||
| 57 | // used to tell a MIME archive (starts with headers) from a #-tagged payload. | ||
| 58 | var headerRe = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9-]*:`) | ||
| 59 | |||
| 60 | // DetectFormat classifies user-data the way cloud-init does: gzip magic first, | ||
| 61 | // then the leading non-blank line's marker (its first whitespace-delimited | ||
| 62 | // token, so a trailing comment is tolerated), then — for a payload that leads | ||
| 63 | // with RFC 5322 headers rather than a #-marker — a real MIME header-block parse | ||
| 64 | // (an archive may put MIME-Version before Content-Type). | ||
| 65 | func DetectFormat(userData string) Format { | ||
| 66 | if len(userData) >= 2 && userData[0] == 0x1f && userData[1] == 0x8b { | ||
| 67 | return FormatGzip | ||
| 68 | } | ||
| 69 | first := firstNonBlankLine(userData) | ||
| 70 | if strings.HasPrefix(first, "## template: jinja") { | ||
| 71 | return FormatJinja | ||
| 72 | } | ||
| 73 | switch marker := firstMarker(userData); { | ||
| 74 | case marker == "#cloud-config": | ||
| 75 | return FormatCloudConfig | ||
| 76 | case strings.HasPrefix(marker, "#!"): | ||
| 77 | return FormatShellScript | ||
| 78 | case marker == "#cloud-boothook": | ||
| 79 | return FormatBoothook | ||
| 80 | case strings.HasPrefix(marker, "#include"): // #include and #include-once | ||
| 81 | return FormatInclude | ||
| 82 | case marker == "#part-handler": | ||
| 83 | return FormatPartHandler | ||
| 84 | } | ||
| 85 | if isMIMEMultipart(userData) { | ||
| 86 | return FormatMultipart | ||
| 87 | } | ||
| 88 | return FormatUnknown | ||
| 89 | } | ||
| 90 | |||
| 91 | // isMIMEMultipart reports whether s is a MIME multipart archive by parsing its | ||
| 92 | // header block. It only tries when the first line looks like a header and is | ||
| 93 | // NOT a #-tagged payload — otherwise a cloud-config line such as `packages:` | ||
| 94 | // would masquerade as an RFC 5322 header. | ||
| 95 | func isMIMEMultipart(s string) bool { | ||
| 96 | first := firstNonBlankLine(s) | ||
| 97 | if strings.HasPrefix(first, "#") || !headerRe.MatchString(first) { | ||
| 98 | return false | ||
| 99 | } | ||
| 100 | msg, err := mail.ReadMessage(strings.NewReader(s)) | ||
| 101 | if err != nil { | ||
| 102 | return false | ||
| 103 | } | ||
| 104 | mt, _, err := mime.ParseMediaType(msg.Header.Get("Content-Type")) | ||
| 105 | return err == nil && strings.HasPrefix(mt, "multipart/") | ||
| 106 | } | ||
| 107 | |||
| 108 | // AddSSHKey returns user-data with key installed for the default user, | ||
| 109 | // regardless of the user-data's format, WITHOUT editing formats where an | ||
| 110 | // in-place edit is unsafe: | ||
| 111 | // - cloud-config → merged into ssh_authorized_keys (a clean single document). | ||
| 112 | // - script / boothook / include / part-handler → wrapped in a multipart | ||
| 113 | // archive alongside a small #cloud-config key part; the original payload is | ||
| 114 | // left byte-for-byte intact as its own part. | ||
| 115 | // - multipart → the key part is APPENDED to the existing archive (not nested), | ||
| 116 | // preserving every original part's body AND headers. | ||
| 117 | // - jinja / gzip / unknown → error: we will not blindly edit a templated or | ||
| 118 | // opaque payload, so the caller surfaces a clear failure instead of a | ||
| 119 | // silent no-op. | ||
| 120 | // | ||
| 121 | // key must already be validated single-line (callers do): it is embedded in a | ||
| 122 | // generated #cloud-config, so a newline would produce a garbage key. It is | ||
| 123 | // added as a YAML scalar node, so it cannot inject structure regardless. | ||
| 124 | func AddSSHKey(userData, key string) (string, error) { | ||
| 125 | switch f := DetectFormat(userData); f { | ||
| 126 | case FormatCloudConfig: | ||
| 127 | return MergeSSHKey(userData, key) | ||
| 128 | case FormatShellScript: | ||
| 129 | return wrapMultipart([]part{typedPart("text/x-shellscript", userData), keyPart(key)}) | ||
| 130 | case FormatBoothook: | ||
| 131 | return wrapMultipart([]part{typedPart("text/cloud-boothook", userData), keyPart(key)}) | ||
| 132 | case FormatInclude: | ||
| 133 | return wrapMultipart([]part{typedPart("text/x-include-url", userData), keyPart(key)}) | ||
| 134 | case FormatPartHandler: | ||
| 135 | return wrapMultipart([]part{typedPart("text/part-handler", userData), keyPart(key)}) | ||
| 136 | case FormatMultipart: | ||
| 137 | return appendToMultipart(userData, key) | ||
| 138 | default: | ||
| 139 | return "", fmt.Errorf("cannot add an ssh key to %s user-data; include the key in the user-data itself", f) | ||
| 140 | } | ||
| 141 | } | ||
| 142 | |||
| 143 | // part is one MIME sub-document: its headers and its (already-decoded) body. | ||
| 144 | type part struct { | ||
| 145 | header textproto.MIMEHeader | ||
| 146 | body string | ||
| 147 | } | ||
| 148 | |||
| 149 | // typedPart builds a part with a single Content-Type (+ charset) and | ||
| 150 | // MIME-Version — used for eitri-generated parts and simple wraps. | ||
| 151 | func typedPart(contentType, body string) part { | ||
| 152 | h := textproto.MIMEHeader{} | ||
| 153 | h.Set("Content-Type", contentType+`; charset="utf-8"`) | ||
| 154 | h.Set("MIME-Version", "1.0") | ||
| 155 | return part{h, body} | ||
| 156 | } | ||
| 157 | |||
| 158 | // keyPart is the generated #cloud-config carrying just the ssh key, built via | ||
| 159 | // MergeSSHKey so the key lands in a valid ssh_authorized_keys list. | ||
| 160 | func keyPart(key string) part { | ||
| 161 | doc, _ := MergeSSHKey("#cloud-config\n", key) // cannot fail on a literal #cloud-config | ||
| 162 | return typedPart("text/cloud-config", doc) | ||
| 163 | } | ||
| 164 | |||
| 165 | // wrapMultipart serializes parts into a cloud-init multipart/mixed archive: | ||
| 166 | // a top-level Content-Type/MIME-Version header block, then each part with its | ||
| 167 | // own headers. cloud-init runs every part by its type and merges the | ||
| 168 | // cloud-config ones, so eitri's key part composes with the user's payload | ||
| 169 | // without eitri ever editing that payload. | ||
| 170 | func wrapMultipart(parts []part) (string, error) { | ||
| 171 | var body bytes.Buffer | ||
| 172 | mw := multipart.NewWriter(&body) | ||
| 173 | for _, p := range parts { | ||
| 174 | h := p.header | ||
| 175 | if h == nil { | ||
| 176 | h = textproto.MIMEHeader{} | ||
| 177 | } | ||
| 178 | if h.Get("Content-Type") == "" { | ||
| 179 | h.Set("Content-Type", "text/plain") | ||
| 180 | } | ||
| 181 | if h.Get("MIME-Version") == "" { | ||
| 182 | h.Set("MIME-Version", "1.0") | ||
| 183 | } | ||
| 184 | pw, err := mw.CreatePart(h) | ||
| 185 | if err != nil { | ||
| 186 | return "", fmt.Errorf("multipart part: %w", err) | ||
| 187 | } | ||
| 188 | if _, err := pw.Write([]byte(p.body)); err != nil { | ||
| 189 | return "", fmt.Errorf("multipart write: %w", err) | ||
| 190 | } | ||
| 191 | } | ||
| 192 | if err := mw.Close(); err != nil { | ||
| 193 | return "", fmt.Errorf("multipart close: %w", err) | ||
| 194 | } | ||
| 195 | return "Content-Type: multipart/mixed; boundary=\"" + mw.Boundary() + "\"\n" + | ||
| 196 | "MIME-Version: 1.0\n\n" + body.String(), nil | ||
| 197 | } | ||
| 198 | |||
| 199 | // appendToMultipart parses an existing multipart archive, keeps every original | ||
| 200 | // part's body AND headers (cloud-init uses part headers like | ||
| 201 | // Content-Disposition/filename to name and order scripts, and Merge-Type to | ||
| 202 | // control cloud-config merging), and re-emits with the key part appended — | ||
| 203 | // deliberately NOT nesting the user's archive inside a new one. | ||
| 204 | func appendToMultipart(userData, key string) (string, error) { | ||
| 205 | msg, err := mail.ReadMessage(strings.NewReader(userData)) | ||
| 206 | if err != nil { | ||
| 207 | return "", fmt.Errorf("parse multipart headers: %w", err) | ||
| 208 | } | ||
| 209 | mediaType, params, err := mime.ParseMediaType(msg.Header.Get("Content-Type")) | ||
| 210 | if err != nil || !strings.HasPrefix(mediaType, "multipart/") { | ||
| 211 | return "", fmt.Errorf("not a multipart archive: %v", err) | ||
| 212 | } | ||
| 213 | boundary, ok := params["boundary"] | ||
| 214 | if !ok { | ||
| 215 | return "", errors.New("multipart archive missing boundary") | ||
| 216 | } | ||
| 217 | mr := multipart.NewReader(msg.Body, boundary) | ||
| 218 | var parts []part | ||
| 219 | for { | ||
| 220 | p, err := mr.NextPart() | ||
| 221 | if errors.Is(err, io.EOF) { | ||
| 222 | break // normal end of archive | ||
| 223 | } | ||
| 224 | if err != nil { | ||
| 225 | return "", fmt.Errorf("read multipart part: %w", err) | ||
| 226 | } | ||
| 227 | buf, err := io.ReadAll(p) | ||
| 228 | if err != nil { | ||
| 229 | return "", fmt.Errorf("read multipart part body: %w", err) | ||
| 230 | } | ||
| 231 | h := textproto.MIMEHeader{} | ||
| 232 | for k, vs := range p.Header { | ||
| 233 | // multipart.Reader has already decoded any transfer encoding, so | ||
| 234 | // re-emitting Content-Transfer-Encoding would mislabel the decoded | ||
| 235 | // body; every other header (Content-Disposition, Merge-Type, …) is | ||
| 236 | // preserved. | ||
| 237 | if strings.EqualFold(k, "Content-Transfer-Encoding") { | ||
| 238 | continue | ||
| 239 | } | ||
| 240 | for _, v := range vs { | ||
| 241 | h.Add(k, v) | ||
| 242 | } | ||
| 243 | } | ||
| 244 | parts = append(parts, part{header: h, body: string(buf)}) | ||
| 245 | } | ||
| 246 | if len(parts) == 0 { | ||
| 247 | return "", errors.New("multipart archive has no parts") | ||
| 248 | } | ||
| 249 | return wrapMultipart(append(parts, keyPart(key))) | ||
| 250 | } | ||
| 251 | |||
| 252 | // firstNonBlankLine returns the first line with non-whitespace content, trimmed. | ||
| 253 | func firstNonBlankLine(s string) string { | ||
| 254 | for _, line := range strings.Split(s, "\n") { | ||
| 255 | if t := strings.TrimSpace(line); t != "" { | ||
| 256 | return t | ||
| 257 | } | ||
| 258 | } | ||
| 259 | return "" | ||
| 260 | } | ||
internal/cloudinit/multipart_test.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,174 @@ | |||
| 1 | package cloudinit | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "io" | ||
| 5 | "mime" | ||
| 6 | "mime/multipart" | ||
| 7 | "net/mail" | ||
| 8 | "strings" | ||
| 9 | "testing" | ||
| 10 | |||
| 11 | "github.com/stretchr/testify/assert" | ||
| 12 | "github.com/stretchr/testify/require" | ||
| 13 | ) | ||
| 14 | |||
| 15 | func TestDetectFormat(t *testing.T) { | ||
| 16 | cases := []struct { | ||
| 17 | in string | ||
| 18 | want Format | ||
| 19 | }{ | ||
| 20 | {"#cloud-config\npackages: [htop]\n", FormatCloudConfig}, | ||
| 21 | {"\n\n#cloud-config\n", FormatCloudConfig}, // leading blank lines tolerated | ||
| 22 | {"#!/bin/bash\necho hi\n", FormatShellScript}, | ||
| 23 | {"#cloud-boothook\n#!/bin/sh\n", FormatBoothook}, | ||
| 24 | {"#include\nhttps://example/x\n", FormatInclude}, | ||
| 25 | {"#include-once\nhttps://example/x\n", FormatInclude}, | ||
| 26 | {"#part-handler\n", FormatPartHandler}, | ||
| 27 | {"## template: jinja\n#cloud-config\n", FormatJinja}, | ||
| 28 | {"Content-Type: multipart/mixed; boundary=\"X\"\n\n", FormatMultipart}, | ||
| 29 | {"\x1f\x8b\x08 gzip bytes", FormatGzip}, | ||
| 30 | {"just some text", FormatUnknown}, | ||
| 31 | } | ||
| 32 | for _, c := range cases { | ||
| 33 | assert.Equal(t, c.want, DetectFormat(c.in), "input %q", c.in) | ||
| 34 | } | ||
| 35 | } | ||
| 36 | |||
| 37 | // mimeParts parses a cloud-init multipart archive back into (contentType, body) | ||
| 38 | // pairs so tests assert on STRUCTURE, not on the random MIME boundary. | ||
| 39 | func mimeParts(t *testing.T, s string) map[string]string { | ||
| 40 | t.Helper() | ||
| 41 | msg, err := mail.ReadMessage(strings.NewReader(s)) | ||
| 42 | require.NoError(t, err) | ||
| 43 | mediaType, params, err := mime.ParseMediaType(msg.Header.Get("Content-Type")) | ||
| 44 | require.NoError(t, err) | ||
| 45 | require.Equal(t, "multipart/mixed", mediaType) | ||
| 46 | mr := multipart.NewReader(msg.Body, params["boundary"]) | ||
| 47 | out := map[string]string{} | ||
| 48 | for { | ||
| 49 | p, err := mr.NextPart() | ||
| 50 | if err != nil { | ||
| 51 | break | ||
| 52 | } | ||
| 53 | body, err := io.ReadAll(p) | ||
| 54 | require.NoError(t, err) | ||
| 55 | mt, _, _ := mime.ParseMediaType(p.Header.Get("Content-Type")) | ||
| 56 | out[mt] = string(body) | ||
| 57 | } | ||
| 58 | return out | ||
| 59 | } | ||
| 60 | |||
| 61 | func TestAddSSHKeyCloudConfigMerges(t *testing.T) { | ||
| 62 | // cloud-config path delegates to MergeSSHKey → a single merged document, | ||
| 63 | // NOT a multipart wrapper. | ||
| 64 | out, err := AddSSHKey("#cloud-config\npackages:\n - htop\n", key) | ||
| 65 | require.NoError(t, err) | ||
| 66 | assert.True(t, strings.HasPrefix(out, "#cloud-config\n"), "stays a single cloud-config doc, got: %q", out[:min(24, len(out))]) | ||
| 67 | assert.Contains(t, out, key) | ||
| 68 | assert.NotContains(t, out, "multipart", "the common case must not become a MIME archive") | ||
| 69 | } | ||
| 70 | |||
| 71 | func TestAddSSHKeyShellScriptWraps(t *testing.T) { | ||
| 72 | script := "#!/bin/bash\necho hello > /tmp/marker\n" | ||
| 73 | out, err := AddSSHKey(script, key) | ||
| 74 | require.NoError(t, err) | ||
| 75 | parts := mimeParts(t, out) | ||
| 76 | // The script survives byte-for-byte as its own part... | ||
| 77 | assert.Equal(t, script, parts["text/x-shellscript"], "the user's script must be intact and untouched") | ||
| 78 | // ...and the key rides a sibling cloud-config part. | ||
| 79 | require.Contains(t, parts, "text/cloud-config") | ||
| 80 | assert.Contains(t, parts["text/cloud-config"], "#cloud-config") | ||
| 81 | assert.Contains(t, parts["text/cloud-config"], key) | ||
| 82 | } | ||
| 83 | |||
| 84 | func TestAddSSHKeyBoothookWraps(t *testing.T) { | ||
| 85 | bh := "#cloud-boothook\n#!/bin/sh\necho early\n" | ||
| 86 | out, err := AddSSHKey(bh, key) | ||
| 87 | require.NoError(t, err) | ||
| 88 | parts := mimeParts(t, out) | ||
| 89 | assert.Equal(t, bh, parts["text/cloud-boothook"]) | ||
| 90 | assert.Contains(t, parts["text/cloud-config"], key) | ||
| 91 | } | ||
| 92 | |||
| 93 | func TestAddSSHKeyAppendsToExistingMultipart_NotNested(t *testing.T) { | ||
| 94 | // A user who already supplied a multipart archive: the key part is appended | ||
| 95 | // alongside their parts, and their parts survive verbatim — no nesting. | ||
| 96 | existing, err := wrapMultipart([]part{ | ||
| 97 | typedPart("text/cloud-config", "#cloud-config\npackages:\n - git\n"), | ||
| 98 | typedPart("text/x-shellscript", "#!/bin/bash\necho hi\n"), | ||
| 99 | }) | ||
| 100 | require.NoError(t, err) | ||
| 101 | |||
| 102 | out, err := AddSSHKey(existing, key) | ||
| 103 | require.NoError(t, err) | ||
| 104 | parts := mimeParts(t, out) | ||
| 105 | // No part is itself a multipart archive → not nested. | ||
| 106 | for ct := range parts { | ||
| 107 | assert.NotContains(t, ct, "multipart", "must append, not nest") | ||
| 108 | } | ||
| 109 | assert.Contains(t, parts["text/x-shellscript"], "echo hi", "original script part survives") | ||
| 110 | assert.Contains(t, parts["text/cloud-config"], key, "key part added") | ||
| 111 | } | ||
| 112 | |||
| 113 | func TestAddSSHKeyRejectsJinjaAndGzipAndUnknown(t *testing.T) { | ||
| 114 | for _, in := range []string{ | ||
| 115 | "## template: jinja\n#cloud-config\nhostname: {{ v1.local_hostname }}\n", | ||
| 116 | "\x1f\x8b\x08 gzipped", | ||
| 117 | "random text that is not user-data", | ||
| 118 | } { | ||
| 119 | _, err := AddSSHKey(in, key) | ||
| 120 | assert.Error(t, err, "must reject %q rather than silently no-op", in[:min(20, len(in))]) | ||
| 121 | } | ||
| 122 | } | ||
| 123 | |||
| 124 | func TestDetectFormatMultipartHeaderOrder(t *testing.T) { | ||
| 125 | // A valid archive may lead with MIME-Version before Content-Type; detection | ||
| 126 | // must parse the header block, not just sniff line one. | ||
| 127 | in := "MIME-Version: 1.0\nContent-Type: multipart/mixed; boundary=\"X\"\n\n--X--\n" | ||
| 128 | assert.Equal(t, FormatMultipart, DetectFormat(in)) | ||
| 129 | } | ||
| 130 | |||
| 131 | func TestDetectFormatCloudConfigTrailingComment(t *testing.T) { | ||
| 132 | // cloud-init detects #cloud-config by prefix, so a trailing comment on the | ||
| 133 | // marker line is valid — don't reject it. | ||
| 134 | assert.Equal(t, FormatCloudConfig, DetectFormat("#cloud-config # my vm\npackages: [htop]\n")) | ||
| 135 | } | ||
| 136 | |||
| 137 | func TestAppendPreservesPartHeaders(t *testing.T) { | ||
| 138 | // A user's multipart part may carry Content-Disposition (filename) that | ||
| 139 | // cloud-init uses to name/order scripts — appendToMultipart must not drop it. | ||
| 140 | existing := "Content-Type: multipart/mixed; boundary=\"BOUND\"\nMIME-Version: 1.0\n\n" + | ||
| 141 | "--BOUND\n" + | ||
| 142 | "Content-Type: text/x-shellscript; charset=\"utf-8\"\n" + | ||
| 143 | "Content-Disposition: attachment; filename=\"setup.sh\"\n" + | ||
| 144 | "MIME-Version: 1.0\n\n" + | ||
| 145 | "#!/bin/bash\necho hi\n" + | ||
| 146 | "--BOUND--\n" | ||
| 147 | out, err := AddSSHKey(existing, key) | ||
| 148 | require.NoError(t, err) | ||
| 149 | assert.Contains(t, out, `filename="setup.sh"`, "Content-Disposition/filename must survive the append") | ||
| 150 | assert.Contains(t, out, key) | ||
| 151 | } | ||
| 152 | |||
| 153 | func TestAppendRejectsMalformedMidStream(t *testing.T) { | ||
| 154 | // A genuinely broken boundary mid-archive should error, not silently | ||
| 155 | // truncate to the parts read so far. | ||
| 156 | in := "Content-Type: multipart/mixed; boundary=\"BOUND\"\nMIME-Version: 1.0\n\n" + | ||
| 157 | "--BOUND\nContent-Type: text/plain\n\nbody without a closing boundary\n" | ||
| 158 | _, err := AddSSHKey(in, key) | ||
| 159 | assert.Error(t, err) | ||
| 160 | } | ||
| 161 | |||
| 162 | func TestWrapMultipartIsWellFormed(t *testing.T) { | ||
| 163 | // The emitted archive must parse with the stdlib MIME reader (well-formed | ||
| 164 | // headers, boundary, MIME-Version) — a proxy for cloud-init accepting it. | ||
| 165 | out, err := wrapMultipart([]part{ | ||
| 166 | typedPart("text/x-shellscript", "#!/bin/bash\n"), | ||
| 167 | typedPart("text/cloud-config", "#cloud-config\n"), | ||
| 168 | }) | ||
| 169 | require.NoError(t, err) | ||
| 170 | assert.True(t, strings.HasPrefix(out, "Content-Type: multipart/mixed; boundary=")) | ||
| 171 | assert.Contains(t, out, "MIME-Version: 1.0") | ||
| 172 | parts := mimeParts(t, out) | ||
| 173 | assert.Len(t, parts, 2) | ||
| 174 | } | ||
internal/names/names.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,14 @@ | |||
| 1 | // Package names validates the DNS-label shape shared across planes: a VM's | ||
| 2 | // name doubles as its guest hostname, so it must be a valid RFC-1123 label. | ||
| 3 | // This package is a dependency-free leaf. | ||
| 4 | package names | ||
| 5 | |||
| 6 | import "regexp" | ||
| 7 | |||
| 8 | // rfc1123Label matches valid RFC-1123 DNS label names. | ||
| 9 | // Rules: lowercase alphanum start/end, lowercase alphanum or hyphen in between, | ||
| 10 | // max 63 characters total. | ||
| 11 | var rfc1123Label = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`) | ||
| 12 | |||
| 13 | // IsRFC1123Label reports whether s is a valid RFC-1123 DNS label. | ||
| 14 | func IsRFC1123Label(s string) bool { return rfc1123Label.MatchString(s) } | ||
internal/pb/sync.pb.go
| Old | New | ||
|---|---|---|---|
| @@ -1,7 +1,7 @@ | |||
| 1 | // Code generated by protoc-gen-go. DO NOT EDIT. | 1 | // Code generated by protoc-gen-go. DO NOT EDIT. |
| 2 | // versions: | 2 | // versions: |
| 3 | // protoc-gen-go v1.36.11 | 3 | // protoc-gen-go v1.36.11 |
| 4 | // protoc v7.35.0 | 4 | // protoc v7.35.1 |
| 5 | // source: proto/eitri/v1/sync.proto | 5 | // source: proto/eitri/v1/sync.proto |
| 6 | 6 | ||
| 7 | package pb | 7 | package pb |
| @@ -27,6 +27,8 @@ type AgentMessage struct { | |||
| 27 | // | 27 | // |
| 28 | // *AgentMessage_Hello | 28 | // *AgentMessage_Hello |
| 29 | // *AgentMessage_Report | 29 | // *AgentMessage_Report |
| 30 | // *AgentMessage_ConsoleOpened | ||
| 31 | // *AgentMessage_TcpOpened | ||
| 30 | Msg isAgentMessage_Msg `protobuf_oneof:"msg"` | 32 | Msg isAgentMessage_Msg `protobuf_oneof:"msg"` |
| 31 | unknownFields protoimpl.UnknownFields | 33 | unknownFields protoimpl.UnknownFields |
| 32 | sizeCache protoimpl.SizeCache | 34 | sizeCache protoimpl.SizeCache |
| @@ -87,6 +89,24 @@ func (x *AgentMessage) GetReport() *ActualStateReport { | |||
| 87 | return nil | 89 | return nil |
| 88 | } | 90 | } |
| 89 | 91 | ||
| 92 | func (x *AgentMessage) GetConsoleOpened() *ConsoleOpened { | ||
| 93 | if x != nil { | ||
| 94 | if x, ok := x.Msg.(*AgentMessage_ConsoleOpened); ok { | ||
| 95 | return x.ConsoleOpened | ||
| 96 | } | ||
| 97 | } | ||
| 98 | return nil | ||
| 99 | } | ||
| 100 | |||
| 101 | func (x *AgentMessage) GetTcpOpened() *TCPOpened { | ||
| 102 | if x != nil { | ||
| 103 | if x, ok := x.Msg.(*AgentMessage_TcpOpened); ok { | ||
| 104 | return x.TcpOpened | ||
| 105 | } | ||
| 106 | } | ||
| 107 | return nil | ||
| 108 | } | ||
| 109 | |||
| 90 | type isAgentMessage_Msg interface { | 110 | type isAgentMessage_Msg interface { |
| 91 | isAgentMessage_Msg() | 111 | isAgentMessage_Msg() |
| 92 | } | 112 | } |
| @@ -99,15 +119,29 @@ type AgentMessage_Report struct { | |||
| 99 | Report *ActualStateReport `protobuf:"bytes,2,opt,name=report,proto3,oneof"` | 119 | Report *ActualStateReport `protobuf:"bytes,2,opt,name=report,proto3,oneof"` |
| 100 | } | 120 | } |
| 101 | 121 | ||
| 122 | type AgentMessage_ConsoleOpened struct { | ||
| 123 | ConsoleOpened *ConsoleOpened `protobuf:"bytes,3,opt,name=console_opened,json=consoleOpened,proto3,oneof"` | ||
| 124 | } | ||
| 125 | |||
| 126 | type AgentMessage_TcpOpened struct { | ||
| 127 | TcpOpened *TCPOpened `protobuf:"bytes,4,opt,name=tcp_opened,json=tcpOpened,proto3,oneof"` | ||
| 128 | } | ||
| 129 | |||
| 102 | func (*AgentMessage_Hello) isAgentMessage_Msg() {} | 130 | func (*AgentMessage_Hello) isAgentMessage_Msg() {} |
| 103 | 131 | ||
| 104 | func (*AgentMessage_Report) isAgentMessage_Msg() {} | 132 | func (*AgentMessage_Report) isAgentMessage_Msg() {} |
| 105 | 133 | ||
| 134 | func (*AgentMessage_ConsoleOpened) isAgentMessage_Msg() {} | ||
| 135 | |||
| 136 | func (*AgentMessage_TcpOpened) isAgentMessage_Msg() {} | ||
| 137 | |||
| 106 | type ServerMessage struct { | 138 | type ServerMessage struct { |
| 107 | state protoimpl.MessageState `protogen:"open.v1"` | 139 | state protoimpl.MessageState `protogen:"open.v1"` |
| 108 | // Types that are valid to be assigned to Msg: | 140 | // Types that are valid to be assigned to Msg: |
| 109 | // | 141 | // |
| 110 | // *ServerMessage_Snapshot | 142 | // *ServerMessage_Snapshot |
| 143 | // *ServerMessage_ConsoleOpen | ||
| 144 | // *ServerMessage_TcpOpen | ||
| 111 | Msg isServerMessage_Msg `protobuf_oneof:"msg"` | 145 | Msg isServerMessage_Msg `protobuf_oneof:"msg"` |
| 112 | unknownFields protoimpl.UnknownFields | 146 | unknownFields protoimpl.UnknownFields |
| 113 | sizeCache protoimpl.SizeCache | 147 | sizeCache protoimpl.SizeCache |
| @@ -159,6 +193,24 @@ func (x *ServerMessage) GetSnapshot() *DesiredStateSnapshot { | |||
| 159 | return nil | 193 | return nil |
| 160 | } | 194 | } |
| 161 | 195 | ||
| 196 | func (x *ServerMessage) GetConsoleOpen() *ConsoleOpen { | ||
| 197 | if x != nil { | ||
| 198 | if x, ok := x.Msg.(*ServerMessage_ConsoleOpen); ok { | ||
| 199 | return x.ConsoleOpen | ||
| 200 | } | ||
| 201 | } | ||
| 202 | return nil | ||
| 203 | } | ||
| 204 | |||
| 205 | func (x *ServerMessage) GetTcpOpen() *TCPOpen { | ||
| 206 | if x != nil { | ||
| 207 | if x, ok := x.Msg.(*ServerMessage_TcpOpen); ok { | ||
| 208 | return x.TcpOpen | ||
| 209 | } | ||
| 210 | } | ||
| 211 | return nil | ||
| 212 | } | ||
| 213 | |||
| 162 | type isServerMessage_Msg interface { | 214 | type isServerMessage_Msg interface { |
| 163 | isServerMessage_Msg() | 215 | isServerMessage_Msg() |
| 164 | } | 216 | } |
| @@ -167,8 +219,20 @@ type ServerMessage_Snapshot struct { | |||
| 167 | Snapshot *DesiredStateSnapshot `protobuf:"bytes,1,opt,name=snapshot,proto3,oneof"` | 219 | Snapshot *DesiredStateSnapshot `protobuf:"bytes,1,opt,name=snapshot,proto3,oneof"` |
| 168 | } | 220 | } |
| 169 | 221 | ||
| 222 | type ServerMessage_ConsoleOpen struct { | ||
| 223 | ConsoleOpen *ConsoleOpen `protobuf:"bytes,2,opt,name=console_open,json=consoleOpen,proto3,oneof"` | ||
| 224 | } | ||
| 225 | |||
| 226 | type ServerMessage_TcpOpen struct { | ||
| 227 | TcpOpen *TCPOpen `protobuf:"bytes,3,opt,name=tcp_open,json=tcpOpen,proto3,oneof"` | ||
| 228 | } | ||
| 229 | |||
| 170 | func (*ServerMessage_Snapshot) isServerMessage_Msg() {} | 230 | func (*ServerMessage_Snapshot) isServerMessage_Msg() {} |
| 171 | 231 | ||
| 232 | func (*ServerMessage_ConsoleOpen) isServerMessage_Msg() {} | ||
| 233 | |||
| 234 | func (*ServerMessage_TcpOpen) isServerMessage_Msg() {} | ||
| 235 | |||
| 172 | type Hello struct { | 236 | type Hello struct { |
| 173 | state protoimpl.MessageState `protogen:"open.v1"` | 237 | state protoimpl.MessageState `protogen:"open.v1"` |
| 174 | HostId string `protobuf:"bytes,1,opt,name=host_id,json=hostId,proto3" json:"host_id,omitempty"` | 238 | HostId string `protobuf:"bytes,1,opt,name=host_id,json=hostId,proto3" json:"host_id,omitempty"` |
| @@ -568,21 +632,24 @@ func (x *ActualStateReport) GetLastSeenEpoch() uint64 { | |||
| 568 | } | 632 | } |
| 569 | 633 | ||
| 570 | type VMDesired struct { | 634 | type VMDesired struct { |
| 571 | state protoimpl.MessageState `protogen:"open.v1"` | 635 | state protoimpl.MessageState `protogen:"open.v1"` |
| 572 | VmId string `protobuf:"bytes,1,opt,name=vm_id,json=vmId,proto3" json:"vm_id,omitempty"` | 636 | VmId string `protobuf:"bytes,1,opt,name=vm_id,json=vmId,proto3" json:"vm_id,omitempty"` |
| 573 | Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` | 637 | Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` |
| 574 | ImageUrl string `protobuf:"bytes,3,opt,name=image_url,json=imageUrl,proto3" json:"image_url,omitempty"` | 638 | ImageUrl string `protobuf:"bytes,3,opt,name=image_url,json=imageUrl,proto3" json:"image_url,omitempty"` |
| 575 | ImageSha256 string `protobuf:"bytes,4,opt,name=image_sha256,json=imageSha256,proto3" json:"image_sha256,omitempty"` | 639 | ImageSha256 string `protobuf:"bytes,4,opt,name=image_sha256,json=imageSha256,proto3" json:"image_sha256,omitempty"` |
| 576 | CloudInit string `protobuf:"bytes,5,opt,name=cloud_init,json=cloudInit,proto3" json:"cloud_init,omitempty"` // user-data YAML, may be empty | 640 | CloudInit string `protobuf:"bytes,5,opt,name=cloud_init,json=cloudInit,proto3" json:"cloud_init,omitempty"` // user-data YAML, may be empty |
| 577 | Vcpus int64 `protobuf:"varint,6,opt,name=vcpus,proto3" json:"vcpus,omitempty"` | 641 | Vcpus int64 `protobuf:"varint,6,opt,name=vcpus,proto3" json:"vcpus,omitempty"` |
| 578 | MemMb int64 `protobuf:"varint,7,opt,name=mem_mb,json=memMb,proto3" json:"mem_mb,omitempty"` | 642 | MemMb int64 `protobuf:"varint,7,opt,name=mem_mb,json=memMb,proto3" json:"mem_mb,omitempty"` |
| 579 | DiskGb int64 `protobuf:"varint,8,opt,name=disk_gb,json=diskGb,proto3" json:"disk_gb,omitempty"` | 643 | DiskGb int64 `protobuf:"varint,8,opt,name=disk_gb,json=diskGb,proto3" json:"disk_gb,omitempty"` |
| 580 | Persistent bool `protobuf:"varint,9,opt,name=persistent,proto3" json:"persistent,omitempty"` | 644 | Persistent bool `protobuf:"varint,9,opt,name=persistent,proto3" json:"persistent,omitempty"` |
| 581 | PowerState string `protobuf:"bytes,10,opt,name=power_state,json=powerState,proto3" json:"power_state,omitempty"` // "running"|"stopped" | 645 | PowerState string `protobuf:"bytes,10,opt,name=power_state,json=powerState,proto3" json:"power_state,omitempty"` // "running"|"stopped" |
| 582 | Tombstoned bool `protobuf:"varint,11,opt,name=tombstoned,proto3" json:"tombstoned,omitempty"` // present-but-tombstoned (drives quarantine + destroyed[]) | 646 | Tombstoned bool `protobuf:"varint,11,opt,name=tombstoned,proto3" json:"tombstoned,omitempty"` // present-but-tombstoned (drives quarantine + destroyed[]) |
| 583 | SshAuthorizedKey string `protobuf:"bytes,12,opt,name=ssh_authorized_key,json=sshAuthorizedKey,proto3" json:"ssh_authorized_key,omitempty"` | 647 | SshAuthorizedKey string `protobuf:"bytes,12,opt,name=ssh_authorized_key,json=sshAuthorizedKey,proto3" json:"ssh_authorized_key,omitempty"` |
| 584 | unknownFields protoimpl.UnknownFields | 648 | SshUserCaAuthorizedKey string `protobuf:"bytes,15,opt,name=ssh_user_ca_authorized_key,json=sshUserCaAuthorizedKey,proto3" json:"ssh_user_ca_authorized_key,omitempty"` // eitri user-CA public key (authorized_keys form); seed injects it as an sshd TrustedUserCAKeys drop-in. Empty when the jump gate is off. |
| 585 | sizeCache protoimpl.SizeCache | 649 | SshHostKeyPem string `protobuf:"bytes,16,opt,name=ssh_host_key_pem,json=sshHostKeyPem,proto3" json:"ssh_host_key_pem,omitempty"` // the VM's persistent ed25519 host private key (OpenSSH PEM); seed installs it as /etc/ssh/ssh_host_ed25519_key. WRITE-ONLY key material. Empty when the jump gate is off. |
| 650 | SshHostCert string `protobuf:"bytes,17,opt,name=ssh_host_cert,json=sshHostCert,proto3" json:"ssh_host_cert,omitempty"` // the VM's CA-signed host cert (authorized_keys form); seed installs it as /etc/ssh/ssh_host_ed25519_key-cert.pub. Empty when the jump gate is off. | ||
| 651 | unknownFields protoimpl.UnknownFields | ||
| 652 | sizeCache protoimpl.SizeCache | ||
| 586 | } | 653 | } |
| 587 | 654 | ||
| 588 | func (x *VMDesired) Reset() { | 655 | func (x *VMDesired) Reset() { |
| @@ -699,6 +766,27 @@ func (x *VMDesired) GetSshAuthorizedKey() string { | |||
| 699 | return "" | 766 | return "" |
| 700 | } | 767 | } |
| 701 | 768 | ||
| 769 | func (x *VMDesired) GetSshUserCaAuthorizedKey() string { | ||
| 770 | if x != nil { | ||
| 771 | return x.SshUserCaAuthorizedKey | ||
| 772 | } | ||
| 773 | return "" | ||
| 774 | } | ||
| 775 | |||
| 776 | func (x *VMDesired) GetSshHostKeyPem() string { | ||
| 777 | if x != nil { | ||
| 778 | return x.SshHostKeyPem | ||
| 779 | } | ||
| 780 | return "" | ||
| 781 | } | ||
| 782 | |||
| 783 | func (x *VMDesired) GetSshHostCert() string { | ||
| 784 | if x != nil { | ||
| 785 | return x.SshHostCert | ||
| 786 | } | ||
| 787 | return "" | ||
| 788 | } | ||
| 789 | |||
| 702 | type DesiredStateSnapshot struct { | 790 | type DesiredStateSnapshot struct { |
| 703 | state protoimpl.MessageState `protogen:"open.v1"` | 791 | state protoimpl.MessageState `protogen:"open.v1"` |
| 704 | Epoch uint64 `protobuf:"varint,1,opt,name=epoch,proto3" json:"epoch,omitempty"` // agents refuse epoch < highest seen | 792 | Epoch uint64 `protobuf:"varint,1,opt,name=epoch,proto3" json:"epoch,omitempty"` // agents refuse epoch < highest seen |
| @@ -751,17 +839,234 @@ func (x *DesiredStateSnapshot) GetVms() []*VMDesired { | |||
| 751 | return nil | 839 | return nil |
| 752 | } | 840 | } |
| 753 | 841 | ||
| 842 | // ConsoleOpen is the first frame on a server-initiated console stream: it names | ||
| 843 | // the VM whose serial console the stream should bridge. After the agent's | ||
| 844 | // ConsoleOpened reply, the stream carries RAW serial bytes (no framing). | ||
| 845 | type ConsoleOpen struct { | ||
| 846 | state protoimpl.MessageState `protogen:"open.v1"` | ||
| 847 | VmId string `protobuf:"bytes,1,opt,name=vm_id,json=vmId,proto3" json:"vm_id,omitempty"` | ||
| 848 | unknownFields protoimpl.UnknownFields | ||
| 849 | sizeCache protoimpl.SizeCache | ||
| 850 | } | ||
| 851 | |||
| 852 | func (x *ConsoleOpen) Reset() { | ||
| 853 | *x = ConsoleOpen{} | ||
| 854 | mi := &file_proto_eitri_v1_sync_proto_msgTypes[9] | ||
| 855 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | ||
| 856 | ms.StoreMessageInfo(mi) | ||
| 857 | } | ||
| 858 | |||
| 859 | func (x *ConsoleOpen) String() string { | ||
| 860 | return protoimpl.X.MessageStringOf(x) | ||
| 861 | } | ||
| 862 | |||
| 863 | func (*ConsoleOpen) ProtoMessage() {} | ||
| 864 | |||
| 865 | func (x *ConsoleOpen) ProtoReflect() protoreflect.Message { | ||
| 866 | mi := &file_proto_eitri_v1_sync_proto_msgTypes[9] | ||
| 867 | if x != nil { | ||
| 868 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | ||
| 869 | if ms.LoadMessageInfo() == nil { | ||
| 870 | ms.StoreMessageInfo(mi) | ||
| 871 | } | ||
| 872 | return ms | ||
| 873 | } | ||
| 874 | return mi.MessageOf(x) | ||
| 875 | } | ||
| 876 | |||
| 877 | // Deprecated: Use ConsoleOpen.ProtoReflect.Descriptor instead. | ||
| 878 | func (*ConsoleOpen) Descriptor() ([]byte, []int) { | ||
| 879 | return file_proto_eitri_v1_sync_proto_rawDescGZIP(), []int{9} | ||
| 880 | } | ||
| 881 | |||
| 882 | func (x *ConsoleOpen) GetVmId() string { | ||
| 883 | if x != nil { | ||
| 884 | return x.VmId | ||
| 885 | } | ||
| 886 | return "" | ||
| 887 | } | ||
| 888 | |||
| 889 | // ConsoleOpened is the agent's reply on the console stream. ok=false carries a | ||
| 890 | // human-readable error (unknown VM, VM not running, console unsupported) and | ||
| 891 | // the stream is then closed by the agent. | ||
| 892 | type ConsoleOpened struct { | ||
| 893 | state protoimpl.MessageState `protogen:"open.v1"` | ||
| 894 | Ok bool `protobuf:"varint,1,opt,name=ok,proto3" json:"ok,omitempty"` | ||
| 895 | Error string `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"` | ||
| 896 | unknownFields protoimpl.UnknownFields | ||
| 897 | sizeCache protoimpl.SizeCache | ||
| 898 | } | ||
| 899 | |||
| 900 | func (x *ConsoleOpened) Reset() { | ||
| 901 | *x = ConsoleOpened{} | ||
| 902 | mi := &file_proto_eitri_v1_sync_proto_msgTypes[10] | ||
| 903 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | ||
| 904 | ms.StoreMessageInfo(mi) | ||
| 905 | } | ||
| 906 | |||
| 907 | func (x *ConsoleOpened) String() string { | ||
| 908 | return protoimpl.X.MessageStringOf(x) | ||
| 909 | } | ||
| 910 | |||
| 911 | func (*ConsoleOpened) ProtoMessage() {} | ||
| 912 | |||
| 913 | func (x *ConsoleOpened) ProtoReflect() protoreflect.Message { | ||
| 914 | mi := &file_proto_eitri_v1_sync_proto_msgTypes[10] | ||
| 915 | if x != nil { | ||
| 916 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | ||
| 917 | if ms.LoadMessageInfo() == nil { | ||
| 918 | ms.StoreMessageInfo(mi) | ||
| 919 | } | ||
| 920 | return ms | ||
| 921 | } | ||
| 922 | return mi.MessageOf(x) | ||
| 923 | } | ||
| 924 | |||
| 925 | // Deprecated: Use ConsoleOpened.ProtoReflect.Descriptor instead. | ||
| 926 | func (*ConsoleOpened) Descriptor() ([]byte, []int) { | ||
| 927 | return file_proto_eitri_v1_sync_proto_rawDescGZIP(), []int{10} | ||
| 928 | } | ||
| 929 | |||
| 930 | func (x *ConsoleOpened) GetOk() bool { | ||
| 931 | if x != nil { | ||
| 932 | return x.Ok | ||
| 933 | } | ||
| 934 | return false | ||
| 935 | } | ||
| 936 | |||
| 937 | func (x *ConsoleOpened) GetError() string { | ||
| 938 | if x != nil { | ||
| 939 | return x.Error | ||
| 940 | } | ||
| 941 | return "" | ||
| 942 | } | ||
| 943 | |||
| 944 | // TCPOpen is the first frame on a server-initiated tunnel stream: it names the | ||
| 945 | // VM and the guest TCP port the stream should bridge to. After the agent's | ||
| 946 | // TCPOpened reply, the stream carries RAW TCP bytes (no framing). | ||
| 947 | type TCPOpen struct { | ||
| 948 | state protoimpl.MessageState `protogen:"open.v1"` | ||
| 949 | VmId string `protobuf:"bytes,1,opt,name=vm_id,json=vmId,proto3" json:"vm_id,omitempty"` | ||
| 950 | Port uint32 `protobuf:"varint,2,opt,name=port,proto3" json:"port,omitempty"` | ||
| 951 | unknownFields protoimpl.UnknownFields | ||
| 952 | sizeCache protoimpl.SizeCache | ||
| 953 | } | ||
| 954 | |||
| 955 | func (x *TCPOpen) Reset() { | ||
| 956 | *x = TCPOpen{} | ||
| 957 | mi := &file_proto_eitri_v1_sync_proto_msgTypes[11] | ||
| 958 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | ||
| 959 | ms.StoreMessageInfo(mi) | ||
| 960 | } | ||
| 961 | |||
| 962 | func (x *TCPOpen) String() string { | ||
| 963 | return protoimpl.X.MessageStringOf(x) | ||
| 964 | } | ||
| 965 | |||
| 966 | func (*TCPOpen) ProtoMessage() {} | ||
| 967 | |||
| 968 | func (x *TCPOpen) ProtoReflect() protoreflect.Message { | ||
| 969 | mi := &file_proto_eitri_v1_sync_proto_msgTypes[11] | ||
| 970 | if x != nil { | ||
| 971 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | ||
| 972 | if ms.LoadMessageInfo() == nil { | ||
| 973 | ms.StoreMessageInfo(mi) | ||
| 974 | } | ||
| 975 | return ms | ||
| 976 | } | ||
| 977 | return mi.MessageOf(x) | ||
| 978 | } | ||
| 979 | |||
| 980 | // Deprecated: Use TCPOpen.ProtoReflect.Descriptor instead. | ||
| 981 | func (*TCPOpen) Descriptor() ([]byte, []int) { | ||
| 982 | return file_proto_eitri_v1_sync_proto_rawDescGZIP(), []int{11} | ||
| 983 | } | ||
| 984 | |||
| 985 | func (x *TCPOpen) GetVmId() string { | ||
| 986 | if x != nil { | ||
| 987 | return x.VmId | ||
| 988 | } | ||
| 989 | return "" | ||
| 990 | } | ||
| 991 | |||
| 992 | func (x *TCPOpen) GetPort() uint32 { | ||
| 993 | if x != nil { | ||
| 994 | return x.Port | ||
| 995 | } | ||
| 996 | return 0 | ||
| 997 | } | ||
| 998 | |||
| 999 | // TCPOpened is the agent's reply on the tunnel stream. ok=false carries a | ||
| 1000 | // human-readable error (unknown VM, VM not running, dial refused) and the | ||
| 1001 | // stream is then closed by the agent. | ||
| 1002 | type TCPOpened struct { | ||
| 1003 | state protoimpl.MessageState `protogen:"open.v1"` | ||
| 1004 | Ok bool `protobuf:"varint,1,opt,name=ok,proto3" json:"ok,omitempty"` | ||
| 1005 | Error string `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"` | ||
| 1006 | unknownFields protoimpl.UnknownFields | ||
| 1007 | sizeCache protoimpl.SizeCache | ||
| 1008 | } | ||
| 1009 | |||
| 1010 | func (x *TCPOpened) Reset() { | ||
| 1011 | *x = TCPOpened{} | ||
| 1012 | mi := &file_proto_eitri_v1_sync_proto_msgTypes[12] | ||
| 1013 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | ||
| 1014 | ms.StoreMessageInfo(mi) | ||
| 1015 | } | ||
| 1016 | |||
| 1017 | func (x *TCPOpened) String() string { | ||
| 1018 | return protoimpl.X.MessageStringOf(x) | ||
| 1019 | } | ||
| 1020 | |||
| 1021 | func (*TCPOpened) ProtoMessage() {} | ||
| 1022 | |||
| 1023 | func (x *TCPOpened) ProtoReflect() protoreflect.Message { | ||
| 1024 | mi := &file_proto_eitri_v1_sync_proto_msgTypes[12] | ||
| 1025 | if x != nil { | ||
| 1026 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | ||
| 1027 | if ms.LoadMessageInfo() == nil { | ||
| 1028 | ms.StoreMessageInfo(mi) | ||
| 1029 | } | ||
| 1030 | return ms | ||
| 1031 | } | ||
| 1032 | return mi.MessageOf(x) | ||
| 1033 | } | ||
| 1034 | |||
| 1035 | // Deprecated: Use TCPOpened.ProtoReflect.Descriptor instead. | ||
| 1036 | func (*TCPOpened) Descriptor() ([]byte, []int) { | ||
| 1037 | return file_proto_eitri_v1_sync_proto_rawDescGZIP(), []int{12} | ||
| 1038 | } | ||
| 1039 | |||
| 1040 | func (x *TCPOpened) GetOk() bool { | ||
| 1041 | if x != nil { | ||
| 1042 | return x.Ok | ||
| 1043 | } | ||
| 1044 | return false | ||
| 1045 | } | ||
| 1046 | |||
| 1047 | func (x *TCPOpened) GetError() string { | ||
| 1048 | if x != nil { | ||
| 1049 | return x.Error | ||
| 1050 | } | ||
| 1051 | return "" | ||
| 1052 | } | ||
| 1053 | |||
| 754 | var File_proto_eitri_v1_sync_proto protoreflect.FileDescriptor | 1054 | var File_proto_eitri_v1_sync_proto protoreflect.FileDescriptor |
| 755 | 1055 | ||
| 756 | const file_proto_eitri_v1_sync_proto_rawDesc = "" + | 1056 | const file_proto_eitri_v1_sync_proto_rawDesc = "" + |
| 757 | "\n" + | 1057 | "\n" + |
| 758 | "\x19proto/eitri/v1/sync.proto\x12\beitri.v1\"u\n" + | 1058 | "\x19proto/eitri/v1/sync.proto\x12\beitri.v1\"\xed\x01\n" + |
| 759 | "\fAgentMessage\x12'\n" + | 1059 | "\fAgentMessage\x12'\n" + |
| 760 | "\x05hello\x18\x01 \x01(\v2\x0f.eitri.v1.HelloH\x00R\x05hello\x125\n" + | 1060 | "\x05hello\x18\x01 \x01(\v2\x0f.eitri.v1.HelloH\x00R\x05hello\x125\n" + |
| 761 | "\x06report\x18\x02 \x01(\v2\x1b.eitri.v1.ActualStateReportH\x00R\x06reportB\x05\n" + | 1061 | "\x06report\x18\x02 \x01(\v2\x1b.eitri.v1.ActualStateReportH\x00R\x06report\x12@\n" + |
| 762 | "\x03msg\"T\n" + | 1062 | "\x0econsole_opened\x18\x03 \x01(\v2\x17.eitri.v1.ConsoleOpenedH\x00R\rconsoleOpened\x124\n" + |
| 1063 | "\n" + | ||
| 1064 | "tcp_opened\x18\x04 \x01(\v2\x13.eitri.v1.TCPOpenedH\x00R\ttcpOpenedB\x05\n" + | ||
| 1065 | "\x03msg\"\xc0\x01\n" + | ||
| 763 | "\rServerMessage\x12<\n" + | 1066 | "\rServerMessage\x12<\n" + |
| 764 | "\bsnapshot\x18\x01 \x01(\v2\x1e.eitri.v1.DesiredStateSnapshotH\x00R\bsnapshotB\x05\n" + | 1067 | "\bsnapshot\x18\x01 \x01(\v2\x1e.eitri.v1.DesiredStateSnapshotH\x00R\bsnapshot\x12:\n" + |
| 1068 | "\fconsole_open\x18\x02 \x01(\v2\x15.eitri.v1.ConsoleOpenH\x00R\vconsoleOpen\x12.\n" + | ||
| 1069 | "\btcp_open\x18\x03 \x01(\v2\x11.eitri.v1.TCPOpenH\x00R\atcpOpenB\x05\n" + | ||
| 765 | "\x03msg\"\x9b\x02\n" + | 1070 | "\x03msg\"\x9b\x02\n" + |
| 766 | "\x05Hello\x12\x17\n" + | 1071 | "\x05Hello\x12\x17\n" + |
| 767 | "\ahost_id\x18\x01 \x01(\tR\x06hostId\x12\x1a\n" + | 1072 | "\ahost_id\x18\x01 \x01(\tR\x06hostId\x12\x1a\n" + |
| @@ -799,7 +1104,7 @@ const file_proto_eitri_v1_sync_proto_rawDesc = "" + | |||
| 799 | "\vquarantined\x18\x03 \x03(\v2\x17.eitri.v1.QuarantinedVMR\vquarantined\x12.\n" + | 1104 | "\vquarantined\x18\x03 \x03(\v2\x17.eitri.v1.QuarantinedVMR\vquarantined\x12.\n" + |
| 800 | "\bcapacity\x18\x04 \x01(\v2\x12.eitri.v1.CapacityR\bcapacity\x12'\n" + | 1105 | "\bcapacity\x18\x04 \x01(\v2\x12.eitri.v1.CapacityR\bcapacity\x12'\n" + |
| 801 | "\x0ffence_violation\x18\x05 \x01(\bR\x0efenceViolation\x12&\n" + | 1106 | "\x0ffence_violation\x18\x05 \x01(\bR\x0efenceViolation\x12&\n" + |
| 802 | "\x0flast_seen_epoch\x18\x06 \x01(\x04R\rlastSeenEpoch\"\xe8\x02\n" + | 1107 | "\x0flast_seen_epoch\x18\x06 \x01(\x04R\rlastSeenEpoch\"\xfd\x03\n" + |
| 803 | "\tVMDesired\x12\x13\n" + | 1108 | "\tVMDesired\x12\x13\n" + |
| 804 | "\x05vm_id\x18\x01 \x01(\tR\x04vmId\x12\x12\n" + | 1109 | "\x05vm_id\x18\x01 \x01(\tR\x04vmId\x12\x12\n" + |
| 805 | "\x04name\x18\x02 \x01(\tR\x04name\x12\x1b\n" + | 1110 | "\x04name\x18\x02 \x01(\tR\x04name\x12\x1b\n" + |
| @@ -819,10 +1124,24 @@ const file_proto_eitri_v1_sync_proto_rawDesc = "" + | |||
| 819 | "\n" + | 1124 | "\n" + |
| 820 | "tombstoned\x18\v \x01(\bR\n" + | 1125 | "tombstoned\x18\v \x01(\bR\n" + |
| 821 | "tombstoned\x12,\n" + | 1126 | "tombstoned\x12,\n" + |
| 822 | "\x12ssh_authorized_key\x18\f \x01(\tR\x10sshAuthorizedKey\"S\n" + | 1127 | "\x12ssh_authorized_key\x18\f \x01(\tR\x10sshAuthorizedKey\x12:\n" + |
| 1128 | "\x1assh_user_ca_authorized_key\x18\x0f \x01(\tR\x16sshUserCaAuthorizedKey\x12'\n" + | ||
| 1129 | "\x10ssh_host_key_pem\x18\x10 \x01(\tR\rsshHostKeyPem\x12\"\n" + | ||
| 1130 | "\rssh_host_cert\x18\x11 \x01(\tR\vsshHostCertJ\x04\b\r\x10\x0eJ\x04\b\x0e\x10\x0f\"S\n" + | ||
| 823 | "\x14DesiredStateSnapshot\x12\x14\n" + | 1131 | "\x14DesiredStateSnapshot\x12\x14\n" + |
| 824 | "\x05epoch\x18\x01 \x01(\x04R\x05epoch\x12%\n" + | 1132 | "\x05epoch\x18\x01 \x01(\x04R\x05epoch\x12%\n" + |
| 825 | "\x03vms\x18\x02 \x03(\v2\x13.eitri.v1.VMDesiredR\x03vmsB&Z$github.com/a73x/eitri/internal/pb;pbb\x06proto3" | 1133 | "\x03vms\x18\x02 \x03(\v2\x13.eitri.v1.VMDesiredR\x03vms\"\"\n" + |
| 1134 | "\vConsoleOpen\x12\x13\n" + | ||
| 1135 | "\x05vm_id\x18\x01 \x01(\tR\x04vmId\"5\n" + | ||
| 1136 | "\rConsoleOpened\x12\x0e\n" + | ||
| 1137 | "\x02ok\x18\x01 \x01(\bR\x02ok\x12\x14\n" + | ||
| 1138 | "\x05error\x18\x02 \x01(\tR\x05error\"2\n" + | ||
| 1139 | "\aTCPOpen\x12\x13\n" + | ||
| 1140 | "\x05vm_id\x18\x01 \x01(\tR\x04vmId\x12\x12\n" + | ||
| 1141 | "\x04port\x18\x02 \x01(\rR\x04port\"1\n" + | ||
| 1142 | "\tTCPOpened\x12\x0e\n" + | ||
| 1143 | "\x02ok\x18\x01 \x01(\bR\x02ok\x12\x14\n" + | ||
| 1144 | "\x05error\x18\x02 \x01(\tR\x05errorB&Z$github.com/a73x/eitri/internal/pb;pbb\x06proto3" | ||
| 826 | 1145 | ||
| 827 | var ( | 1146 | var ( |
| 828 | file_proto_eitri_v1_sync_proto_rawDescOnce sync.Once | 1147 | file_proto_eitri_v1_sync_proto_rawDescOnce sync.Once |
| @@ -836,7 +1155,7 @@ func file_proto_eitri_v1_sync_proto_rawDescGZIP() []byte { | |||
| 836 | return file_proto_eitri_v1_sync_proto_rawDescData | 1155 | return file_proto_eitri_v1_sync_proto_rawDescData |
| 837 | } | 1156 | } |
| 838 | 1157 | ||
| 839 | var file_proto_eitri_v1_sync_proto_msgTypes = make([]protoimpl.MessageInfo, 9) | 1158 | var file_proto_eitri_v1_sync_proto_msgTypes = make([]protoimpl.MessageInfo, 13) |
| 840 | var file_proto_eitri_v1_sync_proto_goTypes = []any{ | 1159 | var file_proto_eitri_v1_sync_proto_goTypes = []any{ |
| 841 | (*AgentMessage)(nil), // 0: eitri.v1.AgentMessage | 1160 | (*AgentMessage)(nil), // 0: eitri.v1.AgentMessage |
| 842 | (*ServerMessage)(nil), // 1: eitri.v1.ServerMessage | 1161 | (*ServerMessage)(nil), // 1: eitri.v1.ServerMessage |
| @@ -847,21 +1166,29 @@ var file_proto_eitri_v1_sync_proto_goTypes = []any{ | |||
| 847 | (*ActualStateReport)(nil), // 6: eitri.v1.ActualStateReport | 1166 | (*ActualStateReport)(nil), // 6: eitri.v1.ActualStateReport |
| 848 | (*VMDesired)(nil), // 7: eitri.v1.VMDesired | 1167 | (*VMDesired)(nil), // 7: eitri.v1.VMDesired |
| 849 | (*DesiredStateSnapshot)(nil), // 8: eitri.v1.DesiredStateSnapshot | 1168 | (*DesiredStateSnapshot)(nil), // 8: eitri.v1.DesiredStateSnapshot |
| 1169 | (*ConsoleOpen)(nil), // 9: eitri.v1.ConsoleOpen | ||
| 1170 | (*ConsoleOpened)(nil), // 10: eitri.v1.ConsoleOpened | ||
| 1171 | (*TCPOpen)(nil), // 11: eitri.v1.TCPOpen | ||
| 1172 | (*TCPOpened)(nil), // 12: eitri.v1.TCPOpened | ||
| 850 | } | 1173 | } |
| 851 | var file_proto_eitri_v1_sync_proto_depIdxs = []int32{ | 1174 | var file_proto_eitri_v1_sync_proto_depIdxs = []int32{ |
| 852 | 2, // 0: eitri.v1.AgentMessage.hello:type_name -> eitri.v1.Hello | 1175 | 2, // 0: eitri.v1.AgentMessage.hello:type_name -> eitri.v1.Hello |
| 853 | 6, // 1: eitri.v1.AgentMessage.report:type_name -> eitri.v1.ActualStateReport | 1176 | 6, // 1: eitri.v1.AgentMessage.report:type_name -> eitri.v1.ActualStateReport |
| 854 | 8, // 2: eitri.v1.ServerMessage.snapshot:type_name -> eitri.v1.DesiredStateSnapshot | 1177 | 10, // 2: eitri.v1.AgentMessage.console_opened:type_name -> eitri.v1.ConsoleOpened |
| 855 | 3, // 3: eitri.v1.Hello.capacity:type_name -> eitri.v1.Capacity | 1178 | 12, // 3: eitri.v1.AgentMessage.tcp_opened:type_name -> eitri.v1.TCPOpened |
| 856 | 4, // 4: eitri.v1.ActualStateReport.vms:type_name -> eitri.v1.ActualVM | 1179 | 8, // 4: eitri.v1.ServerMessage.snapshot:type_name -> eitri.v1.DesiredStateSnapshot |
| 857 | 5, // 5: eitri.v1.ActualStateReport.quarantined:type_name -> eitri.v1.QuarantinedVM | 1180 | 9, // 5: eitri.v1.ServerMessage.console_open:type_name -> eitri.v1.ConsoleOpen |
| 858 | 3, // 6: eitri.v1.ActualStateReport.capacity:type_name -> eitri.v1.Capacity | 1181 | 11, // 6: eitri.v1.ServerMessage.tcp_open:type_name -> eitri.v1.TCPOpen |
| 859 | 7, // 7: eitri.v1.DesiredStateSnapshot.vms:type_name -> eitri.v1.VMDesired | 1182 | 3, // 7: eitri.v1.Hello.capacity:type_name -> eitri.v1.Capacity |
| 860 | 8, // [8:8] is the sub-list for method output_type | 1183 | 4, // 8: eitri.v1.ActualStateReport.vms:type_name -> eitri.v1.ActualVM |
| 861 | 8, // [8:8] is the sub-list for method input_type | 1184 | 5, // 9: eitri.v1.ActualStateReport.quarantined:type_name -> eitri.v1.QuarantinedVM |
| 862 | 8, // [8:8] is the sub-list for extension type_name | 1185 | 3, // 10: eitri.v1.ActualStateReport.capacity:type_name -> eitri.v1.Capacity |
| 863 | 8, // [8:8] is the sub-list for extension extendee | 1186 | 7, // 11: eitri.v1.DesiredStateSnapshot.vms:type_name -> eitri.v1.VMDesired |
| 864 | 0, // [0:8] is the sub-list for field type_name | 1187 | 12, // [12:12] is the sub-list for method output_type |
| 1188 | 12, // [12:12] is the sub-list for method input_type | ||
| 1189 | 12, // [12:12] is the sub-list for extension type_name | ||
| 1190 | 12, // [12:12] is the sub-list for extension extendee | ||
| 1191 | 0, // [0:12] is the sub-list for field type_name | ||
| 865 | } | 1192 | } |
| 866 | 1193 | ||
| 867 | func init() { file_proto_eitri_v1_sync_proto_init() } | 1194 | func init() { file_proto_eitri_v1_sync_proto_init() } |
| @@ -872,9 +1199,13 @@ func file_proto_eitri_v1_sync_proto_init() { | |||
| 872 | file_proto_eitri_v1_sync_proto_msgTypes[0].OneofWrappers = []any{ | 1199 | file_proto_eitri_v1_sync_proto_msgTypes[0].OneofWrappers = []any{ |
| 873 | (*AgentMessage_Hello)(nil), | 1200 | (*AgentMessage_Hello)(nil), |
| 874 | (*AgentMessage_Report)(nil), | 1201 | (*AgentMessage_Report)(nil), |
| 1202 | (*AgentMessage_ConsoleOpened)(nil), | ||
| 1203 | (*AgentMessage_TcpOpened)(nil), | ||
| 875 | } | 1204 | } |
| 876 | file_proto_eitri_v1_sync_proto_msgTypes[1].OneofWrappers = []any{ | 1205 | file_proto_eitri_v1_sync_proto_msgTypes[1].OneofWrappers = []any{ |
| 877 | (*ServerMessage_Snapshot)(nil), | 1206 | (*ServerMessage_Snapshot)(nil), |
| 1207 | (*ServerMessage_ConsoleOpen)(nil), | ||
| 1208 | (*ServerMessage_TcpOpen)(nil), | ||
| 878 | } | 1209 | } |
| 879 | type x struct{} | 1210 | type x struct{} |
| 880 | out := protoimpl.TypeBuilder{ | 1211 | out := protoimpl.TypeBuilder{ |
| @@ -882,7 +1213,7 @@ func file_proto_eitri_v1_sync_proto_init() { | |||
| 882 | GoPackagePath: reflect.TypeOf(x{}).PkgPath(), | 1213 | GoPackagePath: reflect.TypeOf(x{}).PkgPath(), |
| 883 | RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_eitri_v1_sync_proto_rawDesc), len(file_proto_eitri_v1_sync_proto_rawDesc)), | 1214 | RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_eitri_v1_sync_proto_rawDesc), len(file_proto_eitri_v1_sync_proto_rawDesc)), |
| 884 | NumEnums: 0, | 1215 | NumEnums: 0, |
| 885 | NumMessages: 9, | 1216 | NumMessages: 13, |
| 886 | NumExtensions: 0, | 1217 | NumExtensions: 0, |
| 887 | NumServices: 0, | 1218 | NumServices: 0, |
| 888 | }, | 1219 | }, |
internal/server/api/api.go
| Old | New | ||
|---|---|---|---|
| @@ -15,7 +15,9 @@ import ( | |||
| 15 | "strings" | 15 | "strings" |
| 16 | "time" | 16 | "time" |
| 17 | 17 | ||
| 18 | "github.com/a73x/eitri/internal/cloudinit" | ||
| 18 | "github.com/a73x/eitri/internal/joinblob" | 19 | "github.com/a73x/eitri/internal/joinblob" |
| 20 | "github.com/a73x/eitri/internal/names" | ||
| 19 | "github.com/a73x/eitri/internal/server/api/types" | 21 | "github.com/a73x/eitri/internal/server/api/types" |
| 20 | "github.com/a73x/eitri/internal/server/hosttoken" | 22 | "github.com/a73x/eitri/internal/server/hosttoken" |
| 21 | "github.com/a73x/eitri/internal/server/hub" | 23 | "github.com/a73x/eitri/internal/server/hub" |
| @@ -23,10 +25,7 @@ import ( | |||
| 23 | "github.com/a73x/eitri/internal/server/store" | 25 | "github.com/a73x/eitri/internal/server/store" |
| 24 | ) | 26 | ) |
| 25 | 27 | ||
| 26 | // rfc1123Label matches valid RFC-1123 DNS label names. | 28 | // VM names are validated as RFC-1123 DNS labels via internal/names. |
| 27 | // Rules: lowercase alphanum start/end, lowercase alphanum or hyphen in between, | ||
| 28 | // max 63 characters total. | ||
| 29 | var rfc1123Label = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`) | ||
| 30 | 29 | ||
| 31 | // sha256Hex matches exactly 64 lowercase hex characters. | 30 | // sha256Hex matches exactly 64 lowercase hex characters. |
| 32 | var sha256Hex = regexp.MustCompile(`^[a-f0-9]{64}$`) | 31 | var sha256Hex = regexp.MustCompile(`^[a-f0-9]{64}$`) |
| @@ -54,8 +53,12 @@ type API struct { | |||
| 54 | reg *registry.Registry | 53 | reg *registry.Registry |
| 55 | hub *hub.Hub | 54 | hub *hub.Hub |
| 56 | notif *notifier | 55 | notif *notifier |
| 57 | enrolls *ipLimiter // per-client-bucket brake on the unauthenticated enroll endpoint (v4: address, v6: /64) | 56 | enrolls *ipLimiter // per-client-bucket brake on the unauthenticated enroll endpoint (v4: address, v6: /64) |
| 58 | tickets *ticketStore // one-time SSE stream tickets | 57 | tickets *ticketStore // one-time SSE stream tickets |
| 58 | console ConsoleDialer // nil until main wires syncsvc (SetConsoleDialer) | ||
| 59 | certs CertMinter // nil until main wires the SSH user CA (SetCertMinter); nil ⇒ gate off | ||
| 60 | hostCerts HostCertMinter // nil until main wires the SSH host CA (SetHostCertMinter); nil ⇒ gate off | ||
| 61 | sshCAKey string // eitri CA public key (authorized_keys form) served by GET /api/v1/ssh-ca; empty ⇒ gate off | ||
| 59 | } | 62 | } |
| 60 | 63 | ||
| 61 | // New constructs an API. | 64 | // New constructs an API. |
| @@ -179,9 +182,6 @@ func decodeJSON(w http.ResponseWriter, r *http.Request, v any) bool { | |||
| 179 | 182 | ||
| 180 | // --- enrollment --- | 183 | // --- enrollment --- |
| 181 | 184 | ||
| 182 | // validOverlays is the set of accepted overlay values at enrollment. | ||
| 183 | var validOverlays = map[string]bool{"tailscale": true, "none": true} | ||
| 184 | |||
| 185 | // audit appends an audit row, mirrored to the live log. Failures are logged, | 185 | // audit appends an audit row, mirrored to the live log. Failures are logged, |
| 186 | // never fatal — the audit trail must not break the operation it records. | 186 | // never fatal — the audit trail must not break the operation it records. |
| 187 | // BEST-EFFORT: rows written here can be lost on a crash between the audited | 187 | // BEST-EFFORT: rows written here can be lost on a crash between the audited |
| @@ -222,15 +222,7 @@ func (a *API) handleEnroll(w http.ResponseWriter, r *http.Request) { | |||
| 222 | if !decodeJSON(w, r, &req) { | 222 | if !decodeJSON(w, r, &req) { |
| 223 | return | 223 | return |
| 224 | } | 224 | } |
| 225 | // Default overlay to "tailscale" when omitted; reject unknown values. | 225 | host, err := a.st.RedeemEnrollmentToken(req.Token, req.Name, req.OS, req.Arch, req.Provisioner, clientIP(r)) |
| 226 | if req.Overlay == "" { | ||
| 227 | req.Overlay = "tailscale" | ||
| 228 | } | ||
| 229 | if !validOverlays[req.Overlay] { | ||
| 230 | httpError(w, "overlay must be one of: tailscale, none", http.StatusBadRequest) | ||
| 231 | return | ||
| 232 | } | ||
| 233 | host, err := a.st.RedeemEnrollmentToken(req.Token, req.Name, req.OS, req.Arch, req.Provisioner, req.Overlay, clientIP(r)) | ||
| 234 | if err != nil { | 226 | if err != nil { |
| 235 | a.audit("host.enroll.denied", map[string]string{ | 227 | a.audit("host.enroll.denied", map[string]string{ |
| 236 | "remote": clientIP(r), "name": truncate(req.Name, 64), | 228 | "remote": clientIP(r), "name": truncate(req.Name, 64), |
| @@ -244,7 +236,6 @@ func (a *API) handleEnroll(w http.ResponseWriter, r *http.Request) { | |||
| 244 | BridgeCIDR: host.BridgeCIDR, | 236 | BridgeCIDR: host.BridgeCIDR, |
| 245 | Credential: cred, | 237 | Credential: cred, |
| 246 | HostID: host.ID, | 238 | HostID: host.ID, |
| 247 | Overlay: host.Overlay, | ||
| 248 | ServerCertSHA256: a.cfg.ServerCertSHA256, | 239 | ServerCertSHA256: a.cfg.ServerCertSHA256, |
| 249 | }) | 240 | }) |
| 250 | } | 241 | } |
| @@ -276,7 +267,6 @@ func toHostResponse(h store.Host, st registry.HostState, ok bool, alloc store.Al | |||
| 276 | OS: h.OS, | 267 | OS: h.OS, |
| 277 | Arch: h.Arch, | 268 | Arch: h.Arch, |
| 278 | Provisioner: h.Provisioner, | 269 | Provisioner: h.Provisioner, |
| 279 | Overlay: h.Overlay, | ||
| 280 | BridgeCIDR: h.BridgeCIDR, | 270 | BridgeCIDR: h.BridgeCIDR, |
| 281 | Status: h.Status, | 271 | Status: h.Status, |
| 282 | EnrolledAt: h.EnrolledAt, | 272 | EnrolledAt: h.EnrolledAt, |
| @@ -329,7 +319,7 @@ func (a *API) handleListHosts(w http.ResponseWriter, r *http.Request) { | |||
| 329 | 319 | ||
| 330 | // toVMResponse merges a durable VM row with live agent-reported actual-state | 320 | // toVMResponse merges a durable VM row with live agent-reported actual-state |
| 331 | // into the wire shape (types.VM). | 321 | // into the wire shape (types.VM). |
| 332 | func toVMResponse(vm store.VM, actualPower, phase string) types.VM { | 322 | func toVMResponse(vm store.VM, actualPower, phase string, destroyAt int64) types.VM { |
| 333 | return types.VM{ | 323 | return types.VM{ |
| 334 | ID: vm.ID, | 324 | ID: vm.ID, |
| 335 | HostID: vm.HostID, | 325 | HostID: vm.HostID, |
| @@ -347,9 +337,40 @@ func toVMResponse(vm store.VM, actualPower, phase string) types.VM { | |||
| 347 | Deleted: vm.DeletedAt != nil, | 337 | Deleted: vm.DeletedAt != nil, |
| 348 | ActualPower: actualPower, | 338 | ActualPower: actualPower, |
| 349 | Phase: phase, | 339 | Phase: phase, |
| 340 | DestroyAt: destroyAt, | ||
| 341 | Lifecycle: deriveLifecycle(vm, actualPower, phase), | ||
| 350 | } | 342 | } |
| 351 | } | 343 | } |
| 352 | 344 | ||
| 345 | // deriveLifecycle folds the orthogonal state axes into one coarse lifecycle | ||
| 346 | // word. This MUST stay in lockstep with vmStatus() in | ||
| 347 | // web/src/lib/fleet.svelte.ts — the client fold is retained so the UI degrades | ||
| 348 | // gracefully against an older server, and the two must not disagree. | ||
| 349 | func deriveLifecycle(vm store.VM, actualPower, phase string) string { | ||
| 350 | if vm.DeletedAt != nil { | ||
| 351 | return "deleting" | ||
| 352 | } | ||
| 353 | // Prefer the agent's live phase; fall back to the desired status. | ||
| 354 | if phase == "" { | ||
| 355 | phase = vm.Status | ||
| 356 | } | ||
| 357 | switch phase { | ||
| 358 | case "failed": | ||
| 359 | return "failed" | ||
| 360 | case "creating", "pending", "": | ||
| 361 | return "creating" | ||
| 362 | } | ||
| 363 | // phase is ready from here — reconcile power. | ||
| 364 | power := actualPower | ||
| 365 | if power == "" { | ||
| 366 | power = vm.PowerState | ||
| 367 | } | ||
| 368 | if power != "running" { | ||
| 369 | return "stopped" | ||
| 370 | } | ||
| 371 | return "ready" | ||
| 372 | } | ||
| 373 | |||
| 353 | // snapshotVMs builds the wire VM list (durable VM rows merged with live | 374 | // snapshotVMs builds the wire VM list (durable VM rows merged with live |
| 354 | // actual-state from the registry). Used by GET /vms; the SSE stream uses | 375 | // actual-state from the registry). Used by GET /vms; the SSE stream uses |
| 355 | // buildVMResponses over a single-tx store.Snapshot instead. | 376 | // buildVMResponses over a single-tx store.Snapshot instead. |
| @@ -366,6 +387,7 @@ func (a *API) buildVMResponses(vms []store.VM) []types.VM { | |||
| 366 | out := make([]types.VM, len(vms)) | 387 | out := make([]types.VM, len(vms)) |
| 367 | for i, vm := range vms { | 388 | for i, vm := range vms { |
| 368 | var actualPower, phase string | 389 | var actualPower, phase string |
| 390 | var destroyAt int64 | ||
| 369 | if st, ok := a.reg.Get(vm.HostID); ok { | 391 | if st, ok := a.reg.Get(vm.HostID); ok { |
| 370 | for _, av := range st.Report.VMs { | 392 | for _, av := range st.Report.VMs { |
| 371 | if av.VMID == vm.ID { | 393 | if av.VMID == vm.ID { |
| @@ -374,8 +396,16 @@ func (a *API) buildVMResponses(vms []store.VM) []types.VM { | |||
| 374 | break | 396 | break |
| 375 | } | 397 | } |
| 376 | } | 398 | } |
| 399 | // A tombstoned VM the agent has stopped and quarantined carries a | ||
| 400 | // hard destroy deadline; surface it so clients can render a countdown. | ||
| 401 | for _, qv := range st.Report.Quarantined { | ||
| 402 | if qv.VMID == vm.ID { | ||
| 403 | destroyAt = qv.DestroyAtUnix | ||
| 404 | break | ||
| 405 | } | ||
| 406 | } | ||
| 377 | } | 407 | } |
| 378 | out[i] = toVMResponse(vm, actualPower, phase) | 408 | out[i] = toVMResponse(vm, actualPower, phase, destroyAt) |
| 379 | } | 409 | } |
| 380 | return out | 410 | return out |
| 381 | } | 411 | } |
| @@ -424,7 +454,7 @@ func (a *API) applyVMDefaults(req *types.CreateVMRequest) (string, int) { | |||
| 424 | func validateCreateVM(req *types.CreateVMRequest) (string, int) { | 454 | func validateCreateVM(req *types.CreateVMRequest) (string, int) { |
| 425 | // Name becomes the guest hostname and is embedded into cloud-init YAML, so it | 455 | // Name becomes the guest hostname and is embedded into cloud-init YAML, so it |
| 426 | // must be a valid RFC-1123 DNS label. | 456 | // must be a valid RFC-1123 DNS label. |
| 427 | if !rfc1123Label.MatchString(req.Name) { | 457 | if !names.IsRFC1123Label(req.Name) { |
| 428 | return "invalid name", http.StatusBadRequest | 458 | return "invalid name", http.StatusBadRequest |
| 429 | } | 459 | } |
| 430 | // SSH key must be single-line: a newline would allow YAML injection into the | 460 | // SSH key must be single-line: a newline would allow YAML injection into the |
| @@ -465,6 +495,32 @@ func (a *API) handleCreateVM(w http.ResponseWriter, r *http.Request) { | |||
| 465 | return | 495 | return |
| 466 | } | 496 | } |
| 467 | 497 | ||
| 498 | // Install the SSH key into user-supplied cloud-init. When only one of the | ||
| 499 | // two is set the seed builder handles it (verbatim user-data, or the | ||
| 500 | // generated default template); it's the BOTH case that used to silently | ||
| 501 | // drop the key. AddSSHKey is format-aware: it merges into a #cloud-config, | ||
| 502 | // or wraps other formats (shell script, etc.) in a MIME archive with a key | ||
| 503 | // part — eitri never edits the user's payload, only adds to it. An | ||
| 504 | // un-handleable format (jinja/gzip) is a clear 400, not a silent no-op. | ||
| 505 | // | ||
| 506 | // Two things worth knowing: (1) the stored user-data is the REWRITTEN form | ||
| 507 | // (a merged doc or a MIME archive), not the exact text submitted — write-only, | ||
| 508 | // so it is never echoed back. (2) unlike the key-only path (which generates a | ||
| 509 | // full users: block with sudo), here the key is added to the default user | ||
| 510 | // only; on a stock Ubuntu image that is `ubuntu`, but a custom base image | ||
| 511 | // with a different default user gets the key there. The key is embedded as a | ||
| 512 | // YAML scalar node, so it cannot inject structure (validateCreateVM's | ||
| 513 | // single-line check is belt-and-suspenders, not the load-bearing guard). | ||
| 514 | if req.CloudInit != "" && req.SSHAuthorizedKey != "" { | ||
| 515 | merged, err := cloudinit.AddSSHKey(req.CloudInit, req.SSHAuthorizedKey) | ||
| 516 | if err != nil { | ||
| 517 | httpError(w, "cannot add ssh_authorized_key to cloud_init: "+err.Error(), http.StatusBadRequest) | ||
| 518 | return | ||
| 519 | } | ||
| 520 | req.CloudInit = merged | ||
| 521 | req.SSHAuthorizedKey = "" // installed into cloud-init; don't also carry it separately | ||
| 522 | } | ||
| 523 | |||
| 468 | // Generate ID here so we can return it. | 524 | // Generate ID here so we can return it. |
| 469 | id := store.RandHex(16) | 525 | id := store.RandHex(16) |
| 470 | 526 | ||
| @@ -483,6 +539,21 @@ func (a *API) handleCreateVM(w http.ResponseWriter, r *http.Request) { | |||
| 483 | PowerState: req.PowerState, | 539 | PowerState: req.PowerState, |
| 484 | } | 540 | } |
| 485 | 541 | ||
| 542 | // When the jump gate is enabled, mint a persistent per-VM host key + CA-signed | ||
| 543 | // host cert (principal = VM name) once at create, so the VM presents a | ||
| 544 | // verifiable host key clients accept via `@cert-authority` — no TOFU, no | ||
| 545 | // host-key-changed warnings when names/IPs recycle. The private key is | ||
| 546 | // WRITE-ONLY: stored, shipped to the guest via seed, never echoed or logged. | ||
| 547 | if a.hostCerts != nil { | ||
| 548 | keyPEM, cert, err := a.hostCerts.MintHostCert(req.Name) | ||
| 549 | if err != nil { | ||
| 550 | httpError(w, "internal error", http.StatusInternalServerError) | ||
| 551 | return | ||
| 552 | } | ||
| 553 | vm.SSHHostKey = keyPEM | ||
| 554 | vm.SSHHostCert = cert | ||
| 555 | } | ||
| 556 | |||
| 486 | if err := a.st.CreateVM(vm); err != nil { | 557 | if err := a.st.CreateVM(vm); err != nil { |
| 487 | switch { | 558 | switch { |
| 488 | case errors.Is(err, store.ErrNameTaken): | 559 | case errors.Is(err, store.ErrNameTaken): |
| @@ -495,6 +566,7 @@ func (a *API) handleCreateVM(w http.ResponseWriter, r *http.Request) { | |||
| 495 | return | 566 | return |
| 496 | } | 567 | } |
| 497 | 568 | ||
| 569 | a.audit("vm.create", map[string]string{"vm_id": id, "name": req.Name, "host_id": req.HostID}) | ||
| 498 | a.hub.Poke(req.HostID) | 570 | a.hub.Poke(req.HostID) |
| 499 | a.notif.notify() | 571 | a.notif.notify() |
| 500 | writeJSON(w, http.StatusCreated, types.CreateVMResponse{ID: id, Name: req.Name}) | 572 | writeJSON(w, http.StatusCreated, types.CreateVMResponse{ID: id, Name: req.Name}) |
| @@ -518,8 +590,11 @@ func (a *API) handlePatchVM(w http.ResponseWriter, r *http.Request) { | |||
| 518 | httpError(w, "internal error", http.StatusInternalServerError) | 590 | httpError(w, "internal error", http.StatusInternalServerError) |
| 519 | return | 591 | return |
| 520 | } | 592 | } |
| 521 | // Find owning host and poke. | 593 | // The row survives a power change, so we can still scan it for name/host. |
| 522 | a.pokeVMHost(id) | 594 | if vm, ok := a.vmByID(id); ok { |
| 595 | a.audit("vm.power", map[string]string{"vm_id": id, "name": vm.Name, "power": req.PowerState}) | ||
| 596 | a.hub.Poke(vm.HostID) | ||
| 597 | } | ||
| 523 | a.notif.notify() | 598 | a.notif.notify() |
| 524 | w.WriteHeader(http.StatusNoContent) | 599 | w.WriteHeader(http.StatusNoContent) |
| 525 | } | 600 | } |
| @@ -534,23 +609,50 @@ func (a *API) handleDeleteVM(w http.ResponseWriter, r *http.Request) { | |||
| 534 | httpError(w, "internal error", http.StatusInternalServerError) | 609 | httpError(w, "internal error", http.StatusInternalServerError) |
| 535 | return | 610 | return |
| 536 | } | 611 | } |
| 537 | // TombstoneVM keeps the row, so we can still scan for host_id. | 612 | // TombstoneVM keeps the row, so we can still scan for name/host. |
| 538 | a.pokeVMHost(id) | 613 | if vm, ok := a.vmByID(id); ok { |
| 614 | a.audit("vm.delete", map[string]string{"vm_id": id, "name": vm.Name}) | ||
| 615 | a.hub.Poke(vm.HostID) | ||
| 616 | } | ||
| 617 | a.notif.notify() | ||
| 618 | w.WriteHeader(http.StatusNoContent) | ||
| 619 | } | ||
| 620 | |||
| 621 | // handleRestoreVM un-tombstones a VM that is still within the teardown grace | ||
| 622 | // window (row present, not yet hard-deleted). The store reverses the tombstone | ||
| 623 | // and bumps the epoch; the agent's un-delete path re-adopts the guest and | ||
| 624 | // converges it back toward its power_state — so the server only pokes. | ||
| 625 | func (a *API) handleRestoreVM(w http.ResponseWriter, r *http.Request) { | ||
| 626 | id := r.PathValue("id") | ||
| 627 | if err := a.st.RestoreVM(id); err != nil { | ||
| 628 | if errors.Is(err, sql.ErrNoRows) { | ||
| 629 | httpError(w, "vm not restorable (already destroyed or not deleted)", http.StatusConflict) | ||
| 630 | return | ||
| 631 | } | ||
| 632 | httpError(w, "internal error", http.StatusInternalServerError) | ||
| 633 | return | ||
| 634 | } | ||
| 635 | // RestoreVM clears deleted_at, so the row is back in desired state; scan it | ||
| 636 | // for name/host. A lookup miss is non-fatal — the restore already succeeded. | ||
| 637 | if vm, ok := a.vmByID(id); ok { | ||
| 638 | a.audit("vm.restore", map[string]string{"vm_id": id, "name": vm.Name}) | ||
| 639 | a.hub.Poke(vm.HostID) | ||
| 640 | } | ||
| 539 | a.notif.notify() | 641 | a.notif.notify() |
| 540 | w.WriteHeader(http.StatusNoContent) | 642 | w.WriteHeader(http.StatusNoContent) |
| 541 | } | 643 | } |
| 542 | 644 | ||
| 543 | // pokeVMHost scans ListVMs to find the owning host and pokes it. | 645 | // vmByID scans ListVMs for the desired-state VM row with the given id. |
| 544 | // Phase 1 scale: linear scan is acceptable. | 646 | // Phase 1 scale: linear scan is acceptable (callers need name + host_id). |
| 545 | func (a *API) pokeVMHost(vmID string) { | 647 | func (a *API) vmByID(vmID string) (store.VM, bool) { |
| 546 | vms, err := a.st.ListVMs() | 648 | vms, err := a.st.ListVMs() |
| 547 | if err != nil { | 649 | if err != nil { |
| 548 | return | 650 | return store.VM{}, false |
| 549 | } | 651 | } |
| 550 | for _, vm := range vms { | 652 | for _, vm := range vms { |
| 551 | if vm.ID == vmID { | 653 | if vm.ID == vmID { |
| 552 | a.hub.Poke(vm.HostID) | 654 | return vm, true |
| 553 | return | ||
| 554 | } | 655 | } |
| 555 | } | 656 | } |
| 657 | return store.VM{}, false | ||
| 556 | } | 658 | } |
internal/server/api/api_test.go
| Old | New | ||
|---|---|---|---|
| @@ -46,12 +46,13 @@ func TestResponseJSONKeysAreSnakeCase(t *testing.T) { | |||
| 46 | h := items[0] | 46 | h := items[0] |
| 47 | 47 | ||
| 48 | // Required snake_case keys must be present. | 48 | // Required snake_case keys must be present. |
| 49 | for _, k := range []string{"id", "name", "os", "arch", "provisioner", "overlay", "bridge_cidr", "status", "enrolled_at", "online", "capacity"} { | 49 | for _, k := range []string{"id", "name", "os", "arch", "provisioner", "bridge_cidr", "status", "enrolled_at", "online", "capacity"} { |
| 50 | assert.Contains(t, h, k, "host response must contain key %q", k) | 50 | assert.Contains(t, h, k, "host response must contain key %q", k) |
| 51 | } | 51 | } |
| 52 | // PascalCase keys from embedded store.Host must be absent. | 52 | // PascalCase keys from embedded store.Host must be absent, and overlay |
| 53 | for _, k := range []string{"ID", "Name", "OS", "Arch", "Provisioner", "Overlay", "BridgeCIDR", "Status", "EnrolledAt"} { | 53 | // is gone entirely (deleted server-side — Plan D). |
| 54 | assert.NotContains(t, h, k, "host response must NOT contain PascalCase key %q", k) | 54 | for _, k := range []string{"ID", "Name", "OS", "Arch", "Provisioner", "Overlay", "BridgeCIDR", "Status", "EnrolledAt", "overlay"} { |
| 55 | assert.NotContains(t, h, k, "host response must NOT contain key %q", k) | ||
| 55 | } | 56 | } |
| 56 | // Capacity sub-object must use snake_case. | 57 | // Capacity sub-object must use snake_case. |
| 57 | cap, ok := h["capacity"].(map[string]any) | 58 | cap, ok := h["capacity"].(map[string]any) |
| @@ -76,6 +77,7 @@ func TestResponseJSONKeysAreSnakeCase(t *testing.T) { | |||
| 76 | "id", "host_id", "name", "image_url", "vcpus", "mem_mb", "disk_gb", | 77 | "id", "host_id", "name", "image_url", "vcpus", "mem_mb", "disk_gb", |
| 77 | "persistent", "power_state", "status", "last_error", "assigned_ip", | 78 | "persistent", "power_state", "status", "last_error", "assigned_ip", |
| 78 | "created_at", "deleted", "actual_power", "phase", | 79 | "created_at", "deleted", "actual_power", "phase", |
| 80 | "destroy_at", "lifecycle", | ||
| 79 | } { | 81 | } { |
| 80 | assert.Contains(t, v, k, "vm response must contain key %q", k) | 82 | assert.Contains(t, v, k, "vm response must contain key %q", k) |
| 81 | } | 83 | } |
| @@ -89,7 +91,10 @@ func TestResponseJSONKeysAreSnakeCase(t *testing.T) { | |||
| 89 | assert.NotContains(t, v, k, "vm response must NOT contain PascalCase key %q", k) | 91 | assert.NotContains(t, v, k, "vm response must NOT contain PascalCase key %q", k) |
| 90 | } | 92 | } |
| 91 | // Write-only fields must not be on the wire. | 93 | // Write-only fields must not be on the wire. |
| 92 | for _, k := range []string{"image_sha256", "cloud_init", "ssh_authorized_key"} { | 94 | for _, k := range []string{ |
| 95 | "image_sha256", "cloud_init", "ssh_authorized_key", | ||
| 96 | "ssh_host_key", "ssh_host_cert", | ||
| 97 | } { | ||
| 93 | assert.NotContains(t, v, k, "write-only field %q must not appear in response", k) | 98 | assert.NotContains(t, v, k, "write-only field %q must not appear in response", k) |
| 94 | } | 99 | } |
| 95 | // deleted should be false (not tombstoned). | 100 | // deleted should be false (not tombstoned). |
| @@ -126,12 +131,15 @@ func TestCreateVMUnknownHostReturns400(t *testing.T) { | |||
| 126 | assert.Equal(t, 400, resp.StatusCode) | 131 | assert.Equal(t, 400, resp.StatusCode) |
| 127 | } | 132 | } |
| 128 | 133 | ||
| 129 | func testServer(t *testing.T) (*httptest.Server, *store.Store, *hub.Hub) { | 134 | // newServer is the shared builder. It also returns the *API itself for tests |
| 135 | // that need post-construction wiring (SetConsoleDialer, SetCertMinter). | ||
| 136 | func newServer(t *testing.T) (*httptest.Server, *store.Store, *hub.Hub, *registry.Registry, *API) { | ||
| 130 | t.Helper() | 137 | t.Helper() |
| 131 | st, err := store.Open(t.TempDir()+"/db", "10.77.0.0/16") | 138 | st, err := store.Open(t.TempDir()+"/db", "10.77.0.0/16") |
| 132 | require.NoError(t, err) | 139 | require.NoError(t, err) |
| 133 | t.Cleanup(func() { st.Close() }) | 140 | t.Cleanup(func() { st.Close() }) |
| 134 | h := hub.New() | 141 | h := hub.New() |
| 142 | reg := registry.New(time.Now) | ||
| 135 | a := New(Config{ | 143 | a := New(Config{ |
| 136 | AdminToken: "admintok", | 144 | AdminToken: "admintok", |
| 137 | HostSecret: []byte("hostsecret"), | 145 | HostSecret: []byte("hostsecret"), |
| @@ -141,9 +149,15 @@ func testServer(t *testing.T) (*httptest.Server, *store.Store, *hub.Hub) { | |||
| 141 | AdvertiseHTTP: "http://127.0.0.1:8080", | 149 | AdvertiseHTTP: "http://127.0.0.1:8080", |
| 142 | AdvertiseQUIC: "127.0.0.1:8443", | 150 | AdvertiseQUIC: "127.0.0.1:8443", |
| 143 | ServerCertSHA256: strings.Repeat("c", 64), | 151 | ServerCertSHA256: strings.Repeat("c", 64), |
| 144 | }, st, registry.New(time.Now), h) | 152 | }, st, reg, h) |
| 145 | ts := httptest.NewServer(a.Handler()) | 153 | ts := httptest.NewServer(a.Handler()) |
| 146 | t.Cleanup(ts.Close) | 154 | t.Cleanup(ts.Close) |
| 155 | return ts, st, h, reg, a | ||
| 156 | } | ||
| 157 | |||
| 158 | func testServer(t *testing.T) (*httptest.Server, *store.Store, *hub.Hub) { | ||
| 159 | t.Helper() | ||
| 160 | ts, st, h, _, _ := newServer(t) | ||
| 147 | return ts, st, h | 161 | return ts, st, h |
| 148 | } | 162 | } |
| 149 | 163 | ||
| @@ -177,76 +191,6 @@ func enroll(t *testing.T, ts *httptest.Server) map[string]string { | |||
| 177 | return out // host_id, credential, bridge_cidr | 191 | return out // host_id, credential, bridge_cidr |
| 178 | } | 192 | } |
| 179 | 193 | ||
| 180 | // Fix 5: overlay field in enroll request/response tests. | ||
| 181 | |||
| 182 | func TestEnrollWithOverlayNone_PersistsAndReturnsOverlay(t *testing.T) { | ||
| 183 | ts, st, _ := testServer(t) | ||
| 184 | |||
| 185 | resp := do(t, "POST", ts.URL+"/api/v1/enroll-tokens", "admintok", nil) | ||
| 186 | require.Equal(t, 201, resp.StatusCode) | ||
| 187 | var tok map[string]string | ||
| 188 | json.NewDecoder(resp.Body).Decode(&tok) | ||
| 189 | |||
| 190 | // Enroll with overlay=none. | ||
| 191 | resp2 := do(t, "POST", ts.URL+"/api/v1/enroll", "", map[string]string{ | ||
| 192 | "token": tok["token"], | ||
| 193 | "name": "host-none", | ||
| 194 | "os": "linux", | ||
| 195 | "arch": "amd64", | ||
| 196 | "provisioner": "cloudhv", | ||
| 197 | "overlay": "none", | ||
| 198 | }) | ||
| 199 | require.Equal(t, 201, resp2.StatusCode) | ||
| 200 | var enrollOut map[string]string | ||
| 201 | json.NewDecoder(resp2.Body).Decode(&enrollOut) | ||
| 202 | require.NotEmpty(t, enrollOut["host_id"]) | ||
| 203 | |||
| 204 | // Verify persistence: list hosts, check overlay field. | ||
| 205 | hosts, err := st.ListHosts() | ||
| 206 | require.NoError(t, err) | ||
| 207 | var found bool | ||
| 208 | for _, h := range hosts { | ||
| 209 | if h.ID == enrollOut["host_id"] { | ||
| 210 | assert.Equal(t, "none", h.Overlay, "overlay must be persisted as 'none'") | ||
| 211 | found = true | ||
| 212 | } | ||
| 213 | } | ||
| 214 | assert.True(t, found, "enrolled host must appear in store") | ||
| 215 | } | ||
| 216 | |||
| 217 | func TestEnrollWithBogusOverlay_Returns400(t *testing.T) { | ||
| 218 | ts, _, _ := testServer(t) | ||
| 219 | |||
| 220 | resp := do(t, "POST", ts.URL+"/api/v1/enroll-tokens", "admintok", nil) | ||
| 221 | require.Equal(t, 201, resp.StatusCode) | ||
| 222 | var tok map[string]string | ||
| 223 | json.NewDecoder(resp.Body).Decode(&tok) | ||
| 224 | |||
| 225 | resp2 := do(t, "POST", ts.URL+"/api/v1/enroll", "", map[string]string{ | ||
| 226 | "token": tok["token"], | ||
| 227 | "name": "host-bad", | ||
| 228 | "os": "linux", | ||
| 229 | "arch": "amd64", | ||
| 230 | "provisioner": "cloudhv", | ||
| 231 | "overlay": "wireguard", // unsupported value | ||
| 232 | }) | ||
| 233 | assert.Equal(t, 400, resp2.StatusCode, "bogus overlay must return 400") | ||
| 234 | } | ||
| 235 | |||
| 236 | func TestEnrollHostsResponseContainsOverlayField(t *testing.T) { | ||
| 237 | ts, _, _ := testServer(t) | ||
| 238 | out := enroll(t, ts) | ||
| 239 | _ = out | ||
| 240 | |||
| 241 | resp := do(t, "GET", ts.URL+"/api/v1/hosts", "admintok", nil) | ||
| 242 | require.Equal(t, 200, resp.StatusCode) | ||
| 243 | items := decodeJSONKeys(t, resp) | ||
| 244 | require.Len(t, items, 1) | ||
| 245 | h := items[0] | ||
| 246 | assert.Contains(t, h, "overlay", "hostResponse must include 'overlay' field") | ||
| 247 | assert.Equal(t, "tailscale", h["overlay"], "default overlay must be 'tailscale'") | ||
| 248 | } | ||
| 249 | |||
| 250 | func TestAdminAuthRequired(t *testing.T) { | 194 | func TestAdminAuthRequired(t *testing.T) { |
| 251 | ts, _, _ := testServer(t) | 195 | ts, _, _ := testServer(t) |
| 252 | assert.Equal(t, 401, do(t, "GET", ts.URL+"/api/v1/vms", "", nil).StatusCode) | 196 | assert.Equal(t, 401, do(t, "GET", ts.URL+"/api/v1/vms", "", nil).StatusCode) |
| @@ -299,6 +243,49 @@ func TestDeleteTombstones(t *testing.T) { | |||
| 299 | assert.NotNil(t, vms[0].DeletedAt, "DELETE tombstones; the agent reaps") | 243 | assert.NotNil(t, vms[0].DeletedAt, "DELETE tombstones; the agent reaps") |
| 300 | } | 244 | } |
| 301 | 245 | ||
| 246 | // TestVMEventsTimeline pins the per-VM lifecycle timeline: creating then | ||
| 247 | // deleting a VM records vm.create and vm.delete events retrievable via | ||
| 248 | // GET /api/v1/vms/{id}/events, scoped to that VM (a sibling VM's events must | ||
| 249 | // not appear), and the endpoint still serves the history after the row is gone. | ||
| 250 | func TestVMEventsTimeline(t *testing.T) { | ||
| 251 | ts, _, _ := testServer(t) | ||
| 252 | out := enroll(t, ts) | ||
| 253 | |||
| 254 | // Two VMs so we can assert the timeline is scoped to one. | ||
| 255 | r1 := do(t, "POST", ts.URL+"/api/v1/vms", "admintok", | ||
| 256 | map[string]any{"host_id": out["host_id"], "name": "alpha"}) | ||
| 257 | require.Equal(t, 201, r1.StatusCode) | ||
| 258 | var createdA map[string]string | ||
| 259 | json.NewDecoder(r1.Body).Decode(&createdA) | ||
| 260 | idA := createdA["id"] | ||
| 261 | |||
| 262 | r2 := do(t, "POST", ts.URL+"/api/v1/vms", "admintok", | ||
| 263 | map[string]any{"host_id": out["host_id"], "name": "bravo"}) | ||
| 264 | require.Equal(t, 201, r2.StatusCode) | ||
| 265 | var createdB map[string]string | ||
| 266 | json.NewDecoder(r2.Body).Decode(&createdB) | ||
| 267 | idB := createdB["id"] | ||
| 268 | |||
| 269 | require.Equal(t, 204, do(t, "DELETE", ts.URL+"/api/v1/vms/"+idA, "admintok", nil).StatusCode) | ||
| 270 | |||
| 271 | resp := do(t, "GET", ts.URL+"/api/v1/vms/"+idA+"/events", "admintok", nil) | ||
| 272 | require.Equal(t, 200, resp.StatusCode) | ||
| 273 | var events []struct { | ||
| 274 | Action string `json:"action"` | ||
| 275 | Detail json.RawMessage `json:"detail"` | ||
| 276 | } | ||
| 277 | require.NoError(t, json.NewDecoder(resp.Body).Decode(&events)) | ||
| 278 | |||
| 279 | var actions []string | ||
| 280 | for _, e := range events { | ||
| 281 | actions = append(actions, e.Action) | ||
| 282 | assert.Contains(t, string(e.Detail), idA) | ||
| 283 | assert.NotContains(t, string(e.Detail), idB, "must not leak a sibling VM's events") | ||
| 284 | } | ||
| 285 | assert.Contains(t, actions, "vm.create") | ||
| 286 | assert.Contains(t, actions, "vm.delete") | ||
| 287 | } | ||
| 288 | |||
| 302 | // --- C1: input-validation tests --- | 289 | // --- C1: input-validation tests --- |
| 303 | 290 | ||
| 304 | func TestCreateVMNameValidation(t *testing.T) { | 291 | func TestCreateVMNameValidation(t *testing.T) { |
| @@ -598,3 +585,221 @@ func TestAuditEndpointLimitValidation(t *testing.T) { | |||
| 598 | assert.Equal(t, 400, resp.StatusCode, "limit=%s must be rejected", bad) | 585 | assert.Equal(t, 400, resp.StatusCode, "limit=%s must be rejected", bad) |
| 599 | } | 586 | } |
| 600 | } | 587 | } |
| 588 | |||
| 589 | // TestCreateVMMergesSSHKeyIntoCloudInit pins that supplying BOTH a cloud_init | ||
| 590 | // and an ssh_authorized_key folds the key into the stored user-data (rather | ||
| 591 | // than the old silent drop), and clears the now-redundant separate field. | ||
| 592 | func TestCreateVMMergesSSHKeyIntoCloudInit(t *testing.T) { | ||
| 593 | ts, st, _ := testServer(t) | ||
| 594 | out := enroll(t, ts) | ||
| 595 | |||
| 596 | const key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExampleKey user@host" | ||
| 597 | resp := do(t, "POST", ts.URL+"/api/v1/vms", "admintok", map[string]any{ | ||
| 598 | "host_id": out["host_id"], | ||
| 599 | "name": "web", | ||
| 600 | "cloud_init": "#cloud-config\npackages:\n - htop\n", | ||
| 601 | "ssh_authorized_key": key, | ||
| 602 | }) | ||
| 603 | require.Equal(t, 201, resp.StatusCode) | ||
| 604 | |||
| 605 | vms, err := st.ListVMs() | ||
| 606 | require.NoError(t, err) | ||
| 607 | require.Len(t, vms, 1) | ||
| 608 | assert.Contains(t, vms[0].CloudInit, key, "the key must be folded into stored user-data") | ||
| 609 | assert.Contains(t, vms[0].CloudInit, "packages", "the user's cloud-init content survives the merge") | ||
| 610 | assert.True(t, strings.HasPrefix(vms[0].CloudInit, "#cloud-config"), "header preserved") | ||
| 611 | assert.Empty(t, vms[0].SSHAuthorizedKey, "the key is folded in, not also carried separately") | ||
| 612 | } | ||
| 613 | |||
| 614 | // TestCreateVMWrapsShellScriptUserDataWithKey pins that an ssh_authorized_key | ||
| 615 | // alongside a NON-cloud-config user-data (shell script) is not dropped and not | ||
| 616 | // rejected — it's wrapped into a multipart archive so both apply. | ||
| 617 | func TestCreateVMWrapsShellScriptUserDataWithKey(t *testing.T) { | ||
| 618 | ts, st, _ := testServer(t) | ||
| 619 | out := enroll(t, ts) | ||
| 620 | |||
| 621 | const key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExampleKey user@host" | ||
| 622 | resp := do(t, "POST", ts.URL+"/api/v1/vms", "admintok", map[string]any{ | ||
| 623 | "host_id": out["host_id"], | ||
| 624 | "name": "web", | ||
| 625 | "cloud_init": "#!/bin/bash\necho hi\n", | ||
| 626 | "ssh_authorized_key": key, | ||
| 627 | }) | ||
| 628 | require.Equal(t, 201, resp.StatusCode) | ||
| 629 | vms, err := st.ListVMs() | ||
| 630 | require.NoError(t, err) | ||
| 631 | require.Len(t, vms, 1) | ||
| 632 | assert.Contains(t, vms[0].CloudInit, "multipart/mixed", "script user-data is wrapped in a MIME archive") | ||
| 633 | assert.Contains(t, vms[0].CloudInit, "echo hi", "the user's script survives") | ||
| 634 | assert.Contains(t, vms[0].CloudInit, key, "the key rides a cloud-config part") | ||
| 635 | assert.Empty(t, vms[0].SSHAuthorizedKey, "installed into cloud-init, not carried separately") | ||
| 636 | } | ||
| 637 | |||
| 638 | // TestCreateVMRejectsUnhandleableCloudInit pins that an ssh_authorized_key | ||
| 639 | // alongside user-data we cannot safely edit or wrap (a jinja template) is a 400 | ||
| 640 | // (not a silent no-op), before any mint/persist. | ||
| 641 | func TestCreateVMRejectsUnhandleableCloudInit(t *testing.T) { | ||
| 642 | ts, st, _ := testServer(t) | ||
| 643 | out := enroll(t, ts) | ||
| 644 | |||
| 645 | resp := do(t, "POST", ts.URL+"/api/v1/vms", "admintok", map[string]any{ | ||
| 646 | "host_id": out["host_id"], | ||
| 647 | "name": "web", | ||
| 648 | "cloud_init": "## template: jinja\n#cloud-config\nhostname: {{ v1.local_hostname }}\n", | ||
| 649 | "ssh_authorized_key": "ssh-ed25519 AAAAKey user@host", | ||
| 650 | }) | ||
| 651 | assert.Equal(t, 400, resp.StatusCode) | ||
| 652 | vms, err := st.ListVMs() | ||
| 653 | require.NoError(t, err) | ||
| 654 | assert.Empty(t, vms, "reject before persist") | ||
| 655 | } | ||
| 656 | |||
| 657 | // TestDeriveLifecycle pins the server-side rollup of the orthogonal state axes | ||
| 658 | // into one coarse word. It must stay in lockstep with vmStatus() in | ||
| 659 | // web/src/lib/fleet.svelte.ts — the two folds cannot disagree. | ||
| 660 | func TestDeriveLifecycle(t *testing.T) { | ||
| 661 | deleted := time.Unix(0, 0) | ||
| 662 | cases := []struct { | ||
| 663 | name string | ||
| 664 | vm store.VM | ||
| 665 | actualPower string | ||
| 666 | phase string | ||
| 667 | want string | ||
| 668 | }{ | ||
| 669 | {"tombstone wins over everything", | ||
| 670 | store.VM{Status: "ready", PowerState: "running", DeletedAt: &deleted}, "running", "ready", "deleting"}, | ||
| 671 | {"failed phase", | ||
| 672 | store.VM{Status: "ready", PowerState: "running"}, "stopped", "failed", "failed"}, | ||
| 673 | {"still creating (live phase)", | ||
| 674 | store.VM{Status: "ready", PowerState: "running"}, "", "creating", "creating"}, | ||
| 675 | {"empty phase falls back to status=creating", | ||
| 676 | store.VM{Status: "creating", PowerState: "running"}, "", "", "creating"}, | ||
| 677 | {"ready phase but not running -> stopped", | ||
| 678 | store.VM{Status: "ready", PowerState: "stopped"}, "stopped", "ready", "stopped"}, | ||
| 679 | {"desired running but agent reports stopped -> stopped", | ||
| 680 | store.VM{Status: "ready", PowerState: "running"}, "stopped", "ready", "stopped"}, | ||
| 681 | {"running + ready -> ready", | ||
| 682 | store.VM{Status: "ready", PowerState: "running"}, "running", "ready", "ready"}, | ||
| 683 | {"no live phase, desired running falls back to power_state", | ||
| 684 | store.VM{Status: "ready", PowerState: "running"}, "", "ready", "ready"}, | ||
| 685 | } | ||
| 686 | for _, tc := range cases { | ||
| 687 | t.Run(tc.name, func(t *testing.T) { | ||
| 688 | assert.Equal(t, tc.want, deriveLifecycle(tc.vm, tc.actualPower, tc.phase)) | ||
| 689 | }) | ||
| 690 | } | ||
| 691 | } | ||
| 692 | |||
| 693 | // TestRestoreVMUnTombstonesWithinGrace pins the undo/cancel path: a deleted | ||
| 694 | // (tombstoned) VM that is still within the grace window can be restored, which | ||
| 695 | // clears deleted_at so the agent re-adopts it. A vm.restore event lands on the | ||
| 696 | // timeline. | ||
| 697 | func TestRestoreVMUnTombstonesWithinGrace(t *testing.T) { | ||
| 698 | ts, st, _ := testServer(t) | ||
| 699 | out := enroll(t, ts) | ||
| 700 | resp := do(t, "POST", ts.URL+"/api/v1/vms", "admintok", | ||
| 701 | map[string]any{"host_id": out["host_id"], "name": "web"}) | ||
| 702 | require.Equal(t, 201, resp.StatusCode) | ||
| 703 | |||
| 704 | vms, err := st.ListVMs() | ||
| 705 | require.NoError(t, err) | ||
| 706 | require.Len(t, vms, 1) | ||
| 707 | id := vms[0].ID | ||
| 708 | |||
| 709 | // Delete → tombstoned: deleted=true. | ||
| 710 | require.Equal(t, 204, do(t, "DELETE", ts.URL+"/api/v1/vms/"+id, "admintok", nil).StatusCode) | ||
| 711 | items := decodeJSONKeys(t, do(t, "GET", ts.URL+"/api/v1/vms", "admintok", nil)) | ||
| 712 | require.Len(t, items, 1) | ||
| 713 | assert.Equal(t, true, items[0]["deleted"]) | ||
| 714 | |||
| 715 | // Restore → un-tombstoned: deleted=false. | ||
| 716 | require.Equal(t, 204, do(t, "POST", ts.URL+"/api/v1/vms/"+id+"/restore", "admintok", nil).StatusCode) | ||
| 717 | items = decodeJSONKeys(t, do(t, "GET", ts.URL+"/api/v1/vms", "admintok", nil)) | ||
| 718 | require.Len(t, items, 1) | ||
| 719 | assert.Equal(t, false, items[0]["deleted"]) | ||
| 720 | |||
| 721 | // The restore lands on the per-VM timeline. | ||
| 722 | resp = do(t, "GET", ts.URL+"/api/v1/vms/"+id+"/events", "admintok", nil) | ||
| 723 | require.Equal(t, 200, resp.StatusCode) | ||
| 724 | var events []struct { | ||
| 725 | Action string `json:"action"` | ||
| 726 | } | ||
| 727 | require.NoError(t, json.NewDecoder(resp.Body).Decode(&events)) | ||
| 728 | var actions []string | ||
| 729 | for _, e := range events { | ||
| 730 | actions = append(actions, e.Action) | ||
| 731 | } | ||
| 732 | assert.Contains(t, actions, "vm.restore") | ||
| 733 | } | ||
| 734 | |||
| 735 | // TestRestoreVMNotRestorableIs409 pins that restoring a VM that was never | ||
| 736 | // deleted, or an id that does not exist, is rejected with 409 (no raw SQL | ||
| 737 | // leaks) — RestoreVM only matches tombstoned rows. | ||
| 738 | func TestRestoreVMNotRestorableIs409(t *testing.T) { | ||
| 739 | ts, _, _ := testServer(t) | ||
| 740 | out := enroll(t, ts) | ||
| 741 | resp := do(t, "POST", ts.URL+"/api/v1/vms", "admintok", | ||
| 742 | map[string]any{"host_id": out["host_id"], "name": "web"}) | ||
| 743 | require.Equal(t, 201, resp.StatusCode) | ||
| 744 | var created map[string]string | ||
| 745 | json.NewDecoder(resp.Body).Decode(&created) | ||
| 746 | |||
| 747 | // Never deleted → not restorable. | ||
| 748 | resp = do(t, "POST", ts.URL+"/api/v1/vms/"+created["id"]+"/restore", "admintok", nil) | ||
| 749 | assert.Equal(t, 409, resp.StatusCode) | ||
| 750 | |||
| 751 | // Non-existent id → same "not restorable". | ||
| 752 | resp = do(t, "POST", ts.URL+"/api/v1/vms/does-not-exist/restore", "admintok", nil) | ||
| 753 | assert.Equal(t, 409, resp.StatusCode) | ||
| 754 | } | ||
| 755 | |||
| 756 | // TestVMResponseSurfacesTeardownDestroyDeadline pins that a quarantined (deleted | ||
| 757 | // + guest-stopped) VM surfaces the agent's hard destroy deadline as destroy_at, | ||
| 758 | // while a VM that is not scheduled for destruction reports destroy_at == 0. | ||
| 759 | func TestVMResponseSurfacesTeardownDestroyDeadline(t *testing.T) { | ||
| 760 | ts, st, _, reg, _ := newServer(t) | ||
| 761 | out := enroll(t, ts) | ||
| 762 | hostID := out["host_id"] | ||
| 763 | |||
| 764 | // Two VMs on the host: one will be quarantined for teardown, one stays live. | ||
| 765 | for _, name := range []string{"doomed", "healthy"} { | ||
| 766 | resp := do(t, "POST", ts.URL+"/api/v1/vms", "admintok", | ||
| 767 | map[string]any{"host_id": hostID, "name": name}) | ||
| 768 | require.Equal(t, 201, resp.StatusCode) | ||
| 769 | } | ||
| 770 | vms, err := st.ListVMs() | ||
| 771 | require.NoError(t, err) | ||
| 772 | require.Len(t, vms, 2) | ||
| 773 | var doomedID, healthyID string | ||
| 774 | for _, vm := range vms { | ||
| 775 | switch vm.Name { | ||
| 776 | case "doomed": | ||
| 777 | doomedID = vm.ID | ||
| 778 | case "healthy": | ||
| 779 | healthyID = vm.ID | ||
| 780 | } | ||
| 781 | } | ||
| 782 | require.NotEmpty(t, doomedID) | ||
| 783 | require.NotEmpty(t, healthyID) | ||
| 784 | |||
| 785 | // Agent reports the doomed VM quarantined with a hard destroy deadline. | ||
| 786 | const deadline = int64(1234567890) | ||
| 787 | reg.UpdateReport(hostID, registry.Report{ | ||
| 788 | Quarantined: []registry.QuarantinedVM{{VMID: doomedID, DestroyAtUnix: deadline}}, | ||
| 789 | }) | ||
| 790 | |||
| 791 | resp := do(t, "GET", ts.URL+"/api/v1/vms", "admintok", nil) | ||
| 792 | require.Equal(t, 200, resp.StatusCode) | ||
| 793 | items := decodeJSONKeys(t, resp) | ||
| 794 | require.Len(t, items, 2) | ||
| 795 | |||
| 796 | byID := map[string]map[string]any{} | ||
| 797 | for _, v := range items { | ||
| 798 | byID[v["id"].(string)] = v | ||
| 799 | } | ||
| 800 | // JSON numbers decode as float64; deadline is exactly representable. | ||
| 801 | assert.Equal(t, float64(deadline), byID[doomedID]["destroy_at"], | ||
| 802 | "quarantined VM must surface its destroy deadline") | ||
| 803 | assert.Equal(t, float64(0), byID[healthyID]["destroy_at"], | ||
| 804 | "a VM not scheduled for destruction reports destroy_at == 0") | ||
| 805 | } | ||
internal/server/api/console.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,86 @@ | |||
| 1 | package api | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "context" | ||
| 5 | "io" | ||
| 6 | "log/slog" | ||
| 7 | "net/http" | ||
| 8 | "strings" | ||
| 9 | "time" | ||
| 10 | |||
| 11 | "github.com/coder/websocket" | ||
| 12 | ) | ||
| 13 | |||
| 14 | // ConsoleDialer opens a raw byte pipe to a VM's serial console on its host's | ||
| 15 | // live sync connection (consumer-owned; the concrete implementation is | ||
| 16 | // *syncsvc.Service, wired by main via SetConsoleDialer). | ||
| 17 | type ConsoleDialer interface { | ||
| 18 | OpenConsole(ctx context.Context, hostID, vmID string) (io.ReadWriteCloser, error) | ||
| 19 | } | ||
| 20 | |||
| 21 | // SetConsoleDialer wires the console broker. Called once by main after the | ||
| 22 | // sync service is constructed; a nil dialer leaves the endpoint returning 503. | ||
| 23 | func (a *API) SetConsoleDialer(d ConsoleDialer) { a.console = d } | ||
| 24 | |||
| 25 | // consoleOpenTimeout bounds resolving + handshaking the agent-side stream. | ||
| 26 | const consoleOpenTimeout = 10 * time.Second | ||
| 27 | |||
| 28 | // handleConsoleWS bridges a browser WebSocket to a VM serial console. | ||
| 29 | // EventSource-style auth: browsers cannot set headers on a WebSocket dial, so | ||
| 30 | // the request carries a one-time short-TTL ticket minted via the | ||
| 31 | // admin-authenticated POST /api/v1/stream-tickets (same mechanism as SSE) — | ||
| 32 | // the admin token never appears in a URL. | ||
| 33 | func (a *API) handleConsoleWS(w http.ResponseWriter, r *http.Request) { | ||
| 34 | if !a.tickets.consume(r.URL.Query().Get("ticket")) { | ||
| 35 | httpError(w, "unauthorized", http.StatusUnauthorized) | ||
| 36 | return | ||
| 37 | } | ||
| 38 | if a.console == nil { | ||
| 39 | httpError(w, "console unavailable", http.StatusServiceUnavailable) | ||
| 40 | return | ||
| 41 | } | ||
| 42 | id := r.PathValue("id") | ||
| 43 | vm, ok := a.vmByID(id) | ||
| 44 | if !ok { | ||
| 45 | httpError(w, "not found", http.StatusNotFound) | ||
| 46 | return | ||
| 47 | } | ||
| 48 | |||
| 49 | // Upgrade FIRST, then dial the agent. A pre-upgrade HTTP error body is | ||
| 50 | // invisible to browser WebSocket JS, but a close frame's reason is | ||
| 51 | // readable (event.reason in onclose) — and the spec requires the UI to | ||
| 52 | // show WHY ("host offline"). Auth/404 failures above stay pre-upgrade: | ||
| 53 | // they carry no operator-facing reason. | ||
| 54 | c, err := websocket.Accept(w, r, nil) // default same-origin check | ||
| 55 | if err != nil { | ||
| 56 | return | ||
| 57 | } | ||
| 58 | defer c.CloseNow() | ||
| 59 | |||
| 60 | openCtx, cancel := context.WithTimeout(r.Context(), consoleOpenTimeout) | ||
| 61 | stream, err := a.console.OpenConsole(openCtx, vm.HostID, id) | ||
| 62 | cancel() | ||
| 63 | if err != nil { | ||
| 64 | // Agent leg failed (offline host, refused VM): close with the reason. | ||
| 65 | reason := "console unavailable: " + err.Error() | ||
| 66 | if len(reason) > 120 { // close reasons are capped at 123 bytes | ||
| 67 | // The byte trim can bisect a multi-byte rune and RFC 6455 requires | ||
| 68 | // close reasons to be valid UTF-8 — drop any trailing fragment. | ||
| 69 | reason = strings.ToValidUTF8(reason[:120], "") | ||
| 70 | } | ||
| 71 | _ = c.Close(websocket.StatusInternalError, reason) | ||
| 72 | return | ||
| 73 | } | ||
| 74 | |||
| 75 | // Two pumps, raw bytes. NetConn adapts the WS to net.Conn (binary frames). | ||
| 76 | nc := websocket.NetConn(r.Context(), c, websocket.MessageBinary) | ||
| 77 | done := make(chan struct{}, 2) | ||
| 78 | go func() { _, _ = io.Copy(stream, nc); stream.Close(); done <- struct{}{} }() // keystrokes → guest | ||
| 79 | go func() { _, _ = io.Copy(nc, stream); nc.Close(); done <- struct{}{} }() // guest → browser | ||
| 80 | <-done | ||
| 81 | <-done | ||
| 82 | _ = c.Close(websocket.StatusNormalClosure, "") | ||
| 83 | // Unconditional: after websocket.Accept hijacks the connection, net/http | ||
| 84 | // never cancels r.Context(), so there is no cancellation to filter on. | ||
| 85 | slog.Debug("console session closed", "vm", id) | ||
| 86 | } | ||
internal/server/api/console_test.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,183 @@ | |||
| 1 | package api | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "context" | ||
| 5 | "encoding/json" | ||
| 6 | "errors" | ||
| 7 | "io" | ||
| 8 | "net/http/httptest" | ||
| 9 | "strings" | ||
| 10 | "testing" | ||
| 11 | "time" | ||
| 12 | |||
| 13 | "github.com/coder/websocket" | ||
| 14 | "github.com/stretchr/testify/assert" | ||
| 15 | "github.com/stretchr/testify/require" | ||
| 16 | ) | ||
| 17 | |||
| 18 | // fakeConsole hands back an in-memory duplex pipe and records the ask. | ||
| 19 | // Tests that touch serverEnd/hostID/vmID after a successful dial must set | ||
| 20 | // opened and receive from it first: OpenConsole runs on the handler's | ||
| 21 | // goroutine AFTER the WS handshake completes, so the dial returning does not | ||
| 22 | // order the test's reads after the fake's writes — the channel does. | ||
| 23 | type fakeConsole struct { | ||
| 24 | hostID, vmID string | ||
| 25 | err error | ||
| 26 | serverEnd io.ReadWriteCloser | ||
| 27 | opened chan struct{} // optional; signalled once per OpenConsole call | ||
| 28 | } | ||
| 29 | |||
| 30 | type rwc struct { | ||
| 31 | io.Reader | ||
| 32 | io.Writer | ||
| 33 | closeFn func() error | ||
| 34 | } | ||
| 35 | |||
| 36 | func (c rwc) Close() error { return c.closeFn() } | ||
| 37 | |||
| 38 | func duplexPipe() (a, b io.ReadWriteCloser) { | ||
| 39 | ar, bw := io.Pipe() | ||
| 40 | br, aw := io.Pipe() | ||
| 41 | return rwc{ar, aw, func() error { aw.Close(); return ar.Close() }}, | ||
| 42 | rwc{br, bw, func() error { bw.Close(); return br.Close() }} | ||
| 43 | } | ||
| 44 | |||
| 45 | func (f *fakeConsole) OpenConsole(_ context.Context, hostID, vmID string) (io.ReadWriteCloser, error) { | ||
| 46 | f.hostID, f.vmID = hostID, vmID | ||
| 47 | if f.err != nil { | ||
| 48 | return nil, f.err | ||
| 49 | } | ||
| 50 | var clientEnd io.ReadWriteCloser | ||
| 51 | clientEnd, f.serverEnd = duplexPipe() | ||
| 52 | if f.opened != nil { | ||
| 53 | f.opened <- struct{}{} | ||
| 54 | } | ||
| 55 | return clientEnd, nil | ||
| 56 | } | ||
| 57 | |||
| 58 | // consoleFixture is the seed data console WS tests need: the running server | ||
| 59 | // plus the enrolled host + created VM pair the endpoint routes to. | ||
| 60 | type consoleFixture struct { | ||
| 61 | ts *httptest.Server | ||
| 62 | hostID string | ||
| 63 | vmID string | ||
| 64 | } | ||
| 65 | |||
| 66 | // newConsoleAPI builds a test server (shared fixture) and seeds one host with | ||
| 67 | // one VM over the HTTP API, returning the *API for SetConsoleDialer wiring. | ||
| 68 | func newConsoleAPI(t *testing.T) (*API, consoleFixture) { | ||
| 69 | t.Helper() | ||
| 70 | ts, _, _, _, a := newServer(t) | ||
| 71 | out := enroll(t, ts) | ||
| 72 | resp := do(t, "POST", ts.URL+"/api/v1/vms", "admintok", | ||
| 73 | map[string]any{"host_id": out["host_id"], "name": "console-vm"}) | ||
| 74 | require.Equal(t, 201, resp.StatusCode) | ||
| 75 | var created map[string]string | ||
| 76 | require.NoError(t, json.NewDecoder(resp.Body).Decode(&created)) | ||
| 77 | require.NotEmpty(t, created["id"]) | ||
| 78 | return a, consoleFixture{ts: ts, hostID: out["host_id"], vmID: created["id"]} | ||
| 79 | } | ||
| 80 | |||
| 81 | // consoleWSURL builds the ws:// URL for the fixture VM carrying the ticket. | ||
| 82 | func consoleWSURL(fix consoleFixture, ticket string) string { | ||
| 83 | return strings.Replace(fix.ts.URL, "http://", "ws://", 1) + | ||
| 84 | "/api/v1/vms/" + fix.vmID + "/console/ws?ticket=" + ticket | ||
| 85 | } | ||
| 86 | |||
| 87 | func TestConsoleWSRequiresTicket(t *testing.T) { | ||
| 88 | _, fix := newConsoleAPI(t) | ||
| 89 | // No ticket: plain GET (the handler rejects before upgrading). | ||
| 90 | resp, err := fix.ts.Client().Get(fix.ts.URL + "/api/v1/vms/whatever/console/ws") | ||
| 91 | require.NoError(t, err) | ||
| 92 | defer resp.Body.Close() | ||
| 93 | assert.Equal(t, 401, resp.StatusCode) | ||
| 94 | } | ||
| 95 | |||
| 96 | func TestConsoleWSBridgesBytes(t *testing.T) { | ||
| 97 | a, fix := newConsoleAPI(t) | ||
| 98 | fc := &fakeConsole{opened: make(chan struct{}, 1)} | ||
| 99 | a.SetConsoleDialer(fc) | ||
| 100 | |||
| 101 | ticket := mintTicket(t, fix.ts.URL, "admintok") | ||
| 102 | c, _, err := websocket.Dial(context.Background(), consoleWSURL(fix, ticket), nil) | ||
| 103 | require.NoError(t, err) | ||
| 104 | defer c.CloseNow() | ||
| 105 | <-fc.opened // handler has dialed the fake; serverEnd is set | ||
| 106 | |||
| 107 | // guest → browser | ||
| 108 | _, err = fc.serverEnd.Write([]byte("login:")) | ||
| 109 | require.NoError(t, err) | ||
| 110 | typ, data, err := c.Read(context.Background()) | ||
| 111 | require.NoError(t, err) | ||
| 112 | assert.Equal(t, websocket.MessageBinary, typ) | ||
| 113 | assert.Equal(t, "login:", string(data)) | ||
| 114 | |||
| 115 | // browser → guest | ||
| 116 | require.NoError(t, c.Write(context.Background(), websocket.MessageBinary, []byte("root\r"))) | ||
| 117 | buf := make([]byte, 5) | ||
| 118 | _, err = io.ReadFull(fc.serverEnd, buf) | ||
| 119 | require.NoError(t, err) | ||
| 120 | assert.Equal(t, "root\r", string(buf)) | ||
| 121 | assert.Equal(t, fix.vmID, fc.vmID) | ||
| 122 | assert.Equal(t, fix.hostID, fc.hostID) | ||
| 123 | } | ||
| 124 | |||
| 125 | // TestConsoleWSCloseTearsDownAgentStream pins the no-leaked-agent-stream | ||
| 126 | // invariant: when the browser side goes away, the handler's pumps must close | ||
| 127 | // the agent-leg stream — otherwise every abandoned tab would hold a serial | ||
| 128 | // pump hostage forever. | ||
| 129 | func TestConsoleWSCloseTearsDownAgentStream(t *testing.T) { | ||
| 130 | a, fix := newConsoleAPI(t) | ||
| 131 | fc := &fakeConsole{opened: make(chan struct{}, 1)} | ||
| 132 | a.SetConsoleDialer(fc) | ||
| 133 | |||
| 134 | ticket := mintTicket(t, fix.ts.URL, "admintok") | ||
| 135 | c, _, err := websocket.Dial(context.Background(), consoleWSURL(fix, ticket), nil) | ||
| 136 | require.NoError(t, err) | ||
| 137 | <-fc.opened // handler has dialed the fake; serverEnd is set | ||
| 138 | |||
| 139 | // Browser disconnects. | ||
| 140 | require.NoError(t, c.Close(websocket.StatusNormalClosure, "")) | ||
| 141 | |||
| 142 | // The agent end must observe teardown (EOF/closed-pipe), not block. | ||
| 143 | readErr := make(chan error, 1) | ||
| 144 | go func() { | ||
| 145 | _, err := fc.serverEnd.Read(make([]byte, 1)) | ||
| 146 | readErr <- err | ||
| 147 | }() | ||
| 148 | select { | ||
| 149 | case err := <-readErr: | ||
| 150 | require.Error(t, err, "agent stream must be closed, not left readable") | ||
| 151 | case <-time.After(5 * time.Second): | ||
| 152 | t.Fatal("agent stream still open after browser WS close — leaked stream") | ||
| 153 | } | ||
| 154 | } | ||
| 155 | |||
| 156 | func TestConsoleWSHostOfflineClosesWithReason(t *testing.T) { | ||
| 157 | a, fix := newConsoleAPI(t) | ||
| 158 | a.SetConsoleDialer(&fakeConsole{err: errors.New("agent not connected")}) | ||
| 159 | |||
| 160 | ticket := mintTicket(t, fix.ts.URL, "admintok") | ||
| 161 | c, _, err := websocket.Dial(context.Background(), consoleWSURL(fix, ticket), nil) | ||
| 162 | require.NoError(t, err, "the upgrade succeeds; failure arrives as a close frame") | ||
| 163 | defer c.CloseNow() | ||
| 164 | _, _, err = c.Read(context.Background()) | ||
| 165 | var ce websocket.CloseError | ||
| 166 | require.ErrorAs(t, err, &ce) | ||
| 167 | assert.Equal(t, websocket.StatusInternalError, ce.Code) | ||
| 168 | assert.Contains(t, ce.Reason, "agent not connected", "the reason must reach the browser") | ||
| 169 | } | ||
| 170 | |||
| 171 | func TestConsoleWSTicketIsSingleUse(t *testing.T) { | ||
| 172 | a, fix := newConsoleAPI(t) | ||
| 173 | a.SetConsoleDialer(&fakeConsole{}) | ||
| 174 | |||
| 175 | ticket := mintTicket(t, fix.ts.URL, "admintok") | ||
| 176 | url := fix.ts.URL + "/api/v1/vms/" + fix.vmID + "/console/ws?ticket=" + ticket | ||
| 177 | // Consume it once (plain GET is fine — the ticket is consumed before upgrade). | ||
| 178 | _, _ = fix.ts.Client().Get(url) | ||
| 179 | resp, err := fix.ts.Client().Get(url) | ||
| 180 | require.NoError(t, err) | ||
| 181 | defer resp.Body.Close() | ||
| 182 | assert.Equal(t, 401, resp.StatusCode, "one-time ticket must not replay") | ||
| 183 | } | ||
internal/server/api/events.go
| Old | New | ||
|---|---|---|---|
| @@ -79,6 +79,38 @@ func (a *API) handleListAudit(w http.ResponseWriter, r *http.Request) { | |||
| 79 | writeJSON(w, http.StatusOK, out) | 79 | writeJSON(w, http.StatusOK, out) |
| 80 | } | 80 | } |
| 81 | 81 | ||
| 82 | // handleListVMEvents returns one VM's lifecycle timeline: the audit rows whose | ||
| 83 | // detail carries this {id} as "vm_id", newest first (default 100, ?limit=N caps | ||
| 84 | // at 1000). It deliberately does NOT 404 when the VM row is gone — a reaped VM's | ||
| 85 | // history (ending in vm.reap) must stay retrievable. Same wire shape as | ||
| 86 | // handleListAudit. | ||
| 87 | func (a *API) handleListVMEvents(w http.ResponseWriter, r *http.Request) { | ||
| 88 | id := r.PathValue("id") | ||
| 89 | limit := 100 | ||
| 90 | if v := r.URL.Query().Get("limit"); v != "" { | ||
| 91 | n, err := strconv.Atoi(v) | ||
| 92 | if err != nil || n < 1 || n > 1000 { | ||
| 93 | httpError(w, "limit must be 1..1000", http.StatusBadRequest) | ||
| 94 | return | ||
| 95 | } | ||
| 96 | limit = n | ||
| 97 | } | ||
| 98 | rows, err := a.st.ListVMEvents(id, limit) | ||
| 99 | if err != nil { | ||
| 100 | httpError(w, "internal error", http.StatusInternalServerError) | ||
| 101 | return | ||
| 102 | } | ||
| 103 | out := make([]types.AuditEvent, len(rows)) | ||
| 104 | for i, e := range rows { | ||
| 105 | detail := json.RawMessage(e.Detail) | ||
| 106 | if !json.Valid(detail) { // defensive: never emit invalid JSON | ||
| 107 | detail, _ = json.Marshal(e.Detail) | ||
| 108 | } | ||
| 109 | out[i] = types.AuditEvent{At: e.At, Action: e.Action, Detail: detail} | ||
| 110 | } | ||
| 111 | writeJSON(w, http.StatusOK, out) | ||
| 112 | } | ||
| 113 | |||
| 82 | // handleMintStreamTicket issues a one-time short-TTL ticket for the SSE | 114 | // handleMintStreamTicket issues a one-time short-TTL ticket for the SSE |
| 83 | // stream (admin-authenticated; the ticket is the only thing that ever | 115 | // stream (admin-authenticated; the ticket is the only thing that ever |
| 84 | // appears in a URL). | 116 | // appears in a URL). |
internal/server/api/routes.go
| Old | New | ||
|---|---|---|---|
| @@ -22,6 +22,7 @@ type RouteKind int | |||
| 22 | const ( | 22 | const ( |
| 23 | KindJSON RouteKind = iota | 23 | KindJSON RouteKind = iota |
| 24 | KindSSE | 24 | KindSSE |
| 25 | KindWS | ||
| 25 | ) | 26 | ) |
| 26 | 27 | ||
| 27 | // QueryParam is a documented query-string parameter (all string-typed). | 28 | // QueryParam is a documented query-string parameter (all string-typed). |
| @@ -60,6 +61,19 @@ var routeTable = []Route{ | |||
| 60 | Doc: "Redeem a one-time enrollment token: a new host joins the fleet and receives its credential. Unauthenticated but rate-limited; the token is the proof.", | 61 | Doc: "Redeem a one-time enrollment token: a new host joins the fleet and receives its credential. Unauthenticated but rate-limited; the token is the proof.", |
| 61 | handler: (*API).handleEnroll, | 62 | handler: (*API).handleEnroll, |
| 62 | }, | 63 | }, |
| 64 | // Unauthenticated CA-pubkey endpoint: it is public material, and clients need | ||
| 65 | // it to pin `@cert-authority` for host verification BEFORE they hold any | ||
| 66 | // credential. 404s when the jump gate is off (no CA published). | ||
| 67 | { | ||
| 68 | Method: "GET", | ||
| 69 | Path: "/api/v1/ssh-ca", | ||
| 70 | Auth: AuthPublic, | ||
| 71 | Kind: KindJSON, | ||
| 72 | Response: (*types.SSHCAResponse)(nil), | ||
| 73 | Success: http.StatusOK, | ||
| 74 | Doc: "The eitri SSH CA public key (public material) for pinning `@cert-authority` in known_hosts. 404 when the jump gate is off.", | ||
| 75 | handler: (*API).handleSSHCA, | ||
| 76 | }, | ||
| 63 | // SSE live status. EventSource cannot set headers, so the stream | 77 | // SSE live status. EventSource cannot set headers, so the stream |
| 64 | // authenticates with a one-time short-TTL ticket minted via the | 78 | // authenticates with a one-time short-TTL ticket minted via the |
| 65 | // admin-authenticated POST /api/v1/stream-tickets — the long-lived admin | 79 | // admin-authenticated POST /api/v1/stream-tickets — the long-lived admin |
| @@ -75,6 +89,19 @@ var routeTable = []Route{ | |||
| 75 | Doc: "Live fleet state stream (Server-Sent Events); each 'state' event carries a StateSnapshot.", | 89 | Doc: "Live fleet state stream (Server-Sent Events); each 'state' event carries a StateSnapshot.", |
| 76 | handler: (*API).handleEvents, | 90 | handler: (*API).handleEvents, |
| 77 | }, | 91 | }, |
| 92 | // Console WS: ticket-authed like the SSE stream (browsers cannot set | ||
| 93 | // headers on a WebSocket dial). More specific than the /api/v1/ admin | ||
| 94 | // subtree, so ServeMux routes it here without admin auth. | ||
| 95 | { | ||
| 96 | Method: "GET", | ||
| 97 | Path: "/api/v1/vms/{id}/console/ws", | ||
| 98 | Auth: AuthTicket, | ||
| 99 | Kind: KindWS, | ||
| 100 | Success: http.StatusSwitchingProtocols, | ||
| 101 | Query: []QueryParam{{Name: "ticket", Doc: "one-time stream ticket"}}, | ||
| 102 | Doc: "Serial-console WebSocket: raw byte pipe to the VM's serial console.", | ||
| 103 | handler: (*API).handleConsoleWS, | ||
| 104 | }, | ||
| 78 | 105 | ||
| 79 | // Admin routes — wrapped with auth middleware. | 106 | // Admin routes — wrapped with auth middleware. |
| 80 | { | 107 | { |
| @@ -133,7 +160,7 @@ var routeTable = []Route{ | |||
| 133 | Kind: KindJSON, | 160 | Kind: KindJSON, |
| 134 | Response: (*types.StreamTicketResponse)(nil), | 161 | Response: (*types.StreamTicketResponse)(nil), |
| 135 | Success: http.StatusCreated, | 162 | Success: http.StatusCreated, |
| 136 | Doc: "Mint a one-time short-TTL ticket for the SSE stream — the only credential that ever rides in a URL.", | 163 | Doc: "Mint a one-time short-TTL ticket for the SSE stream or console WebSocket — the only credential that ever rides in a URL.", |
| 137 | handler: (*API).handleMintStreamTicket, | 164 | handler: (*API).handleMintStreamTicket, |
| 138 | }, | 165 | }, |
| 139 | { | 166 | { |
| @@ -173,7 +200,63 @@ var routeTable = []Route{ | |||
| 173 | Auth: AuthAdmin, | 200 | Auth: AuthAdmin, |
| 174 | Kind: KindJSON, | 201 | Kind: KindJSON, |
| 175 | Success: http.StatusNoContent, | 202 | Success: http.StatusNoContent, |
| 176 | Doc: "Tombstone a VM for teardown.", | 203 | Doc: "Tombstone a VM for teardown; restorable within the grace window via restore.", |
| 177 | handler: (*API).handleDeleteVM, | 204 | handler: (*API).handleDeleteVM, |
| 178 | }, | 205 | }, |
| 206 | { | ||
| 207 | Method: "POST", | ||
| 208 | Path: "/api/v1/vms/{id}/restore", | ||
| 209 | Auth: AuthAdmin, | ||
| 210 | Kind: KindJSON, | ||
| 211 | Success: http.StatusNoContent, | ||
| 212 | Doc: "Un-tombstone a VM still within the teardown grace window; the agent re-adopts the guest.", | ||
| 213 | handler: (*API).handleRestoreVM, | ||
| 214 | }, | ||
| 215 | { | ||
| 216 | Method: "GET", | ||
| 217 | Path: "/api/v1/vms/{id}/events", | ||
| 218 | Auth: AuthAdmin, | ||
| 219 | Kind: KindJSON, | ||
| 220 | Response: []types.AuditEvent(nil), | ||
| 221 | Success: http.StatusOK, | ||
| 222 | Query: []QueryParam{{Name: "limit", Doc: "max rows to return (default 100, cap 1000)"}}, | ||
| 223 | Doc: "One VM's lifecycle timeline (audit rows carrying its vm_id), newest first; survives the VM row being reaped.", | ||
| 224 | handler: (*API).handleListVMEvents, | ||
| 225 | }, | ||
| 226 | // SSH jump gate: mint a short-lived user cert for the caller's public key. | ||
| 227 | // 404s when the gate is off (no CA wired via SetCertMinter). | ||
| 228 | { | ||
| 229 | Method: "POST", | ||
| 230 | Path: "/api/v1/ssh-certs", | ||
| 231 | Auth: AuthAdmin, | ||
| 232 | Kind: KindJSON, | ||
| 233 | Request: (*types.SSHCertRequest)(nil), | ||
| 234 | Response: (*types.SSHCertResponse)(nil), | ||
| 235 | Success: http.StatusOK, | ||
| 236 | Doc: "Mint a short-lived SSH user certificate for the caller's public key. 404 when the jump gate is off (no CA wired).", | ||
| 237 | handler: (*API).handleMintSSHCert, | ||
| 238 | }, | ||
| 239 | // Revoke a minted user cert (by serial or cert line) and list revocations — | ||
| 240 | // enforced at the gate before a cert's short TTL expires. Pure store ops, | ||
| 241 | // available regardless of whether the minter is wired. | ||
| 242 | { | ||
| 243 | Method: "POST", | ||
| 244 | Path: "/api/v1/ssh-certs/revoke", | ||
| 245 | Auth: AuthAdmin, | ||
| 246 | Kind: KindJSON, | ||
| 247 | Request: (*types.RevokeSSHCertRequest)(nil), | ||
| 248 | Success: http.StatusNoContent, | ||
| 249 | Doc: "Revoke a minted SSH user certificate by serial or certificate line; the gate rejects it before its TTL expires. Idempotent.", | ||
| 250 | handler: (*API).handleRevokeSSHCert, | ||
| 251 | }, | ||
| 252 | { | ||
| 253 | Method: "GET", | ||
| 254 | Path: "/api/v1/ssh-certs/revoked", | ||
| 255 | Auth: AuthAdmin, | ||
| 256 | Kind: KindJSON, | ||
| 257 | Response: []types.RevokedCert(nil), | ||
| 258 | Success: http.StatusOK, | ||
| 259 | Doc: "List revoked SSH user certificate serials (with reason and time), newest first.", | ||
| 260 | handler: (*API).handleListRevokedSSHCerts, | ||
| 261 | }, | ||
| 179 | } | 262 | } |
internal/server/api/routes_test.go
| Old | New | ||
|---|---|---|---|
| @@ -34,7 +34,7 @@ func exemplarElem(t *testing.T, route Route, role string, v any) reflect.Type { | |||
| 34 | // `required` array for request schemas, so a type serving both roles would | 34 | // `required` array for request schemas, so a type serving both roles would |
| 35 | // get the wrong treatment on one of them). | 35 | // get the wrong treatment on one of them). |
| 36 | func TestRouteTable(t *testing.T) { | 36 | func TestRouteTable(t *testing.T) { |
| 37 | const wantRoutes = 12 | 37 | const wantRoutes = 19 |
| 38 | if len(routeTable) != wantRoutes { | 38 | if len(routeTable) != wantRoutes { |
| 39 | t.Fatalf("route table has %d entries, want %d — new endpoint? update this pin and cmd/eitri-apispec coverage together", len(routeTable), wantRoutes) | 39 | t.Fatalf("route table has %d entries, want %d — new endpoint? update this pin and cmd/eitri-apispec coverage together", len(routeTable), wantRoutes) |
| 40 | } | 40 | } |
internal/server/api/spec/spec.go
| Old | New | ||
|---|---|---|---|
| @@ -128,6 +128,10 @@ func (g *generator) operation(r api.Route) map[string]any { | |||
| 128 | }, | 128 | }, |
| 129 | }, | 129 | }, |
| 130 | } | 130 | } |
| 131 | case api.KindWS: | ||
| 132 | responses[success] = map[string]any{ | ||
| 133 | "description": "switching protocols (WebSocket)", | ||
| 134 | } | ||
| 131 | default: | 135 | default: |
| 132 | resp := map[string]any{"description": "success"} | 136 | resp := map[string]any{"description": "success"} |
| 133 | if r.Response != nil { | 137 | if r.Response != nil { |
internal/server/api/spec/spec_test.go
| Old | New | ||
|---|---|---|---|
| @@ -168,7 +168,7 @@ func TestHostSchemaFields(t *testing.T) { | |||
| 168 | } | 168 | } |
| 169 | } | 169 | } |
| 170 | 170 | ||
| 171 | func TestSSEResponses(t *testing.T) { | 171 | func TestSSEAndWSResponses(t *testing.T) { |
| 172 | doc, _ := generate(t) | 172 | doc, _ := generate(t) |
| 173 | 173 | ||
| 174 | // SSE stream: 200 with a text/event-stream body carrying StateSnapshot. | 174 | // SSE stream: 200 with a text/event-stream body carrying StateSnapshot. |
| @@ -177,6 +177,12 @@ func TestSSEResponses(t *testing.T) { | |||
| 177 | if !strings.HasSuffix(ref, "StateSnapshot") { | 177 | if !strings.HasSuffix(ref, "StateSnapshot") { |
| 178 | t.Errorf("events stream $ref = %q, want ...StateSnapshot", ref) | 178 | t.Errorf("events stream $ref = %q, want ...StateSnapshot", ref) |
| 179 | } | 179 | } |
| 180 | |||
| 181 | // Console WebSocket: a 101 response with no content schema. | ||
| 182 | ws := dig(t, doc, "paths", "/api/v1/vms/{id}/console/ws", "get", "responses", "101").(map[string]any) | ||
| 183 | if _, has := ws["content"]; has { | ||
| 184 | t.Errorf("console/ws 101 response carries content: %v", ws["content"]) | ||
| 185 | } | ||
| 180 | } | 186 | } |
| 181 | 187 | ||
| 182 | func TestDefaultErrorResponse(t *testing.T) { | 188 | func TestDefaultErrorResponse(t *testing.T) { |
internal/server/api/sshcert.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,237 @@ | |||
| 1 | package api | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "crypto/rand" | ||
| 5 | "encoding/binary" | ||
| 6 | "net/http" | ||
| 7 | "strconv" | ||
| 8 | "time" | ||
| 9 | |||
| 10 | "github.com/a73x/eitri/internal/server/api/types" | ||
| 11 | "github.com/a73x/eitri/internal/server/sshca" | ||
| 12 | "golang.org/x/crypto/ssh" | ||
| 13 | ) | ||
| 14 | |||
| 15 | // certPrincipal is the VM login user carried by every minted user cert. eitri's | ||
| 16 | // default seed hardcodes `ubuntu`, so v1 mints exactly this principal (spec §B2). | ||
| 17 | // It is set server-side and a client-supplied principal is always ignored — the | ||
| 18 | // multi-user swap (per-owner principals, §9 C1/C2) is then a body change, not an | ||
| 19 | // interface change. | ||
| 20 | const certPrincipal = "ubuntu" | ||
| 21 | |||
| 22 | // CertMinter mints a short-lived SSH user certificate for a caller's public key | ||
| 23 | // (consumer-owned; the concrete implementation is *Minter, wired by main via | ||
| 24 | // SetCertMinter when the jump gate is enabled). Nil ⇒ the gate is off and the | ||
| 25 | // endpoint 404s. | ||
| 26 | type CertMinter interface { | ||
| 27 | Mint(pub ssh.PublicKey) (*ssh.Certificate, error) | ||
| 28 | } | ||
| 29 | |||
| 30 | // SetCertMinter wires the SSH cert minter. Called once by main when the jump | ||
| 31 | // gate is enabled (ssh_listen set); a nil minter leaves the endpoint 404ing. | ||
| 32 | func (a *API) SetCertMinter(m CertMinter) { a.certs = m } | ||
| 33 | |||
| 34 | // Minter signs short-lived user certificates with the persistent SSH user CA. | ||
| 35 | type Minter struct { | ||
| 36 | ca ssh.Signer | ||
| 37 | ttl time.Duration | ||
| 38 | now func() time.Time | ||
| 39 | } | ||
| 40 | |||
| 41 | // NewMinter builds a Minter that signs certs valid for ttl with ca. | ||
| 42 | func NewMinter(ca ssh.Signer, ttl time.Duration) *Minter { | ||
| 43 | return &Minter{ca: ca, ttl: ttl, now: time.Now} | ||
| 44 | } | ||
| 45 | |||
| 46 | // Mint signs a user cert for pub, valid from now for the configured TTL. | ||
| 47 | func (m *Minter) Mint(pub ssh.PublicKey) (*ssh.Certificate, error) { | ||
| 48 | return mintUserCert(m.ca, pub, m.now(), m.ttl) | ||
| 49 | } | ||
| 50 | |||
| 51 | // mintUserCert builds and CA-signs a user certificate for pub. Validity is set | ||
| 52 | // server-side (now .. now+ttl); the principal is fixed to certPrincipal. Kept | ||
| 53 | // free of HTTP so it is unit-testable in isolation. | ||
| 54 | func mintUserCert(ca ssh.Signer, pub ssh.PublicKey, now time.Time, ttl time.Duration) (*ssh.Certificate, error) { | ||
| 55 | var serial uint64 | ||
| 56 | if err := binary.Read(rand.Reader, binary.BigEndian, &serial); err != nil { | ||
| 57 | return nil, err | ||
| 58 | } | ||
| 59 | cert := &ssh.Certificate{ | ||
| 60 | Key: pub, | ||
| 61 | Serial: serial, | ||
| 62 | CertType: ssh.UserCert, | ||
| 63 | KeyId: certPrincipal, | ||
| 64 | ValidPrincipals: []string{certPrincipal}, | ||
| 65 | ValidAfter: uint64(now.Unix()), | ||
| 66 | ValidBefore: uint64(now.Add(ttl).Unix()), | ||
| 67 | Permissions: ssh.Permissions{Extensions: map[string]string{ | ||
| 68 | "permit-pty": "", | ||
| 69 | "permit-port-forwarding": "", | ||
| 70 | "permit-user-rc": "", | ||
| 71 | "permit-agent-forwarding": "", | ||
| 72 | }}, | ||
| 73 | } | ||
| 74 | if err := cert.SignCert(rand.Reader, ca); err != nil { | ||
| 75 | return nil, err | ||
| 76 | } | ||
| 77 | return cert, nil | ||
| 78 | } | ||
| 79 | |||
| 80 | // HostCertMinter generates and signs a per-VM SSH HOST key + cert at VM | ||
| 81 | // create. The concrete implementation is *HostMinter, wired by main via | ||
| 82 | // SetHostCertMinter when the jump gate is enabled. Nil ⇒ the gate is off and | ||
| 83 | // VMs get no host cert (unchanged TOFU behaviour). | ||
| 84 | type HostCertMinter interface { | ||
| 85 | // MintHostCert returns a fresh host private key (OpenSSH PEM, WRITE-ONLY key | ||
| 86 | // material) and a CA-signed host cert (authorized_keys form) whose sole | ||
| 87 | // principal is the VM name a client dials. | ||
| 88 | MintHostCert(principal string) (keyPEM, cert string, err error) | ||
| 89 | } | ||
| 90 | |||
| 91 | // SetHostCertMinter wires the per-VM SSH host-cert minter. Called once by main | ||
| 92 | // when the jump gate is enabled; a nil minter leaves VMs without host certs. | ||
| 93 | func (a *API) SetHostCertMinter(m HostCertMinter) { a.hostCerts = m } | ||
| 94 | |||
| 95 | // SetSSHCAAuthorizedKey publishes the eitri CA public key (authorized_keys / | ||
| 96 | // known_hosts form) served by GET /api/v1/ssh-ca. Called once by main when the | ||
| 97 | // jump gate is enabled; empty ⇒ the endpoint 404s. Public material — safe to | ||
| 98 | // serve unauthenticated so clients can pin `@cert-authority` before they hold | ||
| 99 | // any credential. | ||
| 100 | func (a *API) SetSSHCAAuthorizedKey(line string) { a.sshCAKey = line } | ||
| 101 | |||
| 102 | // handleSSHCA returns the eitri CA public key so a client can write a | ||
| 103 | // `@cert-authority * <ca>` known_hosts entry and verify the gate and every VM | ||
| 104 | // by certificate instead of TOFU. Unauthenticated (it is public material); | ||
| 105 | // 404s when the jump gate is off. | ||
| 106 | func (a *API) handleSSHCA(w http.ResponseWriter, r *http.Request) { | ||
| 107 | if a.sshCAKey == "" { | ||
| 108 | httpError(w, "ssh jump gate not enabled", http.StatusNotFound) | ||
| 109 | return | ||
| 110 | } | ||
| 111 | writeJSON(w, http.StatusOK, types.SSHCAResponse{CA: a.sshCAKey}) | ||
| 112 | } | ||
| 113 | |||
| 114 | // HostMinter signs per-VM host certificates with the persistent SSH user CA | ||
| 115 | // (which doubles as the host CA in v1). Unlike Minter it has no TTL knob: host | ||
| 116 | // certs are long-lived (sshca.HostCertTTL). | ||
| 117 | type HostMinter struct { | ||
| 118 | ca ssh.Signer | ||
| 119 | now func() time.Time | ||
| 120 | } | ||
| 121 | |||
| 122 | // NewHostMinter builds a HostMinter that signs host certs with ca. | ||
| 123 | func NewHostMinter(ca ssh.Signer) *HostMinter { return &HostMinter{ca: ca, now: time.Now} } | ||
| 124 | |||
| 125 | // MintHostCert generates a fresh ed25519 host key and signs a long-lived host | ||
| 126 | // cert scoped to principal (the VM name). The returned PEM is private key | ||
| 127 | // material — the caller persists it write-only and never logs or echoes it. | ||
| 128 | func (m *HostMinter) MintHostCert(principal string) (keyPEM, cert string, err error) { | ||
| 129 | pem, signer, err := sshca.GenerateHostKey() | ||
| 130 | if err != nil { | ||
| 131 | return "", "", err | ||
| 132 | } | ||
| 133 | c, err := sshca.SignHostCert(m.ca, signer.PublicKey(), []string{principal}, principal, m.now(), sshca.HostCertTTL) | ||
| 134 | if err != nil { | ||
| 135 | return "", "", err | ||
| 136 | } | ||
| 137 | return string(pem), string(ssh.MarshalAuthorizedKey(c)), nil | ||
| 138 | } | ||
| 139 | |||
| 140 | // handleMintSSHCert mints a short-lived user cert for the caller's public key, | ||
| 141 | // signed by the user CA (spec §B2). Admin-authed. Returns 404 when the jump | ||
| 142 | // gate is off (no CA wired). | ||
| 143 | func (a *API) handleMintSSHCert(w http.ResponseWriter, r *http.Request) { | ||
| 144 | if a.certs == nil { | ||
| 145 | httpError(w, "ssh jump gate not enabled", http.StatusNotFound) | ||
| 146 | return | ||
| 147 | } | ||
| 148 | var req types.SSHCertRequest | ||
| 149 | if !decodeJSON(w, r, &req) { | ||
| 150 | return | ||
| 151 | } | ||
| 152 | pub, _, _, _, err := ssh.ParseAuthorizedKey([]byte(req.PublicKey)) | ||
| 153 | if err != nil { | ||
| 154 | httpError(w, "invalid public_key", http.StatusBadRequest) | ||
| 155 | return | ||
| 156 | } | ||
| 157 | cert, err := a.certs.Mint(pub) | ||
| 158 | if err != nil { | ||
| 159 | httpError(w, "internal error", http.StatusInternalServerError) | ||
| 160 | return | ||
| 161 | } | ||
| 162 | a.audit("ssh-cert.mint", map[string]string{ | ||
| 163 | "remote": clientIP(r), | ||
| 164 | "principal": certPrincipal, | ||
| 165 | "fingerprint": ssh.FingerprintSHA256(pub), | ||
| 166 | }) | ||
| 167 | writeJSON(w, http.StatusOK, types.SSHCertResponse{ | ||
| 168 | Certificate: string(ssh.MarshalAuthorizedKey(cert)), | ||
| 169 | }) | ||
| 170 | } | ||
| 171 | |||
| 172 | // handleRevokeSSHCert revokes a specific minted user cert by serial so the jump | ||
| 173 | // gate rejects it at auth before its short TTL expires. Admin-authed, idempotent | ||
| 174 | // (re-revoking a serial is a 204 no-op). Revocation is a pure store operation — | ||
| 175 | // it does NOT depend on the minter being wired, so unlike mint it never 404s on | ||
| 176 | // a gate-off server. | ||
| 177 | func (a *API) handleRevokeSSHCert(w http.ResponseWriter, r *http.Request) { | ||
| 178 | var req types.RevokeSSHCertRequest | ||
| 179 | if !decodeJSON(w, r, &req) { | ||
| 180 | return | ||
| 181 | } | ||
| 182 | |||
| 183 | var serial uint64 | ||
| 184 | switch { | ||
| 185 | case req.Certificate != "": | ||
| 186 | // Parse the authorized-key line into a cert and take its serial. The | ||
| 187 | // public key/CA signature are NOT verified here — an admin revoking a | ||
| 188 | // serial is asserting "reject this serial", and the gate is where the | ||
| 189 | // signature is checked. A non-cert key line is a clear 400. | ||
| 190 | pk, _, _, _, err := ssh.ParseAuthorizedKey([]byte(req.Certificate)) | ||
| 191 | if err != nil { | ||
| 192 | httpError(w, "invalid certificate", http.StatusBadRequest) | ||
| 193 | return | ||
| 194 | } | ||
| 195 | cert, ok := pk.(*ssh.Certificate) | ||
| 196 | if !ok { | ||
| 197 | httpError(w, "not a certificate", http.StatusBadRequest) | ||
| 198 | return | ||
| 199 | } | ||
| 200 | serial = cert.Serial | ||
| 201 | case req.Serial != nil: | ||
| 202 | serial = *req.Serial | ||
| 203 | default: | ||
| 204 | httpError(w, "serial or certificate required", http.StatusBadRequest) | ||
| 205 | return | ||
| 206 | } | ||
| 207 | |||
| 208 | if err := a.st.RevokeSSHCert(serial, req.Reason); err != nil { | ||
| 209 | httpError(w, "internal error", http.StatusInternalServerError) | ||
| 210 | return | ||
| 211 | } | ||
| 212 | a.audit("ssh-cert.revoke", map[string]string{ | ||
| 213 | "remote": clientIP(r), | ||
| 214 | "serial": strconv.FormatUint(serial, 10), | ||
| 215 | "reason": req.Reason, | ||
| 216 | }) | ||
| 217 | w.WriteHeader(http.StatusNoContent) | ||
| 218 | } | ||
| 219 | |||
| 220 | // handleListRevokedSSHCerts lists the revoked cert serials (+ reason/time), | ||
| 221 | // newest first. Admin-authed. | ||
| 222 | func (a *API) handleListRevokedSSHCerts(w http.ResponseWriter, r *http.Request) { | ||
| 223 | revoked, err := a.st.ListRevokedSSHCerts() | ||
| 224 | if err != nil { | ||
| 225 | httpError(w, "internal error", http.StatusInternalServerError) | ||
| 226 | return | ||
| 227 | } | ||
| 228 | out := make([]types.RevokedCert, len(revoked)) | ||
| 229 | for i, rc := range revoked { | ||
| 230 | out[i] = types.RevokedCert{ | ||
| 231 | Serial: strconv.FormatUint(rc.Serial, 10), | ||
| 232 | RevokedAt: rc.RevokedAt, | ||
| 233 | Reason: rc.Reason, | ||
| 234 | } | ||
| 235 | } | ||
| 236 | writeJSON(w, http.StatusOK, out) | ||
| 237 | } | ||
internal/server/api/sshcert_test.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,325 @@ | |||
| 1 | package api | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "bytes" | ||
| 5 | "crypto/ed25519" | ||
| 6 | "crypto/rand" | ||
| 7 | "encoding/json" | ||
| 8 | "io" | ||
| 9 | "net/http" | ||
| 10 | "net/http/httptest" | ||
| 11 | "testing" | ||
| 12 | "time" | ||
| 13 | |||
| 14 | "github.com/stretchr/testify/assert" | ||
| 15 | "github.com/stretchr/testify/require" | ||
| 16 | "golang.org/x/crypto/ssh" | ||
| 17 | ) | ||
| 18 | |||
| 19 | // newCASigner returns a throwaway ed25519 ssh.Signer to stand in for the user CA. | ||
| 20 | func newCASigner(t *testing.T) ssh.Signer { | ||
| 21 | t.Helper() | ||
| 22 | _, priv, err := ed25519.GenerateKey(rand.Reader) | ||
| 23 | require.NoError(t, err) | ||
| 24 | s, err := ssh.NewSignerFromSigner(priv) | ||
| 25 | require.NoError(t, err) | ||
| 26 | return s | ||
| 27 | } | ||
| 28 | |||
| 29 | // genUserPubKey returns a fresh ed25519 public key in authorized-keys form. | ||
| 30 | func genUserPubKey(t *testing.T) string { | ||
| 31 | t.Helper() | ||
| 32 | pub, _, err := ed25519.GenerateKey(rand.Reader) | ||
| 33 | require.NoError(t, err) | ||
| 34 | sp, err := ssh.NewPublicKey(pub) | ||
| 35 | require.NoError(t, err) | ||
| 36 | return string(ssh.MarshalAuthorizedKey(sp)) | ||
| 37 | } | ||
| 38 | |||
| 39 | // newServerWithCertMinter builds a test server with the ssh-cert gate enabled, | ||
| 40 | // returning the server and the CA signer whose public key certs are checked against. | ||
| 41 | func newServerWithCertMinter(t *testing.T, ttl time.Duration) (*httptest.Server, ssh.Signer) { | ||
| 42 | t.Helper() | ||
| 43 | ts, _, _, _, a := newServer(t) | ||
| 44 | ca := newCASigner(t) | ||
| 45 | a.SetCertMinter(NewMinter(ca, ttl)) | ||
| 46 | return ts, ca | ||
| 47 | } | ||
| 48 | |||
| 49 | // parseCert decodes an authorized-keys cert line into an *ssh.Certificate. | ||
| 50 | func parseCert(t *testing.T, line string) *ssh.Certificate { | ||
| 51 | t.Helper() | ||
| 52 | pk, _, _, _, err := ssh.ParseAuthorizedKey([]byte(line)) | ||
| 53 | require.NoError(t, err) | ||
| 54 | cert, ok := pk.(*ssh.Certificate) | ||
| 55 | require.True(t, ok, "parsed key must be an *ssh.Certificate") | ||
| 56 | return cert | ||
| 57 | } | ||
| 58 | |||
| 59 | // mintCert POSTs to /api/v1/ssh-certs and returns the response + decoded cert line. | ||
| 60 | func mintCert(t *testing.T, ts *httptest.Server, token string, body map[string]any) (*http.Response, string) { | ||
| 61 | t.Helper() | ||
| 62 | resp := do(t, "POST", ts.URL+"/api/v1/ssh-certs", token, body) | ||
| 63 | if resp.StatusCode != http.StatusOK { | ||
| 64 | return resp, "" | ||
| 65 | } | ||
| 66 | var out map[string]string | ||
| 67 | require.NoError(t, json.NewDecoder(resp.Body).Decode(&out)) | ||
| 68 | return resp, out["certificate"] | ||
| 69 | } | ||
| 70 | |||
| 71 | func TestSSHCertMintReturnsSignedUserCert(t *testing.T) { | ||
| 72 | ttl := 10 * time.Minute | ||
| 73 | ts, ca := newServerWithCertMinter(t, ttl) | ||
| 74 | |||
| 75 | resp, line := mintCert(t, ts, "admintok", map[string]any{"public_key": genUserPubKey(t)}) | ||
| 76 | require.Equal(t, http.StatusOK, resp.StatusCode) | ||
| 77 | |||
| 78 | cert := parseCert(t, line) | ||
| 79 | assert.Equal(t, uint32(ssh.UserCert), cert.CertType) | ||
| 80 | assert.Equal(t, []string{"ubuntu"}, cert.ValidPrincipals) | ||
| 81 | assert.Equal(t, uint64(ttl.Seconds()), cert.ValidBefore-cert.ValidAfter) | ||
| 82 | for _, ext := range []string{ | ||
| 83 | "permit-pty", "permit-port-forwarding", "permit-user-rc", "permit-agent-forwarding", | ||
| 84 | } { | ||
| 85 | _, ok := cert.Permissions.Extensions[ext] | ||
| 86 | assert.True(t, ok, "cert must carry extension %q", ext) | ||
| 87 | } | ||
| 88 | |||
| 89 | // The CA signature must verify for principal ubuntu. | ||
| 90 | checker := &ssh.CertChecker{IsUserAuthority: func(k ssh.PublicKey) bool { | ||
| 91 | return bytes.Equal(k.Marshal(), ca.PublicKey().Marshal()) | ||
| 92 | }} | ||
| 93 | require.NoError(t, checker.CheckCert("ubuntu", cert)) | ||
| 94 | } | ||
| 95 | |||
| 96 | // TestSSHCertServedCertScopedToPrincipalUbuntu asserts the cert actually served | ||
| 97 | // by the HTTP handler (response body → re-parsed *ssh.Certificate) is scoped to | ||
| 98 | // exactly `ubuntu`. An empty ValidPrincipals would make the cert valid for ANY | ||
| 99 | // login user, so the guard is that it is both non-empty AND exactly ["ubuntu"]. | ||
| 100 | func TestSSHCertServedCertScopedToPrincipalUbuntu(t *testing.T) { | ||
| 101 | ts, _ := newServerWithCertMinter(t, 10*time.Minute) | ||
| 102 | |||
| 103 | resp, line := mintCert(t, ts, "admintok", map[string]any{"public_key": genUserPubKey(t)}) | ||
| 104 | require.Equal(t, http.StatusOK, resp.StatusCode) | ||
| 105 | |||
| 106 | cert := parseCert(t, line) | ||
| 107 | require.NotEmpty(t, cert.ValidPrincipals, "served cert must NOT be valid for ANY principal") | ||
| 108 | assert.Equal(t, []string{"ubuntu"}, cert.ValidPrincipals) | ||
| 109 | } | ||
| 110 | |||
| 111 | func TestSSHCertMintIgnoresClientPrincipals(t *testing.T) { | ||
| 112 | ts, _ := newServerWithCertMinter(t, 10*time.Minute) | ||
| 113 | |||
| 114 | resp, line := mintCert(t, ts, "admintok", map[string]any{ | ||
| 115 | "public_key": genUserPubKey(t), | ||
| 116 | "principals": []string{"root", "admin"}, | ||
| 117 | }) | ||
| 118 | require.Equal(t, http.StatusOK, resp.StatusCode) | ||
| 119 | |||
| 120 | cert := parseCert(t, line) | ||
| 121 | assert.Equal(t, []string{"ubuntu"}, cert.ValidPrincipals, | ||
| 122 | "client-supplied principals must be ignored — always ubuntu") | ||
| 123 | } | ||
| 124 | |||
| 125 | func TestSSHCertMintRequiresAdmin(t *testing.T) { | ||
| 126 | ts, _ := newServerWithCertMinter(t, 10*time.Minute) | ||
| 127 | body := map[string]any{"public_key": genUserPubKey(t)} | ||
| 128 | assert.Equal(t, 401, do(t, "POST", ts.URL+"/api/v1/ssh-certs", "", body).StatusCode) | ||
| 129 | assert.Equal(t, 401, do(t, "POST", ts.URL+"/api/v1/ssh-certs", "wrong", body).StatusCode) | ||
| 130 | } | ||
| 131 | |||
| 132 | func TestSSHCertMintMalformedPubKeyIs400(t *testing.T) { | ||
| 133 | ts, _ := newServerWithCertMinter(t, 10*time.Minute) | ||
| 134 | resp, _ := mintCert(t, ts, "admintok", map[string]any{"public_key": "not-a-key"}) | ||
| 135 | assert.Equal(t, 400, resp.StatusCode) | ||
| 136 | } | ||
| 137 | |||
| 138 | func TestSSHCertMintGateOffIs404(t *testing.T) { | ||
| 139 | // No minter wired ⇒ the gate is off; the endpoint must not be reachable. | ||
| 140 | ts, _, _, _, _ := newServer(t) | ||
| 141 | resp, _ := mintCert(t, ts, "admintok", map[string]any{"public_key": genUserPubKey(t)}) | ||
| 142 | assert.Equal(t, 404, resp.StatusCode) | ||
| 143 | } | ||
| 144 | |||
| 145 | func TestSSHCAEndpointServesCAWhenEnabled(t *testing.T) { | ||
| 146 | ts, _, _, _, a := newServer(t) | ||
| 147 | a.SetSSHCAAuthorizedKey("ssh-ed25519 AAAAtestca eitri-user-ca") | ||
| 148 | |||
| 149 | // Unauthenticated: it is public material and clients need it before auth. | ||
| 150 | resp := do(t, "GET", ts.URL+"/api/v1/ssh-ca", "", nil) | ||
| 151 | require.Equal(t, http.StatusOK, resp.StatusCode) | ||
| 152 | var out map[string]string | ||
| 153 | require.NoError(t, json.NewDecoder(resp.Body).Decode(&out)) | ||
| 154 | assert.Equal(t, "ssh-ed25519 AAAAtestca eitri-user-ca", out["ca"]) | ||
| 155 | } | ||
| 156 | |||
| 157 | func TestSSHCAEndpointGateOffIs404(t *testing.T) { | ||
| 158 | ts, _, _, _, _ := newServer(t) | ||
| 159 | resp := do(t, "GET", ts.URL+"/api/v1/ssh-ca", "", nil) | ||
| 160 | assert.Equal(t, http.StatusNotFound, resp.StatusCode) | ||
| 161 | } | ||
| 162 | |||
| 163 | // TestCreateVMMintsPerVMHostCert asserts that, with the gate enabled, creating a | ||
| 164 | // VM persists a per-VM host private key + a CA-signed host cert scoped to the VM | ||
| 165 | // name — and that neither ever appears in the API's VM response. | ||
| 166 | func TestCreateVMMintsPerVMHostCert(t *testing.T) { | ||
| 167 | ts, st, _, _, a := newServer(t) | ||
| 168 | ca := newCASigner(t) | ||
| 169 | a.SetHostCertMinter(NewHostMinter(ca)) | ||
| 170 | out := enroll(t, ts) | ||
| 171 | |||
| 172 | resp := do(t, "POST", ts.URL+"/api/v1/vms", "admintok", | ||
| 173 | map[string]any{"host_id": out["host_id"], "name": "hosty"}) | ||
| 174 | require.Equal(t, http.StatusCreated, resp.StatusCode) | ||
| 175 | |||
| 176 | // The store row carries the private key PEM + the cert. | ||
| 177 | vm, err := st.VMByName("hosty") | ||
| 178 | require.NoError(t, err) | ||
| 179 | require.NotEmpty(t, vm.SSHHostKey, "per-VM host private key must be persisted") | ||
| 180 | require.NotEmpty(t, vm.SSHHostCert, "per-VM host cert must be persisted") | ||
| 181 | assert.Contains(t, vm.SSHHostKey, "OPENSSH PRIVATE KEY") | ||
| 182 | |||
| 183 | // The cert is a HOST cert signed by the CA and scoped to the VM name. | ||
| 184 | cert := parseCert(t, vm.SSHHostCert) | ||
| 185 | assert.Equal(t, uint32(ssh.HostCert), cert.CertType) | ||
| 186 | assert.Equal(t, []string{"hosty"}, cert.ValidPrincipals) | ||
| 187 | checker := &ssh.CertChecker{IsHostAuthority: func(k ssh.PublicKey, _ string) bool { | ||
| 188 | return bytes.Equal(k.Marshal(), ca.PublicKey().Marshal()) | ||
| 189 | }} | ||
| 190 | require.NoError(t, checker.CheckHostKey("hosty:22", nil, cert)) | ||
| 191 | |||
| 192 | // The private key must NEVER leak through the VM listing. | ||
| 193 | listResp := do(t, "GET", ts.URL+"/api/v1/vms", "admintok", nil) | ||
| 194 | require.Equal(t, http.StatusOK, listResp.StatusCode) | ||
| 195 | body, err := io.ReadAll(listResp.Body) | ||
| 196 | require.NoError(t, err) | ||
| 197 | assert.NotContains(t, string(body), "OPENSSH PRIVATE KEY", "host private key must not appear on the wire") | ||
| 198 | assert.NotContains(t, string(body), "ssh_host_key") | ||
| 199 | } | ||
| 200 | |||
| 201 | // TestCreateVMWithoutHostMinterHasNoHostCert confirms the gate-off path is | ||
| 202 | // unchanged: no minter wired ⇒ VMs carry no host key/cert. | ||
| 203 | func TestCreateVMWithoutHostMinterHasNoHostCert(t *testing.T) { | ||
| 204 | ts, st, _, _, _ := newServer(t) | ||
| 205 | out := enroll(t, ts) | ||
| 206 | resp := do(t, "POST", ts.URL+"/api/v1/vms", "admintok", | ||
| 207 | map[string]any{"host_id": out["host_id"], "name": "plainvm"}) | ||
| 208 | require.Equal(t, http.StatusCreated, resp.StatusCode) | ||
| 209 | |||
| 210 | vm, err := st.VMByName("plainvm") | ||
| 211 | require.NoError(t, err) | ||
| 212 | assert.Empty(t, vm.SSHHostKey) | ||
| 213 | assert.Empty(t, vm.SSHHostCert) | ||
| 214 | } | ||
| 215 | |||
| 216 | // TestSSHCertRevokeBySerial revokes a cert by its raw serial and confirms the | ||
| 217 | // list endpoint reflects it (serial as a string, to survive JS clients). | ||
| 218 | func TestSSHCertRevokeBySerial(t *testing.T) { | ||
| 219 | ts, st, _, _, _ := newServer(t) | ||
| 220 | |||
| 221 | const serial = uint64(0xFFFFFFFF00000001) // > MaxInt64, exercises the bit-cast | ||
| 222 | resp := do(t, "POST", ts.URL+"/api/v1/ssh-certs/revoke", "admintok", | ||
| 223 | map[string]any{"serial": serial, "reason": "lost yubikey"}) | ||
| 224 | require.Equal(t, http.StatusNoContent, resp.StatusCode) | ||
| 225 | |||
| 226 | revoked, err := st.IsSSHCertRevoked(serial) | ||
| 227 | require.NoError(t, err) | ||
| 228 | assert.True(t, revoked) | ||
| 229 | |||
| 230 | // List endpoint reflects it, serial rendered as a string. | ||
| 231 | listResp := do(t, "GET", ts.URL+"/api/v1/ssh-certs/revoked", "admintok", nil) | ||
| 232 | require.Equal(t, http.StatusOK, listResp.StatusCode) | ||
| 233 | var out []map[string]any | ||
| 234 | require.NoError(t, json.NewDecoder(listResp.Body).Decode(&out)) | ||
| 235 | require.Len(t, out, 1) | ||
| 236 | assert.Equal(t, "18446744069414584321", out[0]["serial"]) | ||
| 237 | assert.Equal(t, "lost yubikey", out[0]["reason"]) | ||
| 238 | } | ||
| 239 | |||
| 240 | // TestSSHCertRevokeByCertLine revokes by pasting a minted cert authorized-key | ||
| 241 | // line; the server extracts the serial and the matching serial reads revoked. | ||
| 242 | func TestSSHCertRevokeByCertLine(t *testing.T) { | ||
| 243 | ts, st, _, _, a := newServer(t) | ||
| 244 | ca := newCASigner(t) | ||
| 245 | a.SetCertMinter(NewMinter(ca, 10*time.Minute)) | ||
| 246 | |||
| 247 | _, line := mintCert(t, ts, "admintok", map[string]any{"public_key": genUserPubKey(t)}) | ||
| 248 | cert := parseCert(t, line) | ||
| 249 | |||
| 250 | resp := do(t, "POST", ts.URL+"/api/v1/ssh-certs/revoke", "admintok", | ||
| 251 | map[string]any{"certificate": line}) | ||
| 252 | require.Equal(t, http.StatusNoContent, resp.StatusCode) | ||
| 253 | |||
| 254 | revoked, err := st.IsSSHCertRevoked(cert.Serial) | ||
| 255 | require.NoError(t, err) | ||
| 256 | assert.True(t, revoked, "the minted cert's serial must be revoked") | ||
| 257 | } | ||
| 258 | |||
| 259 | // TestSSHCertRevokeIdempotent confirms re-revoking the same serial is a 204 | ||
| 260 | // no-op that leaves a single list entry. | ||
| 261 | func TestSSHCertRevokeIdempotent(t *testing.T) { | ||
| 262 | ts, _, _, _, _ := newServer(t) | ||
| 263 | body := map[string]any{"serial": uint64(7)} | ||
| 264 | require.Equal(t, http.StatusNoContent, do(t, "POST", ts.URL+"/api/v1/ssh-certs/revoke", "admintok", body).StatusCode) | ||
| 265 | require.Equal(t, http.StatusNoContent, do(t, "POST", ts.URL+"/api/v1/ssh-certs/revoke", "admintok", body).StatusCode) | ||
| 266 | |||
| 267 | listResp := do(t, "GET", ts.URL+"/api/v1/ssh-certs/revoked", "admintok", nil) | ||
| 268 | var out []map[string]any | ||
| 269 | require.NoError(t, json.NewDecoder(listResp.Body).Decode(&out)) | ||
| 270 | assert.Len(t, out, 1) | ||
| 271 | } | ||
| 272 | |||
| 273 | func TestSSHCertRevokeMissingFieldsIs400(t *testing.T) { | ||
| 274 | ts, _, _, _, _ := newServer(t) | ||
| 275 | resp := do(t, "POST", ts.URL+"/api/v1/ssh-certs/revoke", "admintok", map[string]any{"reason": "no serial"}) | ||
| 276 | assert.Equal(t, http.StatusBadRequest, resp.StatusCode) | ||
| 277 | } | ||
| 278 | |||
| 279 | func TestSSHCertRevokeBadCertLineIs400(t *testing.T) { | ||
| 280 | ts, _, _, _, _ := newServer(t) | ||
| 281 | resp := do(t, "POST", ts.URL+"/api/v1/ssh-certs/revoke", "admintok", map[string]any{"certificate": "not-a-cert"}) | ||
| 282 | assert.Equal(t, http.StatusBadRequest, resp.StatusCode) | ||
| 283 | } | ||
| 284 | |||
| 285 | func TestSSHCertRevokeRequiresAdmin(t *testing.T) { | ||
| 286 | ts, _, _, _, _ := newServer(t) | ||
| 287 | body := map[string]any{"serial": uint64(1)} | ||
| 288 | assert.Equal(t, 401, do(t, "POST", ts.URL+"/api/v1/ssh-certs/revoke", "", body).StatusCode) | ||
| 289 | assert.Equal(t, 401, do(t, "POST", ts.URL+"/api/v1/ssh-certs/revoke", "wrong", body).StatusCode) | ||
| 290 | assert.Equal(t, 401, do(t, "GET", ts.URL+"/api/v1/ssh-certs/revoked", "", nil).StatusCode) | ||
| 291 | } | ||
| 292 | |||
| 293 | // TestMintUserCert exercises the pure mint function without HTTP. | ||
| 294 | func TestMintUserCert(t *testing.T) { | ||
| 295 | ca := newCASigner(t) | ||
| 296 | pk, _, _, _, err := ssh.ParseAuthorizedKey([]byte(genUserPubKey(t))) | ||
| 297 | require.NoError(t, err) | ||
| 298 | |||
| 299 | now := time.Unix(1_700_000_000, 0) | ||
| 300 | cert, err := mintUserCert(ca, pk, now, 5*time.Minute) | ||
| 301 | require.NoError(t, err) | ||
| 302 | |||
| 303 | assert.Equal(t, uint32(ssh.UserCert), cert.CertType) | ||
| 304 | assert.Equal(t, []string{"ubuntu"}, cert.ValidPrincipals) | ||
| 305 | assert.Equal(t, uint64(now.Unix()), cert.ValidAfter) | ||
| 306 | assert.Equal(t, uint64(now.Add(5*time.Minute).Unix()), cert.ValidBefore) | ||
| 307 | assert.NotZero(t, cert.Serial) | ||
| 308 | |||
| 309 | // The cert must grant pty + forwarding so guest sshd permits an interactive | ||
| 310 | // shell, scp, and port-forwarding over the tunnelled session. | ||
| 311 | for _, ext := range []string{ | ||
| 312 | "permit-pty", "permit-port-forwarding", "permit-user-rc", "permit-agent-forwarding", | ||
| 313 | } { | ||
| 314 | _, ok := cert.Permissions.Extensions[ext] | ||
| 315 | assert.True(t, ok, "cert must carry extension %q", ext) | ||
| 316 | } | ||
| 317 | |||
| 318 | checker := &ssh.CertChecker{ | ||
| 319 | Clock: func() time.Time { return now.Add(time.Minute) }, // inside validity | ||
| 320 | IsUserAuthority: func(k ssh.PublicKey) bool { | ||
| 321 | return bytes.Equal(k.Marshal(), ca.PublicKey().Marshal()) | ||
| 322 | }, | ||
| 323 | } | ||
| 324 | require.NoError(t, checker.CheckCert("ubuntu", cert)) | ||
| 325 | } | ||
internal/server/api/testdata/enroll-request.golden.json
| Old | New | ||
|---|---|---|---|
| @@ -3,6 +3,5 @@ | |||
| 3 | "name": "host-nine", | 3 | "name": "host-nine", |
| 4 | "os": "linux", | 4 | "os": "linux", |
| 5 | "arch": "arm64", | 5 | "arch": "arm64", |
| 6 | "provisioner": "cloudhypervisor", | 6 | "provisioner": "cloudhypervisor" |
| 7 | "overlay": "none" | ||
| 8 | } | 7 | } |
internal/server/api/testdata/enroll-response.golden.json
| Old | New | ||
|---|---|---|---|
| @@ -2,6 +2,5 @@ | |||
| 2 | "bridge_cidr": "10.77.1.0/24", | 2 | "bridge_cidr": "10.77.1.0/24", |
| 3 | "credential": "cred-opaque-01", | 3 | "credential": "cred-opaque-01", |
| 4 | "host_id": "h-1234", | 4 | "host_id": "h-1234", |
| 5 | "overlay": "tailscale", | ||
| 6 | "server_cert_sha256": "cafebabef00d" | 5 | "server_cert_sha256": "cafebabef00d" |
| 7 | } | 6 | } |
internal/server/api/testdata/host.golden.json
| Old | New | ||
|---|---|---|---|
| @@ -4,7 +4,6 @@ | |||
| 4 | "os": "linux", | 4 | "os": "linux", |
| 5 | "arch": "amd64", | 5 | "arch": "amd64", |
| 6 | "provisioner": "cloudhypervisor", | 6 | "provisioner": "cloudhypervisor", |
| 7 | "overlay": "tailscale", | ||
| 8 | "bridge_cidr": "10.77.1.0/24", | 7 | "bridge_cidr": "10.77.1.0/24", |
| 9 | "status": "active", | 8 | "status": "active", |
| 10 | "enrolled_at": "2026-07-27T12:00:00Z", | 9 | "enrolled_at": "2026-07-27T12:00:00Z", |
internal/server/api/testdata/revoke-cert-request.golden.json
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,5 @@ | |||
| 1 | { | ||
| 2 | "serial": 9007199254740993, | ||
| 3 | "certificate": "ssh-ed25519-cert-v01@openssh.com AAAAB3Nza cert-comment", | ||
| 4 | "reason": "rotated out" | ||
| 5 | } | ||
internal/server/api/testdata/revoked-cert.golden.json
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,7 @@ | |||
| 1 | [ | ||
| 2 | { | ||
| 3 | "serial": "18446744073709551615", | ||
| 4 | "revoked_at": "2026-07-27T12:03:00Z", | ||
| 5 | "reason": "key compromised" | ||
| 6 | } | ||
| 7 | ] | ||
internal/server/api/testdata/snapshot.golden.json
| Old | New | ||
|---|---|---|---|
| @@ -6,7 +6,6 @@ | |||
| 6 | "os": "linux", | 6 | "os": "linux", |
| 7 | "arch": "amd64", | 7 | "arch": "amd64", |
| 8 | "provisioner": "cloudhypervisor", | 8 | "provisioner": "cloudhypervisor", |
| 9 | "overlay": "tailscale", | ||
| 10 | "bridge_cidr": "10.77.1.0/24", | 9 | "bridge_cidr": "10.77.1.0/24", |
| 11 | "status": "active", | 10 | "status": "active", |
| 12 | "enrolled_at": "2026-07-27T12:00:00Z", | 11 | "enrolled_at": "2026-07-27T12:00:00Z", |
| @@ -40,7 +39,9 @@ | |||
| 40 | "created_at": "2026-07-27T12:01:00Z", | 39 | "created_at": "2026-07-27T12:01:00Z", |
| 41 | "deleted": true, | 40 | "deleted": true, |
| 42 | "actual_power": "stopped", | 41 | "actual_power": "stopped", |
| 43 | "phase": "creating" | 42 | "phase": "creating", |
| 43 | "destroy_at": 1785153600, | ||
| 44 | "lifecycle": "deleting" | ||
| 44 | } | 45 | } |
| 45 | ] | 46 | ] |
| 46 | } | 47 | } |
internal/server/api/testdata/ssh-ca-response.golden.json
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,3 @@ | |||
| 1 | { | ||
| 2 | "ca": "ssh-ed25519 AAAAC3Nza eitri-host-ca" | ||
| 3 | } | ||
internal/server/api/testdata/ssh-cert-request.golden.json
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,6 @@ | |||
| 1 | { | ||
| 2 | "public_key": "ssh-ed25519 AAAAC3Nza key-comment", | ||
| 3 | "principals": [ | ||
| 4 | "ubuntu" | ||
| 5 | ] | ||
| 6 | } | ||
internal/server/api/testdata/ssh-cert-response.golden.json
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,3 @@ | |||
| 1 | { | ||
| 2 | "certificate": "ssh-ed25519-cert-v01@openssh.com AAAAB3Nza cert-comment" | ||
| 3 | } | ||
internal/server/api/testdata/vm.golden.json
| Old | New | ||
|---|---|---|---|
| @@ -14,5 +14,7 @@ | |||
| 14 | "created_at": "2026-07-27T12:01:00Z", | 14 | "created_at": "2026-07-27T12:01:00Z", |
| 15 | "deleted": true, | 15 | "deleted": true, |
| 16 | "actual_power": "stopped", | 16 | "actual_power": "stopped", |
| 17 | "phase": "creating" | 17 | "phase": "creating", |
| 18 | "destroy_at": 1785153600, | ||
| 19 | "lifecycle": "deleting" | ||
| 18 | } | 20 | } |
internal/server/api/ticket.go
| Old | New | ||
|---|---|---|---|
| @@ -13,9 +13,12 @@ import ( | |||
| 13 | const streamTicketTTL = time.Minute | 13 | const streamTicketTTL = time.Minute |
| 14 | 14 | ||
| 15 | // ticketStore holds one-time SSE stream tickets in memory. Tickets are | 15 | // ticketStore holds one-time SSE stream tickets in memory. Tickets are |
| 16 | // ephemeral session bootstrap — a server restart just means the client mints | 16 | // deliberately endpoint-agnostic: the SSE stream and the console WS share |
| 17 | // a fresh one on its next reconnect — so no durability is needed. now is | 17 | // this one store — both mints are admin-authed at the same privilege, so |
| 18 | // injectable for tests. | 18 | // per-endpoint scoping would add ceremony without adding a boundary. Tickets |
| 19 | // are ephemeral session bootstrap — a server restart just means the client | ||
| 20 | // mints a fresh one on its next reconnect — so no durability is needed. now | ||
| 21 | // is injectable for tests. | ||
| 19 | type ticketStore struct { | 22 | type ticketStore struct { |
| 20 | mu sync.Mutex | 23 | mu sync.Mutex |
| 21 | tickets map[string]time.Time // ticket → expiry | 24 | tickets map[string]time.Time // ticket → expiry |
internal/server/api/types/types.go
| Old | New | ||
|---|---|---|---|
| @@ -28,7 +28,6 @@ type Host struct { | |||
| 28 | OS string `json:"os"` | 28 | OS string `json:"os"` |
| 29 | Arch string `json:"arch"` | 29 | Arch string `json:"arch"` |
| 30 | Provisioner string `json:"provisioner"` | 30 | Provisioner string `json:"provisioner"` |
| 31 | Overlay string `json:"overlay"` | ||
| 32 | BridgeCIDR string `json:"bridge_cidr"` | 31 | BridgeCIDR string `json:"bridge_cidr"` |
| 33 | Status string `json:"status"` | 32 | Status string `json:"status"` |
| 34 | EnrolledAt time.Time `json:"enrolled_at"` | 33 | EnrolledAt time.Time `json:"enrolled_at"` |
| @@ -58,6 +57,19 @@ type VM struct { | |||
| 58 | Deleted bool `json:"deleted"` | 57 | Deleted bool `json:"deleted"` |
| 59 | ActualPower string `json:"actual_power"` | 58 | ActualPower string `json:"actual_power"` |
| 60 | Phase string `json:"phase"` | 59 | Phase string `json:"phase"` |
| 60 | // DestroyAt is the unix-seconds deadline at which the agent will hard-destroy | ||
| 61 | // this VM. It is only set while the VM is quarantined for teardown (deleted + | ||
| 62 | // guest stopped, awaiting the tombstone grace window); 0 in the normal case. | ||
| 63 | // Clients render a countdown instead of an opaque "deleting". | ||
| 64 | DestroyAt int64 `json:"destroy_at"` | ||
| 65 | // Lifecycle is a server-derived rollup of the orthogonal state axes above | ||
| 66 | // (deleted / phase / power) into a single coarse word, so | ||
| 67 | // every client agrees on "what is this VM doing" without re-deriving it. | ||
| 68 | // It is NOT authoritative and NOT stored: like a Kubernetes Pod's | ||
| 69 | // status.phase, it is a lossy projection computed on read — the control | ||
| 70 | // loops (reconciler, reaper, agent) read and write the underlying axes, | ||
| 71 | // never this field. Values: creating | ready | stopped | failed | deleting. | ||
| 72 | Lifecycle string `json:"lifecycle"` | ||
| 61 | } | 73 | } |
| 62 | 74 | ||
| 63 | // StateSnapshot is the full fleet state pushed as each `event: state` frame | 75 | // StateSnapshot is the full fleet state pushed as each `event: state` frame |
| @@ -75,7 +87,6 @@ type EnrollRequest struct { | |||
| 75 | OS string `json:"os"` | 87 | OS string `json:"os"` |
| 76 | Arch string `json:"arch"` | 88 | Arch string `json:"arch"` |
| 77 | Provisioner string `json:"provisioner"` | 89 | Provisioner string `json:"provisioner"` |
| 78 | Overlay string `json:"overlay"` // optional; defaults to "tailscale" | ||
| 79 | } | 90 | } |
| 80 | 91 | ||
| 81 | // CreateVMRequest is the POST /api/v1/vms body. Every field except host_id is | 92 | // CreateVMRequest is the POST /api/v1/vms body. Every field except host_id is |
| @@ -112,7 +123,6 @@ type EnrollResponse struct { | |||
| 112 | BridgeCIDR string `json:"bridge_cidr"` | 123 | BridgeCIDR string `json:"bridge_cidr"` |
| 113 | Credential string `json:"credential"` | 124 | Credential string `json:"credential"` |
| 114 | HostID string `json:"host_id"` | 125 | HostID string `json:"host_id"` |
| 115 | Overlay string `json:"overlay"` | ||
| 116 | ServerCertSHA256 string `json:"server_cert_sha256"` | 126 | ServerCertSHA256 string `json:"server_cert_sha256"` |
| 117 | } | 127 | } |
| 118 | 128 | ||
| @@ -128,15 +138,60 @@ type CreateVMResponse struct { | |||
| 128 | Name string `json:"name"` | 138 | Name string `json:"name"` |
| 129 | } | 139 | } |
| 130 | 140 | ||
| 141 | // SSHCAResponse answers GET /api/v1/ssh-ca. | ||
| 142 | type SSHCAResponse struct { | ||
| 143 | CA string `json:"ca"` | ||
| 144 | } | ||
| 145 | |||
| 146 | // SSHCertResponse answers POST /api/v1/ssh-certs: the CA-signed user | ||
| 147 | // certificate in authorized_keys form. | ||
| 148 | type SSHCertResponse struct { | ||
| 149 | Certificate string `json:"certificate"` | ||
| 150 | } | ||
| 151 | |||
| 131 | // StreamTicketResponse answers POST /api/v1/stream-tickets. | 152 | // StreamTicketResponse answers POST /api/v1/stream-tickets. |
| 132 | type StreamTicketResponse struct { | 153 | type StreamTicketResponse struct { |
| 133 | Ticket string `json:"ticket"` | 154 | Ticket string `json:"ticket"` |
| 134 | } | 155 | } |
| 135 | 156 | ||
| 136 | // AuditEvent is the wire shape of one audit row, served by GET /api/v1/audit; | 157 | // AuditEvent is the wire shape of one audit row, served by GET /api/v1/audit |
| 137 | // detail is embedded as raw JSON (it is always a marshaled object). | 158 | // and GET /api/v1/vms/{id}/events; detail is embedded as raw JSON (it is |
| 159 | // always a marshaled object). | ||
| 138 | type AuditEvent struct { | 160 | type AuditEvent struct { |
| 139 | At time.Time `json:"at"` | 161 | At time.Time `json:"at"` |
| 140 | Action string `json:"action"` | 162 | Action string `json:"action"` |
| 141 | Detail json.RawMessage `json:"detail"` | 163 | Detail json.RawMessage `json:"detail"` |
| 142 | } | 164 | } |
| 165 | |||
| 166 | // RevokedCert is the wire form of one revoked SSH cert, served by | ||
| 167 | // GET /api/v1/ssh-certs/revoked. Serial is a STRING, not a JSON number: a | ||
| 168 | // uint64 serial routinely exceeds 2^53 and would lose precision in a | ||
| 169 | // JavaScript client that parsed it as a double. | ||
| 170 | type RevokedCert struct { | ||
| 171 | Serial string `json:"serial"` | ||
| 172 | RevokedAt time.Time `json:"revoked_at"` | ||
| 173 | Reason string `json:"reason"` | ||
| 174 | } | ||
| 175 | |||
| 176 | // SSHCertRequest is the POST /api/v1/ssh-certs body: the caller's public key | ||
| 177 | // for which the jump gate mints a short-lived user certificate. | ||
| 178 | type SSHCertRequest struct { | ||
| 179 | PublicKey string `json:"public_key"` | ||
| 180 | // Principals is accepted on the wire but DELIBERATELY IGNORED — principals | ||
| 181 | // are set server-side. Kept as a field so a client that sends it gets a | ||
| 182 | // well-formed decode rather than a surprise, and so the ignore is explicit | ||
| 183 | // rather than implicit. | ||
| 184 | Principals []string `json:"principals"` | ||
| 185 | } | ||
| 186 | |||
| 187 | // RevokeSSHCertRequest is the POST /api/v1/ssh-certs/revoke body. It accepts | ||
| 188 | // EITHER a raw serial OR a full cert authorized-key line (from which the serial | ||
| 189 | // is extracted) — the by-line form is the ergonomic one (paste the cert you | ||
| 190 | // minted), the by-serial form is for programmatic callers. Serial is a pointer | ||
| 191 | // so an absent field is distinguishable from an explicit 0. Reason is optional | ||
| 192 | // audit metadata. | ||
| 193 | type RevokeSSHCertRequest struct { | ||
| 194 | Serial *uint64 `json:"serial"` | ||
| 195 | Certificate string `json:"certificate"` | ||
| 196 | Reason string `json:"reason"` | ||
| 197 | } | ||
internal/server/api/wire_golden_test.go
| Old | New | ||
|---|---|---|---|
| @@ -55,7 +55,6 @@ func TestWireGolden(t *testing.T) { | |||
| 55 | OS: "linux", | 55 | OS: "linux", |
| 56 | Arch: "amd64", | 56 | Arch: "amd64", |
| 57 | Provisioner: "cloudhypervisor", | 57 | Provisioner: "cloudhypervisor", |
| 58 | Overlay: "tailscale", | ||
| 59 | BridgeCIDR: "10.77.1.0/24", | 58 | BridgeCIDR: "10.77.1.0/24", |
| 60 | Status: "active", | 59 | Status: "active", |
| 61 | EnrolledAt: base, | 60 | EnrolledAt: base, |
| @@ -82,6 +81,8 @@ func TestWireGolden(t *testing.T) { | |||
| 82 | Deleted: true, | 81 | Deleted: true, |
| 83 | ActualPower: "stopped", | 82 | ActualPower: "stopped", |
| 84 | Phase: "creating", | 83 | Phase: "creating", |
| 84 | DestroyAt: 1785153600, | ||
| 85 | Lifecycle: "deleting", | ||
| 85 | } | 86 | } |
| 86 | goldenCheck(t, "vm", vm) | 87 | goldenCheck(t, "vm", vm) |
| 87 | 88 | ||
| @@ -96,7 +97,6 @@ func TestWireGolden(t *testing.T) { | |||
| 96 | OS: "linux", | 97 | OS: "linux", |
| 97 | Arch: "arm64", | 98 | Arch: "arm64", |
| 98 | Provisioner: "cloudhypervisor", | 99 | Provisioner: "cloudhypervisor", |
| 99 | Overlay: "none", | ||
| 100 | }) | 100 | }) |
| 101 | 101 | ||
| 102 | goldenCheck(t, "create-vm-request", types.CreateVMRequest{ | 102 | goldenCheck(t, "create-vm-request", types.CreateVMRequest{ |
| @@ -117,6 +117,18 @@ func TestWireGolden(t *testing.T) { | |||
| 117 | PowerState: "stopped", | 117 | PowerState: "stopped", |
| 118 | }) | 118 | }) |
| 119 | 119 | ||
| 120 | goldenCheck(t, "ssh-cert-request", types.SSHCertRequest{ | ||
| 121 | PublicKey: "ssh-ed25519 AAAAC3Nza key-comment", | ||
| 122 | Principals: []string{"ubuntu"}, | ||
| 123 | }) | ||
| 124 | |||
| 125 | serial := uint64(9007199254740993) | ||
| 126 | goldenCheck(t, "revoke-cert-request", types.RevokeSSHCertRequest{ | ||
| 127 | Serial: &serial, | ||
| 128 | Certificate: "ssh-ed25519-cert-v01@openssh.com AAAAB3Nza cert-comment", | ||
| 129 | Reason: "rotated out", | ||
| 130 | }) | ||
| 131 | |||
| 120 | // The named response shapes that replaced handlers' inline | 132 | // The named response shapes that replaced handlers' inline |
| 121 | // map[string]string literals (their declarations in the types package | 133 | // map[string]string literals (their declarations in the types package |
| 122 | // explain the byte-compatible field ordering). | 134 | // explain the byte-compatible field ordering). |
| @@ -124,7 +136,6 @@ func TestWireGolden(t *testing.T) { | |||
| 124 | BridgeCIDR: "10.77.1.0/24", | 136 | BridgeCIDR: "10.77.1.0/24", |
| 125 | Credential: "cred-opaque-01", | 137 | Credential: "cred-opaque-01", |
| 126 | HostID: "h-1234", | 138 | HostID: "h-1234", |
| 127 | Overlay: "tailscale", | ||
| 128 | ServerCertSHA256: "cafebabef00d", | 139 | ServerCertSHA256: "cafebabef00d", |
| 129 | }) | 140 | }) |
| 130 | 141 | ||
| @@ -138,6 +149,14 @@ func TestWireGolden(t *testing.T) { | |||
| 138 | Name: "sandbox-abc123", | 149 | Name: "sandbox-abc123", |
| 139 | }) | 150 | }) |
| 140 | 151 | ||
| 152 | goldenCheck(t, "ssh-ca-response", types.SSHCAResponse{ | ||
| 153 | CA: "ssh-ed25519 AAAAC3Nza eitri-host-ca", | ||
| 154 | }) | ||
| 155 | |||
| 156 | goldenCheck(t, "ssh-cert-response", types.SSHCertResponse{ | ||
| 157 | Certificate: "ssh-ed25519-cert-v01@openssh.com AAAAB3Nza cert-comment", | ||
| 158 | }) | ||
| 159 | |||
| 141 | goldenCheck(t, "stream-ticket-response", types.StreamTicketResponse{ | 160 | goldenCheck(t, "stream-ticket-response", types.StreamTicketResponse{ |
| 142 | Ticket: "ticket-opaque-01", | 161 | Ticket: "ticket-opaque-01", |
| 143 | }) | 162 | }) |
| @@ -147,4 +166,10 @@ func TestWireGolden(t *testing.T) { | |||
| 147 | Action: "host.enroll", | 166 | Action: "host.enroll", |
| 148 | Detail: json.RawMessage(`{"host_id":"h-1234","remote":"203.0.113.7"}`), | 167 | Detail: json.RawMessage(`{"host_id":"h-1234","remote":"203.0.113.7"}`), |
| 149 | }) | 168 | }) |
| 169 | |||
| 170 | goldenCheck(t, "revoked-cert", []types.RevokedCert{{ | ||
| 171 | Serial: "18446744073709551615", | ||
| 172 | RevokedAt: base.Add(3 * time.Minute), | ||
| 173 | Reason: "key compromised", | ||
| 174 | }}) | ||
| 150 | } | 175 | } |
internal/server/health/health.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,72 @@ | |||
| 1 | // Package health serves the eitri-server liveness and readiness probes. | ||
| 2 | // | ||
| 3 | // The split follows the Kubernetes convention: /livez answers "is the process | ||
| 4 | // alive" (restart me if not) and must never depend on an external service, | ||
| 5 | // while /readyz answers "should I receive traffic" (route around me until I say | ||
| 6 | // yes) and runs the dependency checks. Both are unauthenticated and mounted | ||
| 7 | // outside /api/ so a load balancer or the deploy script can probe them without | ||
| 8 | // a token. | ||
| 9 | // | ||
| 10 | // The package holds no eitri dependencies: callers pass dependency probes as | ||
| 11 | // Check closures, so this stays a leaf that main wires against the concrete | ||
| 12 | // store. | ||
| 13 | package health | ||
| 14 | |||
| 15 | import ( | ||
| 16 | "context" | ||
| 17 | "encoding/json" | ||
| 18 | "io" | ||
| 19 | "log/slog" | ||
| 20 | "net/http" | ||
| 21 | "time" | ||
| 22 | ) | ||
| 23 | |||
| 24 | // Check is one named readiness dependency probe. Probe returns nil when the | ||
| 25 | // dependency is reachable; any error marks it unavailable. | ||
| 26 | type Check struct { | ||
| 27 | Name string | ||
| 28 | Probe func(ctx context.Context) error | ||
| 29 | } | ||
| 30 | |||
| 31 | // Live handles GET /livez: 200 for as long as the process can serve HTTP. It | ||
| 32 | // runs no dependency checks by design — a liveness probe that failed because a | ||
| 33 | // dependency was briefly slow or down would trigger a needless restart, taking | ||
| 34 | // out a server that was merely waiting on someone else. | ||
| 35 | func Live(w http.ResponseWriter, _ *http.Request) { | ||
| 36 | w.Header().Set("Content-Type", "text/plain; charset=utf-8") | ||
| 37 | w.WriteHeader(http.StatusOK) | ||
| 38 | _, _ = io.WriteString(w, "ok\n") | ||
| 39 | } | ||
| 40 | |||
| 41 | // Ready returns a handler for GET /readyz. It runs every check under a context | ||
| 42 | // bounded by timeout and reports 200 {"status":"ready"} when all pass, or 503 | ||
| 43 | // {"status":"unready"} when any fails. The JSON body names each check so an | ||
| 44 | // operator sees WHICH dependency is unready ("ok" | "unavailable"); the raw | ||
| 45 | // probe error is logged, never returned — this endpoint is unauthenticated and | ||
| 46 | // a probe error can carry internal detail (socket paths, driver messages). | ||
| 47 | func Ready(timeout time.Duration, checks ...Check) http.HandlerFunc { | ||
| 48 | return func(w http.ResponseWriter, r *http.Request) { | ||
| 49 | ctx, cancel := context.WithTimeout(r.Context(), timeout) | ||
| 50 | defer cancel() | ||
| 51 | |||
| 52 | results := make(map[string]string, len(checks)) | ||
| 53 | ready := true | ||
| 54 | for _, c := range checks { | ||
| 55 | if err := c.Probe(ctx); err != nil { | ||
| 56 | results[c.Name] = "unavailable" | ||
| 57 | ready = false | ||
| 58 | slog.Warn("readiness check failed", "check", c.Name, "err", err) | ||
| 59 | } else { | ||
| 60 | results[c.Name] = "ok" | ||
| 61 | } | ||
| 62 | } | ||
| 63 | |||
| 64 | status, label := http.StatusOK, "ready" | ||
| 65 | if !ready { | ||
| 66 | status, label = http.StatusServiceUnavailable, "unready" | ||
| 67 | } | ||
| 68 | w.Header().Set("Content-Type", "application/json") | ||
| 69 | w.WriteHeader(status) | ||
| 70 | _ = json.NewEncoder(w).Encode(map[string]any{"status": label, "checks": results}) | ||
| 71 | } | ||
| 72 | } | ||
internal/server/health/health_test.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,108 @@ | |||
| 1 | package health | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "context" | ||
| 5 | "encoding/json" | ||
| 6 | "errors" | ||
| 7 | "net/http" | ||
| 8 | "net/http/httptest" | ||
| 9 | "testing" | ||
| 10 | "time" | ||
| 11 | ) | ||
| 12 | |||
| 13 | func TestLive(t *testing.T) { | ||
| 14 | rec := httptest.NewRecorder() | ||
| 15 | Live(rec, httptest.NewRequest(http.MethodGet, "/livez", nil)) | ||
| 16 | if rec.Code != http.StatusOK { | ||
| 17 | t.Fatalf("status = %d, want 200", rec.Code) | ||
| 18 | } | ||
| 19 | if body := rec.Body.String(); body != "ok\n" { | ||
| 20 | t.Fatalf("body = %q, want %q", body, "ok\n") | ||
| 21 | } | ||
| 22 | } | ||
| 23 | |||
| 24 | func TestReadyAllPass(t *testing.T) { | ||
| 25 | h := Ready(time.Second, | ||
| 26 | Check{Name: "db", Probe: func(context.Context) error { return nil }}, | ||
| 27 | Check{Name: "cache", Probe: func(context.Context) error { return nil }}, | ||
| 28 | ) | ||
| 29 | rec := httptest.NewRecorder() | ||
| 30 | h(rec, httptest.NewRequest(http.MethodGet, "/readyz", nil)) | ||
| 31 | if rec.Code != http.StatusOK { | ||
| 32 | t.Fatalf("status = %d, want 200", rec.Code) | ||
| 33 | } | ||
| 34 | var got struct { | ||
| 35 | Status string `json:"status"` | ||
| 36 | Checks map[string]string `json:"checks"` | ||
| 37 | } | ||
| 38 | if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { | ||
| 39 | t.Fatalf("decode: %v", err) | ||
| 40 | } | ||
| 41 | if got.Status != "ready" { | ||
| 42 | t.Fatalf("status = %q, want ready", got.Status) | ||
| 43 | } | ||
| 44 | if got.Checks["db"] != "ok" || got.Checks["cache"] != "ok" { | ||
| 45 | t.Fatalf("checks = %v, want all ok", got.Checks) | ||
| 46 | } | ||
| 47 | } | ||
| 48 | |||
| 49 | func TestReadyOneFails(t *testing.T) { | ||
| 50 | h := Ready(time.Second, | ||
| 51 | Check{Name: "db", Probe: func(context.Context) error { return nil }}, | ||
| 52 | Check{Name: "cache", Probe: func(context.Context) error { return errors.New("dial /run/cache.sock: connection refused") }}, | ||
| 53 | ) | ||
| 54 | rec := httptest.NewRecorder() | ||
| 55 | h(rec, httptest.NewRequest(http.MethodGet, "/readyz", nil)) | ||
| 56 | if rec.Code != http.StatusServiceUnavailable { | ||
| 57 | t.Fatalf("status = %d, want 503", rec.Code) | ||
| 58 | } | ||
| 59 | var got struct { | ||
| 60 | Status string `json:"status"` | ||
| 61 | Checks map[string]string `json:"checks"` | ||
| 62 | } | ||
| 63 | if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { | ||
| 64 | t.Fatalf("decode: %v", err) | ||
| 65 | } | ||
| 66 | if got.Status != "unready" { | ||
| 67 | t.Fatalf("status = %q, want unready", got.Status) | ||
| 68 | } | ||
| 69 | if got.Checks["db"] != "ok" { | ||
| 70 | t.Fatalf("db check = %q, want ok", got.Checks["db"]) | ||
| 71 | } | ||
| 72 | // The body reports the failing check as unavailable WITHOUT leaking the raw | ||
| 73 | // error (which may carry socket paths / internal detail) to an | ||
| 74 | // unauthenticated endpoint. | ||
| 75 | if got.Checks["cache"] != "unavailable" { | ||
| 76 | t.Fatalf("cache check = %q, want unavailable", got.Checks["cache"]) | ||
| 77 | } | ||
| 78 | if raw := rec.Body.String(); containsAny(raw, "connection refused", "/run/cache.sock") { | ||
| 79 | t.Fatalf("body leaked internal error detail: %q", raw) | ||
| 80 | } | ||
| 81 | } | ||
| 82 | |||
| 83 | func TestReadyProbeSeesTimeout(t *testing.T) { | ||
| 84 | // A probe that outlives the readiness budget must see a cancelled context — | ||
| 85 | // Ready bounds each probe so a wedged dependency cannot hang the endpoint. | ||
| 86 | var deadlineOK bool | ||
| 87 | h := Ready(10*time.Millisecond, Check{Name: "slow", Probe: func(ctx context.Context) error { | ||
| 88 | _, ok := ctx.Deadline() | ||
| 89 | deadlineOK = ok | ||
| 90 | return ctx.Err() | ||
| 91 | }}) | ||
| 92 | rec := httptest.NewRecorder() | ||
| 93 | h(rec, httptest.NewRequest(http.MethodGet, "/readyz", nil)) | ||
| 94 | if !deadlineOK { | ||
| 95 | t.Fatal("probe did not receive a deadline-bounded context") | ||
| 96 | } | ||
| 97 | } | ||
| 98 | |||
| 99 | func containsAny(s string, subs ...string) bool { | ||
| 100 | for _, sub := range subs { | ||
| 101 | for i := 0; i+len(sub) <= len(s); i++ { | ||
| 102 | if s[i:i+len(sub)] == sub { | ||
| 103 | return true | ||
| 104 | } | ||
| 105 | } | ||
| 106 | } | ||
| 107 | return false | ||
| 108 | } | ||
internal/server/sshca/sshca.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,152 @@ | |||
| 1 | // Package sshca manages eitri's SSH key material: a persistent user CA (whose | ||
| 2 | // short-lived certs authenticate admins to the jump gate and VMs) and a | ||
| 3 | // persistent gate host key. Both are load-or-create — generated once on first | ||
| 4 | // boot into a configured path and reused thereafter, so users never see | ||
| 5 | // host-key-changed warnings. | ||
| 6 | // | ||
| 7 | // The private key material is handled like AdminToken / the server TLS key: | ||
| 8 | // written 0600, server-user-owned, and NEVER logged or exposed in API | ||
| 9 | // responses. Only the CA *public* key is exported (for VM trust injection and | ||
| 10 | // known_hosts pinning). | ||
| 11 | package sshca | ||
| 12 | |||
| 13 | import ( | ||
| 14 | "crypto/ed25519" | ||
| 15 | "crypto/rand" | ||
| 16 | "encoding/binary" | ||
| 17 | "encoding/pem" | ||
| 18 | "fmt" | ||
| 19 | "os" | ||
| 20 | "time" | ||
| 21 | |||
| 22 | "golang.org/x/crypto/ssh" | ||
| 23 | ) | ||
| 24 | |||
| 25 | // HostCertTTL is the validity window of a signed HOST certificate. Host certs | ||
| 26 | // are long-lived on purpose: they are pinned by CA (a client trusts anything | ||
| 27 | // the CA signs via `@cert-authority`), not rotated per-session like the | ||
| 28 | // short-lived user certs. Ten years keeps them out of the operator's way. | ||
| 29 | const HostCertTTL = 10 * 365 * 24 * time.Hour | ||
| 30 | |||
| 31 | // CA holds eitri's persistent SSH key material. In v1 the same key acts as both | ||
| 32 | // user CA and host CA (§B4 permits reusing ssh_ca_key); the gate host key is a | ||
| 33 | // separate persistent key. | ||
| 34 | type CA struct { | ||
| 35 | userCA ssh.Signer | ||
| 36 | hostKey ssh.Signer | ||
| 37 | } | ||
| 38 | |||
| 39 | // New loads-or-creates the user CA (caPath) and the gate host key (hostKeyPath). | ||
| 40 | // Both files are created 0600 if absent and reused if present. | ||
| 41 | func New(caPath, hostKeyPath string) (*CA, error) { | ||
| 42 | userCA, err := LoadOrCreate(caPath) | ||
| 43 | if err != nil { | ||
| 44 | return nil, fmt.Errorf("ssh user CA: %w", err) | ||
| 45 | } | ||
| 46 | hostKey, err := LoadOrCreate(hostKeyPath) | ||
| 47 | if err != nil { | ||
| 48 | return nil, fmt.Errorf("ssh host key: %w", err) | ||
| 49 | } | ||
| 50 | return &CA{userCA: userCA, hostKey: hostKey}, nil | ||
| 51 | } | ||
| 52 | |||
| 53 | // UserCA returns the signer used to sign user (and, in v1, host) certificates. | ||
| 54 | func (c *CA) UserCA() ssh.Signer { return c.userCA } | ||
| 55 | |||
| 56 | // HostKey returns the gate's persistent host key. | ||
| 57 | func (c *CA) HostKey() ssh.Signer { return c.hostKey } | ||
| 58 | |||
| 59 | // UserCAAuthorizedKey returns the user CA public key in authorized_keys / | ||
| 60 | // known_hosts form (e.g. "ssh-ed25519 AAAA... \n"), suitable for | ||
| 61 | // TrustedUserCAKeys injection and "@cert-authority" known_hosts pinning. This | ||
| 62 | // is public material — safe to expose. | ||
| 63 | func (c *CA) UserCAAuthorizedKey() []byte { | ||
| 64 | return ssh.MarshalAuthorizedKey(c.userCA.PublicKey()) | ||
| 65 | } | ||
| 66 | |||
| 67 | // LoadOrCreate returns a stable ssh.Signer for the key at path. If the file is | ||
| 68 | // absent it generates an ed25519 key, writes it 0600 (OpenSSH PEM), and returns | ||
| 69 | // its signer; if present it parses and returns the existing key. The public key | ||
| 70 | // is stable across reloads. | ||
| 71 | // | ||
| 72 | // Never logs or returns key material in errors. | ||
| 73 | func LoadOrCreate(path string) (ssh.Signer, error) { | ||
| 74 | pemBytes, err := os.ReadFile(path) | ||
| 75 | if err == nil { | ||
| 76 | signer, perr := ssh.ParsePrivateKey(pemBytes) | ||
| 77 | if perr != nil { | ||
| 78 | return nil, fmt.Errorf("parse ssh key %q: %w", path, perr) | ||
| 79 | } | ||
| 80 | return signer, nil | ||
| 81 | } | ||
| 82 | if !os.IsNotExist(err) { | ||
| 83 | return nil, fmt.Errorf("read ssh key %q: %w", path, err) | ||
| 84 | } | ||
| 85 | |||
| 86 | // Absent — generate a fresh ed25519 key and persist it 0600. | ||
| 87 | pemBytes, signer, err := GenerateHostKey() | ||
| 88 | if err != nil { | ||
| 89 | return nil, err | ||
| 90 | } | ||
| 91 | // Write 0600 exclusively so a concurrent creator can't race us into a | ||
| 92 | // clobbered key; O_EXCL also guards against following a symlink. | ||
| 93 | f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) | ||
| 94 | if err != nil { | ||
| 95 | return nil, fmt.Errorf("create ssh key %q: %w", path, err) | ||
| 96 | } | ||
| 97 | if _, werr := f.Write(pemBytes); werr != nil { | ||
| 98 | f.Close() | ||
| 99 | return nil, fmt.Errorf("write ssh key %q: %w", path, werr) | ||
| 100 | } | ||
| 101 | if cerr := f.Close(); cerr != nil { | ||
| 102 | return nil, fmt.Errorf("close ssh key %q: %w", path, cerr) | ||
| 103 | } | ||
| 104 | return signer, nil | ||
| 105 | } | ||
| 106 | |||
| 107 | // GenerateHostKey generates a fresh ed25519 key and returns it both as an | ||
| 108 | // OpenSSH-format private-key PEM (for persisting / shipping to a guest as | ||
| 109 | // /etc/ssh/ssh_host_ed25519_key) and as a ready-to-use signer. The PEM is | ||
| 110 | // unencrypted (0600 at rest, like the CA and gate host keys) and never logged. | ||
| 111 | func GenerateHostKey() (pemBytes []byte, signer ssh.Signer, err error) { | ||
| 112 | _, priv, err := ed25519.GenerateKey(rand.Reader) | ||
| 113 | if err != nil { | ||
| 114 | return nil, nil, fmt.Errorf("generate ssh key: %w", err) | ||
| 115 | } | ||
| 116 | block, err := ssh.MarshalPrivateKey(priv, "") | ||
| 117 | if err != nil { | ||
| 118 | return nil, nil, fmt.Errorf("marshal ssh key: %w", err) | ||
| 119 | } | ||
| 120 | signer, err = ssh.NewSignerFromSigner(priv) | ||
| 121 | if err != nil { | ||
| 122 | return nil, nil, fmt.Errorf("new signer: %w", err) | ||
| 123 | } | ||
| 124 | return pem.EncodeToMemory(block), signer, nil | ||
| 125 | } | ||
| 126 | |||
| 127 | // SignHostCert signs hostPub as an OpenSSH HOST certificate valid for | ||
| 128 | // principals (the hostnames a client may connect to), signed by ca. In v1 the | ||
| 129 | // user CA doubles as the host CA (§B4), so the same key that a guest trusts via | ||
| 130 | // TrustedUserCAKeys also certifies host keys that a client trusts via | ||
| 131 | // `@cert-authority`. keyID is a free-form label recorded in the cert (e.g. | ||
| 132 | // "eitri-gate" or the VM name) for audit. Validity runs now .. now+ttl; host | ||
| 133 | // certs use the long HostCertTTL. Kept free of I/O so it is unit-testable. | ||
| 134 | func SignHostCert(ca ssh.Signer, hostPub ssh.PublicKey, principals []string, keyID string, now time.Time, ttl time.Duration) (*ssh.Certificate, error) { | ||
| 135 | var serial uint64 | ||
| 136 | if err := binary.Read(rand.Reader, binary.BigEndian, &serial); err != nil { | ||
| 137 | return nil, err | ||
| 138 | } | ||
| 139 | cert := &ssh.Certificate{ | ||
| 140 | Key: hostPub, | ||
| 141 | Serial: serial, | ||
| 142 | CertType: ssh.HostCert, | ||
| 143 | KeyId: keyID, | ||
| 144 | ValidPrincipals: principals, | ||
| 145 | ValidAfter: uint64(now.Unix()), | ||
| 146 | ValidBefore: uint64(now.Add(ttl).Unix()), | ||
| 147 | } | ||
| 148 | if err := cert.SignCert(rand.Reader, ca); err != nil { | ||
| 149 | return nil, err | ||
| 150 | } | ||
| 151 | return cert, nil | ||
| 152 | } | ||
internal/server/sshca/sshca_test.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,149 @@ | |||
| 1 | package sshca | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "bytes" | ||
| 5 | "os" | ||
| 6 | "path/filepath" | ||
| 7 | "testing" | ||
| 8 | "time" | ||
| 9 | |||
| 10 | "golang.org/x/crypto/ssh" | ||
| 11 | ) | ||
| 12 | |||
| 13 | func TestLoadOrCreate_CreatesWith0600(t *testing.T) { | ||
| 14 | dir := t.TempDir() | ||
| 15 | path := filepath.Join(dir, "ca") | ||
| 16 | |||
| 17 | signer, err := LoadOrCreate(path) | ||
| 18 | if err != nil { | ||
| 19 | t.Fatalf("LoadOrCreate: %v", err) | ||
| 20 | } | ||
| 21 | if signer == nil || signer.PublicKey() == nil { | ||
| 22 | t.Fatal("LoadOrCreate returned a nil signer") | ||
| 23 | } | ||
| 24 | |||
| 25 | fi, err := os.Stat(path) | ||
| 26 | if err != nil { | ||
| 27 | t.Fatalf("stat created key: %v", err) | ||
| 28 | } | ||
| 29 | if perm := fi.Mode().Perm(); perm != 0o600 { | ||
| 30 | t.Fatalf("key file perms = %o, want 0600", perm) | ||
| 31 | } | ||
| 32 | } | ||
| 33 | |||
| 34 | func TestLoadOrCreate_ReloadStable(t *testing.T) { | ||
| 35 | dir := t.TempDir() | ||
| 36 | path := filepath.Join(dir, "ca") | ||
| 37 | |||
| 38 | first, err := LoadOrCreate(path) | ||
| 39 | if err != nil { | ||
| 40 | t.Fatalf("LoadOrCreate (create): %v", err) | ||
| 41 | } | ||
| 42 | second, err := LoadOrCreate(path) | ||
| 43 | if err != nil { | ||
| 44 | t.Fatalf("LoadOrCreate (reload): %v", err) | ||
| 45 | } | ||
| 46 | |||
| 47 | a := ssh.MarshalAuthorizedKey(first.PublicKey()) | ||
| 48 | b := ssh.MarshalAuthorizedKey(second.PublicKey()) | ||
| 49 | if !bytes.Equal(a, b) { | ||
| 50 | t.Fatalf("public key changed across reload:\n first: %s second: %s", a, b) | ||
| 51 | } | ||
| 52 | } | ||
| 53 | |||
| 54 | func TestNew_AccessorsAndAuthorizedKey(t *testing.T) { | ||
| 55 | dir := t.TempDir() | ||
| 56 | caPath := filepath.Join(dir, "ca") | ||
| 57 | hostPath := filepath.Join(dir, "host") | ||
| 58 | |||
| 59 | ca, err := New(caPath, hostPath) | ||
| 60 | if err != nil { | ||
| 61 | t.Fatalf("New: %v", err) | ||
| 62 | } | ||
| 63 | if ca.UserCA() == nil { | ||
| 64 | t.Fatal("UserCA() is nil") | ||
| 65 | } | ||
| 66 | if ca.HostKey() == nil { | ||
| 67 | t.Fatal("HostKey() is nil") | ||
| 68 | } | ||
| 69 | |||
| 70 | // The user CA and host key must be distinct key material. | ||
| 71 | if bytes.Equal(ssh.MarshalAuthorizedKey(ca.UserCA().PublicKey()), | ||
| 72 | ssh.MarshalAuthorizedKey(ca.HostKey().PublicKey())) { | ||
| 73 | t.Fatal("UserCA and HostKey share the same public key") | ||
| 74 | } | ||
| 75 | |||
| 76 | authLine := ca.UserCAAuthorizedKey() | ||
| 77 | if len(authLine) == 0 { | ||
| 78 | t.Fatal("UserCAAuthorizedKey() is empty") | ||
| 79 | } | ||
| 80 | // It must be a parseable authorized_keys line matching the user CA. | ||
| 81 | pub, _, _, _, err := ssh.ParseAuthorizedKey(authLine) | ||
| 82 | if err != nil { | ||
| 83 | t.Fatalf("ParseAuthorizedKey(UserCAAuthorizedKey()): %v", err) | ||
| 84 | } | ||
| 85 | if !bytes.Equal(ssh.MarshalAuthorizedKey(pub), | ||
| 86 | ssh.MarshalAuthorizedKey(ca.UserCA().PublicKey())) { | ||
| 87 | t.Fatal("UserCAAuthorizedKey() does not match UserCA public key") | ||
| 88 | } | ||
| 89 | } | ||
| 90 | |||
| 91 | func TestGenerateHostKey_PEMParsesToSigner(t *testing.T) { | ||
| 92 | pemBytes, signer, err := GenerateHostKey() | ||
| 93 | if err != nil { | ||
| 94 | t.Fatalf("GenerateHostKey: %v", err) | ||
| 95 | } | ||
| 96 | if signer == nil || signer.PublicKey() == nil { | ||
| 97 | t.Fatal("GenerateHostKey returned a nil signer") | ||
| 98 | } | ||
| 99 | // The PEM must round-trip to the SAME public key so a guest that loads it as | ||
| 100 | // /etc/ssh/ssh_host_ed25519_key presents the key the cert was signed for. | ||
| 101 | parsed, err := ssh.ParsePrivateKey(pemBytes) | ||
| 102 | if err != nil { | ||
| 103 | t.Fatalf("ParsePrivateKey(GenerateHostKey PEM): %v", err) | ||
| 104 | } | ||
| 105 | if !bytes.Equal(ssh.MarshalAuthorizedKey(parsed.PublicKey()), | ||
| 106 | ssh.MarshalAuthorizedKey(signer.PublicKey())) { | ||
| 107 | t.Fatal("PEM public key does not match the returned signer") | ||
| 108 | } | ||
| 109 | } | ||
| 110 | |||
| 111 | func TestSignHostCert_SignedByCAAndScopedToPrincipal(t *testing.T) { | ||
| 112 | ca, err := LoadOrCreate(filepath.Join(t.TempDir(), "ca")) | ||
| 113 | if err != nil { | ||
| 114 | t.Fatalf("LoadOrCreate CA: %v", err) | ||
| 115 | } | ||
| 116 | _, host, err := GenerateHostKey() | ||
| 117 | if err != nil { | ||
| 118 | t.Fatalf("GenerateHostKey: %v", err) | ||
| 119 | } | ||
| 120 | |||
| 121 | now := time.Unix(1_700_000_000, 0) | ||
| 122 | cert, err := SignHostCert(ca, host.PublicKey(), []string{"gate.example.com"}, "eitri-gate", now, HostCertTTL) | ||
| 123 | if err != nil { | ||
| 124 | t.Fatalf("SignHostCert: %v", err) | ||
| 125 | } | ||
| 126 | if cert.CertType != ssh.HostCert { | ||
| 127 | t.Fatalf("CertType = %d, want HostCert", cert.CertType) | ||
| 128 | } | ||
| 129 | if got := cert.ValidPrincipals; len(got) != 1 || got[0] != "gate.example.com" { | ||
| 130 | t.Fatalf("ValidPrincipals = %v, want [gate.example.com]", got) | ||
| 131 | } | ||
| 132 | if cert.ValidBefore-cert.ValidAfter != uint64(HostCertTTL.Seconds()) { | ||
| 133 | t.Fatalf("validity window = %d, want %d", cert.ValidBefore-cert.ValidAfter, uint64(HostCertTTL.Seconds())) | ||
| 134 | } | ||
| 135 | |||
| 136 | // A client trusting the CA as a host authority must accept the cert for its | ||
| 137 | // principal — this is the `@cert-authority` verification path. | ||
| 138 | checker := &ssh.CertChecker{ | ||
| 139 | Clock: func() time.Time { return now.Add(time.Hour) }, | ||
| 140 | IsHostAuthority: func(k ssh.PublicKey, _ string) bool { return bytes.Equal(k.Marshal(), ca.PublicKey().Marshal()) }, | ||
| 141 | } | ||
| 142 | if err := checker.CheckHostKey("gate.example.com:22", nil, cert); err != nil { | ||
| 143 | t.Fatalf("CheckHostKey (trusted CA, matching principal): %v", err) | ||
| 144 | } | ||
| 145 | // A different hostname must NOT be accepted (principal scoping holds). | ||
| 146 | if err := checker.CheckHostKey("other.example.com:22", nil, cert); err == nil { | ||
| 147 | t.Fatal("CheckHostKey accepted a hostname not in the cert principals") | ||
| 148 | } | ||
| 149 | } | ||
internal/server/sshgate/gate.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,224 @@ | |||
| 1 | // Package sshgate is eitri's hardened SSH jump gate: a bastion front-end that | ||
| 2 | // admins reach with `ssh -J gate ubuntu@<vm>`. It authenticates users by short- | ||
| 3 | // lived certificates signed by the eitri user CA and permits exactly one thing — | ||
| 4 | // a `direct-tcpip` tunnel to `<vm>:22`, forwarded to the VM's host over the sync | ||
| 5 | // connection. Every other SSH surface is refused: no sessions/shells/exec (which | ||
| 6 | // would be code-exec on eitri-server), no `tcpip-forward`/`-R` (which would make | ||
| 7 | // the bastion an open ingress relay), no ports other than 22. | ||
| 8 | // | ||
| 9 | // Key material is never logged. | ||
| 10 | package sshgate | ||
| 11 | |||
| 12 | import ( | ||
| 13 | "bytes" | ||
| 14 | "context" | ||
| 15 | "errors" | ||
| 16 | "io" | ||
| 17 | "log/slog" | ||
| 18 | "net" | ||
| 19 | "strings" | ||
| 20 | |||
| 21 | "golang.org/x/crypto/ssh" | ||
| 22 | ) | ||
| 23 | |||
| 24 | // Resolver maps a VM name (as typed in `ssh -J gate user@<name>`) to its host | ||
| 25 | // and VM IDs. ok=false ⇒ unknown name; the channel is rejected. Resolution is | ||
| 26 | // NOT an authorization boundary — authz is (§9 M4). | ||
| 27 | type Resolver func(name string) (hostID, vmID string, ok bool) | ||
| 28 | |||
| 29 | // Authorizer reports whether the verified cert principal may reach vmID. v1 wires | ||
| 30 | // an always-true stub (single-admin; only the admin can mint certs); the per-user | ||
| 31 | // implementation is the F3 choke point and must fail closed (§5). | ||
| 32 | type Authorizer func(principal, vmID string) bool | ||
| 33 | |||
| 34 | // Dialer opens a raw byte pipe to vmID:port on hostID (wired to syncsvc.OpenTCP). | ||
| 35 | type Dialer func(ctx context.Context, hostID, vmID string, port uint32) (io.ReadWriteCloser, error) | ||
| 36 | |||
| 37 | // principalsExt is the Permissions.Extensions key under which the verified cert | ||
| 38 | // principal(s) are stashed for the channel handler to authorize on. This is the | ||
| 39 | // ONLY trusted source of the principal — never the requested hostname/username. | ||
| 40 | const principalsExt = "principals" | ||
| 41 | |||
| 42 | // directTCPIP is the wire payload of an SSH `direct-tcpip` channel-open. | ||
| 43 | type directTCPIP struct { | ||
| 44 | HostToConnect string | ||
| 45 | PortToConnect uint32 | ||
| 46 | OriginatorIP string | ||
| 47 | OriginatorPort uint32 | ||
| 48 | } | ||
| 49 | |||
| 50 | // Gate is a hardened SSH bastion. Construct with New and run with Serve. | ||
| 51 | type Gate struct { | ||
| 52 | cfg *ssh.ServerConfig | ||
| 53 | resolve Resolver | ||
| 54 | authorize Authorizer | ||
| 55 | dial Dialer | ||
| 56 | } | ||
| 57 | |||
| 58 | // Revoker reports whether the user cert bearing serial has been revoked. It is | ||
| 59 | // consulted on every cert authentication and MUST fail closed: a nil Revoker is | ||
| 60 | // treated as "nothing revoked", but the wired implementation (main) returns true | ||
| 61 | // on a store error so a DB hiccup rejects the single connection rather than | ||
| 62 | // silently letting a possibly-revoked cert through. | ||
| 63 | // | ||
| 64 | // SCOPE: revocation is enforced at the GATE ONLY. VM guests trust the CA | ||
| 65 | // (TrustedUserCAKeys) with NO guest-side KRL, so a revoked cert would still be | ||
| 66 | // accepted by a VM's sshd if a client reached it directly. That is fine for | ||
| 67 | // single-user — VMs are reachable ONLY via this gate. | ||
| 68 | // Guest-side KRL distribution is a multi-user/rotation follow-up (the deferred | ||
| 69 | // CA-rotation-push problem). | ||
| 70 | type Revoker func(serial uint64) bool | ||
| 71 | |||
| 72 | // New builds a Gate that presents hostKey, trusts only certificates signed by | ||
| 73 | // userCA, resolves VM names with resolve, gates them with authorize, rejects | ||
| 74 | // certs isRevoked flags, and tunnels through dial. A nil isRevoked disables | ||
| 75 | // revocation checks (nothing is revoked). | ||
| 76 | func New(hostKey ssh.Signer, userCA ssh.PublicKey, resolve Resolver, authorize Authorizer, dial Dialer, isRevoked Revoker) *Gate { | ||
| 77 | checker := &ssh.CertChecker{ | ||
| 78 | IsUserAuthority: func(auth ssh.PublicKey) bool { return keysEqual(auth, userCA) }, | ||
| 79 | } | ||
| 80 | // CheckCert consults IsRevoked during validation: a true result fails the | ||
| 81 | // cert authentication outright, so a revoked cert cannot open the tunnel. | ||
| 82 | if isRevoked != nil { | ||
| 83 | checker.IsRevoked = func(cert *ssh.Certificate) bool { return isRevoked(cert.Serial) } | ||
| 84 | } | ||
| 85 | cfg := &ssh.ServerConfig{ | ||
| 86 | PublicKeyCallback: func(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) { | ||
| 87 | cert, ok := key.(*ssh.Certificate) | ||
| 88 | if !ok { | ||
| 89 | return nil, errors.New("sshgate: only certificate authentication is accepted") | ||
| 90 | } | ||
| 91 | if cert.CertType != ssh.UserCert { | ||
| 92 | return nil, errors.New("sshgate: not a user certificate") | ||
| 93 | } | ||
| 94 | if !checker.IsUserAuthority(cert.SignatureKey) { | ||
| 95 | return nil, errors.New("sshgate: certificate not signed by the eitri CA") | ||
| 96 | } | ||
| 97 | // A cert with an empty principal set is, per CheckCert's rules, valid | ||
| 98 | // for ANY principal — a wildcard. eitri always mints exactly one | ||
| 99 | // principal (`ubuntu`), so an empty set is a malformed/over-broad cert: | ||
| 100 | // reject it outright. This also removes the ValidPrincipals[0] | ||
| 101 | // out-of-range panic below. | ||
| 102 | if len(cert.ValidPrincipals) == 0 { | ||
| 103 | return nil, errors.New("sshgate: certificate has no principals") | ||
| 104 | } | ||
| 105 | // Validate revocation / critical options / validity window / signature | ||
| 106 | // WITHOUT binding the cert's principals to the outer SSH username. A jump | ||
| 107 | // user connects as `ssh -J gate ubuntu@vm` with an arbitrary local | ||
| 108 | // username on this outer hop, so CertChecker.Authenticate's | ||
| 109 | // principal-against-conn.User() check would wrongly force `ssh -J | ||
| 110 | // ubuntu@gate`. Instead we feed CheckCert one of the cert's own principals | ||
| 111 | // (a tautology) so only CA-signature + validity gate authentication; the | ||
| 112 | // verified principal is then captured for per-channel authz. | ||
| 113 | if err := checker.CheckCert(cert.ValidPrincipals[0], cert); err != nil { | ||
| 114 | return nil, err | ||
| 115 | } | ||
| 116 | return &ssh.Permissions{ | ||
| 117 | Extensions: map[string]string{ | ||
| 118 | principalsExt: strings.Join(cert.ValidPrincipals, ","), | ||
| 119 | }, | ||
| 120 | }, nil | ||
| 121 | }, | ||
| 122 | } | ||
| 123 | cfg.AddHostKey(hostKey) | ||
| 124 | return &Gate{cfg: cfg, resolve: resolve, authorize: authorize, dial: dial} | ||
| 125 | } | ||
| 126 | |||
| 127 | // Serve accepts connections on l until it returns an error (e.g. l is closed). | ||
| 128 | func (g *Gate) Serve(l net.Listener) error { | ||
| 129 | for { | ||
| 130 | nConn, err := l.Accept() | ||
| 131 | if err != nil { | ||
| 132 | return err | ||
| 133 | } | ||
| 134 | go g.handleConn(nConn) | ||
| 135 | } | ||
| 136 | } | ||
| 137 | |||
| 138 | // handleConn runs the SSH handshake and dispatches channels for one connection. | ||
| 139 | func (g *Gate) handleConn(nConn net.Conn) { | ||
| 140 | defer nConn.Close() | ||
| 141 | sConn, chans, reqs, err := ssh.NewServerConn(nConn, g.cfg) | ||
| 142 | if err != nil { | ||
| 143 | return // handshake or auth failure — nothing to serve | ||
| 144 | } | ||
| 145 | defer sConn.Close() | ||
| 146 | |||
| 147 | // Refuse EVERY out-of-band global request. tcpip-forward (`ssh -R`) would turn | ||
| 148 | // the bastion into an open ingress relay; no other global request is | ||
| 149 | // legitimate here. Draining the channel also keeps the transport unblocked. | ||
| 150 | go rejectRequests(reqs) | ||
| 151 | |||
| 152 | principal := "" | ||
| 153 | if sConn.Permissions != nil { | ||
| 154 | principal = sConn.Permissions.Extensions[principalsExt] | ||
| 155 | } | ||
| 156 | for newChan := range chans { | ||
| 157 | // Only direct-tcpip is permitted; this rejects session/exec/shell/ | ||
| 158 | // subsystem/x11/auth-agent — any granted session is code-exec on the server. | ||
| 159 | if newChan.ChannelType() != "direct-tcpip" { | ||
| 160 | _ = newChan.Reject(ssh.UnknownChannelType, "only direct-tcpip is permitted") | ||
| 161 | continue | ||
| 162 | } | ||
| 163 | go g.handleDirectTCPIP(newChan, principal) | ||
| 164 | } | ||
| 165 | } | ||
| 166 | |||
| 167 | // rejectRequests replies false to every global request that wants a reply and | ||
| 168 | // discards the rest. | ||
| 169 | func rejectRequests(reqs <-chan *ssh.Request) { | ||
| 170 | for req := range reqs { | ||
| 171 | if req.WantReply { | ||
| 172 | _ = req.Reply(false, nil) | ||
| 173 | } | ||
| 174 | } | ||
| 175 | } | ||
| 176 | |||
| 177 | // handleDirectTCPIP validates a direct-tcpip open, authorizes it, dials the VM, | ||
| 178 | // and bridges the channel to the VM byte-for-byte. | ||
| 179 | func (g *Gate) handleDirectTCPIP(newChan ssh.NewChannel, principal string) { | ||
| 180 | var p directTCPIP | ||
| 181 | if err := ssh.Unmarshal(newChan.ExtraData(), &p); err != nil { | ||
| 182 | _ = newChan.Reject(ssh.ConnectionFailed, "malformed direct-tcpip request") | ||
| 183 | return | ||
| 184 | } | ||
| 185 | // Port policy: only 22, and reject others rather than silently rewriting, so | ||
| 186 | // intent stays auditable. | ||
| 187 | if p.PortToConnect != 22 { | ||
| 188 | _ = newChan.Reject(ssh.Prohibited, "only port 22 is permitted") | ||
| 189 | return | ||
| 190 | } | ||
| 191 | hostID, vmID, ok := g.resolve(p.HostToConnect) | ||
| 192 | if !ok { | ||
| 193 | _ = newChan.Reject(ssh.ConnectionFailed, "unknown VM") | ||
| 194 | return | ||
| 195 | } | ||
| 196 | if !g.authorize(principal, vmID) { | ||
| 197 | _ = newChan.Reject(ssh.Prohibited, "not authorized for this VM") | ||
| 198 | return | ||
| 199 | } | ||
| 200 | rwc, err := g.dial(context.Background(), hostID, vmID, 22) | ||
| 201 | if err != nil { | ||
| 202 | _ = newChan.Reject(ssh.ConnectionFailed, "cannot reach VM") | ||
| 203 | return | ||
| 204 | } | ||
| 205 | ch, chReqs, err := newChan.Accept() | ||
| 206 | if err != nil { | ||
| 207 | _ = rwc.Close() | ||
| 208 | return | ||
| 209 | } | ||
| 210 | go ssh.DiscardRequests(chReqs) // no channel requests (env/pty/exec) are honored | ||
| 211 | |||
| 212 | // Two pumps, raw bytes (mirrors console.go): close both legs on either EOF. | ||
| 213 | done := make(chan struct{}, 2) | ||
| 214 | go func() { _, _ = io.Copy(rwc, ch); _ = rwc.Close(); done <- struct{}{} }() | ||
| 215 | go func() { _, _ = io.Copy(ch, rwc); _ = ch.Close(); done <- struct{}{} }() | ||
| 216 | <-done | ||
| 217 | <-done | ||
| 218 | slog.Debug("sshgate tunnel closed", "vm", vmID) | ||
| 219 | } | ||
| 220 | |||
| 221 | // keysEqual reports whether two SSH public keys are byte-identical. | ||
| 222 | func keysEqual(a, b ssh.PublicKey) bool { | ||
| 223 | return a != nil && b != nil && bytes.Equal(a.Marshal(), b.Marshal()) | ||
| 224 | } | ||
internal/server/sshgate/gate_test.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,400 @@ | |||
| 1 | package sshgate | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "context" | ||
| 5 | "crypto/ed25519" | ||
| 6 | "crypto/rand" | ||
| 7 | "io" | ||
| 8 | "net" | ||
| 9 | "testing" | ||
| 10 | "time" | ||
| 11 | |||
| 12 | "github.com/stretchr/testify/assert" | ||
| 13 | "github.com/stretchr/testify/require" | ||
| 14 | "golang.org/x/crypto/ssh" | ||
| 15 | ) | ||
| 16 | |||
| 17 | // newSigner returns a throwaway ed25519 ssh.Signer. | ||
| 18 | func newSigner(t *testing.T) ssh.Signer { | ||
| 19 | t.Helper() | ||
| 20 | _, priv, err := ed25519.GenerateKey(rand.Reader) | ||
| 21 | require.NoError(t, err) | ||
| 22 | s, err := ssh.NewSignerFromSigner(priv) | ||
| 23 | require.NoError(t, err) | ||
| 24 | return s | ||
| 25 | } | ||
| 26 | |||
| 27 | // mintCertSigner signs clientKey with ca into a user cert (principal ubuntu) and | ||
| 28 | // returns a cert signer usable as an SSH auth method. | ||
| 29 | func mintCertSigner(t *testing.T, ca, clientKey ssh.Signer) ssh.Signer { | ||
| 30 | t.Helper() | ||
| 31 | cert := &ssh.Certificate{ | ||
| 32 | Key: clientKey.PublicKey(), | ||
| 33 | Serial: 1, | ||
| 34 | CertType: ssh.UserCert, | ||
| 35 | KeyId: "ubuntu", | ||
| 36 | ValidPrincipals: []string{"ubuntu"}, | ||
| 37 | ValidAfter: uint64(time.Now().Add(-time.Minute).Unix()), | ||
| 38 | ValidBefore: uint64(time.Now().Add(time.Hour).Unix()), | ||
| 39 | } | ||
| 40 | require.NoError(t, cert.SignCert(rand.Reader, ca)) | ||
| 41 | cs, err := ssh.NewCertSigner(cert, clientKey) | ||
| 42 | require.NoError(t, err) | ||
| 43 | return cs | ||
| 44 | } | ||
| 45 | |||
| 46 | // mintCertSignerNoPrincipals signs clientKey with ca into a user cert with an | ||
| 47 | // EMPTY principal set. Under CheckCert's wildcard rule such a cert is valid for | ||
| 48 | // ANY principal, so the gate must reject it outright. | ||
| 49 | func mintCertSignerNoPrincipals(t *testing.T, ca, clientKey ssh.Signer) ssh.Signer { | ||
| 50 | t.Helper() | ||
| 51 | cert := &ssh.Certificate{ | ||
| 52 | Key: clientKey.PublicKey(), | ||
| 53 | Serial: 1, | ||
| 54 | CertType: ssh.UserCert, | ||
| 55 | KeyId: "ubuntu", | ||
| 56 | ValidAfter: uint64(time.Now().Add(-time.Minute).Unix()), | ||
| 57 | ValidBefore: uint64(time.Now().Add(time.Hour).Unix()), | ||
| 58 | } | ||
| 59 | require.NoError(t, cert.SignCert(rand.Reader, ca)) | ||
| 60 | cs, err := ssh.NewCertSigner(cert, clientKey) | ||
| 61 | require.NoError(t, err) | ||
| 62 | return cs | ||
| 63 | } | ||
| 64 | |||
| 65 | // testGate wires a gate over a loopback listener and returns the client-side | ||
| 66 | // dial address plus the wired fakes' observed state. | ||
| 67 | type testGate struct { | ||
| 68 | addr string | ||
| 69 | hostKey ssh.Signer | ||
| 70 | dialCalls chan [3]string // hostID, vmID, port-as-string per dial | ||
| 71 | authorized bool | ||
| 72 | } | ||
| 73 | |||
| 74 | // startGate builds a gate with an echoing dialer and a single known VM "vm1", | ||
| 75 | // serving on 127.0.0.1:0. authorize returns the given result. | ||
| 76 | func startGate(t *testing.T, userCA ssh.PublicKey, authorized bool) *testGate { | ||
| 77 | t.Helper() | ||
| 78 | tg := &testGate{ | ||
| 79 | hostKey: newSigner(t), | ||
| 80 | dialCalls: make(chan [3]string, 4), | ||
| 81 | authorized: authorized, | ||
| 82 | } | ||
| 83 | resolve := func(name string) (string, string, bool) { | ||
| 84 | if name == "vm1" { | ||
| 85 | return "host-1", "vm-1", true | ||
| 86 | } | ||
| 87 | return "", "", false | ||
| 88 | } | ||
| 89 | authorize := func(principal, vmID string) bool { return tg.authorized } | ||
| 90 | dial := func(_ context.Context, hostID, vmID string, port uint32) (io.ReadWriteCloser, error) { | ||
| 91 | tg.dialCalls <- [3]string{hostID, vmID, "22"} | ||
| 92 | a, b := net.Pipe() | ||
| 93 | go func() { _, _ = io.Copy(b, b); b.Close() }() // echo server = fake VM sshd | ||
| 94 | return a, nil | ||
| 95 | } | ||
| 96 | g := New(tg.hostKey, userCA, resolve, authorize, dial, nil) | ||
| 97 | |||
| 98 | l, err := net.Listen("tcp", "127.0.0.1:0") | ||
| 99 | require.NoError(t, err) | ||
| 100 | tg.addr = l.Addr().String() | ||
| 101 | go func() { _ = g.Serve(l) }() | ||
| 102 | t.Cleanup(func() { _ = l.Close() }) | ||
| 103 | return tg | ||
| 104 | } | ||
| 105 | |||
| 106 | // startGateRevoked is startGate with an explicit revocation predicate wired, so | ||
| 107 | // a test can assert a revoked serial fails auth. | ||
| 108 | func startGateRevoked(t *testing.T, userCA ssh.PublicKey, isRevoked Revoker) *testGate { | ||
| 109 | t.Helper() | ||
| 110 | tg := &testGate{hostKey: newSigner(t), dialCalls: make(chan [3]string, 4), authorized: true} | ||
| 111 | resolve := func(name string) (string, string, bool) { | ||
| 112 | if name == "vm1" { | ||
| 113 | return "host-1", "vm-1", true | ||
| 114 | } | ||
| 115 | return "", "", false | ||
| 116 | } | ||
| 117 | authorize := func(principal, vmID string) bool { return tg.authorized } | ||
| 118 | dial := func(_ context.Context, hostID, vmID string, port uint32) (io.ReadWriteCloser, error) { | ||
| 119 | tg.dialCalls <- [3]string{hostID, vmID, "22"} | ||
| 120 | a, b := net.Pipe() | ||
| 121 | go func() { _, _ = io.Copy(b, b); b.Close() }() | ||
| 122 | return a, nil | ||
| 123 | } | ||
| 124 | g := New(tg.hostKey, userCA, resolve, authorize, dial, isRevoked) | ||
| 125 | l, err := net.Listen("tcp", "127.0.0.1:0") | ||
| 126 | require.NoError(t, err) | ||
| 127 | tg.addr = l.Addr().String() | ||
| 128 | go func() { _ = g.Serve(l) }() | ||
| 129 | t.Cleanup(func() { _ = l.Close() }) | ||
| 130 | return tg | ||
| 131 | } | ||
| 132 | |||
| 133 | // mintCertSignerSerial signs clientKey with ca into a user cert carrying an | ||
| 134 | // explicit serial, so a revocation test can target that serial. | ||
| 135 | func mintCertSignerSerial(t *testing.T, ca, clientKey ssh.Signer, serial uint64) ssh.Signer { | ||
| 136 | t.Helper() | ||
| 137 | cert := &ssh.Certificate{ | ||
| 138 | Key: clientKey.PublicKey(), | ||
| 139 | Serial: serial, | ||
| 140 | CertType: ssh.UserCert, | ||
| 141 | KeyId: "ubuntu", | ||
| 142 | ValidPrincipals: []string{"ubuntu"}, | ||
| 143 | ValidAfter: uint64(time.Now().Add(-time.Minute).Unix()), | ||
| 144 | ValidBefore: uint64(time.Now().Add(time.Hour).Unix()), | ||
| 145 | } | ||
| 146 | require.NoError(t, cert.SignCert(rand.Reader, ca)) | ||
| 147 | cs, err := ssh.NewCertSigner(cert, clientKey) | ||
| 148 | require.NoError(t, err) | ||
| 149 | return cs | ||
| 150 | } | ||
| 151 | |||
| 152 | // dialClient connects an SSH client to the gate using certSigner. | ||
| 153 | func dialClient(t *testing.T, tg *testGate, certSigner ssh.Signer) *ssh.Client { | ||
| 154 | t.Helper() | ||
| 155 | cfg := &ssh.ClientConfig{ | ||
| 156 | User: "some-random-outer-name", // must NOT matter on the gate hop | ||
| 157 | Auth: []ssh.AuthMethod{ssh.PublicKeys(certSigner)}, | ||
| 158 | HostKeyCallback: ssh.FixedHostKey(tg.hostKey.PublicKey()), | ||
| 159 | Timeout: 5 * time.Second, | ||
| 160 | } | ||
| 161 | c, err := ssh.Dial("tcp", tg.addr, cfg) | ||
| 162 | require.NoError(t, err) | ||
| 163 | t.Cleanup(func() { _ = c.Close() }) | ||
| 164 | return c | ||
| 165 | } | ||
| 166 | |||
| 167 | // startGateWithHostKey is startGate with an explicit host-key signer, so a test | ||
| 168 | // can present a CA-signed host certificate (via ssh.NewCertSigner) rather than a | ||
| 169 | // bare host key. | ||
| 170 | func startGateWithHostKey(t *testing.T, hostKey ssh.Signer, userCA ssh.PublicKey) *testGate { | ||
| 171 | t.Helper() | ||
| 172 | tg := &testGate{hostKey: hostKey, dialCalls: make(chan [3]string, 4), authorized: true} | ||
| 173 | resolve := func(name string) (string, string, bool) { | ||
| 174 | if name == "vm1" { | ||
| 175 | return "host-1", "vm-1", true | ||
| 176 | } | ||
| 177 | return "", "", false | ||
| 178 | } | ||
| 179 | authorize := func(principal, vmID string) bool { return tg.authorized } | ||
| 180 | dial := func(_ context.Context, hostID, vmID string, port uint32) (io.ReadWriteCloser, error) { | ||
| 181 | tg.dialCalls <- [3]string{hostID, vmID, "22"} | ||
| 182 | a, b := net.Pipe() | ||
| 183 | go func() { _, _ = io.Copy(b, b); b.Close() }() | ||
| 184 | return a, nil | ||
| 185 | } | ||
| 186 | g := New(hostKey, userCA, resolve, authorize, dial, nil) | ||
| 187 | l, err := net.Listen("tcp", "127.0.0.1:0") | ||
| 188 | require.NoError(t, err) | ||
| 189 | tg.addr = l.Addr().String() | ||
| 190 | go func() { _ = g.Serve(l) }() | ||
| 191 | t.Cleanup(func() { _ = l.Close() }) | ||
| 192 | return tg | ||
| 193 | } | ||
| 194 | |||
| 195 | // TestGatePresentsCASignedHostCert verifies the gate presents a host key whose | ||
| 196 | // certificate is signed by the eitri CA, and that a client doing | ||
| 197 | // `@cert-authority`-style verification (CertChecker.IsHostAuthority) accepts it | ||
| 198 | // WITHOUT any prior TOFU pin — the whole point of host-cert signing. | ||
| 199 | func TestGatePresentsCASignedHostCert(t *testing.T) { | ||
| 200 | ca := newSigner(t) | ||
| 201 | hostKey := newSigner(t) | ||
| 202 | |||
| 203 | // Sign a HOST cert for the gate's host key, scoped to the name the client | ||
| 204 | // dials ("gate"), and present it via a cert signer. | ||
| 205 | cert := &ssh.Certificate{ | ||
| 206 | Key: hostKey.PublicKey(), | ||
| 207 | Serial: 1, | ||
| 208 | CertType: ssh.HostCert, | ||
| 209 | KeyId: "eitri-gate", | ||
| 210 | ValidPrincipals: []string{"gate"}, | ||
| 211 | ValidAfter: uint64(time.Now().Add(-time.Minute).Unix()), | ||
| 212 | ValidBefore: uint64(time.Now().Add(time.Hour).Unix()), | ||
| 213 | } | ||
| 214 | require.NoError(t, cert.SignCert(rand.Reader, ca)) | ||
| 215 | hostCertSigner, err := ssh.NewCertSigner(cert, hostKey) | ||
| 216 | require.NoError(t, err) | ||
| 217 | |||
| 218 | tg := startGateWithHostKey(t, hostCertSigner, ca.PublicKey()) | ||
| 219 | |||
| 220 | checker := &ssh.CertChecker{ | ||
| 221 | IsHostAuthority: func(k ssh.PublicKey, _ string) bool { | ||
| 222 | return keysEqual(k, ca.PublicKey()) | ||
| 223 | }, | ||
| 224 | } | ||
| 225 | cfg := &ssh.ClientConfig{ | ||
| 226 | User: "ubuntu", | ||
| 227 | Auth: []ssh.AuthMethod{ssh.PublicKeys(mintCertSigner(t, ca, newSigner(t)))}, | ||
| 228 | HostKeyCallback: checker.CheckHostKey, | ||
| 229 | Timeout: 5 * time.Second, | ||
| 230 | } | ||
| 231 | // The client dials the gate as host "gate" (the cert principal) so principal | ||
| 232 | // scoping is exercised, not just the CA signature. | ||
| 233 | nc, err := net.Dial("tcp", tg.addr) | ||
| 234 | require.NoError(t, err) | ||
| 235 | c, chans, reqs, err := ssh.NewClientConn(nc, "gate:22", cfg) | ||
| 236 | require.NoError(t, err, "client must accept a CA-signed host cert without a TOFU pin") | ||
| 237 | client := ssh.NewClient(c, chans, reqs) | ||
| 238 | t.Cleanup(func() { _ = client.Close() }) | ||
| 239 | |||
| 240 | // And the tunnel still works end-to-end over the cert-authenticated host. | ||
| 241 | conn, err := client.Dial("tcp", "vm1:22") | ||
| 242 | require.NoError(t, err) | ||
| 243 | _ = conn.Close() | ||
| 244 | } | ||
| 245 | |||
| 246 | func TestGateRejectsSessionChannel(t *testing.T) { | ||
| 247 | ca := newSigner(t) | ||
| 248 | tg := startGate(t, ca.PublicKey(), true) | ||
| 249 | client := dialClient(t, tg, mintCertSigner(t, ca, newSigner(t))) | ||
| 250 | |||
| 251 | _, _, err := client.OpenChannel("session", nil) | ||
| 252 | require.Error(t, err, "session channel must be rejected") | ||
| 253 | var oce *ssh.OpenChannelError | ||
| 254 | require.ErrorAs(t, err, &oce) | ||
| 255 | assert.Equal(t, ssh.UnknownChannelType, oce.Reason) | ||
| 256 | } | ||
| 257 | |||
| 258 | func TestGateRejectsTCPIPForwardGlobalRequest(t *testing.T) { | ||
| 259 | ca := newSigner(t) | ||
| 260 | tg := startGate(t, ca.PublicKey(), true) | ||
| 261 | client := dialClient(t, tg, mintCertSigner(t, ca, newSigner(t))) | ||
| 262 | |||
| 263 | // tcpip-forward (ssh -R) must NOT be honored: the bastion is not an ingress relay. | ||
| 264 | ok, _, err := client.SendRequest("tcpip-forward", true, ssh.Marshal(struct { | ||
| 265 | Addr string | ||
| 266 | Port uint32 | ||
| 267 | }{"0.0.0.0", 0})) | ||
| 268 | require.NoError(t, err) | ||
| 269 | assert.False(t, ok, "tcpip-forward must get a false reply") | ||
| 270 | } | ||
| 271 | |||
| 272 | func TestGateDirectTCPIPToPort22RoundTrips(t *testing.T) { | ||
| 273 | ca := newSigner(t) | ||
| 274 | tg := startGate(t, ca.PublicKey(), true) | ||
| 275 | client := dialClient(t, tg, mintCertSigner(t, ca, newSigner(t))) | ||
| 276 | |||
| 277 | conn, err := client.Dial("tcp", "vm1:22") | ||
| 278 | require.NoError(t, err) | ||
| 279 | defer conn.Close() | ||
| 280 | |||
| 281 | // The dialer must have been reached with the resolved host/vm. | ||
| 282 | select { | ||
| 283 | case got := <-tg.dialCalls: | ||
| 284 | assert.Equal(t, [3]string{"host-1", "vm-1", "22"}, got) | ||
| 285 | case <-time.After(2 * time.Second): | ||
| 286 | t.Fatal("dialer was never called") | ||
| 287 | } | ||
| 288 | |||
| 289 | // Bytes must round-trip through the echoing fake VM. | ||
| 290 | want := []byte("hello-vm") | ||
| 291 | _, err = conn.Write(want) | ||
| 292 | require.NoError(t, err) | ||
| 293 | got := make([]byte, len(want)) | ||
| 294 | _, err = io.ReadFull(conn, got) | ||
| 295 | require.NoError(t, err) | ||
| 296 | assert.Equal(t, want, got) | ||
| 297 | } | ||
| 298 | |||
| 299 | func TestGateDirectTCPIPToNonSSHPortRejected(t *testing.T) { | ||
| 300 | ca := newSigner(t) | ||
| 301 | tg := startGate(t, ca.PublicKey(), true) | ||
| 302 | client := dialClient(t, tg, mintCertSigner(t, ca, newSigner(t))) | ||
| 303 | |||
| 304 | _, err := client.Dial("tcp", "vm1:2222") | ||
| 305 | require.Error(t, err, "only port 22 may be tunnelled") | ||
| 306 | } | ||
| 307 | |||
| 308 | func TestGateUnknownVMRejected(t *testing.T) { | ||
| 309 | ca := newSigner(t) | ||
| 310 | tg := startGate(t, ca.PublicKey(), true) | ||
| 311 | client := dialClient(t, tg, mintCertSigner(t, ca, newSigner(t))) | ||
| 312 | |||
| 313 | _, err := client.Dial("tcp", "nope:22") | ||
| 314 | require.Error(t, err, "unknown VM name must be rejected") | ||
| 315 | } | ||
| 316 | |||
| 317 | func TestGateAuthzDenyRejected(t *testing.T) { | ||
| 318 | ca := newSigner(t) | ||
| 319 | tg := startGate(t, ca.PublicKey(), false) // authorize → deny | ||
| 320 | client := dialClient(t, tg, mintCertSigner(t, ca, newSigner(t))) | ||
| 321 | |||
| 322 | _, err := client.Dial("tcp", "vm1:22") | ||
| 323 | require.Error(t, err, "authz denial must reject the channel") | ||
| 324 | } | ||
| 325 | |||
| 326 | func TestGateRejectsCertFromForeignCA(t *testing.T) { | ||
| 327 | ca := newSigner(t) | ||
| 328 | foreignCA := newSigner(t) | ||
| 329 | tg := startGate(t, ca.PublicKey(), true) | ||
| 330 | |||
| 331 | // A cert signed by a CA the gate does not trust must fail auth outright. | ||
| 332 | certSigner := mintCertSigner(t, foreignCA, newSigner(t)) | ||
| 333 | cfg := &ssh.ClientConfig{ | ||
| 334 | User: "ubuntu", | ||
| 335 | Auth: []ssh.AuthMethod{ssh.PublicKeys(certSigner)}, | ||
| 336 | HostKeyCallback: ssh.FixedHostKey(tg.hostKey.PublicKey()), | ||
| 337 | Timeout: 5 * time.Second, | ||
| 338 | } | ||
| 339 | _, err := ssh.Dial("tcp", tg.addr, cfg) | ||
| 340 | require.Error(t, err, "cert not signed by the eitri CA must fail auth") | ||
| 341 | } | ||
| 342 | |||
| 343 | func TestGateRejectsCertWithNoPrincipals(t *testing.T) { | ||
| 344 | ca := newSigner(t) | ||
| 345 | tg := startGate(t, ca.PublicKey(), true) | ||
| 346 | |||
| 347 | // A cert with an empty principal set is valid for ANY principal under | ||
| 348 | // CheckCert's wildcard rule — the gate must refuse it rather than let the | ||
| 349 | // wildcard (and the ValidPrincipals[0] index) through. | ||
| 350 | certSigner := mintCertSignerNoPrincipals(t, ca, newSigner(t)) | ||
| 351 | cfg := &ssh.ClientConfig{ | ||
| 352 | User: "ubuntu", | ||
| 353 | Auth: []ssh.AuthMethod{ssh.PublicKeys(certSigner)}, | ||
| 354 | HostKeyCallback: ssh.FixedHostKey(tg.hostKey.PublicKey()), | ||
| 355 | Timeout: 5 * time.Second, | ||
| 356 | } | ||
| 357 | _, err := ssh.Dial("tcp", tg.addr, cfg) | ||
| 358 | require.Error(t, err, "cert with no principals must fail auth") | ||
| 359 | } | ||
| 360 | |||
| 361 | // TestGateRejectsRevokedCert mints a user cert with a known serial and asserts | ||
| 362 | // that with isRevoked true for that serial the client's auth FAILS, and with | ||
| 363 | // isRevoked false the same cert authenticates and tunnels through. | ||
| 364 | func TestGateRejectsRevokedCert(t *testing.T) { | ||
| 365 | ca := newSigner(t) | ||
| 366 | const serial = uint64(0xDEADBEEFCAFEF00D) | ||
| 367 | |||
| 368 | // Revoked ⇒ auth fails. | ||
| 369 | revoked := startGateRevoked(t, ca.PublicKey(), func(s uint64) bool { return s == serial }) | ||
| 370 | cfg := &ssh.ClientConfig{ | ||
| 371 | User: "ubuntu", | ||
| 372 | Auth: []ssh.AuthMethod{ssh.PublicKeys(mintCertSignerSerial(t, ca, newSigner(t), serial))}, | ||
| 373 | HostKeyCallback: ssh.FixedHostKey(revoked.hostKey.PublicKey()), | ||
| 374 | Timeout: 5 * time.Second, | ||
| 375 | } | ||
| 376 | _, err := ssh.Dial("tcp", revoked.addr, cfg) | ||
| 377 | require.Error(t, err, "a revoked cert must fail auth at the gate") | ||
| 378 | |||
| 379 | // Not revoked ⇒ the same serial authenticates and tunnels. | ||
| 380 | allowed := startGateRevoked(t, ca.PublicKey(), func(uint64) bool { return false }) | ||
| 381 | client := dialClient(t, allowed, mintCertSignerSerial(t, ca, newSigner(t), serial)) | ||
| 382 | conn, err := client.Dial("tcp", "vm1:22") | ||
| 383 | require.NoError(t, err, "a non-revoked cert must still tunnel") | ||
| 384 | _ = conn.Close() | ||
| 385 | } | ||
| 386 | |||
| 387 | func TestGateRejectsBarePublicKey(t *testing.T) { | ||
| 388 | ca := newSigner(t) | ||
| 389 | tg := startGate(t, ca.PublicKey(), true) | ||
| 390 | |||
| 391 | // A raw (non-certificate) key must be rejected — the gate is cert-only. | ||
| 392 | cfg := &ssh.ClientConfig{ | ||
| 393 | User: "ubuntu", | ||
| 394 | Auth: []ssh.AuthMethod{ssh.PublicKeys(newSigner(t))}, | ||
| 395 | HostKeyCallback: ssh.FixedHostKey(tg.hostKey.PublicKey()), | ||
| 396 | Timeout: 5 * time.Second, | ||
| 397 | } | ||
| 398 | _, err := ssh.Dial("tcp", tg.addr, cfg) | ||
| 399 | require.Error(t, err, "bare public key (no cert) must fail auth") | ||
| 400 | } | ||
internal/server/store/allocation_test.go
| Old | New | ||
|---|---|---|---|
| @@ -48,7 +48,7 @@ func TestAllocatedByHostSeparatesHosts(t *testing.T) { | |||
| 48 | s := newStore(t) | 48 | s := newStore(t) |
| 49 | h1 := enrollHost(t, s) | 49 | h1 := enrollHost(t, s) |
| 50 | tok, _ := s.CreateEnrollmentToken() | 50 | tok, _ := s.CreateEnrollmentToken() |
| 51 | h2, _ := s.RedeemEnrollmentToken(tok, "h2", "linux", "amd64", "cloudhv", "", "") | 51 | h2, _ := s.RedeemEnrollmentToken(tok, "h2", "linux", "amd64", "cloudhv", "") |
| 52 | vmWithResources(t, s, h1, "a", 2, 2048, 10) | 52 | vmWithResources(t, s, h1, "a", 2, 2048, 10) |
| 53 | vmWithResources(t, s, h2, "b", 8, 8192, 40) | 53 | vmWithResources(t, s, h2, "b", 8, 8192, 40) |
| 54 | 54 | ||
internal/server/store/decommission_test.go
| Old | New | ||
|---|---|---|---|
| @@ -49,7 +49,7 @@ func TestRemoveHostFreesCIDRForReuse(t *testing.T) { | |||
| 49 | h1 := enrollHost(t, s) | 49 | h1 := enrollHost(t, s) |
| 50 | assert.Equal(t, "10.77.1.0/24", h1.BridgeCIDR) | 50 | assert.Equal(t, "10.77.1.0/24", h1.BridgeCIDR) |
| 51 | tok2, _ := s.CreateEnrollmentToken() | 51 | tok2, _ := s.CreateEnrollmentToken() |
| 52 | h2, _ := s.RedeemEnrollmentToken(tok2, "b", "linux", "amd64", "cloudhv", "", "") | 52 | h2, _ := s.RedeemEnrollmentToken(tok2, "b", "linux", "amd64", "cloudhv", "") |
| 53 | assert.Equal(t, "10.77.2.0/24", h2.BridgeCIDR) | 53 | assert.Equal(t, "10.77.2.0/24", h2.BridgeCIDR) |
| 54 | 54 | ||
| 55 | // Decommission h1 and simulate the agent reaping its VMs (hard-delete). | 55 | // Decommission h1 and simulate the agent reaping its VMs (hard-delete). |
| @@ -64,7 +64,7 @@ func TestRemoveHostFreesCIDRForReuse(t *testing.T) { | |||
| 64 | 64 | ||
| 65 | // A new enrollment reuses h1's freed CIDR rather than allocating a fresh one. | 65 | // A new enrollment reuses h1's freed CIDR rather than allocating a fresh one. |
| 66 | tok3, _ := s.CreateEnrollmentToken() | 66 | tok3, _ := s.CreateEnrollmentToken() |
| 67 | h3, err := s.RedeemEnrollmentToken(tok3, "c", "linux", "amd64", "cloudhv", "", "") | 67 | h3, err := s.RedeemEnrollmentToken(tok3, "c", "linux", "amd64", "cloudhv", "") |
| 68 | require.NoError(t, err) | 68 | require.NoError(t, err) |
| 69 | assert.Equal(t, "10.77.1.0/24", h3.BridgeCIDR, "freed CIDR should be reused") | 69 | assert.Equal(t, "10.77.1.0/24", h3.BridgeCIDR, "freed CIDR should be reused") |
| 70 | } | 70 | } |
internal/server/store/store.go
| Old | New | ||
|---|---|---|---|
| @@ -5,6 +5,7 @@ | |||
| 5 | package store | 5 | package store |
| 6 | 6 | ||
| 7 | import ( | 7 | import ( |
| 8 | "context" | ||
| 8 | "crypto/rand" | 9 | "crypto/rand" |
| 9 | "crypto/sha256" | 10 | "crypto/sha256" |
| 10 | "database/sql" | 11 | "database/sql" |
| @@ -19,8 +20,8 @@ import ( | |||
| 19 | "strings" | 20 | "strings" |
| 20 | "time" | 21 | "time" |
| 21 | 22 | ||
| 22 | _ "modernc.org/sqlite" | ||
| 23 | "github.com/a73x/eitri/internal/transport" | 23 | "github.com/a73x/eitri/internal/transport" |
| 24 | _ "modernc.org/sqlite" | ||
| 24 | ) | 25 | ) |
| 25 | 26 | ||
| 26 | // ErrNameTaken is returned by CreateVM when the name is already in use by a live VM. | 27 | // ErrNameTaken is returned by CreateVM when the name is already in use by a live VM. |
| @@ -43,7 +44,7 @@ type Store struct { | |||
| 43 | } | 44 | } |
| 44 | 45 | ||
| 45 | type Host struct { | 46 | type Host struct { |
| 46 | ID, Name, OS, Arch, Provisioner, Overlay, BridgeCIDR, Status string | 47 | ID, Name, OS, Arch, Provisioner, BridgeCIDR, Status string |
| 47 | // CredGeneration is the host's current credential generation. Credentials | 48 | // CredGeneration is the host's current credential generation. Credentials |
| 48 | // minted at an older generation are rejected — bumping it revokes that | 49 | // minted at an older generation are rejected — bumping it revokes that |
| 49 | // one host's outstanding credential without rotating the fleet secret. | 50 | // one host's outstanding credential without rotating the fleet secret. |
| @@ -57,8 +58,14 @@ type VM struct { | |||
| 57 | Persistent bool | 58 | Persistent bool |
| 58 | PowerState, Status, LastError, AssignedIP string | 59 | PowerState, Status, LastError, AssignedIP string |
| 59 | SSHAuthorizedKey string | 60 | SSHAuthorizedKey string |
| 60 | CreatedAt time.Time | 61 | // SSHHostKey is the VM's persistent ed25519 host private key (OpenSSH PEM), |
| 61 | DeletedAt *time.Time | 62 | // generated once at create when the jump gate is enabled and shipped to the |
| 63 | // guest via seed. WRITE-ONLY key material: handled like SSHAuthorizedKey — | ||
| 64 | // never returned in vmResponse and never logged. SSHHostCert is the matching | ||
| 65 | // CA-signed host cert (authorized_keys form); public, but grouped here. | ||
| 66 | SSHHostKey, SSHHostCert string | ||
| 67 | CreatedAt time.Time | ||
| 68 | DeletedAt *time.Time | ||
| 62 | } | 69 | } |
| 63 | 70 | ||
| 64 | const schema = ` | 71 | const schema = ` |
| @@ -73,7 +80,6 @@ CREATE TABLE IF NOT EXISTS hosts ( | |||
| 73 | os TEXT NOT NULL, | 80 | os TEXT NOT NULL, |
| 74 | arch TEXT NOT NULL, | 81 | arch TEXT NOT NULL, |
| 75 | provisioner TEXT NOT NULL, | 82 | provisioner TEXT NOT NULL, |
| 76 | overlay TEXT NOT NULL DEFAULT 'tailscale', | ||
| 77 | bridge_cidr TEXT NOT NULL, | 83 | bridge_cidr TEXT NOT NULL, |
| 78 | status TEXT NOT NULL DEFAULT 'enrolled', | 84 | status TEXT NOT NULL DEFAULT 'enrolled', |
| 79 | enrolled_at DATETIME NOT NULL, | 85 | enrolled_at DATETIME NOT NULL, |
| @@ -94,6 +100,8 @@ CREATE TABLE IF NOT EXISTS vms ( | |||
| 94 | image_sha256 TEXT NOT NULL, | 100 | image_sha256 TEXT NOT NULL, |
| 95 | cloud_init TEXT NOT NULL DEFAULT '', | 101 | cloud_init TEXT NOT NULL DEFAULT '', |
| 96 | ssh_authorized_key TEXT NOT NULL DEFAULT '', | 102 | ssh_authorized_key TEXT NOT NULL DEFAULT '', |
| 103 | ssh_host_key TEXT NOT NULL DEFAULT '', | ||
| 104 | ssh_host_cert TEXT NOT NULL DEFAULT '', | ||
| 97 | vcpus INTEGER NOT NULL, | 105 | vcpus INTEGER NOT NULL, |
| 98 | mem_mb INTEGER NOT NULL, | 106 | mem_mb INTEGER NOT NULL, |
| 99 | disk_gb INTEGER NOT NULL, | 107 | disk_gb INTEGER NOT NULL, |
| @@ -114,6 +122,18 @@ CREATE TABLE IF NOT EXISTS freed_cidrs ( | |||
| 114 | bridge_cidr TEXT PRIMARY KEY | 122 | bridge_cidr TEXT PRIMARY KEY |
| 115 | ); | 123 | ); |
| 116 | 124 | ||
| 125 | -- revoked SSH user certs: an admin can revoke a specific minted user cert by | ||
| 126 | -- its serial (crypto-random uint64, set at mint) so it is rejected at the jump | ||
| 127 | -- gate before its short TTL expires. serial is stored as the int64 bit-pattern | ||
| 128 | -- of the uint64 (SQLite INTEGER is signed 64-bit) — a bijection, so PRIMARY KEY | ||
| 129 | -- uniqueness and lookups are preserved. Enforced at the GATE only (see sshgate); | ||
| 130 | -- guests trust the CA with no guest-side KRL — a multi-user/rotation follow-up. | ||
| 131 | CREATE TABLE IF NOT EXISTS revoked_ssh_certs ( | ||
| 132 | serial INTEGER PRIMARY KEY, | ||
| 133 | revoked_at DATETIME NOT NULL, | ||
| 134 | reason TEXT NOT NULL DEFAULT '' | ||
| 135 | ); | ||
| 136 | |||
| 117 | -- append-only operational audit trail (enrollment, decommission). Read via | 137 | -- append-only operational audit trail (enrollment, decommission). Read via |
| 118 | -- ListAudit / GET /api/v1/audit; rows are never UPDATEd, and the only DELETE | 138 | -- ListAudit / GET /api/v1/audit; rows are never UPDATEd, and the only DELETE |
| 119 | -- is retention pruning (PruneAudit, driven by the server's audit_retention | 139 | -- is retention pruning (PruneAudit, driven by the server's audit_retention |
| @@ -155,6 +175,10 @@ func Open(path, cidrPool string) (*Store, error) { | |||
| 155 | 175 | ||
| 156 | func (s *Store) Close() error { return s.db.Close() } | 176 | func (s *Store) Close() error { return s.db.Close() } |
| 157 | 177 | ||
| 178 | // Ping verifies the database handle is live with a cheap round-trip. Used by | ||
| 179 | // the readiness probe; the context bounds a wedged driver. | ||
| 180 | func (s *Store) Ping(ctx context.Context) error { return s.db.PingContext(ctx) } | ||
| 181 | |||
| 158 | func (s *Store) Epoch() (uint64, error) { | 182 | func (s *Store) Epoch() (uint64, error) { |
| 159 | var v uint64 | 183 | var v uint64 |
| 160 | err := s.db.QueryRow(`SELECT CAST(value AS INTEGER) FROM meta WHERE key='epoch'`).Scan(&v) | 184 | err := s.db.QueryRow(`SELECT CAST(value AS INTEGER) FROM meta WHERE key='epoch'`).Scan(&v) |
| @@ -204,10 +228,7 @@ func (s *Store) CreateEnrollmentToken() (string, error) { | |||
| 204 | // remote (the enrolling client's IP) is recorded in a host.enroll audit row | 228 | // remote (the enrolling client's IP) is recorded in a host.enroll audit row |
| 205 | // written in the SAME transaction, so an enrolled host can never exist | 229 | // written in the SAME transaction, so an enrolled host can never exist |
| 206 | // without its durable audit record. | 230 | // without its durable audit record. |
| 207 | func (s *Store) RedeemEnrollmentToken(tok, name, osName, arch, provisioner, overlay, remote string) (Host, error) { | 231 | func (s *Store) RedeemEnrollmentToken(tok, name, osName, arch, provisioner, remote string) (Host, error) { |
| 208 | if overlay == "" { | ||
| 209 | overlay = "tailscale" | ||
| 210 | } | ||
| 211 | h := sha256.Sum256([]byte(tok)) | 232 | h := sha256.Sum256([]byte(tok)) |
| 212 | hash := hex.EncodeToString(h[:]) | 233 | hash := hex.EncodeToString(h[:]) |
| 213 | 234 | ||
| @@ -266,8 +287,8 @@ func (s *Store) RedeemEnrollmentToken(tok, name, osName, arch, provisioner, over | |||
| 266 | id := RandHex(16) | 287 | id := RandHex(16) |
| 267 | 288 | ||
| 268 | if _, err := tx.Exec( | 289 | if _, err := tx.Exec( |
| 269 | `INSERT INTO hosts(id, name, os, arch, provisioner, overlay, bridge_cidr, enrolled_at) VALUES (?,?,?,?,?,?,?,?)`, | 290 | `INSERT INTO hosts(id, name, os, arch, provisioner, bridge_cidr, enrolled_at) VALUES (?,?,?,?,?,?,?)`, |
| 270 | id, name, osName, arch, provisioner, overlay, bridgeCIDR, now.Format(time.RFC3339), | 291 | id, name, osName, arch, provisioner, bridgeCIDR, now.Format(time.RFC3339), |
| 271 | ); err != nil { | 292 | ); err != nil { |
| 272 | return Host{}, fmt.Errorf("insert host: %w", err) | 293 | return Host{}, fmt.Errorf("insert host: %w", err) |
| 273 | } | 294 | } |
| @@ -290,13 +311,12 @@ func (s *Store) RedeemEnrollmentToken(tok, name, osName, arch, provisioner, over | |||
| 290 | } | 311 | } |
| 291 | 312 | ||
| 292 | return Host{ | 313 | return Host{ |
| 293 | ID: id, | 314 | ID: id, |
| 294 | Name: name, | 315 | Name: name, |
| 295 | OS: osName, | 316 | OS: osName, |
| 296 | Arch: arch, | 317 | Arch: arch, |
| 297 | Provisioner: provisioner, | 318 | Provisioner: provisioner, |
| 298 | Overlay: overlay, | 319 | BridgeCIDR: bridgeCIDR, |
| 299 | BridgeCIDR: bridgeCIDR, | ||
| 300 | Status: "enrolled", | 320 | Status: "enrolled", |
| 301 | CredGeneration: 1, | 321 | CredGeneration: 1, |
| 302 | EnrolledAt: now, | 322 | EnrolledAt: now, |
| @@ -307,8 +327,8 @@ func (s *Store) GetHost(id string) (Host, error) { | |||
| 307 | var h Host | 327 | var h Host |
| 308 | var enrolledAt string | 328 | var enrolledAt string |
| 309 | err := s.db.QueryRow( | 329 | err := s.db.QueryRow( |
| 310 | `SELECT id, name, os, arch, provisioner, overlay, bridge_cidr, status, enrolled_at, cred_generation FROM hosts WHERE id=?`, id, | 330 | `SELECT id, name, os, arch, provisioner, bridge_cidr, status, enrolled_at, cred_generation FROM hosts WHERE id=?`, id, |
| 311 | ).Scan(&h.ID, &h.Name, &h.OS, &h.Arch, &h.Provisioner, &h.Overlay, &h.BridgeCIDR, &h.Status, &enrolledAt, &h.CredGeneration) | 331 | ).Scan(&h.ID, &h.Name, &h.OS, &h.Arch, &h.Provisioner, &h.BridgeCIDR, &h.Status, &enrolledAt, &h.CredGeneration) |
| 312 | if err != nil { | 332 | if err != nil { |
| 313 | return Host{}, err | 333 | return Host{}, err |
| 314 | } | 334 | } |
| @@ -352,7 +372,7 @@ type querier interface { | |||
| 352 | func (s *Store) ListHosts() ([]Host, error) { return listHosts(s.db) } | 372 | func (s *Store) ListHosts() ([]Host, error) { return listHosts(s.db) } |
| 353 | 373 | ||
| 354 | func listHosts(q querier) ([]Host, error) { | 374 | func listHosts(q querier) ([]Host, error) { |
| 355 | rows, err := q.Query(`SELECT id, name, os, arch, provisioner, overlay, bridge_cidr, status, enrolled_at, cred_generation FROM hosts`) | 375 | rows, err := q.Query(`SELECT id, name, os, arch, provisioner, bridge_cidr, status, enrolled_at, cred_generation FROM hosts`) |
| 356 | if err != nil { | 376 | if err != nil { |
| 357 | return nil, err | 377 | return nil, err |
| 358 | } | 378 | } |
| @@ -361,7 +381,7 @@ func listHosts(q querier) ([]Host, error) { | |||
| 361 | for rows.Next() { | 381 | for rows.Next() { |
| 362 | var h Host | 382 | var h Host |
| 363 | var enrolledAt string | 383 | var enrolledAt string |
| 364 | if err := rows.Scan(&h.ID, &h.Name, &h.OS, &h.Arch, &h.Provisioner, &h.Overlay, &h.BridgeCIDR, &h.Status, &enrolledAt, &h.CredGeneration); err != nil { | 384 | if err := rows.Scan(&h.ID, &h.Name, &h.OS, &h.Arch, &h.Provisioner, &h.BridgeCIDR, &h.Status, &enrolledAt, &h.CredGeneration); err != nil { |
| 365 | return nil, err | 385 | return nil, err |
| 366 | } | 386 | } |
| 367 | h.EnrolledAt, _ = time.Parse(time.RFC3339, enrolledAt) | 387 | h.EnrolledAt, _ = time.Parse(time.RFC3339, enrolledAt) |
| @@ -384,10 +404,12 @@ func (s *Store) CreateVM(vm VM) error { | |||
| 384 | 404 | ||
| 385 | _, err = tx.Exec( | 405 | _, err = tx.Exec( |
| 386 | `INSERT INTO vms(id, host_id, name, image_url, image_sha256, cloud_init, ssh_authorized_key, | 406 | `INSERT INTO vms(id, host_id, name, image_url, image_sha256, cloud_init, ssh_authorized_key, |
| 407 | ssh_host_key, ssh_host_cert, | ||
| 387 | vcpus, mem_mb, disk_gb, persistent, power_state, created_at) | 408 | vcpus, mem_mb, disk_gb, persistent, power_state, created_at) |
| 388 | VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`, | 409 | VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, |
| 389 | vm.ID, vm.HostID, vm.Name, vm.ImageURL, vm.ImageSHA256, | 410 | vm.ID, vm.HostID, vm.Name, vm.ImageURL, vm.ImageSHA256, |
| 390 | vm.CloudInit, vm.SSHAuthorizedKey, | 411 | vm.CloudInit, vm.SSHAuthorizedKey, |
| 412 | vm.SSHHostKey, vm.SSHHostCert, | ||
| 391 | vm.VCPUs, vm.MemMB, vm.DiskGB, vm.Persistent, vm.PowerState, | 413 | vm.VCPUs, vm.MemMB, vm.DiskGB, vm.Persistent, vm.PowerState, |
| 392 | now.Format(time.RFC3339), | 414 | now.Format(time.RFC3339), |
| 393 | ) | 415 | ) |
| @@ -441,8 +463,37 @@ func (s *Store) TombstoneVM(id string) error { | |||
| 441 | time.Now().UTC().Format(time.RFC3339), id) | 463 | time.Now().UTC().Format(time.RFC3339), id) |
| 442 | } | 464 | } |
| 443 | 465 | ||
| 466 | // RestoreVM un-tombstones a VM that is still within the teardown grace window | ||
| 467 | // (row present, not yet hard-deleted): clears deleted_at so the agent re-adopts | ||
| 468 | // it. Returns sql.ErrNoRows if the row is not restorable — never deleted, or | ||
| 469 | // already reaped (the row is gone). Bumps the epoch so agents re-snapshot and | ||
| 470 | // re-add it to desired. | ||
| 471 | func (s *Store) RestoreVM(id string) error { | ||
| 472 | return s.mutate(`UPDATE vms SET deleted_at=NULL WHERE id=? AND deleted_at IS NOT NULL`, id) | ||
| 473 | } | ||
| 474 | |||
| 475 | // HardDeleteVM removes a tombstoned VM row (only tombstoned rows are deletable; | ||
| 476 | // sql.ErrNoRows otherwise) and bumps the epoch. Called after the agent acks the | ||
| 477 | // destroy. | ||
| 444 | func (s *Store) HardDeleteVM(id string) error { | 478 | func (s *Store) HardDeleteVM(id string) error { |
| 445 | return s.mutate(`DELETE FROM vms WHERE id=? AND deleted_at IS NOT NULL`, id) | 479 | tx, err := s.db.Begin() |
| 480 | if err != nil { | ||
| 481 | return err | ||
| 482 | } | ||
| 483 | defer tx.Rollback() | ||
| 484 | |||
| 485 | res, err := tx.Exec(`DELETE FROM vms WHERE id=? AND deleted_at IS NOT NULL`, id) | ||
| 486 | if err != nil { | ||
| 487 | return fmt.Errorf("delete vm: %w", err) | ||
| 488 | } | ||
| 489 | if n, _ := res.RowsAffected(); n == 0 { | ||
| 490 | return sql.ErrNoRows | ||
| 491 | } | ||
| 492 | |||
| 493 | if err := bumpEpoch(tx); err != nil { | ||
| 494 | return err | ||
| 495 | } | ||
| 496 | return tx.Commit() | ||
| 446 | } | 497 | } |
| 447 | 498 | ||
| 448 | // DecommissionHost marks a host as decommissioning and tombstones all its live | 499 | // DecommissionHost marks a host as decommissioning and tombstones all its live |
| @@ -536,6 +587,33 @@ func (s *Store) ListAudit(limit int) ([]AuditEntry, error) { | |||
| 536 | return out, rows.Err() | 587 | return out, rows.Err() |
| 537 | } | 588 | } |
| 538 | 589 | ||
| 590 | // ListVMEvents returns up to limit audit rows whose detail JSON carries the | ||
| 591 | // given vm_id (the lifecycle timeline for one VM), newest first. It filters on | ||
| 592 | // json_extract(detail,'$.vm_id'), so every lifecycle emitter must key the VM id | ||
| 593 | // as exactly "vm_id". Historical events for a hard-deleted VM stay returnable: | ||
| 594 | // the append-only log outlives the VM row. | ||
| 595 | func (s *Store) ListVMEvents(vmID string, limit int) ([]AuditEntry, error) { | ||
| 596 | rows, err := s.db.Query( | ||
| 597 | `SELECT at, action, detail FROM audit_log WHERE json_extract(detail,'$.vm_id')=? ORDER BY id DESC LIMIT ?`, | ||
| 598 | vmID, limit, | ||
| 599 | ) | ||
| 600 | if err != nil { | ||
| 601 | return nil, err | ||
| 602 | } | ||
| 603 | defer rows.Close() | ||
| 604 | var out []AuditEntry | ||
| 605 | for rows.Next() { | ||
| 606 | var e AuditEntry | ||
| 607 | var at string | ||
| 608 | if err := rows.Scan(&at, &e.Action, &e.Detail); err != nil { | ||
| 609 | return nil, err | ||
| 610 | } | ||
| 611 | e.At, _ = time.Parse(time.RFC3339, at) | ||
| 612 | out = append(out, e) | ||
| 613 | } | ||
| 614 | return out, rows.Err() | ||
| 615 | } | ||
| 616 | |||
| 539 | // PruneAudit deletes audit rows older than olderThan and reports how many | 617 | // PruneAudit deletes audit rows older than olderThan and reports how many |
| 540 | // were removed. Retention keeps the append-only log bounded; the caller | 618 | // were removed. Retention keeps the append-only log bounded; the caller |
| 541 | // (eitri-server) runs it at startup and daily. | 619 | // (eitri-server) runs it at startup and daily. |
| @@ -553,6 +631,66 @@ func (s *Store) PruneAudit(olderThan time.Duration) (int64, error) { | |||
| 553 | return res.RowsAffected() | 631 | return res.RowsAffected() |
| 554 | } | 632 | } |
| 555 | 633 | ||
| 634 | // RevokedCert is one row of the SSH user-cert revocation list. | ||
| 635 | type RevokedCert struct { | ||
| 636 | Serial uint64 | ||
| 637 | RevokedAt time.Time | ||
| 638 | Reason string | ||
| 639 | } | ||
| 640 | |||
| 641 | // RevokeSSHCert adds serial to the revocation list so the gate rejects any cert | ||
| 642 | // carrying it. Idempotent: revoking an already-revoked serial is a no-op that | ||
| 643 | // keeps the ORIGINAL revoked_at/reason (a re-revoke does not overwrite the | ||
| 644 | // audit-relevant first record). serial is bit-cast to int64 for storage — | ||
| 645 | // SQLite INTEGER is signed 64-bit, and the cast is a bijection so uniqueness and | ||
| 646 | // lookups by serial are preserved. | ||
| 647 | func (s *Store) RevokeSSHCert(serial uint64, reason string) error { | ||
| 648 | _, err := s.db.Exec( | ||
| 649 | `INSERT INTO revoked_ssh_certs(serial, revoked_at, reason) VALUES (?, ?, ?) | ||
| 650 | ON CONFLICT(serial) DO NOTHING`, | ||
| 651 | int64(serial), time.Now().UTC().Format(time.RFC3339), reason, | ||
| 652 | ) | ||
| 653 | if err != nil { | ||
| 654 | return fmt.Errorf("revoke ssh cert: %w", err) | ||
| 655 | } | ||
| 656 | return nil | ||
| 657 | } | ||
| 658 | |||
| 659 | // IsSSHCertRevoked reports whether serial is on the revocation list. The gate | ||
| 660 | // consults this on every cert authentication, so it is a hot read; the single | ||
| 661 | // PRIMARY KEY lookup is cheap. | ||
| 662 | func (s *Store) IsSSHCertRevoked(serial uint64) (bool, error) { | ||
| 663 | var n int | ||
| 664 | err := s.db.QueryRow(`SELECT COUNT(*) FROM revoked_ssh_certs WHERE serial=?`, int64(serial)).Scan(&n) | ||
| 665 | if err != nil { | ||
| 666 | return false, fmt.Errorf("lookup revoked ssh cert: %w", err) | ||
| 667 | } | ||
| 668 | return n > 0, nil | ||
| 669 | } | ||
| 670 | |||
| 671 | // ListRevokedSSHCerts returns every revoked cert serial (+ reason/time), newest | ||
| 672 | // first, for the admin list endpoint. | ||
| 673 | func (s *Store) ListRevokedSSHCerts() ([]RevokedCert, error) { | ||
| 674 | rows, err := s.db.Query(`SELECT serial, revoked_at, reason FROM revoked_ssh_certs ORDER BY revoked_at DESC, serial DESC`) | ||
| 675 | if err != nil { | ||
| 676 | return nil, err | ||
| 677 | } | ||
| 678 | defer rows.Close() | ||
| 679 | var out []RevokedCert | ||
| 680 | for rows.Next() { | ||
| 681 | var rc RevokedCert | ||
| 682 | var serial int64 | ||
| 683 | var revokedAt string | ||
| 684 | if err := rows.Scan(&serial, &revokedAt, &rc.Reason); err != nil { | ||
| 685 | return nil, err | ||
| 686 | } | ||
| 687 | rc.Serial = uint64(serial) // reverse the int64 bit-cast used at insert | ||
| 688 | rc.RevokedAt, _ = time.Parse(time.RFC3339, revokedAt) | ||
| 689 | out = append(out, rc) | ||
| 690 | } | ||
| 691 | return out, rows.Err() | ||
| 692 | } | ||
| 693 | |||
| 556 | // HostVMCount returns the number of VM rows for a host (live + tombstoned). | 694 | // HostVMCount returns the number of VM rows for a host (live + tombstoned). |
| 557 | // Rows are hard-deleted only after the agent acks destroy, so a count of 0 means | 695 | // Rows are hard-deleted only after the agent acks destroy, so a count of 0 means |
| 558 | // the host is fully drained. | 696 | // the host is fully drained. |
| @@ -637,13 +775,15 @@ func (s *Store) RecordVMStatus(id, status, lastErr, ip string) error { | |||
| 637 | return nil | 775 | return nil |
| 638 | } | 776 | } |
| 639 | 777 | ||
| 778 | // scanVM's column order must match the SELECT lists in listVMs and | ||
| 779 | // DesiredForHost exactly — it is positional, not name-based. | ||
| 640 | func scanVM(rows *sql.Rows) (VM, error) { | 780 | func scanVM(rows *sql.Rows) (VM, error) { |
| 641 | var vm VM | 781 | var vm VM |
| 642 | var createdAt string | 782 | var createdAt string |
| 643 | var deletedAt sql.NullString | 783 | var deletedAt sql.NullString |
| 644 | err := rows.Scan( | 784 | err := rows.Scan( |
| 645 | &vm.ID, &vm.HostID, &vm.Name, &vm.ImageURL, &vm.ImageSHA256, | 785 | &vm.ID, &vm.HostID, &vm.Name, &vm.ImageURL, &vm.ImageSHA256, |
| 646 | &vm.CloudInit, &vm.SSHAuthorizedKey, | 786 | &vm.CloudInit, &vm.SSHAuthorizedKey, &vm.SSHHostKey, &vm.SSHHostCert, |
| 647 | &vm.VCPUs, &vm.MemMB, &vm.DiskGB, &vm.Persistent, | 787 | &vm.VCPUs, &vm.MemMB, &vm.DiskGB, &vm.Persistent, |
| 648 | &vm.PowerState, &vm.Status, &vm.LastError, &vm.AssignedIP, | 788 | &vm.PowerState, &vm.Status, &vm.LastError, &vm.AssignedIP, |
| 649 | &createdAt, &deletedAt, | 789 | &createdAt, &deletedAt, |
| @@ -664,6 +804,7 @@ func (s *Store) ListVMs() ([]VM, error) { return listVMs(s.db) } | |||
| 664 | func listVMs(q querier) ([]VM, error) { | 804 | func listVMs(q querier) ([]VM, error) { |
| 665 | rows, err := q.Query( | 805 | rows, err := q.Query( |
| 666 | `SELECT id, host_id, name, image_url, image_sha256, cloud_init, ssh_authorized_key, | 806 | `SELECT id, host_id, name, image_url, image_sha256, cloud_init, ssh_authorized_key, |
| 807 | ssh_host_key, ssh_host_cert, | ||
| 667 | vcpus, mem_mb, disk_gb, persistent, power_state, status, last_error, assigned_ip, | 808 | vcpus, mem_mb, disk_gb, persistent, power_state, status, last_error, assigned_ip, |
| 668 | created_at, deleted_at FROM vms`, | 809 | created_at, deleted_at FROM vms`, |
| 669 | ) | 810 | ) |
| @@ -682,6 +823,30 @@ func listVMs(q querier) ([]VM, error) { | |||
| 682 | return vms, rows.Err() | 823 | return vms, rows.Err() |
| 683 | } | 824 | } |
| 684 | 825 | ||
| 826 | // VMByName returns the live (non-tombstoned) VM with the given name. The | ||
| 827 | // vms_name unique index guarantees at most one match. sql.ErrNoRows ⇒ no such | ||
| 828 | // VM. Read-only; used by the SSH jump gate to resolve `ssh -J gate user@<name>` | ||
| 829 | // to a host/VM ID pair. | ||
| 830 | func (s *Store) VMByName(name string) (VM, error) { | ||
| 831 | rows, err := s.db.Query( | ||
| 832 | `SELECT id, host_id, name, image_url, image_sha256, cloud_init, ssh_authorized_key, | ||
| 833 | ssh_host_key, ssh_host_cert, | ||
| 834 | vcpus, mem_mb, disk_gb, persistent, power_state, status, last_error, assigned_ip, | ||
| 835 | created_at, deleted_at FROM vms WHERE name=? AND deleted_at IS NULL`, name, | ||
| 836 | ) | ||
| 837 | if err != nil { | ||
| 838 | return VM{}, err | ||
| 839 | } | ||
| 840 | defer rows.Close() | ||
| 841 | if !rows.Next() { | ||
| 842 | if err := rows.Err(); err != nil { | ||
| 843 | return VM{}, err | ||
| 844 | } | ||
| 845 | return VM{}, sql.ErrNoRows | ||
| 846 | } | ||
| 847 | return scanVM(rows) | ||
| 848 | } | ||
| 849 | |||
| 685 | // Snapshot reads hosts, per-host allocation, and VMs in a single read | 850 | // Snapshot reads hosts, per-host allocation, and VMs in a single read |
| 686 | // transaction, so the trio is mutually consistent — a concurrent desired-state | 851 | // transaction, so the trio is mutually consistent — a concurrent desired-state |
| 687 | // mutation between the reads cannot produce a payload mixing two epochs. | 852 | // mutation between the reads cannot produce a payload mixing two epochs. |
| @@ -721,6 +886,7 @@ func (s *Store) DesiredForHost(hostID string) (uint64, []VM, error) { | |||
| 721 | 886 | ||
| 722 | rows, err := tx.Query( | 887 | rows, err := tx.Query( |
| 723 | `SELECT id, host_id, name, image_url, image_sha256, cloud_init, ssh_authorized_key, | 888 | `SELECT id, host_id, name, image_url, image_sha256, cloud_init, ssh_authorized_key, |
| 889 | ssh_host_key, ssh_host_cert, | ||
| 724 | vcpus, mem_mb, disk_gb, persistent, power_state, status, last_error, assigned_ip, | 890 | vcpus, mem_mb, disk_gb, persistent, power_state, status, last_error, assigned_ip, |
| 725 | created_at, deleted_at FROM vms WHERE host_id=?`, hostID, | 891 | created_at, deleted_at FROM vms WHERE host_id=?`, hostID, |
| 726 | ) | 892 | ) |
internal/server/store/store_test.go
| Old | New | ||
|---|---|---|---|
| @@ -1,6 +1,8 @@ | |||
| 1 | package store | 1 | package store |
| 2 | 2 | ||
| 3 | import ( | 3 | import ( |
| 4 | "context" | ||
| 5 | "database/sql" | ||
| 4 | "path/filepath" | 6 | "path/filepath" |
| 5 | "testing" | 7 | "testing" |
| 6 | "time" | 8 | "time" |
| @@ -9,6 +11,14 @@ import ( | |||
| 9 | "github.com/stretchr/testify/require" | 11 | "github.com/stretchr/testify/require" |
| 10 | ) | 12 | ) |
| 11 | 13 | ||
| 14 | func TestPing(t *testing.T) { | ||
| 15 | s := newStore(t) | ||
| 16 | require.NoError(t, s.Ping(context.Background())) | ||
| 17 | // After Close the handle is dead — Ping must surface that (readyz then 503s). | ||
| 18 | require.NoError(t, s.Close()) | ||
| 19 | assert.Error(t, s.Ping(context.Background())) | ||
| 20 | } | ||
| 21 | |||
| 12 | func newStore(t *testing.T) *Store { | 22 | func newStore(t *testing.T) *Store { |
| 13 | t.Helper() | 23 | t.Helper() |
| 14 | s, err := Open(t.TempDir()+"/eitri.db", "10.77.0.0/16") | 24 | s, err := Open(t.TempDir()+"/eitri.db", "10.77.0.0/16") |
| @@ -21,23 +31,123 @@ func enrollHost(t *testing.T, s *Store) Host { | |||
| 21 | t.Helper() | 31 | t.Helper() |
| 22 | tok, err := s.CreateEnrollmentToken() | 32 | tok, err := s.CreateEnrollmentToken() |
| 23 | require.NoError(t, err) | 33 | require.NoError(t, err) |
| 24 | h, err := s.RedeemEnrollmentToken(tok, "host-a", "linux", "amd64", "cloudhv", "", "") | 34 | h, err := s.RedeemEnrollmentToken(tok, "host-a", "linux", "amd64", "cloudhv", "") |
| 25 | require.NoError(t, err) | 35 | require.NoError(t, err) |
| 26 | return h | 36 | return h |
| 27 | } | 37 | } |
| 28 | 38 | ||
| 39 | func TestVMByName(t *testing.T) { | ||
| 40 | s := newStore(t) | ||
| 41 | h := enrollHost(t, s) | ||
| 42 | vm := makeVM(t, s, h, "web-1") | ||
| 43 | |||
| 44 | got, err := s.VMByName("web-1") | ||
| 45 | require.NoError(t, err) | ||
| 46 | assert.Equal(t, vm.ID, got.ID) | ||
| 47 | assert.Equal(t, h.ID, got.HostID) | ||
| 48 | |||
| 49 | // Unknown name ⇒ ErrNoRows (the resolver reads this as ok=false). | ||
| 50 | _, err = s.VMByName("nope") | ||
| 51 | assert.ErrorIs(t, err, sql.ErrNoRows) | ||
| 52 | |||
| 53 | // Tombstoned VMs are not resolvable — the gate must not tunnel to a dead VM. | ||
| 54 | require.NoError(t, s.TombstoneVM(vm.ID)) | ||
| 55 | _, err = s.VMByName("web-1") | ||
| 56 | assert.ErrorIs(t, err, sql.ErrNoRows) | ||
| 57 | } | ||
| 58 | |||
| 59 | func TestVMHostKeyAndCertPersist(t *testing.T) { | ||
| 60 | s := newStore(t) | ||
| 61 | h := enrollHost(t, s) | ||
| 62 | |||
| 63 | const keyPEM = "-----BEGIN OPENSSH PRIVATE KEY-----\nAAAAfake\n-----END OPENSSH PRIVATE KEY-----\n" | ||
| 64 | const cert = "ssh-ed25519-cert-v01@openssh.com AAAAfakecert host\n" | ||
| 65 | require.NoError(t, s.CreateVM(VM{ | ||
| 66 | ID: "vm1", HostID: h.ID, Name: "with-hostcert", ImageURL: "u", ImageSHA256: "abc", | ||
| 67 | VCPUs: 1, MemMB: 512, DiskGB: 5, PowerState: "running", | ||
| 68 | SSHHostKey: keyPEM, SSHHostCert: cert, | ||
| 69 | })) | ||
| 70 | |||
| 71 | // The private key + cert must round-trip through both read paths the | ||
| 72 | // snapshot/gate rely on: DesiredForHost (agent-facing) and VMByName. | ||
| 73 | _, vms, err := s.DesiredForHost(h.ID) | ||
| 74 | require.NoError(t, err) | ||
| 75 | require.Len(t, vms, 1) | ||
| 76 | assert.Equal(t, keyPEM, vms[0].SSHHostKey) | ||
| 77 | assert.Equal(t, cert, vms[0].SSHHostCert) | ||
| 78 | |||
| 79 | byName, err := s.VMByName("with-hostcert") | ||
| 80 | require.NoError(t, err) | ||
| 81 | assert.Equal(t, keyPEM, byName.SSHHostKey) | ||
| 82 | assert.Equal(t, cert, byName.SSHHostCert) | ||
| 83 | } | ||
| 84 | |||
| 85 | func TestSSHCertRevocation(t *testing.T) { | ||
| 86 | s := newStore(t) | ||
| 87 | |||
| 88 | // Unknown serial is not revoked. | ||
| 89 | revoked, err := s.IsSSHCertRevoked(42) | ||
| 90 | require.NoError(t, err) | ||
| 91 | assert.False(t, revoked) | ||
| 92 | |||
| 93 | // Revoke, then it reads back as revoked. | ||
| 94 | require.NoError(t, s.RevokeSSHCert(42, "leaked laptop")) | ||
| 95 | revoked, err = s.IsSSHCertRevoked(42) | ||
| 96 | require.NoError(t, err) | ||
| 97 | assert.True(t, revoked) | ||
| 98 | |||
| 99 | // A different serial is unaffected. | ||
| 100 | revoked, err = s.IsSSHCertRevoked(43) | ||
| 101 | require.NoError(t, err) | ||
| 102 | assert.False(t, revoked) | ||
| 103 | |||
| 104 | // Idempotent: re-revoking keeps the original reason and does not error. | ||
| 105 | require.NoError(t, s.RevokeSSHCert(42, "different reason")) | ||
| 106 | list, err := s.ListRevokedSSHCerts() | ||
| 107 | require.NoError(t, err) | ||
| 108 | require.Len(t, list, 1) | ||
| 109 | assert.Equal(t, uint64(42), list[0].Serial) | ||
| 110 | assert.Equal(t, "leaked laptop", list[0].Reason) | ||
| 111 | assert.False(t, list[0].RevokedAt.IsZero()) | ||
| 112 | } | ||
| 113 | |||
| 114 | // TestSSHCertRevocationLargeSerial guards the uint64→int64 bit-cast: a serial | ||
| 115 | // above math.MaxInt64 (as real crypto-random serials routinely are) must | ||
| 116 | // round-trip through insert, lookup, and list without truncation or collision. | ||
| 117 | func TestSSHCertRevocationLargeSerial(t *testing.T) { | ||
| 118 | s := newStore(t) | ||
| 119 | |||
| 120 | const big = uint64(0xFFFFFFFFFFFFFFFF) // all-ones: well past MaxInt64 | ||
| 121 | const other = uint64(0x8000000000000000) | ||
| 122 | |||
| 123 | require.NoError(t, s.RevokeSSHCert(big, "big")) | ||
| 124 | revoked, err := s.IsSSHCertRevoked(big) | ||
| 125 | require.NoError(t, err) | ||
| 126 | assert.True(t, revoked) | ||
| 127 | |||
| 128 | // A distinct large serial must not collide with the first. | ||
| 129 | revoked, err = s.IsSSHCertRevoked(other) | ||
| 130 | require.NoError(t, err) | ||
| 131 | assert.False(t, revoked) | ||
| 132 | |||
| 133 | require.NoError(t, s.RevokeSSHCert(other, "other")) | ||
| 134 | list, err := s.ListRevokedSSHCerts() | ||
| 135 | require.NoError(t, err) | ||
| 136 | require.Len(t, list, 2) | ||
| 137 | } | ||
| 138 | |||
| 29 | func TestEnrollmentAssignsSequentialCIDRsAndIsOneTimeUse(t *testing.T) { | 139 | func TestEnrollmentAssignsSequentialCIDRsAndIsOneTimeUse(t *testing.T) { |
| 30 | s := newStore(t) | 140 | s := newStore(t) |
| 31 | tok1, _ := s.CreateEnrollmentToken() | 141 | tok1, _ := s.CreateEnrollmentToken() |
| 32 | h1, err := s.RedeemEnrollmentToken(tok1, "a", "linux", "amd64", "cloudhv", "", "") | 142 | h1, err := s.RedeemEnrollmentToken(tok1, "a", "linux", "amd64", "cloudhv", "") |
| 33 | require.NoError(t, err) | 143 | require.NoError(t, err) |
| 34 | assert.Equal(t, "10.77.1.0/24", h1.BridgeCIDR) | 144 | assert.Equal(t, "10.77.1.0/24", h1.BridgeCIDR) |
| 35 | 145 | ||
| 36 | _, err = s.RedeemEnrollmentToken(tok1, "b", "linux", "amd64", "cloudhv", "", "") | 146 | _, err = s.RedeemEnrollmentToken(tok1, "b", "linux", "amd64", "cloudhv", "") |
| 37 | assert.Error(t, err, "token must be one-time use") | 147 | assert.Error(t, err, "token must be one-time use") |
| 38 | 148 | ||
| 39 | tok2, _ := s.CreateEnrollmentToken() | 149 | tok2, _ := s.CreateEnrollmentToken() |
| 40 | h2, _ := s.RedeemEnrollmentToken(tok2, "b", "linux", "amd64", "cloudhv", "", "") | 150 | h2, _ := s.RedeemEnrollmentToken(tok2, "b", "linux", "amd64", "cloudhv", "") |
| 41 | assert.Equal(t, "10.77.2.0/24", h2.BridgeCIDR) | 151 | assert.Equal(t, "10.77.2.0/24", h2.BridgeCIDR) |
| 42 | } | 152 | } |
| 43 | 153 | ||
| @@ -111,7 +221,7 @@ func TestEnrollmentCIDRWorksForNonSlash16Pools(t *testing.T) { | |||
| 111 | require.NoError(t, err) | 221 | require.NoError(t, err) |
| 112 | defer s.Close() | 222 | defer s.Close() |
| 113 | tok, _ := s.CreateEnrollmentToken() | 223 | tok, _ := s.CreateEnrollmentToken() |
| 114 | h, err := s.RedeemEnrollmentToken(tok, "a", "linux", "amd64", "cloudhv", "", "") | 224 | h, err := s.RedeemEnrollmentToken(tok, "a", "linux", "amd64", "cloudhv", "") |
| 115 | require.NoError(t, err) | 225 | require.NoError(t, err) |
| 116 | assert.Equal(t, "192.168.5.0/24", h.BridgeCIDR, "1st /24 within the pool, 0th reserved") | 226 | assert.Equal(t, "192.168.5.0/24", h.BridgeCIDR, "1st /24 within the pool, 0th reserved") |
| 117 | } | 227 | } |
| @@ -121,11 +231,11 @@ func TestEnrollmentFailsWhenPoolExhausted(t *testing.T) { | |||
| 121 | require.NoError(t, err) | 231 | require.NoError(t, err) |
| 122 | defer s.Close() | 232 | defer s.Close() |
| 123 | tok1, _ := s.CreateEnrollmentToken() | 233 | tok1, _ := s.CreateEnrollmentToken() |
| 124 | h, err := s.RedeemEnrollmentToken(tok1, "a", "linux", "amd64", "cloudhv", "", "") | 234 | h, err := s.RedeemEnrollmentToken(tok1, "a", "linux", "amd64", "cloudhv", "") |
| 125 | require.NoError(t, err) | 235 | require.NoError(t, err) |
| 126 | assert.Equal(t, "10.9.9.0/24", h.BridgeCIDR) | 236 | assert.Equal(t, "10.9.9.0/24", h.BridgeCIDR) |
| 127 | tok2, _ := s.CreateEnrollmentToken() | 237 | tok2, _ := s.CreateEnrollmentToken() |
| 128 | _, err = s.RedeemEnrollmentToken(tok2, "b", "linux", "amd64", "cloudhv", "", "") | 238 | _, err = s.RedeemEnrollmentToken(tok2, "b", "linux", "amd64", "cloudhv", "") |
| 129 | assert.ErrorContains(t, err, "exhausted") | 239 | assert.ErrorContains(t, err, "exhausted") |
| 130 | } | 240 | } |
| 131 | 241 | ||
| @@ -135,34 +245,6 @@ func TestRecordVMStatusUnknownVMErrors(t *testing.T) { | |||
| 135 | assert.Error(t, err) | 245 | assert.Error(t, err) |
| 136 | } | 246 | } |
| 137 | 247 | ||
| 138 | // Fix 5: hosts.overlay column tests. | ||
| 139 | |||
| 140 | func TestRedeemEnrollmentToken_OverlayPersistedAndDefaultsTailscale(t *testing.T) { | ||
| 141 | s := newStore(t) | ||
| 142 | tok, _ := s.CreateEnrollmentToken() | ||
| 143 | // Explicit overlay="none" must be persisted. | ||
| 144 | h, err := s.RedeemEnrollmentToken(tok, "host-b", "linux", "amd64", "cloudhv", "none", "") | ||
| 145 | require.NoError(t, err) | ||
| 146 | assert.Equal(t, "none", h.Overlay, "overlay must be 'none' as requested") | ||
| 147 | |||
| 148 | // Empty overlay → defaults to "tailscale". | ||
| 149 | tok2, _ := s.CreateEnrollmentToken() | ||
| 150 | h2, err := s.RedeemEnrollmentToken(tok2, "host-c", "linux", "amd64", "cloudhv", "", "") | ||
| 151 | require.NoError(t, err) | ||
| 152 | assert.Equal(t, "tailscale", h2.Overlay, "empty overlay must default to 'tailscale'") | ||
| 153 | } | ||
| 154 | |||
| 155 | func TestListHosts_ReturnsOverlay(t *testing.T) { | ||
| 156 | s := newStore(t) | ||
| 157 | tok, _ := s.CreateEnrollmentToken() | ||
| 158 | _, err := s.RedeemEnrollmentToken(tok, "host-x", "linux", "amd64", "cloudhv", "none", "") | ||
| 159 | require.NoError(t, err) | ||
| 160 | hosts, err := s.ListHosts() | ||
| 161 | require.NoError(t, err) | ||
| 162 | require.Len(t, hosts, 1) | ||
| 163 | assert.Equal(t, "none", hosts[0].Overlay) | ||
| 164 | } | ||
| 165 | |||
| 166 | func TestServerCertLoadOrCreatePersists(t *testing.T) { | 248 | func TestServerCertLoadOrCreatePersists(t *testing.T) { |
| 167 | dir := t.TempDir() | 249 | dir := t.TempDir() |
| 168 | st, err := Open(filepath.Join(dir, "x.db"), "10.77.0.0/16") | 250 | st, err := Open(filepath.Join(dir, "x.db"), "10.77.0.0/16") |
| @@ -175,7 +257,7 @@ func TestServerCertLoadOrCreatePersists(t *testing.T) { | |||
| 175 | 257 | ||
| 176 | cert2, fp2, err := st.ServerCert() | 258 | cert2, fp2, err := st.ServerCert() |
| 177 | require.NoError(t, err) | 259 | require.NoError(t, err) |
| 178 | assert.Equal(t, fp1, fp2) // same persisted cert, not regenerated | 260 | assert.Equal(t, fp1, fp2) // same persisted cert, not regenerated |
| 179 | assert.Equal(t, cert1, cert2) | 261 | assert.Equal(t, cert1, cert2) |
| 180 | 262 | ||
| 181 | key, err := st.ServerKeyPEM() | 263 | key, err := st.ServerKeyPEM() |
| @@ -271,6 +353,31 @@ func TestAuditLogRoundTrip(t *testing.T) { | |||
| 271 | assert.Equal(t, "host.enroll", one[0].Action) | 353 | assert.Equal(t, "host.enroll", one[0].Action) |
| 272 | } | 354 | } |
| 273 | 355 | ||
| 356 | // TestListVMEvents pins the per-VM timeline filter: only audit rows whose | ||
| 357 | // detail JSON carries the queried vm_id are returned, newest-first, respecting | ||
| 358 | // the limit — the endpoint's filter (json_extract on '$.vm_id') keys off it. | ||
| 359 | func TestListVMEvents(t *testing.T) { | ||
| 360 | s := newStore(t) | ||
| 361 | require.NoError(t, s.AppendAudit("vm.create", `{"vm_id":"vm-a","name":"alpha"}`)) | ||
| 362 | require.NoError(t, s.AppendAudit("vm.create", `{"vm_id":"vm-b","name":"bravo"}`)) | ||
| 363 | require.NoError(t, s.AppendAudit("vm.power", `{"vm_id":"vm-a","power":"stopped"}`)) | ||
| 364 | require.NoError(t, s.AppendAudit("vm.delete", `{"vm_id":"vm-a","name":"alpha"}`)) | ||
| 365 | |||
| 366 | rows, err := s.ListVMEvents("vm-a", 100) | ||
| 367 | require.NoError(t, err) | ||
| 368 | require.Len(t, rows, 3, "only vm-a rows, not vm-b's") | ||
| 369 | assert.Equal(t, "vm.delete", rows[0].Action, "newest first") | ||
| 370 | for _, e := range rows { | ||
| 371 | assert.Contains(t, e.Detail, "vm-a") | ||
| 372 | assert.NotContains(t, e.Detail, "vm-b") | ||
| 373 | } | ||
| 374 | |||
| 375 | limited, err := s.ListVMEvents("vm-a", 1) | ||
| 376 | require.NoError(t, err) | ||
| 377 | require.Len(t, limited, 1, "limit respected") | ||
| 378 | assert.Equal(t, "vm.delete", limited[0].Action) | ||
| 379 | } | ||
| 380 | |||
| 274 | // TestRedeemWritesAuditRowAtomically pins audit durability: the host.enroll | 381 | // TestRedeemWritesAuditRowAtomically pins audit durability: the host.enroll |
| 275 | // audit row is written inside the SAME transaction as the redeem, so an | 382 | // audit row is written inside the SAME transaction as the redeem, so an |
| 276 | // enrolled host can never exist without its durable audit record. | 383 | // enrolled host can never exist without its durable audit record. |
| @@ -278,7 +385,7 @@ func TestRedeemWritesAuditRowAtomically(t *testing.T) { | |||
| 278 | s := newStore(t) | 385 | s := newStore(t) |
| 279 | tok, err := s.CreateEnrollmentToken() | 386 | tok, err := s.CreateEnrollmentToken() |
| 280 | require.NoError(t, err) | 387 | require.NoError(t, err) |
| 281 | h, err := s.RedeemEnrollmentToken(tok, "host-a", "linux", "amd64", "cloudhv", "", "192.0.2.9") | 388 | h, err := s.RedeemEnrollmentToken(tok, "host-a", "linux", "amd64", "cloudhv", "192.0.2.9") |
| 282 | require.NoError(t, err) | 389 | require.NoError(t, err) |
| 283 | 390 | ||
| 284 | rows, err := s.ListAudit(5) | 391 | rows, err := s.ListAudit(5) |
| @@ -314,7 +421,6 @@ func TestCredGenerationLifecycle(t *testing.T) { | |||
| 314 | assert.Error(t, err, "unknown host must error") | 421 | assert.Error(t, err, "unknown host must error") |
| 315 | } | 422 | } |
| 316 | 423 | ||
| 317 | |||
| 318 | // TestBumpCredGenerationAuditsAtomically pins that the revoke audit row is | 424 | // TestBumpCredGenerationAuditsAtomically pins that the revoke audit row is |
| 319 | // written in the same transaction as the bump. | 425 | // written in the same transaction as the bump. |
| 320 | func TestBumpCredGenerationAuditsAtomically(t *testing.T) { | 426 | func TestBumpCredGenerationAuditsAtomically(t *testing.T) { |
internal/server/syncsvc/syncsvc.go
| Old | New | ||
|---|---|---|---|
| @@ -3,10 +3,12 @@ package syncsvc | |||
| 3 | 3 | ||
| 4 | import ( | 4 | import ( |
| 5 | "context" | 5 | "context" |
| 6 | "encoding/json" | ||
| 6 | "errors" | 7 | "errors" |
| 7 | "fmt" | 8 | "fmt" |
| 8 | "io" | 9 | "io" |
| 9 | "log/slog" | 10 | "log/slog" |
| 11 | "sync" | ||
| 10 | "time" | 12 | "time" |
| 11 | 13 | ||
| 12 | "github.com/a73x/eitri/internal/pb" | 14 | "github.com/a73x/eitri/internal/pb" |
| @@ -37,8 +39,25 @@ type Service struct { | |||
| 37 | maxCredAge time.Duration | 39 | maxCredAge time.Duration |
| 38 | // writeTimeout bounds each down-stream snapshot write (see defaultWriteTimeout). | 40 | // writeTimeout bounds each down-stream snapshot write (see defaultWriteTimeout). |
| 39 | writeTimeout time.Duration | 41 | writeTimeout time.Duration |
| 42 | // consoleMu guards conns: the live QUIC connection per agent, registered | ||
| 43 | // after auth in handleConn and deregistered when the session ends. The | ||
| 44 | // console broker opens per-session streams on it. | ||
| 45 | consoleMu sync.Mutex | ||
| 46 | conns map[string]quic.Connection | ||
| 47 | // sshUserCAKey is the eitri user-CA public key (authorized_keys form), | ||
| 48 | // gate-wide and set once at startup via SetSSHUserCAKey when the jump gate | ||
| 49 | // is enabled. Empty when the gate is off: no VM gets the CA drop-in. It is | ||
| 50 | // public material, so it is safe to fan out to every desired-VM snapshot. | ||
| 51 | sshUserCAKey string | ||
| 40 | } | 52 | } |
| 41 | 53 | ||
| 54 | // SetSSHUserCAKey installs the eitri user-CA public key (authorized_keys form) | ||
| 55 | // that every desired-VM snapshot advertises so guests trust CA-signed certs. | ||
| 56 | // Called once at startup when the SSH jump gate is enabled; a no-op (empty | ||
| 57 | // string) leaves CA injection off. Set before Serve; not safe for concurrent | ||
| 58 | // mutation once snapshots are being pushed. | ||
| 59 | func (s *Service) SetSSHUserCAKey(key string) { s.sshUserCAKey = key } | ||
| 60 | |||
| 42 | // New constructs a Service with the production-default down-stream write | 61 | // New constructs a Service with the production-default down-stream write |
| 43 | // timeout. maxCredAge zero disables the credential age check. | 62 | // timeout. maxCredAge zero disables the credential age check. |
| 44 | func New(st *store.Store, reg *registry.Registry, h *hub.Hub, secret []byte, maxCredAge time.Duration) *Service { | 63 | func New(st *store.Store, reg *registry.Registry, h *hub.Hub, secret []byte, maxCredAge time.Duration) *Service { |
| @@ -52,7 +71,8 @@ func newWithWriteTimeout(st *store.Store, reg *registry.Registry, h *hub.Hub, se | |||
| 52 | if writeTimeout <= 0 { | 71 | if writeTimeout <= 0 { |
| 53 | writeTimeout = defaultWriteTimeout | 72 | writeTimeout = defaultWriteTimeout |
| 54 | } | 73 | } |
| 55 | return &Service{st: st, reg: reg, hub: h, secret: secret, maxCredAge: maxCredAge, writeTimeout: writeTimeout} | 74 | return &Service{st: st, reg: reg, hub: h, secret: secret, maxCredAge: maxCredAge, writeTimeout: writeTimeout, |
| 75 | conns: map[string]quic.Connection{}} | ||
| 56 | } | 76 | } |
| 57 | 77 | ||
| 58 | // Serve accepts QUIC connections until ctx is cancelled. | 78 | // Serve accepts QUIC connections until ctx is cancelled. |
| @@ -114,6 +134,21 @@ func (s *Service) handleConn(ctx context.Context, conn quic.Connection) { | |||
| 114 | return | 134 | return |
| 115 | } | 135 | } |
| 116 | 136 | ||
| 137 | // Console reachability: only now — with the down-stream open — is it safe | ||
| 138 | // for OpenConsole to add streams to this connection (stream-order | ||
| 139 | // invariant: the snapshot down-stream is always the first accepted). | ||
| 140 | s.consoleMu.Lock() | ||
| 141 | s.conns[hostID] = conn | ||
| 142 | s.consoleMu.Unlock() | ||
| 143 | defer func() { | ||
| 144 | s.consoleMu.Lock() | ||
| 145 | // Only deregister OUR conn: a reconnect may already have replaced it. | ||
| 146 | if s.conns[hostID] == conn { | ||
| 147 | delete(s.conns, hostID) | ||
| 148 | } | ||
| 149 | s.consoleMu.Unlock() | ||
| 150 | }() | ||
| 151 | |||
| 117 | // Single writer for the down-stream: the poke goroutine. | 152 | // Single writer for the down-stream: the poke goroutine. |
| 118 | pokes, cancel := s.hub.Subscribe(hostID) | 153 | pokes, cancel := s.hub.Subscribe(hostID) |
| 119 | defer cancel() | 154 | defer cancel() |
| @@ -188,7 +223,10 @@ func (s *Service) pushSnapshot(down quic.Stream, hostID string) error { | |||
| 188 | VmId: v.ID, Name: v.Name, ImageUrl: v.ImageURL, ImageSha256: v.ImageSHA256, | 223 | VmId: v.ID, Name: v.Name, ImageUrl: v.ImageURL, ImageSha256: v.ImageSHA256, |
| 189 | CloudInit: v.CloudInit, Vcpus: v.VCPUs, MemMb: v.MemMB, DiskGb: v.DiskGB, | 224 | CloudInit: v.CloudInit, Vcpus: v.VCPUs, MemMb: v.MemMB, DiskGb: v.DiskGB, |
| 190 | Persistent: v.Persistent, PowerState: v.PowerState, Tombstoned: v.DeletedAt != nil, | 225 | Persistent: v.Persistent, PowerState: v.PowerState, Tombstoned: v.DeletedAt != nil, |
| 191 | SshAuthorizedKey: v.SSHAuthorizedKey, | 226 | SshAuthorizedKey: v.SSHAuthorizedKey, |
| 227 | SshUserCaAuthorizedKey: s.sshUserCAKey, | ||
| 228 | SshHostKeyPem: v.SSHHostKey, | ||
| 229 | SshHostCert: v.SSHHostCert, | ||
| 192 | }) | 230 | }) |
| 193 | } | 231 | } |
| 194 | // Bound the write: if a stalled agent stops reading the down-stream but keeps | 232 | // Bound the write: if a stalled agent stops reading the down-stream but keeps |
| @@ -255,6 +293,15 @@ func (s *Service) applyReport(hostID string, rep *pb.ActualStateReport) { | |||
| 255 | slog.Warn("HardDeleteVM failed", "vm", id, "host", hostID, "err", err) | 293 | slog.Warn("HardDeleteVM failed", "vm", id, "host", hostID, "err", err) |
| 256 | } else { | 294 | } else { |
| 257 | anyDeleted = true | 295 | anyDeleted = true |
| 296 | // Terminal lifecycle event. The VM row is already gone, so only the | ||
| 297 | // id and host survive — vm_id is the key the per-VM timeline filters | ||
| 298 | // on. Best-effort: a failed audit must not break the reap. | ||
| 299 | detail, _ := json.Marshal(map[string]string{ | ||
| 300 | "vm_id": id, "host_id": hostID, "reason": "destroyed after tombstone grace", | ||
| 301 | }) | ||
| 302 | if err := s.st.AppendAudit("vm.reap", string(detail)); err != nil { | ||
| 303 | slog.Warn("audit vm.reap failed", "vm", id, "host", hostID, "err", err) | ||
| 304 | } | ||
| 258 | } | 305 | } |
| 259 | } | 306 | } |
| 260 | if anyDeleted { | 307 | if anyDeleted { |
| @@ -306,3 +353,122 @@ func toRegistryCapacity(c *pb.Capacity) registry.Capacity { | |||
| 306 | } | 353 | } |
| 307 | return registry.Capacity{VCPUs: c.GetVcpus(), MemMB: c.GetMemMb(), DiskGB: c.GetDiskGb()} | 354 | return registry.Capacity{VCPUs: c.GetVcpus(), MemMB: c.GetMemMb(), DiskGB: c.GetDiskGb()} |
| 308 | } | 355 | } |
| 356 | |||
| 357 | // ErrAgentOffline reports that the target host has no live sync connection. | ||
| 358 | var ErrAgentOffline = errors.New("agent not connected") | ||
| 359 | |||
| 360 | // consoleHandshakeTimeout bounds the ConsoleOpen/ConsoleOpened exchange so a | ||
| 361 | // wedged agent cannot pin the WS handler. The bridged session itself has no | ||
| 362 | // deadline — consoles are long-lived. | ||
| 363 | const consoleHandshakeTimeout = 10 * time.Second | ||
| 364 | |||
| 365 | // OpenConsole opens a console stream to vmID's agent on the live sync | ||
| 366 | // connection: sends ConsoleOpen, awaits ConsoleOpened, and returns the stream | ||
| 367 | // as a raw byte pipe. The returned Close tears down both directions. | ||
| 368 | // | ||
| 369 | // ctx bounds stream OPENING only; the handshake that follows is bounded by | ||
| 370 | // consoleHandshakeTimeout instead, so a call can outlive ctx cancellation by | ||
| 371 | // up to that long (10s) before returning. | ||
| 372 | func (s *Service) OpenConsole(ctx context.Context, hostID, vmID string) (io.ReadWriteCloser, error) { | ||
| 373 | s.consoleMu.Lock() | ||
| 374 | conn, ok := s.conns[hostID] | ||
| 375 | s.consoleMu.Unlock() | ||
| 376 | if !ok { | ||
| 377 | return nil, ErrAgentOffline | ||
| 378 | } | ||
| 379 | st, err := conn.OpenStreamSync(ctx) | ||
| 380 | if err != nil { | ||
| 381 | return nil, fmt.Errorf("open console stream: %w", err) | ||
| 382 | } | ||
| 383 | cs := consoleStream{st} | ||
| 384 | if err := st.SetDeadline(time.Now().Add(consoleHandshakeTimeout)); err != nil { | ||
| 385 | cs.Close() | ||
| 386 | return nil, err | ||
| 387 | } | ||
| 388 | open := &pb.ServerMessage{Msg: &pb.ServerMessage_ConsoleOpen{ConsoleOpen: &pb.ConsoleOpen{VmId: vmID}}} | ||
| 389 | if err := transport.WriteMsg(st, open); err != nil { | ||
| 390 | cs.Close() | ||
| 391 | return nil, fmt.Errorf("console open: %w", err) | ||
| 392 | } | ||
| 393 | var reply pb.AgentMessage | ||
| 394 | if err := transport.ReadMsg(st, &reply, transport.DefaultMaxFrame); err != nil { | ||
| 395 | cs.Close() | ||
| 396 | return nil, fmt.Errorf("console reply: %w", err) | ||
| 397 | } | ||
| 398 | co := reply.GetConsoleOpened() | ||
| 399 | if co == nil { | ||
| 400 | cs.Close() | ||
| 401 | return nil, errors.New("console refused: unexpected reply") | ||
| 402 | } | ||
| 403 | if !co.GetOk() { | ||
| 404 | cs.Close() | ||
| 405 | return nil, fmt.Errorf("console refused: %s", co.GetError()) | ||
| 406 | } | ||
| 407 | // Handshake done — clear the deadline; the session is long-lived. | ||
| 408 | if err := st.SetDeadline(time.Time{}); err != nil { | ||
| 409 | cs.Close() | ||
| 410 | return nil, err | ||
| 411 | } | ||
| 412 | return cs, nil | ||
| 413 | } | ||
| 414 | |||
| 415 | // OpenTCP opens a tunnel stream to vmID's agent on the live sync connection: | ||
| 416 | // sends TCPOpen (naming the VM and guest TCP port), awaits TCPOpened, and | ||
| 417 | // returns the stream as a raw byte pipe. The returned Close tears down both | ||
| 418 | // directions. It mirrors OpenConsole exactly, differing only in the handshake | ||
| 419 | // message pair. | ||
| 420 | // | ||
| 421 | // ctx bounds stream OPENING only; the handshake that follows is bounded by | ||
| 422 | // consoleHandshakeTimeout instead, so a call can outlive ctx cancellation by | ||
| 423 | // up to that long (10s) before returning. | ||
| 424 | func (s *Service) OpenTCP(ctx context.Context, hostID, vmID string, port uint32) (io.ReadWriteCloser, error) { | ||
| 425 | s.consoleMu.Lock() | ||
| 426 | conn, ok := s.conns[hostID] | ||
| 427 | s.consoleMu.Unlock() | ||
| 428 | if !ok { | ||
| 429 | return nil, ErrAgentOffline | ||
| 430 | } | ||
| 431 | st, err := conn.OpenStreamSync(ctx) | ||
| 432 | if err != nil { | ||
| 433 | return nil, fmt.Errorf("open tcp stream: %w", err) | ||
| 434 | } | ||
| 435 | cs := consoleStream{st} | ||
| 436 | if err := st.SetDeadline(time.Now().Add(consoleHandshakeTimeout)); err != nil { | ||
| 437 | cs.Close() | ||
| 438 | return nil, err | ||
| 439 | } | ||
| 440 | open := &pb.ServerMessage{Msg: &pb.ServerMessage_TcpOpen{TcpOpen: &pb.TCPOpen{VmId: vmID, Port: port}}} | ||
| 441 | if err := transport.WriteMsg(st, open); err != nil { | ||
| 442 | cs.Close() | ||
| 443 | return nil, fmt.Errorf("tcp open: %w", err) | ||
| 444 | } | ||
| 445 | var reply pb.AgentMessage | ||
| 446 | if err := transport.ReadMsg(st, &reply, transport.DefaultMaxFrame); err != nil { | ||
| 447 | cs.Close() | ||
| 448 | return nil, fmt.Errorf("tcp reply: %w", err) | ||
| 449 | } | ||
| 450 | to := reply.GetTcpOpened() | ||
| 451 | if to == nil { | ||
| 452 | cs.Close() | ||
| 453 | return nil, errors.New("tcp refused: unexpected reply") | ||
| 454 | } | ||
| 455 | if !to.GetOk() { | ||
| 456 | cs.Close() | ||
| 457 | return nil, fmt.Errorf("tcp refused: %s", to.GetError()) | ||
| 458 | } | ||
| 459 | // Handshake done — clear the deadline; the session is long-lived. | ||
| 460 | if err := st.SetDeadline(time.Time{}); err != nil { | ||
| 461 | cs.Close() | ||
| 462 | return nil, err | ||
| 463 | } | ||
| 464 | return cs, nil | ||
| 465 | } | ||
| 466 | |||
| 467 | // consoleStream adapts a quic.Stream to io.ReadWriteCloser with a Close that | ||
| 468 | // tears down BOTH directions (Stream.Close only closes the write side). | ||
| 469 | type consoleStream struct{ quic.Stream } | ||
| 470 | |||
| 471 | func (c consoleStream) Close() error { | ||
| 472 | c.CancelRead(0) | ||
| 473 | return c.Stream.Close() | ||
| 474 | } | ||
internal/server/syncsvc/syncsvc_test.go
| Old | New | ||
|---|---|---|---|
| @@ -3,6 +3,7 @@ package syncsvc | |||
| 3 | import ( | 3 | import ( |
| 4 | "context" | 4 | "context" |
| 5 | "errors" | 5 | "errors" |
| 6 | "io" | ||
| 6 | "runtime" | 7 | "runtime" |
| 7 | "strings" | 8 | "strings" |
| 8 | "testing" | 9 | "testing" |
| @@ -28,6 +29,7 @@ type fixture struct { | |||
| 28 | secret []byte | 29 | secret []byte |
| 29 | host store.Host | 30 | host store.Host |
| 30 | cred string | 31 | cred string |
| 32 | svc *Service | ||
| 31 | } | 33 | } |
| 32 | 34 | ||
| 33 | func setup(t *testing.T) *fixture { | 35 | func setup(t *testing.T) *fixture { |
| @@ -41,22 +43,22 @@ func setupWithWriteTimeout(t *testing.T, writeTimeout time.Duration) *fixture { | |||
| 41 | require.NoError(t, err) | 43 | require.NoError(t, err) |
| 42 | t.Cleanup(func() { st.Close() }) | 44 | t.Cleanup(func() { st.Close() }) |
| 43 | tok, _ := st.CreateEnrollmentToken() | 45 | tok, _ := st.CreateEnrollmentToken() |
| 44 | host, err := st.RedeemEnrollmentToken(tok, "h", "linux", "amd64", "cloudhv", "", "") | 46 | host, err := st.RedeemEnrollmentToken(tok, "h", "linux", "amd64", "cloudhv", "") |
| 45 | require.NoError(t, err) | 47 | require.NoError(t, err) |
| 46 | 48 | ||
| 47 | reg := registry.New(time.Now) | 49 | reg := registry.New(time.Now) |
| 48 | h := hub.New() | 50 | h := hub.New() |
| 49 | secret := []byte("s3cret") | 51 | secret := []byte("s3cret") |
| 50 | 52 | ||
| 51 | addr, fp, _ := startTestServer(t, st, reg, h, secret, writeTimeout) | 53 | addr, fp, svc, _ := startTestServer(t, st, reg, h, secret, writeTimeout) |
| 52 | return &fixture{st: st, reg: reg, hub: h, addr: addr, fp: fp, secret: secret, | 54 | return &fixture{st: st, reg: reg, hub: h, addr: addr, fp: fp, secret: secret, |
| 53 | host: host, cred: hosttoken.Mint(secret, host.ID, host.CredGeneration, time.Now())} | 55 | host: host, cred: hosttoken.Mint(secret, host.ID, host.CredGeneration, time.Now()), svc: svc} |
| 54 | } | 56 | } |
| 55 | 57 | ||
| 56 | // startTestServer listens on 127.0.0.1:0 (random UDP port) and returns the addr, | 58 | // startTestServer listens on 127.0.0.1:0 (random UDP port) and returns the addr, |
| 57 | // the server cert fingerprint, and a cleanup func. A zero writeTimeout uses the | 59 | // the server cert fingerprint, the service, and a cleanup func. A zero |
| 58 | // production default. | 60 | // writeTimeout uses the production default. |
| 59 | func startTestServer(t *testing.T, st *store.Store, reg *registry.Registry, h *hub.Hub, secret []byte, writeTimeout time.Duration) (addr, fp string, stop func()) { | 61 | func startTestServer(t *testing.T, st *store.Store, reg *registry.Registry, h *hub.Hub, secret []byte, writeTimeout time.Duration) (addr, fp string, svc *Service, stop func()) { |
| 60 | t.Helper() | 62 | t.Helper() |
| 61 | certPEM, keyPEM, err := transport.GenerateServerCert() | 63 | certPEM, keyPEM, err := transport.GenerateServerCert() |
| 62 | require.NoError(t, err) | 64 | require.NoError(t, err) |
| @@ -66,12 +68,12 @@ func startTestServer(t *testing.T, st *store.Store, reg *registry.Registry, h *h | |||
| 66 | require.NoError(t, err) | 68 | require.NoError(t, err) |
| 67 | lis, err := quic.ListenAddr("127.0.0.1:0", tlsConf, &quic.Config{MaxIdleTimeout: 5 * time.Second}) | 69 | lis, err := quic.ListenAddr("127.0.0.1:0", tlsConf, &quic.Config{MaxIdleTimeout: 5 * time.Second}) |
| 68 | require.NoError(t, err) | 70 | require.NoError(t, err) |
| 69 | svc := newWithWriteTimeout(st, reg, h, secret, 0, writeTimeout) | 71 | svc = newWithWriteTimeout(st, reg, h, secret, 0, writeTimeout) |
| 70 | ctx, cancel := context.WithCancel(context.Background()) | 72 | ctx, cancel := context.WithCancel(context.Background()) |
| 71 | go svc.Serve(ctx, lis) //nolint:errcheck | 73 | go svc.Serve(ctx, lis) //nolint:errcheck |
| 72 | stop = func() { cancel(); lis.Close() } | 74 | stop = func() { cancel(); lis.Close() } |
| 73 | t.Cleanup(stop) | 75 | t.Cleanup(stop) |
| 74 | return lis.Addr().String(), fp, stop | 76 | return lis.Addr().String(), fp, svc, stop |
| 75 | } | 77 | } |
| 76 | 78 | ||
| 77 | // conn is the agent-side dual-stream connection used by the test scenarios. | 79 | // conn is the agent-side dual-stream connection used by the test scenarios. |
| @@ -380,7 +382,7 @@ func TestExpiredCredentialRejectedWhenMaxAgeSet(t *testing.T) { | |||
| 380 | require.NoError(t, err) | 382 | require.NoError(t, err) |
| 381 | t.Cleanup(func() { st.Close() }) | 383 | t.Cleanup(func() { st.Close() }) |
| 382 | tok, _ := st.CreateEnrollmentToken() | 384 | tok, _ := st.CreateEnrollmentToken() |
| 383 | host, err := st.RedeemEnrollmentToken(tok, "h", "linux", "amd64", "cloudhv", "", "") | 385 | host, err := st.RedeemEnrollmentToken(tok, "h", "linux", "amd64", "cloudhv", "") |
| 384 | require.NoError(t, err) | 386 | require.NoError(t, err) |
| 385 | 387 | ||
| 386 | reg := registry.New(time.Now) | 388 | reg := registry.New(time.Now) |
| @@ -422,7 +424,7 @@ func TestMaxAgeEnforcedMidSession(t *testing.T) { | |||
| 422 | require.NoError(t, err) | 424 | require.NoError(t, err) |
| 423 | t.Cleanup(func() { st.Close() }) | 425 | t.Cleanup(func() { st.Close() }) |
| 424 | tok, _ := st.CreateEnrollmentToken() | 426 | tok, _ := st.CreateEnrollmentToken() |
| 425 | host, err := st.RedeemEnrollmentToken(tok, "h", "linux", "amd64", "cloudhv", "", "") | 427 | host, err := st.RedeemEnrollmentToken(tok, "h", "linux", "amd64", "cloudhv", "") |
| 426 | require.NoError(t, err) | 428 | require.NoError(t, err) |
| 427 | 429 | ||
| 428 | reg := registry.New(time.Now) | 430 | reg := registry.New(time.Now) |
| @@ -467,3 +469,144 @@ func TestMaxAgeEnforcedMidSession(t *testing.T) { | |||
| 467 | require.ErrorAs(t, readErr, &appErr) | 469 | require.ErrorAs(t, readErr, &appErr) |
| 468 | assert.Equal(t, quic.ApplicationErrorCode(transport.CodeAuthRejected), appErr.ErrorCode) | 470 | assert.Equal(t, quic.ApplicationErrorCode(transport.CodeAuthRejected), appErr.ErrorCode) |
| 469 | } | 471 | } |
| 472 | |||
| 473 | // TestReconnectKeepsNewConnRegistered pins the deregistration guard in | ||
| 474 | // handleConn: when a host reconnects, the NEW connection's registration | ||
| 475 | // overwrites the old one's, and the OLD connection's deferred deregistration | ||
| 476 | // must not delete the new entry — OpenConsole must still reach the host. | ||
| 477 | func TestReconnectKeepsNewConnRegistered(t *testing.T) { | ||
| 478 | f := setup(t) | ||
| 479 | a := mustDial(t, f) | ||
| 480 | a.recv(t) // initial snapshot: conn A fully registered | ||
| 481 | b := mustDial(t, f) | ||
| 482 | b.recv(t) // conn B registered for the same host, overwriting A's entry | ||
| 483 | |||
| 484 | // B answers every console handshake with ok=true so OpenConsole completes. | ||
| 485 | go func() { | ||
| 486 | for { | ||
| 487 | cs, err := b.conn.AcceptStream(context.Background()) | ||
| 488 | if err != nil { | ||
| 489 | return | ||
| 490 | } | ||
| 491 | var open pb.ServerMessage | ||
| 492 | if transport.ReadMsg(cs, &open, transport.DefaultMaxFrame) != nil { | ||
| 493 | return | ||
| 494 | } | ||
| 495 | _ = transport.WriteMsg(cs, &pb.AgentMessage{Msg: &pb.AgentMessage_ConsoleOpened{ | ||
| 496 | ConsoleOpened: &pb.ConsoleOpened{Ok: true}}}) | ||
| 497 | } | ||
| 498 | }() | ||
| 499 | |||
| 500 | // Close A. Its handler's deferred deregistration runs asynchronously; the | ||
| 501 | // guard (only delete if the map still holds OUR conn) must leave B alone — | ||
| 502 | // so across the whole teardown window no attempt may ever see | ||
| 503 | // ErrAgentOffline. | ||
| 504 | a.conn.CloseWithError(0, "") | ||
| 505 | require.Never(t, func() bool { | ||
| 506 | stream, err := f.svc.OpenConsole(context.Background(), f.host.ID, "vm-1") | ||
| 507 | if err == nil { | ||
| 508 | stream.Close() | ||
| 509 | } | ||
| 510 | return errors.Is(err, ErrAgentOffline) | ||
| 511 | }, time.Second, 100*time.Millisecond, | ||
| 512 | "old conn's deregistration deleted the new conn's registry entry") | ||
| 513 | |||
| 514 | // And the console must actually still open on the surviving connection. | ||
| 515 | stream, err := f.svc.OpenConsole(context.Background(), f.host.ID, "vm-1") | ||
| 516 | require.NoError(t, err, "OpenConsole must keep working on the surviving connection") | ||
| 517 | stream.Close() | ||
| 518 | } | ||
| 519 | |||
| 520 | func TestOpenConsoleNoAgent(t *testing.T) { | ||
| 521 | svc := New(nil, nil, nil, []byte("s"), 0) // no store use on this path | ||
| 522 | _, err := svc.OpenConsole(context.Background(), "host-x", "vm-1") | ||
| 523 | assert.ErrorIs(t, err, ErrAgentOffline) | ||
| 524 | } | ||
| 525 | |||
| 526 | func TestOpenTCPNoAgent(t *testing.T) { | ||
| 527 | svc := New(nil, nil, nil, []byte("s"), 0) // no store use on this path | ||
| 528 | _, err := svc.OpenTCP(context.Background(), "host-x", "vm-1", 22) | ||
| 529 | assert.ErrorIs(t, err, ErrAgentOffline) | ||
| 530 | } | ||
| 531 | |||
| 532 | // TestOpenTCPBridgesBytes drives the full TCPOpen/TCPOpened handshake against a | ||
| 533 | // fake agent that answers ok=true, then asserts a usable RWC is returned, bytes | ||
| 534 | // pass both directions, and — after the handshake — the stream carries no | ||
| 535 | // residual read deadline (a delayed read still succeeds, proving the session is | ||
| 536 | // long-lived, mirroring OpenConsole's SetDeadline(time.Time{}) clear). | ||
| 537 | func TestOpenTCPBridgesBytes(t *testing.T) { | ||
| 538 | f := setup(t) | ||
| 539 | c := mustDial(t, f) | ||
| 540 | c.recv(t) // initial snapshot | ||
| 541 | |||
| 542 | // Fake agent: accept the tunnel stream, read TCPOpen, reply ok=true, then | ||
| 543 | // echo whatever the server writes so we can prove bytes bridge. | ||
| 544 | gotPort := make(chan uint32, 1) | ||
| 545 | go func() { | ||
| 546 | st, err := c.conn.AcceptStream(context.Background()) | ||
| 547 | if err != nil { | ||
| 548 | return | ||
| 549 | } | ||
| 550 | var open pb.ServerMessage | ||
| 551 | if transport.ReadMsg(st, &open, transport.DefaultMaxFrame) != nil { | ||
| 552 | return | ||
| 553 | } | ||
| 554 | gotPort <- open.GetTcpOpen().GetPort() | ||
| 555 | if transport.WriteMsg(st, &pb.AgentMessage{Msg: &pb.AgentMessage_TcpOpened{ | ||
| 556 | TcpOpened: &pb.TCPOpened{Ok: true}}}) != nil { | ||
| 557 | return | ||
| 558 | } | ||
| 559 | // Echo raw bytes back after the handshake. | ||
| 560 | buf := make([]byte, 32) | ||
| 561 | for { | ||
| 562 | n, err := st.Read(buf) | ||
| 563 | if n > 0 { | ||
| 564 | _, _ = st.Write(buf[:n]) | ||
| 565 | } | ||
| 566 | if err != nil { | ||
| 567 | return | ||
| 568 | } | ||
| 569 | } | ||
| 570 | }() | ||
| 571 | |||
| 572 | rwc, err := f.svc.OpenTCP(context.Background(), f.host.ID, "vm-1", 8080) | ||
| 573 | require.NoError(t, err) | ||
| 574 | defer rwc.Close() | ||
| 575 | |||
| 576 | assert.Equal(t, uint32(8080), <-gotPort, "agent must receive the requested port") | ||
| 577 | |||
| 578 | // The handshake deadline (10s) must have been cleared: a delayed write/read | ||
| 579 | // well short of that still round-trips. | ||
| 580 | time.Sleep(50 * time.Millisecond) | ||
| 581 | _, err = rwc.Write([]byte("ping")) | ||
| 582 | require.NoError(t, err) | ||
| 583 | buf := make([]byte, 4) | ||
| 584 | _, err = io.ReadFull(rwc, buf) | ||
| 585 | require.NoError(t, err) | ||
| 586 | assert.Equal(t, "ping", string(buf)) | ||
| 587 | } | ||
| 588 | |||
| 589 | // TestOpenTCPRefused pins the ok=false path: the agent replies with an error | ||
| 590 | // string and OpenTCP surfaces it (not a usable stream). | ||
| 591 | func TestOpenTCPRefused(t *testing.T) { | ||
| 592 | f := setup(t) | ||
| 593 | c := mustDial(t, f) | ||
| 594 | c.recv(t) // initial snapshot | ||
| 595 | |||
| 596 | go func() { | ||
| 597 | st, err := c.conn.AcceptStream(context.Background()) | ||
| 598 | if err != nil { | ||
| 599 | return | ||
| 600 | } | ||
| 601 | var open pb.ServerMessage | ||
| 602 | if transport.ReadMsg(st, &open, transport.DefaultMaxFrame) != nil { | ||
| 603 | return | ||
| 604 | } | ||
| 605 | _ = transport.WriteMsg(st, &pb.AgentMessage{Msg: &pb.AgentMessage_TcpOpened{ | ||
| 606 | TcpOpened: &pb.TCPOpened{Ok: false, Error: "connection refused"}}}) | ||
| 607 | }() | ||
| 608 | |||
| 609 | _, err := f.svc.OpenTCP(context.Background(), f.host.ID, "vm-1", 22) | ||
| 610 | require.Error(t, err) | ||
| 611 | assert.Contains(t, err.Error(), "connection refused") | ||
| 612 | } | ||
internal/shape/classify.go
| Old | New | ||
|---|---|---|---|
| @@ -31,7 +31,9 @@ func classify(rel string) Plane { | |||
| 31 | return PlaneData | 31 | return PlaneData |
| 32 | case strings.HasPrefix(rel, "internal/pb"), | 32 | case strings.HasPrefix(rel, "internal/pb"), |
| 33 | strings.HasPrefix(rel, "internal/transport"), | 33 | strings.HasPrefix(rel, "internal/transport"), |
| 34 | strings.HasPrefix(rel, "internal/joinblob"): | 34 | strings.HasPrefix(rel, "internal/joinblob"), |
| 35 | strings.HasPrefix(rel, "internal/cloudinit"), | ||
| 36 | strings.HasPrefix(rel, "internal/names"): | ||
| 35 | return PlaneWire | 37 | return PlaneWire |
| 36 | case strings.HasPrefix(rel, "cmd/"): | 38 | case strings.HasPrefix(rel, "cmd/"): |
| 37 | return PlaneBinaries | 39 | return PlaneBinaries |
internal/shape/classify_test.go
| Old | New | ||
|---|---|---|---|
| @@ -4,11 +4,11 @@ import "testing" | |||
| 4 | 4 | ||
| 5 | func TestSynopsisTakesFirstSentence(t *testing.T) { | 5 | func TestSynopsisTakesFirstSentence(t *testing.T) { |
| 6 | cases := map[string]string{ | 6 | cases := map[string]string{ |
| 7 | "": "", | 7 | "": "", |
| 8 | "Package reconcile does X.": "Package reconcile does X.", | 8 | "Package reconcile does X.": "Package reconcile does X.", |
| 9 | "First sentence. Second one.": "First sentence.", | 9 | "First sentence. Second one.": "First sentence.", |
| 10 | "Two\nlines collapsed.": "Two lines collapsed.", | 10 | "Two\nlines collapsed.": "Two lines collapsed.", |
| 11 | "Para one.\n\nPara two.": "Para one.", | 11 | "Para one.\n\nPara two.": "Para one.", |
| 12 | // A garbled run-on like devstack's: cut at the first ". " boundary, | 12 | // A garbled run-on like devstack's: cut at the first ". " boundary, |
| 13 | // dropping the trailing concatenated section. | 13 | // dropping the trailing concatenated section. |
| 14 | "Brings up the stack until Ctrl-C. It is the companion — both ride harness.Stack blocks.": "Brings up the stack until Ctrl-C.", | 14 | "Brings up the stack until Ctrl-C. It is the companion — both ride harness.Stack blocks.": "Brings up the stack until Ctrl-C.", |
| @@ -22,21 +22,22 @@ func TestSynopsisTakesFirstSentence(t *testing.T) { | |||
| 22 | 22 | ||
| 23 | func TestClassifyAssignsPlaneByPrefix(t *testing.T) { | 23 | func TestClassifyAssignsPlaneByPrefix(t *testing.T) { |
| 24 | cases := map[string]Plane{ | 24 | cases := map[string]Plane{ |
| 25 | "internal/server/api": PlaneControl, | 25 | "internal/server/api": PlaneControl, |
| 26 | "internal/server/store": PlaneControl, | 26 | "internal/server/store": PlaneControl, |
| 27 | "internal/agent/reconcile": PlaneData, | 27 | "internal/agent/reconcile": PlaneData, |
| 28 | "internal/agent/exec": PlaneData, | 28 | "internal/agent/exec": PlaneData, |
| 29 | "internal/pb": PlaneWire, | 29 | "internal/pb": PlaneWire, |
| 30 | "internal/transport": PlaneWire, | 30 | "internal/transport": PlaneWire, |
| 31 | "cmd/eitri-server": PlaneBinaries, | 31 | "internal/names": PlaneWire, |
| 32 | "cmd/eitri-shape": PlaneBinaries, | 32 | "cmd/eitri-server": PlaneBinaries, |
| 33 | "internal/arch": PlaneTooling, | 33 | "cmd/eitri-shape": PlaneBinaries, |
| 34 | "internal/integration": PlaneTooling, | 34 | "internal/arch": PlaneTooling, |
| 35 | "internal/integration/harness": PlaneTooling, | 35 | "internal/integration": PlaneTooling, |
| 36 | "internal/integration/sandbox": PlaneTooling, | 36 | "internal/integration/harness": PlaneTooling, |
| 37 | "internal/shape": PlaneTooling, | 37 | "internal/integration/sandbox": PlaneTooling, |
| 38 | "internal/somethingnew": PlaneUnclassified, | 38 | "internal/shape": PlaneTooling, |
| 39 | "pkg/whatever": PlaneUnclassified, | 39 | "internal/somethingnew": PlaneUnclassified, |
| 40 | "pkg/whatever": PlaneUnclassified, | ||
| 40 | } | 41 | } |
| 41 | for rel, want := range cases { | 42 | for rel, want := range cases { |
| 42 | if got := classify(rel); got != want { | 43 | if got := classify(rel); got != want { |
internal/shape/viewer.html
| Old | New | ||
|---|---|---|---|
| @@ -55,7 +55,8 @@ | |||
| 55 | const PLANES = [ | 55 | const PLANES = [ |
| 56 | ["control", "control", "#4571c4"], ["data", "data", "#d04a4a"], | 56 | ["control", "control", "#4571c4"], ["data", "data", "#d04a4a"], |
| 57 | ["wire", "wire", "#2fa85a"], ["binaries", "binaries", "#8a4fd0"], | 57 | ["wire", "wire", "#2fa85a"], ["binaries", "binaries", "#8a4fd0"], |
| 58 | ["tooling", "tooling", "#7a7a7a"], ["unclassified", "unclassified", "#d4a017"], | 58 | ["tooling", "tooling", "#7a7a7a"], ["mesh", "mesh", "#17a2b8"], |
| 59 | ["unclassified", "unclassified", "#d4a017"], | ||
| 59 | ]; | 60 | ]; |
| 60 | const COLOR = Object.fromEntries(PLANES.map(([k, , c]) => [k, c])); | 61 | const COLOR = Object.fromEntries(PLANES.map(([k, , c]) => [k, c])); |
| 61 | const esc = s => String(s).replace(/[&<>]/g, c => ({ "&": "&", "<": "<", ">": ">" }[c])); | 62 | const esc = s => String(s).replace(/[&<>]/g, c => ({ "&": "&", "<": "<", ">": ">" }[c])); |
internal/transport/contract_test.go
| Old | New | ||
|---|---|---|---|
| @@ -41,6 +41,7 @@ func TestServerMessageSnapshotRoundTrip(t *testing.T) { | |||
| 41 | VmId: "vm-1", Name: "web", ImageUrl: "https://img/x.qcow2", ImageSha256: "abc", | 41 | VmId: "vm-1", Name: "web", ImageUrl: "https://img/x.qcow2", ImageSha256: "abc", |
| 42 | CloudInit: "#cloud-config", Vcpus: 4, MemMb: 8192, DiskGb: 40, | 42 | CloudInit: "#cloud-config", Vcpus: 4, MemMb: 8192, DiskGb: 40, |
| 43 | Persistent: true, PowerState: "on", Tombstoned: false, SshAuthorizedKey: "ssh-ed25519 AAAA", | 43 | Persistent: true, PowerState: "on", Tombstoned: false, SshAuthorizedKey: "ssh-ed25519 AAAA", |
| 44 | SshUserCaAuthorizedKey: "ssh-ed25519 CAAAAA eitri-user-ca", | ||
| 44 | }}, | 45 | }}, |
| 45 | }}} | 46 | }}} |
| 46 | 47 | ||
proto/eitri/v1/sync.proto
| Old | New | ||
|---|---|---|---|
| @@ -6,12 +6,16 @@ message AgentMessage { | |||
| 6 | oneof msg { | 6 | oneof msg { |
| 7 | Hello hello = 1; | 7 | Hello hello = 1; |
| 8 | ActualStateReport report = 2; | 8 | ActualStateReport report = 2; |
| 9 | ConsoleOpened console_opened = 3; | ||
| 10 | TCPOpened tcp_opened = 4; | ||
| 9 | } | 11 | } |
| 10 | } | 12 | } |
| 11 | 13 | ||
| 12 | message ServerMessage { | 14 | message ServerMessage { |
| 13 | oneof msg { | 15 | oneof msg { |
| 14 | DesiredStateSnapshot snapshot = 1; | 16 | DesiredStateSnapshot snapshot = 1; |
| 17 | ConsoleOpen console_open = 2; | ||
| 18 | TCPOpen tcp_open = 3; | ||
| 15 | } | 19 | } |
| 16 | } | 20 | } |
| 17 | 21 | ||
| @@ -72,9 +76,44 @@ message VMDesired { | |||
| 72 | string power_state = 10; // "running"|"stopped" | 76 | string power_state = 10; // "running"|"stopped" |
| 73 | bool tombstoned = 11; // present-but-tombstoned (drives quarantine + destroyed[]) | 77 | bool tombstoned = 11; // present-but-tombstoned (drives quarantine + destroyed[]) |
| 74 | string ssh_authorized_key = 12; | 78 | string ssh_authorized_key = 12; |
| 79 | reserved 13, 14; // formerly mesh_invite / mesh_name (rayfish, removed) | ||
| 80 | string ssh_user_ca_authorized_key = 15; // eitri user-CA public key (authorized_keys form); seed injects it as an sshd TrustedUserCAKeys drop-in. Empty when the jump gate is off. | ||
| 81 | string ssh_host_key_pem = 16; // the VM's persistent ed25519 host private key (OpenSSH PEM); seed installs it as /etc/ssh/ssh_host_ed25519_key. WRITE-ONLY key material. Empty when the jump gate is off. | ||
| 82 | string ssh_host_cert = 17; // the VM's CA-signed host cert (authorized_keys form); seed installs it as /etc/ssh/ssh_host_ed25519_key-cert.pub. Empty when the jump gate is off. | ||
| 75 | } | 83 | } |
| 76 | 84 | ||
| 77 | message DesiredStateSnapshot { | 85 | message DesiredStateSnapshot { |
| 78 | uint64 epoch = 1; // agents refuse epoch < highest seen | 86 | uint64 epoch = 1; // agents refuse epoch < highest seen |
| 79 | repeated VMDesired vms = 2; // FULL set for this host, including tombstoned | 87 | repeated VMDesired vms = 2; // FULL set for this host, including tombstoned |
| 80 | } | 88 | } |
| 89 | |||
| 90 | // ConsoleOpen is the first frame on a server-initiated console stream: it names | ||
| 91 | // the VM whose serial console the stream should bridge. After the agent's | ||
| 92 | // ConsoleOpened reply, the stream carries RAW serial bytes (no framing). | ||
| 93 | message ConsoleOpen { | ||
| 94 | string vm_id = 1; | ||
| 95 | } | ||
| 96 | |||
| 97 | // ConsoleOpened is the agent's reply on the console stream. ok=false carries a | ||
| 98 | // human-readable error (unknown VM, VM not running, console unsupported) and | ||
| 99 | // the stream is then closed by the agent. | ||
| 100 | message ConsoleOpened { | ||
| 101 | bool ok = 1; | ||
| 102 | string error = 2; | ||
| 103 | } | ||
| 104 | |||
| 105 | // TCPOpen is the first frame on a server-initiated tunnel stream: it names the | ||
| 106 | // VM and the guest TCP port the stream should bridge to. After the agent's | ||
| 107 | // TCPOpened reply, the stream carries RAW TCP bytes (no framing). | ||
| 108 | message TCPOpen { | ||
| 109 | string vm_id = 1; | ||
| 110 | uint32 port = 2; | ||
| 111 | } | ||
| 112 | |||
| 113 | // TCPOpened is the agent's reply on the tunnel stream. ok=false carries a | ||
| 114 | // human-readable error (unknown VM, VM not running, dial refused) and the | ||
| 115 | // stream is then closed by the agent. | ||
| 116 | message TCPOpened { | ||
| 117 | bool ok = 1; | ||
| 118 | string error = 2; | ||
| 119 | } | ||
scripts/coverage.sh
| Old | New | ||
|---|---|---|---|
| @@ -15,16 +15,15 @@ cd "$(dirname "$0")/.." | |||
| 15 | 15 | ||
| 16 | # package (module-relative) -> minimum acceptable coverage % | 16 | # package (module-relative) -> minimum acceptable coverage % |
| 17 | declare -A FLOOR=( | 17 | declare -A FLOOR=( |
| 18 | [internal/agent/reconcile]=80 | 18 | [internal/agent/reconcile]=81 |
| 19 | [internal/agent/state]=50 | 19 | [internal/agent/state]=50 |
| 20 | [internal/agent/seed]=78 | 20 | [internal/agent/seed]=79 |
| 21 | [internal/agent/ipalloc]=83 | 21 | [internal/agent/ipalloc]=83 |
| 22 | [internal/agent/imagecache]=66 | 22 | [internal/agent/imagecache]=72 |
| 23 | [internal/agent/netenv]=75 | 23 | [internal/agent/netenv]=76 |
| 24 | [internal/agent/overlay]=86 | ||
| 25 | [internal/agent/cloudhv]=40 | 24 | [internal/agent/cloudhv]=40 |
| 26 | [internal/agent/syncclient]=73 | 25 | [internal/agent/syncclient]=74 |
| 27 | [internal/server/api]=66 | 26 | [internal/server/api]=73 |
| 28 | [internal/server/api/spec]=81 | 27 | [internal/server/api/spec]=81 |
| 29 | [internal/server/store]=73 | 28 | [internal/server/store]=73 |
| 30 | [internal/server/registry]=95 | 29 | [internal/server/registry]=95 |
scripts/deploy.env.example
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,37 @@ | |||
| 1 | # eitri deploy config — sourced by scripts/deploy.sh (`make deploy`). | ||
| 2 | # | ||
| 3 | # Copy this to the location scripts/deploy.sh reads (default | ||
| 4 | # ~/eitri-deploy/deploy.env, override with $EITRI_DEPLOY_ENV) and fill in your | ||
| 5 | # fleet's values. This file is site-specific and may reference secrets — keep it | ||
| 6 | # OUT of the repo (deploy.env is gitignored). | ||
| 7 | |||
| 8 | # ── Control plane (local eitri-server) ──────────────────────────────────────── | ||
| 9 | SERVER_BIN="$HOME/eitri-deploy/bin/eitri-server" # where the running binary lives | ||
| 10 | SERVER_CONFIG="$HOME/eitri-deploy/server.json" # --config passed to it | ||
| 11 | SERVER_LOG="$HOME/eitri-deploy/logs/server.log" # relaunch appends here | ||
| 12 | SERVER_URL="http://127.0.0.1:8080" # http_listen, for health checks | ||
| 13 | ADMIN_TOKEN_FILE="$HOME/eitri-deploy/admin-token" # optional: enables API verification | ||
| 14 | |||
| 15 | # ── Hosts (remote eitri-agent) ──────────────────────────────────────────────── | ||
| 16 | # Space-separated list of ssh targets, each "user@host[:port]" (port defaults 22). | ||
| 17 | AGENT_HOSTS="ubuntu@192.168.0.193:2222" | ||
| 18 | AGENT_BIN="/usr/local/bin/eitri-agent" # install destination on each host | ||
| 19 | AGENT_STATE_DIR="/var/lib/eitri-agent" | ||
| 20 | AGENT_LOG="/var/lib/eitri-agent/agent.log" | ||
| 21 | |||
| 22 | # Agent launch flags shared by all hosts. | ||
| 23 | CH_BIN="/usr/local/bin/cloud-hypervisor" | ||
| 24 | FIRMWARE="/usr/share/eitri/hypervisor-fw" | ||
| 25 | MESH_BIN_URL="http://192.168.0.190:8090/ray.bin" | ||
| 26 | MESH_BIN_SHA256="e806d523cf50bec454ef299be3bb5b8aad123ded93911fa86a66d70341915d05" | ||
| 27 | |||
| 28 | # Optional overrides (leave unset to use the agent's built-in defaults). | ||
| 29 | # TOMBSTONE_GRACE — how long a deleted VM lingers (stopped) before destroy. | ||
| 30 | # Agent default is 5m; lower it for a snappier dashboard teardown. | ||
| 31 | # TOMBSTONE_GRACE="30s" | ||
| 32 | # VANISH_GRACE — grace for a VM that vanished WITHOUT a tombstone (agent default 1h). | ||
| 33 | # VANISH_GRACE="1h" | ||
| 34 | # AGENT_EXTRA_FLAGS — any additional eitri-agent flags, appended verbatim. | ||
| 35 | # Host resource caps live here (0/unset = offer the whole machine): reserve | ||
| 36 | # headroom by capping the CPU/mem/disk the agent advertises AND enforces. | ||
| 37 | # AGENT_EXTRA_FLAGS="--max-vcpus 8 --max-mem-mb 16384 --max-disk-gb 200" | ||
scripts/deploy.sh
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,139 @@ | |||
| 1 | #!/usr/bin/env bash | ||
| 2 | # | ||
| 3 | # Roll the freshly-built HEAD binaries to the live fleet: the local control | ||
| 4 | # plane (eitri-server) and every remote host (eitri-agent). | ||
| 5 | # | ||
| 6 | # This is the manual, restart-based deploy. It bounces the server (a brief | ||
| 7 | # API/SSE + QUIC-sync blip; agents redial within a few seconds) and each agent. | ||
| 8 | # Running VMs SURVIVE an agent restart: cloud-hypervisor runs in its own process | ||
| 9 | # group and the agent re-adopts a still-running VM on startup (lost-detection | ||
| 10 | # keys on /proc/sys/kernel/random/boot_id, stable across an agent restart). This | ||
| 11 | # is NOT zero-downtime — the control plane has a gap during the bounce. Genuine | ||
| 12 | # rolling updates are a separate, later effort. | ||
| 13 | # | ||
| 14 | # Config is sourced from $EITRI_DEPLOY_ENV (default ~/eitri-deploy/deploy.env); | ||
| 15 | # copy scripts/deploy.env.example there and fill it in. No site-specific values | ||
| 16 | # or secrets live in the repo. | ||
| 17 | set -euo pipefail | ||
| 18 | |||
| 19 | ENV_FILE="${EITRI_DEPLOY_ENV:-$HOME/eitri-deploy/deploy.env}" | ||
| 20 | if [[ ! -f "$ENV_FILE" ]]; then | ||
| 21 | echo "deploy: config not found: $ENV_FILE" >&2 | ||
| 22 | echo " cp scripts/deploy.env.example \"$ENV_FILE\" and edit it." >&2 | ||
| 23 | exit 1 | ||
| 24 | fi | ||
| 25 | # shellcheck disable=SC1090 | ||
| 26 | source "$ENV_FILE" | ||
| 27 | |||
| 28 | : "${SERVER_BIN:?set in $ENV_FILE}" "${SERVER_CONFIG:?}" "${SERVER_LOG:?}" "${SERVER_URL:?}" | ||
| 29 | : "${AGENT_HOSTS:?}" "${AGENT_BIN:?}" "${AGENT_STATE_DIR:?}" "${AGENT_LOG:?}" | ||
| 30 | : "${CH_BIN:?}" "${FIRMWARE:?}" | ||
| 31 | |||
| 32 | REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" | ||
| 33 | cd "$REPO_ROOT" | ||
| 34 | |||
| 35 | bold() { printf '\n\033[1;36m==> %s\033[0m\n' "$*"; } | ||
| 36 | short() { sha256sum "$1" | cut -c1-12; } | ||
| 37 | |||
| 38 | SHA="$(git rev-parse --short HEAD)" | ||
| 39 | git diff --quiet || SHA="$SHA-dirty" | ||
| 40 | bold "Deploying HEAD $SHA to fleet" | ||
| 41 | |||
| 42 | # ── 0. Build ────────────────────────────────────────────────────────────────── | ||
| 43 | bold "Building binaries (make build)" | ||
| 44 | make build | ||
| 45 | |||
| 46 | # ── 1. Control plane (local eitri-server) ───────────────────────────────────── | ||
| 47 | bold "Rolling server -> $SERVER_BIN" | ||
| 48 | # Stop first: a running executable cannot be overwritten (ETXTBSY); install | ||
| 49 | # does unlink+create so the new binary lands cleanly, then relaunch detached. | ||
| 50 | pkill -TERM -f "eitri-server --config $SERVER_CONFIG" 2>/dev/null || true | ||
| 51 | for _ in $(seq 1 20); do pgrep -x eitri-server >/dev/null || break; sleep 0.5; done | ||
| 52 | if pgrep -x eitri-server >/dev/null; then | ||
| 53 | echo "deploy: server did not stop; aborting before swap" >&2 | ||
| 54 | exit 1 | ||
| 55 | fi | ||
| 56 | install -m 0755 bin/eitri-server "$SERVER_BIN" | ||
| 57 | setsid "$SERVER_BIN" --config "$SERVER_CONFIG" </dev/null >>"$SERVER_LOG" 2>&1 & | ||
| 58 | # Liveness gate: /livez is 200 as soon as the HTTP mux serves — an honest | ||
| 59 | # "process is up" signal (unlike GET /, which returns the SPA even before the | ||
| 60 | # app is wired). Readiness (DB + warden) is gated separately below, after the | ||
| 61 | # agents have had a chance to redial. | ||
| 62 | for _ in $(seq 1 30); do | ||
| 63 | curl -fsS -o /dev/null "$SERVER_URL/livez" 2>/dev/null && break | ||
| 64 | sleep 0.5 | ||
| 65 | done | ||
| 66 | if ! curl -fsS -o /dev/null "$SERVER_URL/livez" 2>/dev/null; then | ||
| 67 | echo "deploy: server did not come up at $SERVER_URL (see $SERVER_LOG)" >&2 | ||
| 68 | exit 1 | ||
| 69 | fi | ||
| 70 | echo "server up: $(short "$SERVER_BIN") listening at $SERVER_URL" | ||
| 71 | |||
| 72 | # ── 2. Hosts (remote eitri-agent) ───────────────────────────────────────────── | ||
| 73 | # Shared agent launch flags. TOMBSTONE_GRACE / VANISH_GRACE / EXTRA are optional. | ||
| 74 | agent_flags="--state-dir $AGENT_STATE_DIR --ch-bin $CH_BIN --firmware $FIRMWARE" | ||
| 75 | [[ -n "${TOMBSTONE_GRACE:-}" ]] && agent_flags+=" --tombstone-grace $TOMBSTONE_GRACE" | ||
| 76 | [[ -n "${VANISH_GRACE:-}" ]] && agent_flags+=" --vanish-grace $VANISH_GRACE" | ||
| 77 | [[ -n "${AGENT_EXTRA_FLAGS:-}" ]] && agent_flags+=" $AGENT_EXTRA_FLAGS" | ||
| 78 | |||
| 79 | for entry in $AGENT_HOSTS; do | ||
| 80 | # entry = user@host[:port] | ||
| 81 | userhost="${entry%%:*}" | ||
| 82 | port="${entry##*:}"; [[ "$port" == "$entry" ]] && port=22 | ||
| 83 | bold "Rolling agent -> $userhost (port $port)" | ||
| 84 | scp -q -P "$port" -o ConnectTimeout=10 bin/eitri-agent "$userhost:/tmp/eitri-agent-new" | ||
| 85 | # Unquoted heredoc: local vars expand here; \$(...) runs on the remote. | ||
| 86 | ssh -p "$port" -o BatchMode=yes "$userhost" bash -s <<REMOTE | ||
| 87 | set -e | ||
| 88 | sudo kill -TERM \$(pgrep -x eitri-agent) 2>/dev/null || true | ||
| 89 | for _ in \$(seq 1 20); do pgrep -x eitri-agent >/dev/null || break; sleep 0.5; done | ||
| 90 | if pgrep -x eitri-agent >/dev/null; then echo "agent did not stop on \$(hostname)" >&2; exit 1; fi | ||
| 91 | sudo install -m 0755 /tmp/eitri-agent-new "$AGENT_BIN" | ||
| 92 | rm -f /tmp/eitri-agent-new | ||
| 93 | sudo bash -c "setsid $AGENT_BIN $agent_flags </dev/null >>$AGENT_LOG 2>&1 &" | ||
| 94 | sleep 2 | ||
| 95 | pgrep -x eitri-agent >/dev/null || { echo "agent FAILED to start on \$(hostname) (see $AGENT_LOG)" >&2; exit 1; } | ||
| 96 | echo "agent up: \$(sha256sum $AGENT_BIN | cut -c1-12) on \$(hostname)" | ||
| 97 | REMOTE | ||
| 98 | done | ||
| 99 | |||
| 100 | # ── 3. Post-deploy verification ─────────────────────────────────────────────── | ||
| 101 | bold "Verifying fleet on $SHA" | ||
| 102 | # Readiness gate: /readyz 200s only when the server's dependencies (DB open + | ||
| 103 | # warden daemon reachable) are healthy. Poll briefly — the warden socket is | ||
| 104 | # local and up whenever the box is, so this settles fast; a persistent 503 | ||
| 105 | # names the failed dependency in its JSON body. | ||
| 106 | ready="" | ||
| 107 | for _ in $(seq 1 15); do | ||
| 108 | ready="$(curl -fsS "$SERVER_URL/readyz" 2>/dev/null)" && break | ||
| 109 | sleep 1 | ||
| 110 | done | ||
| 111 | if [[ -n "$ready" ]]; then | ||
| 112 | echo "server ready: $ready" | ||
| 113 | else | ||
| 114 | # curl -f makes a 503 a non-zero exit, so a stuck-unready server lands here. | ||
| 115 | echo "WARNING: server not ready after deploy: $(curl -s "$SERVER_URL/readyz" 2>/dev/null)" >&2 | ||
| 116 | fi | ||
| 117 | if [[ -n "${ADMIN_TOKEN_FILE:-}" && -f "$ADMIN_TOKEN_FILE" ]]; then | ||
| 118 | tok="$(cat "$ADMIN_TOKEN_FILE")" | ||
| 119 | up=0 total=0 | ||
| 120 | # Give agents a moment to redial the freshly-restarted server. | ||
| 121 | for _ in $(seq 1 15); do | ||
| 122 | online="$(curl -fsS -H "Authorization: Bearer $tok" "$SERVER_URL/api/v1/hosts" 2>/dev/null \ | ||
| 123 | | python3 -c 'import sys,json; hs=json.load(sys.stdin); print(sum(1 for h in hs if h["online"]), len(hs))' 2>/dev/null || echo "0 0")" | ||
| 124 | read -r up total <<<"$online" | ||
| 125 | [[ "$total" -gt 0 && "$up" == "$total" ]] && break | ||
| 126 | sleep 2 | ||
| 127 | done | ||
| 128 | echo "hosts online: $up/$total" | ||
| 129 | # Confirm the new server serves the derived lifecycle field. | ||
| 130 | if curl -fsS -H "Authorization: Bearer $tok" "$SERVER_URL/api/v1/vms" 2>/dev/null \ | ||
| 131 | | python3 -c 'import sys,json; vs=json.load(sys.stdin); sys.exit(0 if not vs or "lifecycle" in vs[0] else 1)'; then | ||
| 132 | echo "server serving lifecycle field: yes" | ||
| 133 | else | ||
| 134 | echo "WARNING: server not serving lifecycle field" >&2 | ||
| 135 | fi | ||
| 136 | else | ||
| 137 | echo "(no ADMIN_TOKEN_FILE set — skipping API verification)" | ||
| 138 | fi | ||
| 139 | bold "Deploy complete: $SHA" | ||
web/package-lock.json
| Old | New | ||
|---|---|---|---|
| @@ -7,6 +7,9 @@ | |||
| 7 | "": { | 7 | "": { |
| 8 | "name": "web", | 8 | "name": "web", |
| 9 | "version": "0.0.1", | 9 | "version": "0.0.1", |
| 10 | "dependencies": { | ||
| 11 | "@xterm/xterm": "^6.0.0" | ||
| 12 | }, | ||
| 10 | "devDependencies": { | 13 | "devDependencies": { |
| 11 | "@sveltejs/adapter-auto": "^7.0.1", | 14 | "@sveltejs/adapter-auto": "^7.0.1", |
| 12 | "@sveltejs/adapter-static": "^3.0.10", | 15 | "@sveltejs/adapter-static": "^3.0.10", |
| @@ -633,6 +636,15 @@ | |||
| 633 | "dev": true, | 636 | "dev": true, |
| 634 | "license": "MIT" | 637 | "license": "MIT" |
| 635 | }, | 638 | }, |
| 639 | "node_modules/@xterm/xterm": { | ||
| 640 | "version": "6.0.0", | ||
| 641 | "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz", | ||
| 642 | "integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==", | ||
| 643 | "license": "MIT", | ||
| 644 | "workspaces": [ | ||
| 645 | "addons/*" | ||
| 646 | ] | ||
| 647 | }, | ||
| 636 | "node_modules/acorn": { | 648 | "node_modules/acorn": { |
| 637 | "version": "8.16.0", | 649 | "version": "8.16.0", |
| 638 | "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", | 650 | "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", |
web/package.json
| Old | New | ||
|---|---|---|---|
| @@ -27,5 +27,8 @@ | |||
| 27 | "svelte-check": "^4.6.0", | 27 | "svelte-check": "^4.6.0", |
| 28 | "typescript": "^6.0.3", | 28 | "typescript": "^6.0.3", |
| 29 | "vite": "^8.0.16" | 29 | "vite": "^8.0.16" |
| 30 | }, | ||
| 31 | "dependencies": { | ||
| 32 | "@xterm/xterm": "^6.0.0" | ||
| 30 | } | 33 | } |
| 31 | } | 34 | } |
web/src/lib/Console.svelte
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,187 @@ | |||
| 1 | <script lang="ts"> | ||
| 2 | // Static CSS import: extracted at build time, never executes in SSR — | ||
| 3 | // prerender-safe, and more robust than a dynamic pure-CSS import (flaky | ||
| 4 | // across Vite majors). Only the xterm JS needs the dynamic import. | ||
| 5 | import '@xterm/xterm/css/xterm.css'; | ||
| 6 | // Type-only import: erased at compile time, so it's just as SSR-safe as | ||
| 7 | // the dynamic runtime import below. | ||
| 8 | import type { Terminal } from '@xterm/xterm'; | ||
| 9 | import { mintTicket } from '$lib/fleet.svelte'; | ||
| 10 | |||
| 11 | let { vmId }: { vmId: string } = $props(); | ||
| 12 | |||
| 13 | let holder: HTMLDivElement | undefined = $state(); | ||
| 14 | let status = $state<'closed' | 'connecting' | 'open' | 'error'>('closed'); | ||
| 15 | let error = $state(''); | ||
| 16 | // Not runes state: xterm + WS are imperative resources, not render inputs. | ||
| 17 | let term: Terminal | null = null; | ||
| 18 | let ws: WebSocket | null = null; | ||
| 19 | |||
| 20 | // gen invalidates a superseded console session across open()'s async gap | ||
| 21 | // (import + ticket mint) and its socket's late close handshake: unmount / | ||
| 22 | // vm navigation runs close() while open() is mid-await, and without this | ||
| 23 | // the resumed open() would connect a WebSocket nobody can ever close | ||
| 24 | // (the server holds its stream until the tab dies). Same pattern as | ||
| 25 | // connectGen in fleet.svelte.ts and forId in the detail page's loadEvents. | ||
| 26 | let gen = 0; | ||
| 27 | |||
| 28 | async function open() { | ||
| 29 | if (status === 'connecting' || status === 'open' || !holder) return; | ||
| 30 | // A remote close/error leaves the dead terminal's DOM in holder — | ||
| 31 | // release it so reopening doesn't stack a second xterm below it. | ||
| 32 | release(); | ||
| 33 | const myGen = ++gen; | ||
| 34 | status = 'connecting'; | ||
| 35 | error = ''; | ||
| 36 | // Hoisted out of the try so the catch can close a socket constructed | ||
| 37 | // before the `ws = sock` handoff — a throw in that window would | ||
| 38 | // otherwise leak a live connection the server holds open. | ||
| 39 | let sock: WebSocket | undefined; | ||
| 40 | try { | ||
| 41 | // Dynamic import: xterm touches `document`, and this SPA prerenders | ||
| 42 | // (adapter-static SSR) — never load its JS at module scope. | ||
| 43 | const { Terminal } = await import('@xterm/xterm'); | ||
| 44 | const ticket = await mintTicket(); | ||
| 45 | if (myGen !== gen || !holder) return; // superseded while importing/minting | ||
| 46 | const proto = location.protocol === 'https:' ? 'wss' : 'ws'; | ||
| 47 | sock = new WebSocket( | ||
| 48 | `${proto}://${location.host}/api/v1/vms/${encodeURIComponent(vmId)}/console/ws?ticket=${encodeURIComponent(ticket)}` | ||
| 49 | ); | ||
| 50 | sock.binaryType = 'arraybuffer'; | ||
| 51 | const t = new Terminal({ scrollback: 5000, fontSize: 13 }); | ||
| 52 | t.open(holder); | ||
| 53 | const enc = new TextEncoder(); | ||
| 54 | t.onData((d: string) => { | ||
| 55 | if (sock?.readyState === WebSocket.OPEN) sock.send(enc.encode(d)); | ||
| 56 | }); | ||
| 57 | sock.onmessage = (e) => t.write(new Uint8Array(e.data as ArrayBuffer)); | ||
| 58 | sock.onopen = () => { | ||
| 59 | status = 'open'; | ||
| 60 | t.focus(); | ||
| 61 | }; | ||
| 62 | sock.onclose = (e) => { | ||
| 63 | if (myGen !== gen) return; // superseded socket: don't touch live state | ||
| 64 | // The server delivers agent-leg failures as a close reason | ||
| 65 | // (host offline, VM refused) — surface it verbatim. | ||
| 66 | if (e.reason) { | ||
| 67 | status = 'error'; | ||
| 68 | error = e.reason; | ||
| 69 | } else if (status === 'open' || status === 'connecting') { | ||
| 70 | status = 'closed'; | ||
| 71 | } | ||
| 72 | }; | ||
| 73 | sock.onerror = () => { | ||
| 74 | if (myGen !== gen) return; // superseded socket: don't touch live state | ||
| 75 | if (status !== 'error') { | ||
| 76 | status = 'error'; | ||
| 77 | error = 'console connection failed'; | ||
| 78 | } | ||
| 79 | }; | ||
| 80 | term = t; | ||
| 81 | ws = sock; | ||
| 82 | } catch (e) { | ||
| 83 | sock?.close(); // constructed but never handed to ws: don't leak it | ||
| 84 | if (myGen !== gen) return; // superseded: state belongs to the new session | ||
| 85 | status = 'error'; | ||
| 86 | error = e instanceof Error ? e.message : String(e); | ||
| 87 | } | ||
| 88 | } | ||
| 89 | |||
| 90 | // release frees the imperative resources without touching status — used | ||
| 91 | // by close() and by open() to clear a remotely-closed session's leftovers | ||
| 92 | // before starting fresh. | ||
| 93 | function release() { | ||
| 94 | ws?.close(); | ||
| 95 | term?.dispose(); | ||
| 96 | ws = null; | ||
| 97 | term = null; | ||
| 98 | } | ||
| 99 | |||
| 100 | function close() { | ||
| 101 | gen++; // invalidate any in-flight open() and its socket's handlers | ||
| 102 | release(); | ||
| 103 | status = 'closed'; | ||
| 104 | } | ||
| 105 | |||
| 106 | // Reset the session when this component is REUSED for a different VM: the | ||
| 107 | // detail page reuses one Console instance across /vms/A → /vms/B, so an | ||
| 108 | // open console must tear down when vmId actually changes. Guarded on a real | ||
| 109 | // change — NOT returned as an effect cleanup — because the detail page | ||
| 110 | // re-renders ~1/s from the SSE fleet stream (fleet.vms is reassigned on | ||
| 111 | // every push), and an effect that returned close() as its cleanup re-ran | ||
| 112 | // that cleanup on every push, killing a live console ~1s after it opened. | ||
| 113 | let sessionVm: string | undefined; | ||
| 114 | $effect(() => { | ||
| 115 | if (sessionVm !== undefined && sessionVm !== vmId) close(); | ||
| 116 | sessionVm = vmId; | ||
| 117 | }); | ||
| 118 | |||
| 119 | // Teardown on unmount only. The body reads nothing reactive, so its cleanup | ||
| 120 | // runs solely on destroy — never on a re-render. | ||
| 121 | $effect(() => close); | ||
| 122 | </script> | ||
| 123 | |||
| 124 | <section class="console"> | ||
| 125 | <header> | ||
| 126 | <h3>Console</h3> | ||
| 127 | {#if status === 'open'} | ||
| 128 | <button class="ghost" onclick={close}>Disconnect</button> | ||
| 129 | {:else} | ||
| 130 | <button onclick={open} disabled={status === 'connecting'}> | ||
| 131 | {status === 'connecting' ? 'Connecting…' : 'Open console'} | ||
| 132 | </button> | ||
| 133 | {/if} | ||
| 134 | </header> | ||
| 135 | {#if status === 'error'} | ||
| 136 | <p class="err">{error}</p> | ||
| 137 | {/if} | ||
| 138 | <div class="term" bind:this={holder} class:hidden={status === 'closed' || status === 'error'}></div> | ||
| 139 | {#if status === 'closed'} | ||
| 140 | <p class="hint"> | ||
| 141 | Serial console — you'll see the boot log and anything printed to ttyS0. | ||
| 142 | Logging in requires credentials your cloud-init set up; eitri injects none. | ||
| 143 | </p> | ||
| 144 | {/if} | ||
| 145 | </section> | ||
| 146 | |||
| 147 | <style> | ||
| 148 | .console { | ||
| 149 | background: #15171c; | ||
| 150 | border: 1px solid #2a2e37; | ||
| 151 | border-radius: 6px; | ||
| 152 | padding: 0.6rem 0.8rem; | ||
| 153 | margin-top: 0.8rem; | ||
| 154 | } | ||
| 155 | .console header { | ||
| 156 | display: flex; | ||
| 157 | align-items: center; | ||
| 158 | justify-content: space-between; | ||
| 159 | } | ||
| 160 | .console h3 { | ||
| 161 | margin: 0; | ||
| 162 | font-size: 13px; | ||
| 163 | } | ||
| 164 | .term { | ||
| 165 | min-height: 320px; | ||
| 166 | background: #000; | ||
| 167 | padding: 4px; | ||
| 168 | border-radius: 4px; | ||
| 169 | margin-top: 0.5rem; | ||
| 170 | /* xterm renders a fixed 80-col canvas (~640px): scroll it inside the | ||
| 171 | card instead of overflowing narrow viewports. */ | ||
| 172 | overflow-x: auto; | ||
| 173 | } | ||
| 174 | .term.hidden { | ||
| 175 | display: none; | ||
| 176 | } | ||
| 177 | .err { | ||
| 178 | color: #ffb4b4; | ||
| 179 | margin: 0.4rem 0 0; | ||
| 180 | } | ||
| 181 | /* color comes from the global .hint rule (+layout.svelte); this one just | ||
| 182 | wants a smaller size and top margin. */ | ||
| 183 | .hint { | ||
| 184 | font-size: 0.85em; | ||
| 185 | margin: 0.4rem 0 0; | ||
| 186 | } | ||
| 187 | </style> | ||
web/src/lib/api-types.ts
| Old | New | ||
|---|---|---|---|
| @@ -329,6 +329,192 @@ export interface paths { | |||
| 329 | patch?: never; | 329 | patch?: never; |
| 330 | trace?: never; | 330 | trace?: never; |
| 331 | }; | 331 | }; |
| 332 | "/api/v1/ssh-ca": { | ||
| 333 | parameters: { | ||
| 334 | query?: never; | ||
| 335 | header?: never; | ||
| 336 | path?: never; | ||
| 337 | cookie?: never; | ||
| 338 | }; | ||
| 339 | /** The eitri SSH CA public key (public material) for pinning `@cert-authority` in known_hosts. 404 when the jump gate is off. */ | ||
| 340 | get: { | ||
| 341 | parameters: { | ||
| 342 | query?: never; | ||
| 343 | header?: never; | ||
| 344 | path?: never; | ||
| 345 | cookie?: never; | ||
| 346 | }; | ||
| 347 | requestBody?: never; | ||
| 348 | responses: { | ||
| 349 | /** @description success */ | ||
| 350 | 200: { | ||
| 351 | headers: { | ||
| 352 | [name: string]: unknown; | ||
| 353 | }; | ||
| 354 | content: { | ||
| 355 | "application/json": components["schemas"]["SSHCAResponse"]; | ||
| 356 | }; | ||
| 357 | }; | ||
| 358 | /** @description error (plain text) */ | ||
| 359 | default: { | ||
| 360 | headers: { | ||
| 361 | [name: string]: unknown; | ||
| 362 | }; | ||
| 363 | content: { | ||
| 364 | "text/plain": string; | ||
| 365 | }; | ||
| 366 | }; | ||
| 367 | }; | ||
| 368 | }; | ||
| 369 | put?: never; | ||
| 370 | post?: never; | ||
| 371 | delete?: never; | ||
| 372 | options?: never; | ||
| 373 | head?: never; | ||
| 374 | patch?: never; | ||
| 375 | trace?: never; | ||
| 376 | }; | ||
| 377 | "/api/v1/ssh-certs": { | ||
| 378 | parameters: { | ||
| 379 | query?: never; | ||
| 380 | header?: never; | ||
| 381 | path?: never; | ||
| 382 | cookie?: never; | ||
| 383 | }; | ||
| 384 | get?: never; | ||
| 385 | put?: never; | ||
| 386 | /** Mint a short-lived SSH user certificate for the caller's public key. 404 when the jump gate is off (no CA wired). */ | ||
| 387 | post: { | ||
| 388 | parameters: { | ||
| 389 | query?: never; | ||
| 390 | header?: never; | ||
| 391 | path?: never; | ||
| 392 | cookie?: never; | ||
| 393 | }; | ||
| 394 | requestBody: { | ||
| 395 | content: { | ||
| 396 | "application/json": components["schemas"]["SSHCertRequest"]; | ||
| 397 | }; | ||
| 398 | }; | ||
| 399 | responses: { | ||
| 400 | /** @description success */ | ||
| 401 | 200: { | ||
| 402 | headers: { | ||
| 403 | [name: string]: unknown; | ||
| 404 | }; | ||
| 405 | content: { | ||
| 406 | "application/json": components["schemas"]["SSHCertResponse"]; | ||
| 407 | }; | ||
| 408 | }; | ||
| 409 | /** @description error (plain text) */ | ||
| 410 | default: { | ||
| 411 | headers: { | ||
| 412 | [name: string]: unknown; | ||
| 413 | }; | ||
| 414 | content: { | ||
| 415 | "text/plain": string; | ||
| 416 | }; | ||
| 417 | }; | ||
| 418 | }; | ||
| 419 | }; | ||
| 420 | delete?: never; | ||
| 421 | options?: never; | ||
| 422 | head?: never; | ||
| 423 | patch?: never; | ||
| 424 | trace?: never; | ||
| 425 | }; | ||
| 426 | "/api/v1/ssh-certs/revoke": { | ||
| 427 | parameters: { | ||
| 428 | query?: never; | ||
| 429 | header?: never; | ||
| 430 | path?: never; | ||
| 431 | cookie?: never; | ||
| 432 | }; | ||
| 433 | get?: never; | ||
| 434 | put?: never; | ||
| 435 | /** Revoke a minted SSH user certificate by serial or certificate line; the gate rejects it before its TTL expires. Idempotent. */ | ||
| 436 | post: { | ||
| 437 | parameters: { | ||
| 438 | query?: never; | ||
| 439 | header?: never; | ||
| 440 | path?: never; | ||
| 441 | cookie?: never; | ||
| 442 | }; | ||
| 443 | requestBody: { | ||
| 444 | content: { | ||
| 445 | "application/json": components["schemas"]["RevokeSSHCertRequest"]; | ||
| 446 | }; | ||
| 447 | }; | ||
| 448 | responses: { | ||
| 449 | /** @description success */ | ||
| 450 | 204: { | ||
| 451 | headers: { | ||
| 452 | [name: string]: unknown; | ||
| 453 | }; | ||
| 454 | content?: never; | ||
| 455 | }; | ||
| 456 | /** @description error (plain text) */ | ||
| 457 | default: { | ||
| 458 | headers: { | ||
| 459 | [name: string]: unknown; | ||
| 460 | }; | ||
| 461 | content: { | ||
| 462 | "text/plain": string; | ||
| 463 | }; | ||
| 464 | }; | ||
| 465 | }; | ||
| 466 | }; | ||
| 467 | delete?: never; | ||
| 468 | options?: never; | ||
| 469 | head?: never; | ||
| 470 | patch?: never; | ||
| 471 | trace?: never; | ||
| 472 | }; | ||
| 473 | "/api/v1/ssh-certs/revoked": { | ||
| 474 | parameters: { | ||
| 475 | query?: never; | ||
| 476 | header?: never; | ||
| 477 | path?: never; | ||
| 478 | cookie?: never; | ||
| 479 | }; | ||
| 480 | /** List revoked SSH user certificate serials (with reason and time), newest first. */ | ||
| 481 | get: { | ||
| 482 | parameters: { | ||
| 483 | query?: never; | ||
| 484 | header?: never; | ||
| 485 | path?: never; | ||
| 486 | cookie?: never; | ||
| 487 | }; | ||
| 488 | requestBody?: never; | ||
| 489 | responses: { | ||
| 490 | /** @description success */ | ||
| 491 | 200: { | ||
| 492 | headers: { | ||
| 493 | [name: string]: unknown; | ||
| 494 | }; | ||
| 495 | content: { | ||
| 496 | "application/json": components["schemas"]["RevokedCert"][]; | ||
| 497 | }; | ||
| 498 | }; | ||
| 499 | /** @description error (plain text) */ | ||
| 500 | default: { | ||
| 501 | headers: { | ||
| 502 | [name: string]: unknown; | ||
| 503 | }; | ||
| 504 | content: { | ||
| 505 | "text/plain": string; | ||
| 506 | }; | ||
| 507 | }; | ||
| 508 | }; | ||
| 509 | }; | ||
| 510 | put?: never; | ||
| 511 | post?: never; | ||
| 512 | delete?: never; | ||
| 513 | options?: never; | ||
| 514 | head?: never; | ||
| 515 | patch?: never; | ||
| 516 | trace?: never; | ||
| 517 | }; | ||
| 332 | "/api/v1/stream-tickets": { | 518 | "/api/v1/stream-tickets": { |
| 333 | parameters: { | 519 | parameters: { |
| 334 | query?: never; | 520 | query?: never; |
| @@ -338,7 +524,7 @@ export interface paths { | |||
| 338 | }; | 524 | }; |
| 339 | get?: never; | 525 | get?: never; |
| 340 | put?: never; | 526 | put?: never; |
| 341 | /** Mint a one-time short-TTL ticket for the SSE stream — the only credential that ever rides in a URL. */ | 527 | /** Mint a one-time short-TTL ticket for the SSE stream or console WebSocket — the only credential that ever rides in a URL. */ |
| 342 | post: { | 528 | post: { |
| 343 | parameters: { | 529 | parameters: { |
| 344 | query?: never; | 530 | query?: never; |
| @@ -462,7 +648,7 @@ export interface paths { | |||
| 462 | get?: never; | 648 | get?: never; |
| 463 | put?: never; | 649 | put?: never; |
| 464 | post?: never; | 650 | post?: never; |
| 465 | /** Tombstone a VM for teardown. */ | 651 | /** Tombstone a VM for teardown; restorable within the grace window via restore. */ |
| 466 | delete: { | 652 | delete: { |
| 467 | parameters: { | 653 | parameters: { |
| 468 | query?: never; | 654 | query?: never; |
| @@ -530,6 +716,149 @@ export interface paths { | |||
| 530 | }; | 716 | }; |
| 531 | trace?: never; | 717 | trace?: never; |
| 532 | }; | 718 | }; |
| 719 | "/api/v1/vms/{id}/console/ws": { | ||
| 720 | parameters: { | ||
| 721 | query?: never; | ||
| 722 | header?: never; | ||
| 723 | path?: never; | ||
| 724 | cookie?: never; | ||
| 725 | }; | ||
| 726 | /** Serial-console WebSocket: raw byte pipe to the VM's serial console. */ | ||
| 727 | get: { | ||
| 728 | parameters: { | ||
| 729 | query?: { | ||
| 730 | /** @description one-time stream ticket */ | ||
| 731 | ticket?: string; | ||
| 732 | }; | ||
| 733 | header?: never; | ||
| 734 | path: { | ||
| 735 | id: string; | ||
| 736 | }; | ||
| 737 | cookie?: never; | ||
| 738 | }; | ||
| 739 | requestBody?: never; | ||
| 740 | responses: { | ||
| 741 | /** @description switching protocols (WebSocket) */ | ||
| 742 | 101: { | ||
| 743 | headers: { | ||
| 744 | [name: string]: unknown; | ||
| 745 | }; | ||
| 746 | content?: never; | ||
| 747 | }; | ||
| 748 | /** @description error (plain text) */ | ||
| 749 | default: { | ||
| 750 | headers: { | ||
| 751 | [name: string]: unknown; | ||
| 752 | }; | ||
| 753 | content: { | ||
| 754 | "text/plain": string; | ||
| 755 | }; | ||
| 756 | }; | ||
| 757 | }; | ||
| 758 | }; | ||
| 759 | put?: never; | ||
| 760 | post?: never; | ||
| 761 | delete?: never; | ||
| 762 | options?: never; | ||
| 763 | head?: never; | ||
| 764 | patch?: never; | ||
| 765 | trace?: never; | ||
| 766 | }; | ||
| 767 | "/api/v1/vms/{id}/events": { | ||
| 768 | parameters: { | ||
| 769 | query?: never; | ||
| 770 | header?: never; | ||
| 771 | path?: never; | ||
| 772 | cookie?: never; | ||
| 773 | }; | ||
| 774 | /** One VM's lifecycle timeline (audit rows carrying its vm_id), newest first; survives the VM row being reaped. */ | ||
| 775 | get: { | ||
| 776 | parameters: { | ||
| 777 | query?: { | ||
| 778 | /** @description max rows to return (default 100, cap 1000) */ | ||
| 779 | limit?: string; | ||
| 780 | }; | ||
| 781 | header?: never; | ||
| 782 | path: { | ||
| 783 | id: string; | ||
| 784 | }; | ||
| 785 | cookie?: never; | ||
| 786 | }; | ||
| 787 | requestBody?: never; | ||
| 788 | responses: { | ||
| 789 | /** @description success */ | ||
| 790 | 200: { | ||
| 791 | headers: { | ||
| 792 | [name: string]: unknown; | ||
| 793 | }; | ||
| 794 | content: { | ||
| 795 | "application/json": components["schemas"]["AuditEvent"][]; | ||
| 796 | }; | ||
| 797 | }; | ||
| 798 | /** @description error (plain text) */ | ||
| 799 | default: { | ||
| 800 | headers: { | ||
| 801 | [name: string]: unknown; | ||
| 802 | }; | ||
| 803 | content: { | ||
| 804 | "text/plain": string; | ||
| 805 | }; | ||
| 806 | }; | ||
| 807 | }; | ||
| 808 | }; | ||
| 809 | put?: never; | ||
| 810 | post?: never; | ||
| 811 | delete?: never; | ||
| 812 | options?: never; | ||
| 813 | head?: never; | ||
| 814 | patch?: never; | ||
| 815 | trace?: never; | ||
| 816 | }; | ||
| 817 | "/api/v1/vms/{id}/restore": { | ||
| 818 | parameters: { | ||
| 819 | query?: never; | ||
| 820 | header?: never; | ||
| 821 | path?: never; | ||
| 822 | cookie?: never; | ||
| 823 | }; | ||
| 824 | get?: never; | ||
| 825 | put?: never; | ||
| 826 | /** Un-tombstone a VM still within the teardown grace window; the agent re-adopts the guest. */ | ||
| 827 | post: { | ||
| 828 | parameters: { | ||
| 829 | query?: never; | ||
| 830 | header?: never; | ||
| 831 | path: { | ||
| 832 | id: string; | ||
| 833 | }; | ||
| 834 | cookie?: never; | ||
| 835 | }; | ||
| 836 | requestBody?: never; | ||
| 837 | responses: { | ||
| 838 | /** @description success */ | ||
| 839 | 204: { | ||
| 840 | headers: { | ||
| 841 | [name: string]: unknown; | ||
| 842 | }; | ||
| 843 | content?: never; | ||
| 844 | }; | ||
| 845 | /** @description error (plain text) */ | ||
| 846 | default: { | ||
| 847 | headers: { | ||
| 848 | [name: string]: unknown; | ||
| 849 | }; | ||
| 850 | content: { | ||
| 851 | "text/plain": string; | ||
| 852 | }; | ||
| 853 | }; | ||
| 854 | }; | ||
| 855 | }; | ||
| 856 | delete?: never; | ||
| 857 | options?: never; | ||
| 858 | head?: never; | ||
| 859 | patch?: never; | ||
| 860 | trace?: never; | ||
| 861 | }; | ||
| 533 | } | 862 | } |
| 534 | export type webhooks = Record<string, never>; | 863 | export type webhooks = Record<string, never>; |
| 535 | export interface components { | 864 | export interface components { |
| @@ -566,7 +895,6 @@ export interface components { | |||
| 566 | arch?: string; | 895 | arch?: string; |
| 567 | name?: string; | 896 | name?: string; |
| 568 | os?: string; | 897 | os?: string; |
| 569 | overlay?: string; | ||
| 570 | provisioner?: string; | 898 | provisioner?: string; |
| 571 | token?: string; | 899 | token?: string; |
| 572 | }; | 900 | }; |
| @@ -574,7 +902,6 @@ export interface components { | |||
| 574 | bridge_cidr: string; | 902 | bridge_cidr: string; |
| 575 | credential: string; | 903 | credential: string; |
| 576 | host_id: string; | 904 | host_id: string; |
| 577 | overlay: string; | ||
| 578 | server_cert_sha256: string; | 905 | server_cert_sha256: string; |
| 579 | }; | 906 | }; |
| 580 | EnrollTokenResponse: { | 907 | EnrollTokenResponse: { |
| @@ -592,13 +919,33 @@ export interface components { | |||
| 592 | name: string; | 919 | name: string; |
| 593 | online: boolean; | 920 | online: boolean; |
| 594 | os: string; | 921 | os: string; |
| 595 | overlay: string; | ||
| 596 | provisioner: string; | 922 | provisioner: string; |
| 597 | status: string; | 923 | status: string; |
| 598 | }; | 924 | }; |
| 599 | PatchVMRequest: { | 925 | PatchVMRequest: { |
| 600 | power_state?: string; | 926 | power_state?: string; |
| 601 | }; | 927 | }; |
| 928 | RevokeSSHCertRequest: { | ||
| 929 | certificate?: string; | ||
| 930 | reason?: string; | ||
| 931 | serial?: number | null; | ||
| 932 | }; | ||
| 933 | RevokedCert: { | ||
| 934 | reason: string; | ||
| 935 | /** Format: date-time */ | ||
| 936 | revoked_at: string; | ||
| 937 | serial: string; | ||
| 938 | }; | ||
| 939 | SSHCAResponse: { | ||
| 940 | ca: string; | ||
| 941 | }; | ||
| 942 | SSHCertRequest: { | ||
| 943 | principals?: string[]; | ||
| 944 | public_key?: string; | ||
| 945 | }; | ||
| 946 | SSHCertResponse: { | ||
| 947 | certificate: string; | ||
| 948 | }; | ||
| 602 | StateSnapshot: { | 949 | StateSnapshot: { |
| 603 | hosts: components["schemas"]["Host"][]; | 950 | hosts: components["schemas"]["Host"][]; |
| 604 | vms: components["schemas"]["VM"][]; | 951 | vms: components["schemas"]["VM"][]; |
| @@ -612,11 +959,13 @@ export interface components { | |||
| 612 | /** Format: date-time */ | 959 | /** Format: date-time */ |
| 613 | created_at: string; | 960 | created_at: string; |
| 614 | deleted: boolean; | 961 | deleted: boolean; |
| 962 | destroy_at: number; | ||
| 615 | disk_gb: number; | 963 | disk_gb: number; |
| 616 | host_id: string; | 964 | host_id: string; |
| 617 | id: string; | 965 | id: string; |
| 618 | image_url: string; | 966 | image_url: string; |
| 619 | last_error: string; | 967 | last_error: string; |
| 968 | lifecycle: string; | ||
| 620 | mem_mb: number; | 969 | mem_mb: number; |
| 621 | name: string; | 970 | name: string; |
| 622 | persistent: boolean; | 971 | persistent: boolean; |
web/src/lib/fleet.svelte.ts
| Old | New | ||
|---|---|---|---|
| @@ -11,6 +11,10 @@ export type Capacity = components['schemas']['Capacity']; | |||
| 11 | export type Host = components['schemas']['Host']; | 11 | export type Host = components['schemas']['Host']; |
| 12 | export type VM = components['schemas']['VM']; | 12 | export type VM = components['schemas']['VM']; |
| 13 | 13 | ||
| 14 | /** VMEvent is one lifecycle event (see vmEvents); detail is embedded raw JSON | ||
| 15 | * on the wire, so after res.json() it is already a decoded object. */ | ||
| 16 | export type VMEvent = components['schemas']['AuditEvent']; | ||
| 17 | |||
| 14 | export type CreateVMRequest = components['schemas']['CreateVMRequest']; | 18 | export type CreateVMRequest = components['schemas']['CreateVMRequest']; |
| 15 | 19 | ||
| 16 | const TOKEN_KEY = 'eitri_token'; | 20 | const TOKEN_KEY = 'eitri_token'; |
| @@ -23,6 +27,30 @@ export const fleet = $state({ | |||
| 23 | error: '' | 27 | error: '' |
| 24 | }); | 28 | }); |
| 25 | 29 | ||
| 30 | // clock is a single shared ticking wall-clock (unix seconds). Countdowns read | ||
| 31 | // clock.now instead of each spinning its own interval, so every countdown on | ||
| 32 | // the page ticks in lockstep and none drifts. One interval feeds them all. | ||
| 33 | // Starts at 0 (no Date.now() at module scope, so static prerender stays pure); | ||
| 34 | // startClock() sets the real time in the browser before any countdown shows. | ||
| 35 | export const clock = $state({ now: 0 }); | ||
| 36 | |||
| 37 | function nowSec(): number { | ||
| 38 | return Math.floor(Date.now() / 1000); | ||
| 39 | } | ||
| 40 | |||
| 41 | let clockTimer: ReturnType<typeof setInterval> | null = null; | ||
| 42 | |||
| 43 | /** startClock arms the shared 1s clock tick. Idempotent; call from a browser | ||
| 44 | * context (onMount) only — module code runs during static prerender, where | ||
| 45 | * there is no window and Date.now() must not drive render. */ | ||
| 46 | export function startClock() { | ||
| 47 | if (typeof window === 'undefined' || clockTimer) return; | ||
| 48 | clock.now = nowSec(); | ||
| 49 | clockTimer = setInterval(() => { | ||
| 50 | clock.now = nowSec(); | ||
| 51 | }, 1000); | ||
| 52 | } | ||
| 53 | |||
| 26 | let es: EventSource | null = null; | 54 | let es: EventSource | null = null; |
| 27 | 55 | ||
| 28 | // sseParseError marks that fleet.error came from the SSE stream itself (not a | 56 | // sseParseError marks that fleet.error came from the SSE stream itself (not a |
| @@ -54,6 +82,14 @@ export function setToken(t: string) { | |||
| 54 | connect(); | 82 | connect(); |
| 55 | } | 83 | } |
| 56 | 84 | ||
| 85 | /** mintTicket fetches a one-time stream ticket (SSE + console WS auth): the | ||
| 86 | * admin token never rides in a URL. Throws on failure. */ | ||
| 87 | export async function mintTicket(): Promise<string> { | ||
| 88 | const r = await (await req('POST', '/api/v1/stream-tickets')).json(); | ||
| 89 | if (typeof r.ticket !== 'string' || !r.ticket) throw new Error('malformed ticket response'); | ||
| 90 | return r.ticket; | ||
| 91 | } | ||
| 92 | |||
| 57 | let reconnectTimer: ReturnType<typeof setTimeout> | null = null; | 93 | let reconnectTimer: ReturnType<typeof setTimeout> | null = null; |
| 58 | 94 | ||
| 59 | // connectGen guards against overlapping async connect() calls: only the | 95 | // connectGen guards against overlapping async connect() calls: only the |
| @@ -75,9 +111,7 @@ export async function connect() { | |||
| 75 | const gen = ++connectGen; | 111 | const gen = ++connectGen; |
| 76 | let ticket: string; | 112 | let ticket: string; |
| 77 | try { | 113 | try { |
| 78 | const r = await (await req('POST', '/api/v1/stream-tickets')).json(); | 114 | ticket = await mintTicket(); |
| 79 | if (typeof r.ticket !== 'string' || !r.ticket) throw new Error('malformed ticket response'); | ||
| 80 | ticket = r.ticket; | ||
| 81 | } catch { | 115 | } catch { |
| 82 | if (gen !== connectGen) return; // superseded while minting | 116 | if (gen !== connectGen) return; // superseded while minting |
| 83 | fleet.connected = false; | 117 | fleet.connected = false; |
| @@ -133,6 +167,21 @@ export function dismissError() { | |||
| 133 | fleet.error = ''; | 167 | fleet.error = ''; |
| 134 | } | 168 | } |
| 135 | 169 | ||
| 170 | /** action runs a fire-and-forget API call from a UI handler, capturing any | ||
| 171 | * failure into fleet.error (the app's only failure surface) exactly as the | ||
| 172 | * ten call sites used to do by hand. Returns whether it succeeded, so the | ||
| 173 | * caller can gate success-only follow-up (closing a dialog, resetting a | ||
| 174 | * form) without needing its own try/catch. */ | ||
| 175 | export async function action(fn: () => Promise<unknown>): Promise<boolean> { | ||
| 176 | try { | ||
| 177 | await fn(); | ||
| 178 | return true; | ||
| 179 | } catch (err) { | ||
| 180 | fleet.error = String(err); | ||
| 181 | return false; | ||
| 182 | } | ||
| 183 | } | ||
| 184 | |||
| 136 | /** refresh does a one-shot fetch (used before SSE connects or as a fallback). */ | 185 | /** refresh does a one-shot fetch (used before SSE connects or as a fallback). */ |
| 137 | export async function refresh() { | 186 | export async function refresh() { |
| 138 | try { | 187 | try { |
| @@ -160,6 +209,17 @@ export async function deleteVM(id: string) { | |||
| 160 | await req('DELETE', `/api/v1/vms/${id}`); | 209 | await req('DELETE', `/api/v1/vms/${id}`); |
| 161 | } | 210 | } |
| 162 | 211 | ||
| 212 | /** restoreVM un-deletes a VM still within its teardown grace window. req throws | ||
| 213 | * on non-2xx, so a 409 (grace window closed) surfaces to the caller. */ | ||
| 214 | export async function restoreVM(id: string) { | ||
| 215 | await req('POST', `/api/v1/vms/${id}/restore`); | ||
| 216 | } | ||
| 217 | |||
| 218 | /** vmEvents fetches the VM's lifecycle events, newest-first. */ | ||
| 219 | export async function vmEvents(id: string): Promise<VMEvent[]> { | ||
| 220 | return (await req('GET', `/api/v1/vms/${id}/events`)).json(); | ||
| 221 | } | ||
| 222 | |||
| 163 | export async function decommissionHost(id: string) { | 223 | export async function decommissionHost(id: string) { |
| 164 | await req('DELETE', `/api/v1/hosts/${id}`); | 224 | await req('DELETE', `/api/v1/hosts/${id}`); |
| 165 | } | 225 | } |
| @@ -193,7 +253,53 @@ export function vmIP(vm: VM): string { | |||
| 193 | return vm.assigned_ip || '—'; | 253 | return vm.assigned_ip || '—'; |
| 194 | } | 254 | } |
| 195 | 255 | ||
| 256 | /** vmStatus is the single lifecycle status folded from the orthogonal state axes. | ||
| 257 | * | ||
| 258 | * The server owns this derivation (deriveLifecycle in internal/server/api) | ||
| 259 | * and always ships the result as vm.lifecycle; this SPA is served | ||
| 260 | * same-origin by that same binary, so there's no version-skew case to | ||
| 261 | * degrade for. The fallback below only covers a VM record missing the field | ||
| 262 | * entirely (e.g. a malformed/partial snapshot). */ | ||
| 263 | export function vmStatus(vm: VM): string { | ||
| 264 | return vm.lifecycle || (vm.deleted ? 'deleting' : vm.phase || vm.status || 'unknown'); | ||
| 265 | } | ||
| 266 | |||
| 196 | /** vmIsRunning reports whether the VM is actually running (gates Start/Stop). */ | 267 | /** vmIsRunning reports whether the VM is actually running (gates Start/Stop). */ |
| 197 | export function vmIsRunning(vm: VM): boolean { | 268 | export function vmIsRunning(vm: VM): boolean { |
| 198 | return vm.actual_power === 'running'; | 269 | return vm.actual_power === 'running'; |
| 199 | } | 270 | } |
| 271 | |||
| 272 | /** teardownApprox renders a calm, coarse estimate of the time left to undo a | ||
| 273 | * deletion before the agent destroys the VM — "~30s", "~2m", "any moment" — or | ||
| 274 | * '' when the destroy clock hasn't started yet (destroy_at 0, guest still | ||
| 275 | * shutting down). Rounded to 10s buckets so it doesn't jitter every second; | ||
| 276 | * the emphasis is "you can still undo", not a precise doom clock. Pass clock.now. */ | ||
| 277 | export function teardownApprox(vm: VM, nowSec: number): string { | ||
| 278 | if (!vm.destroy_at) return ''; | ||
| 279 | const left = vm.destroy_at - nowSec; | ||
| 280 | if (left <= 0) return 'any moment'; | ||
| 281 | if (left < 60) return `~${Math.max(10, Math.round(left / 10) * 10)}s`; | ||
| 282 | return `~${Math.ceil(left / 60)}m`; | ||
| 283 | } | ||
| 284 | |||
| 285 | /** eventLabel maps a lifecycle VMEvent to a human label, folding in the one | ||
| 286 | * detail that matters per action. detail arrives already decoded (raw JSON on | ||
| 287 | * the wire); guard the shape defensively rather than parsing. */ | ||
| 288 | export function eventLabel(ev: VMEvent): string { | ||
| 289 | const detail = | ||
| 290 | ev.detail && typeof ev.detail === 'object' ? (ev.detail as Record<string, unknown>) : {}; | ||
| 291 | switch (ev.action) { | ||
| 292 | case 'vm.create': | ||
| 293 | return 'Created'; | ||
| 294 | case 'vm.power': | ||
| 295 | return `Power → ${detail.power ?? '?'}`; | ||
| 296 | case 'vm.delete': | ||
| 297 | return 'Deleted'; | ||
| 298 | case 'vm.restore': | ||
| 299 | return 'Restored'; | ||
| 300 | case 'vm.reap': | ||
| 301 | return `Destroyed (${detail.reason ?? '?'})`; | ||
| 302 | default: | ||
| 303 | return ev.action; | ||
| 304 | } | ||
| 305 | } | ||
web/src/lib/index.ts
| Old | New | ||
|---|---|---|---|
| @@ -1 +0,0 @@ | |||
| 1 | // place files you want to import through the `$lib` alias in this folder. | ||
web/src/routes/+layout.svelte
| Old | New | ||
|---|---|---|---|
| @@ -1,11 +1,12 @@ | |||
| 1 | <script lang="ts"> | 1 | <script lang="ts"> |
| 2 | import favicon from '$lib/assets/favicon.svg'; | 2 | import favicon from '$lib/assets/favicon.svg'; |
| 3 | import { onMount } from 'svelte'; | 3 | import { onMount } from 'svelte'; |
| 4 | import { fleet, setToken, connect, refresh, dismissError } from '$lib/fleet.svelte'; | 4 | import { fleet, setToken, connect, refresh, dismissError, startClock } from '$lib/fleet.svelte'; |
| 5 | let { children } = $props(); | 5 | let { children } = $props(); |
| 6 | let tokenInput = $state(''); | 6 | let tokenInput = $state(''); |
| 7 | 7 | ||
| 8 | onMount(() => { | 8 | onMount(() => { |
| 9 | startClock(); | ||
| 9 | if (fleet.token) { | 10 | if (fleet.token) { |
| 10 | tokenInput = fleet.token; | 11 | tokenInput = fleet.token; |
| 11 | refresh(); | 12 | refresh(); |
| @@ -99,6 +100,21 @@ | |||
| 99 | font-weight: 600; | 100 | font-weight: 600; |
| 100 | border-bottom: 1px solid #2a2e37; | 101 | border-bottom: 1px solid #2a2e37; |
| 101 | } | 102 | } |
| 103 | :global(.dot) { | ||
| 104 | width: 8px; | ||
| 105 | height: 8px; | ||
| 106 | border-radius: 50%; | ||
| 107 | display: inline-block; | ||
| 108 | } | ||
| 109 | :global(.dot.on) { | ||
| 110 | background: #34d399; | ||
| 111 | } | ||
| 112 | :global(.dot.off) { | ||
| 113 | background: #f87171; | ||
| 114 | } | ||
| 115 | :global(.hint) { | ||
| 116 | color: #6b7280; | ||
| 117 | } | ||
| 102 | header { | 118 | header { |
| 103 | display: flex; | 119 | display: flex; |
| 104 | align-items: center; | 120 | align-items: center; |
| @@ -118,18 +134,6 @@ | |||
| 118 | .spacer { | 134 | .spacer { |
| 119 | flex: 1; | 135 | flex: 1; |
| 120 | } | 136 | } |
| 121 | .dot { | ||
| 122 | width: 8px; | ||
| 123 | height: 8px; | ||
| 124 | border-radius: 50%; | ||
| 125 | display: inline-block; | ||
| 126 | } | ||
| 127 | .dot.on { | ||
| 128 | background: #34d399; | ||
| 129 | } | ||
| 130 | .dot.off { | ||
| 131 | background: #f87171; | ||
| 132 | } | ||
| 133 | .conn { | 137 | .conn { |
| 134 | color: #9aa0aa; | 138 | color: #9aa0aa; |
| 135 | margin-right: 0.5rem; | 139 | margin-right: 0.5rem; |
| @@ -146,6 +150,7 @@ | |||
| 146 | padding: 0.3rem 0.5rem; | 150 | padding: 0.3rem 0.5rem; |
| 147 | font-family: inherit; | 151 | font-family: inherit; |
| 148 | font-size: 12px; | 152 | font-size: 12px; |
| 153 | box-sizing: border-box; | ||
| 149 | } | 154 | } |
| 150 | :global(button) { | 155 | :global(button) { |
| 151 | cursor: pointer; | 156 | cursor: pointer; |
| @@ -188,7 +193,4 @@ | |||
| 188 | .error .dismiss:hover { | 193 | .error .dismiss:hover { |
| 189 | background: #5a2025; | 194 | background: #5a2025; |
| 190 | } | 195 | } |
| 191 | .hint { | ||
| 192 | color: #6b7280; | ||
| 193 | } | ||
| 194 | </style> | 196 | </style> |
web/src/routes/+page.svelte
| Old | New | ||
|---|---|---|---|
| @@ -1,13 +1,14 @@ | |||
| 1 | <script lang="ts"> | 1 | <script lang="ts"> |
| 2 | import { | 2 | import { |
| 3 | fleet, | 3 | fleet, |
| 4 | action, | ||
| 4 | createVM, | 5 | createVM, |
| 5 | setPower, | 6 | setPower, |
| 6 | deleteVM, | 7 | deleteVM, |
| 7 | decommissionHost, | 8 | decommissionHost, |
| 8 | createJoinBlob, | 9 | createJoinBlob, |
| 9 | vmsForHost, | 10 | vmStatus, |
| 10 | vmPhase, | 11 | restoreVM, |
| 11 | vmPower, | 12 | vmPower, |
| 12 | vmIP, | 13 | vmIP, |
| 13 | vmIsRunning, | 14 | vmIsRunning, |
| @@ -32,11 +33,24 @@ | |||
| 32 | .includes(needle) | 33 | .includes(needle) |
| 33 | ) | 34 | ) |
| 34 | ); | 35 | ); |
| 36 | // hostNameById / vmCountByHost: computed once per hosts/vms snapshot instead | ||
| 37 | // of re-scanning on every row (host lookup was O(hosts) per VM row, VM count | ||
| 38 | // was O(vms) per host row — both now O(1) lookups into a map built once). | ||
| 39 | const hostNameById = $derived.by(() => new Map(fleet.hosts.map((h) => [h.id, h.name]))); | ||
| 40 | const vmCountByHost = $derived.by(() => { | ||
| 41 | const m = new Map<string, number>(); | ||
| 42 | for (const v of fleet.vms) m.set(v.host_id, (m.get(v.host_id) ?? 0) + 1); | ||
| 43 | return m; | ||
| 44 | }); | ||
| 45 | // hostName is the display name for a VM's host: falls back to the id's | ||
| 46 | // first 8 chars if the host isn't in the current snapshot (e.g. mid-delete). | ||
| 47 | function hostName(hostId: string): string { | ||
| 48 | return hostNameById.get(hostId) ?? hostId.slice(0, 8); | ||
| 49 | } | ||
| 35 | const shownVMs = $derived( | 50 | const shownVMs = $derived( |
| 36 | fleet.vms.filter((v) => { | 51 | fleet.vms.filter((v) => { |
| 37 | if (!needle) return true; | 52 | if (!needle) return true; |
| 38 | const hostName = fleet.hosts.find((h) => h.id === v.host_id)?.name ?? ''; | 53 | return `${v.name} ${hostName(v.host_id)} ${vmStatus(v)} ${vmPower(v)} ${vmIP(v)}` |
| 39 | return `${v.name} ${hostName} ${vmPhase(v)} ${vmPower(v)} ${vmIP(v)}` | ||
| 40 | .toLowerCase() | 54 | .toLowerCase() |
| 41 | .includes(needle); | 55 | .includes(needle); |
| 42 | }) | 56 | }) |
| @@ -52,56 +66,43 @@ | |||
| 52 | async function submitCreate(e: Event) { | 66 | async function submitCreate(e: Event) { |
| 53 | e.preventDefault(); | 67 | e.preventDefault(); |
| 54 | busy = 'create'; | 68 | busy = 'create'; |
| 55 | try { | 69 | if (await action(() => createVM(stripEmpty(form)))) { |
| 56 | await createVM(stripEmpty(form)); | ||
| 57 | showCreate = false; | 70 | showCreate = false; |
| 58 | } catch (err) { | ||
| 59 | fleet.error = String(err); | ||
| 60 | } finally { | ||
| 61 | busy = ''; | ||
| 62 | } | 71 | } |
| 72 | busy = ''; | ||
| 63 | } | 73 | } |
| 64 | 74 | ||
| 65 | function stripEmpty(f: CreateVMRequest): CreateVMRequest { | 75 | // stripEmpty drops keys whose value is empty/undefined/null before sending |
| 66 | const out: Record<string, unknown> = {}; | 76 | // the create-VM form — the caller guarantees the required keys (host_id) |
| 67 | for (const [k, v] of Object.entries(f)) { | 77 | // are non-empty, so this only ever strips optional-but-blank fields. |
| 68 | if (v !== '' && v !== undefined && v !== null) out[k] = v; | 78 | function stripEmpty<T extends object>(f: T): T { |
| 69 | } | 79 | return Object.fromEntries( |
| 70 | return out as CreateVMRequest; | 80 | Object.entries(f).filter(([, v]) => v !== '' && v !== undefined && v !== null) |
| 81 | ) as T; | ||
| 71 | } | 82 | } |
| 72 | 83 | ||
| 73 | async function power(id: string, p: 'running' | 'stopped') { | 84 | async function power(id: string, p: 'running' | 'stopped') { |
| 74 | try { | 85 | await action(() => setPower(id, p)); |
| 75 | await setPower(id, p); | ||
| 76 | } catch (err) { | ||
| 77 | fleet.error = String(err); | ||
| 78 | } | ||
| 79 | } | 86 | } |
| 80 | 87 | ||
| 81 | async function remove(id: string) { | 88 | async function remove(id: string) { |
| 82 | if (!confirm(`Delete VM ${id}?`)) return; | 89 | if (!confirm(`Delete VM ${id}?`)) return; |
| 83 | try { | 90 | await action(() => deleteVM(id)); |
| 84 | await deleteVM(id); | 91 | } |
| 85 | } catch (err) { | 92 | |
| 86 | fleet.error = String(err); | 93 | async function restore(id: string) { |
| 87 | } | 94 | await action(() => restoreVM(id)); |
| 88 | } | 95 | } |
| 89 | 96 | ||
| 90 | async function decommission(id: string, name: string) { | 97 | async function decommission(id: string, name: string) { |
| 91 | if (!confirm(`Decommission host ${name}? Its VMs will be drained and removed.`)) return; | 98 | if (!confirm(`Decommission host ${name}? Its VMs will be drained and removed.`)) return; |
| 92 | try { | 99 | await action(() => decommissionHost(id)); |
| 93 | await decommissionHost(id); | ||
| 94 | } catch (err) { | ||
| 95 | fleet.error = String(err); | ||
| 96 | } | ||
| 97 | } | 100 | } |
| 98 | 101 | ||
| 99 | async function addHost() { | 102 | async function addHost() { |
| 100 | try { | 103 | await action(async () => { |
| 101 | joinBlob = await createJoinBlob(); | 104 | joinBlob = await createJoinBlob(); |
| 102 | } catch (err) { | 105 | }); |
| 103 | fleet.error = String(err); | ||
| 104 | } | ||
| 105 | } | 106 | } |
| 106 | </script> | 107 | </script> |
| 107 | 108 | ||
| @@ -146,7 +147,7 @@ | |||
| 146 | <span class="dot {h.online ? 'on' : 'off'}"></span> | 147 | <span class="dot {h.online ? 'on' : 'off'}"></span> |
| 147 | {h.status}{h.online ? '' : ' · offline'} | 148 | {h.status}{h.online ? '' : ' · offline'} |
| 148 | </td> | 149 | </td> |
| 149 | <td>{vmsForHost(h.id).length}</td> | 150 | <td>{vmCountByHost.get(h.id) ?? 0}</td> |
| 150 | <td>{h.bridge_cidr}</td> | 151 | <td>{h.bridge_cidr}</td> |
| 151 | <td> | 152 | <td> |
| 152 | {h.allocated.vcpus}/{h.capacity.vcpus || '?'}c · | 153 | {h.allocated.vcpus}/{h.capacity.vcpus || '?'}c · |
| @@ -180,23 +181,30 @@ | |||
| 180 | {:else} | 181 | {:else} |
| 181 | <table> | 182 | <table> |
| 182 | <thead> | 183 | <thead> |
| 183 | <tr><th>Name</th><th>Host</th><th>Phase</th><th>Power</th><th>IP</th><th></th></tr> | 184 | <tr><th>Name</th><th>Host</th><th>Status</th><th>Power</th><th></th></tr> |
| 184 | </thead> | 185 | </thead> |
| 185 | <tbody> | 186 | <tbody> |
| 186 | {#each shownVMs as v (v.id)} | 187 | {#each shownVMs as v (v.id)} |
| 187 | <tr> | 188 | <tr> |
| 188 | <td><a href="/vms/{v.id}">{v.name}</a></td> | 189 | <td><a href="/vms/{v.id}">{v.name}</a></td> |
| 189 | <td>{fleet.hosts.find((h) => h.id === v.host_id)?.name ?? v.host_id.slice(0, 8)}</td> | 190 | <td>{hostName(v.host_id)}</td> |
| 190 | <td>{vmPhase(v)}{v.last_error ? ` · ${v.last_error}` : ''}</td> | 191 | <td> |
| 192 | {#if v.deleted}<span class="teardown">deleting — undo available</span>{:else}{vmStatus(v)}{/if}{v.last_error | ||
| 193 | ? ` · ${v.last_error}` | ||
| 194 | : ''} | ||
| 195 | </td> | ||
| 191 | <td>{vmPower(v)}</td> | 196 | <td>{vmPower(v)}</td> |
| 192 | <td>{vmIP(v)}</td> | ||
| 193 | <td class="actions"> | 197 | <td class="actions"> |
| 194 | {#if vmIsRunning(v)} | 198 | {#if v.deleted} |
| 195 | <button class="ghost" onclick={() => power(v.id, 'stopped')}>Stop</button> | 199 | <button class="restore" onclick={() => restore(v.id)}>Undo delete</button> |
| 196 | {:else} | 200 | {:else} |
| 197 | <button class="ghost" onclick={() => power(v.id, 'running')}>Start</button> | 201 | {#if vmIsRunning(v)} |
| 202 | <button class="ghost" onclick={() => power(v.id, 'stopped')}>Stop</button> | ||
| 203 | {:else} | ||
| 204 | <button class="ghost" onclick={() => power(v.id, 'running')}>Start</button> | ||
| 205 | {/if} | ||
| 206 | <button class="danger" onclick={() => remove(v.id)}>Delete</button> | ||
| 198 | {/if} | 207 | {/if} |
| 199 | <button class="danger" onclick={() => remove(v.id)}>Delete</button> | ||
| 200 | </td> | 208 | </td> |
| 201 | </tr> | 209 | </tr> |
| 202 | {/each} | 210 | {/each} |
| @@ -266,19 +274,11 @@ | |||
| 266 | font-size: 14px; | 274 | font-size: 14px; |
| 267 | margin: 1rem 0 0; | 275 | margin: 1rem 0 0; |
| 268 | } | 276 | } |
| 277 | /* base .dot rule and .on/.off colors are global (+layout.svelte); this | ||
| 278 | page's dot just wants a bit more breathing room before the text. */ | ||
| 269 | .dot { | 279 | .dot { |
| 270 | width: 8px; | ||
| 271 | height: 8px; | ||
| 272 | border-radius: 50%; | ||
| 273 | display: inline-block; | ||
| 274 | margin-right: 3px; | 280 | margin-right: 3px; |
| 275 | } | 281 | } |
| 276 | .dot.on { | ||
| 277 | background: #34d399; | ||
| 278 | } | ||
| 279 | .dot.off { | ||
| 280 | background: #f87171; | ||
| 281 | } | ||
| 282 | .actions { | 282 | .actions { |
| 283 | display: flex; | 283 | display: flex; |
| 284 | gap: 0.3rem; | 284 | gap: 0.3rem; |
| @@ -334,11 +334,22 @@ | |||
| 334 | grid-template-columns: 1fr 1fr 1fr; | 334 | grid-template-columns: 1fr 1fr 1fr; |
| 335 | gap: 0.5rem; | 335 | gap: 0.5rem; |
| 336 | } | 336 | } |
| 337 | .hint { | 337 | /* Let grid columns shrink to their 1fr share; number inputs have an |
| 338 | color: #6b7280; | 338 | intrinsic min-width that otherwise pushes the third column past the card. */ |
| 339 | .grid > label { | ||
| 340 | min-width: 0; | ||
| 341 | } | ||
| 342 | .card input, | ||
| 343 | .card select, | ||
| 344 | .card textarea { | ||
| 345 | width: 100%; | ||
| 339 | } | 346 | } |
| 340 | .filter { | 347 | .filter { |
| 341 | margin-top: 0.8rem; | 348 | margin-top: 0.8rem; |
| 342 | width: 260px; | 349 | width: 260px; |
| 343 | } | 350 | } |
| 351 | .teardown { | ||
| 352 | color: #f0b429; | ||
| 353 | font-weight: 600; | ||
| 354 | } | ||
| 344 | </style> | 355 | </style> |
web/src/routes/hosts/[id]/+page.svelte
| Old | New | ||
|---|---|---|---|
| @@ -1,20 +1,25 @@ | |||
| 1 | <script lang="ts"> | 1 | <script lang="ts"> |
| 2 | import { page } from '$app/state'; | 2 | import { page } from '$app/state'; |
| 3 | import { fleet, decommissionHost, vmsForHost, vmPhase, vmPower, vmIP } from '$lib/fleet.svelte'; | 3 | import { |
| 4 | fleet, | ||
| 5 | action, | ||
| 6 | decommissionHost, | ||
| 7 | vmsForHost, | ||
| 8 | vmPhase, | ||
| 9 | vmPower, | ||
| 10 | vmIP | ||
| 11 | } from '$lib/fleet.svelte'; | ||
| 4 | import ResourceBar from '$lib/ResourceBar.svelte'; | 12 | import ResourceBar from '$lib/ResourceBar.svelte'; |
| 5 | 13 | ||
| 6 | const id = $derived(page.params.id); | 14 | const id = $derived(page.params.id); |
| 7 | const host = $derived(fleet.hosts.find((h) => h.id === id)); | 15 | const host = $derived(fleet.hosts.find((h) => h.id === id)); |
| 8 | const vms = $derived(vmsForHost(id)); | 16 | const vms = $derived(id ? vmsForHost(id) : []); |
| 9 | 17 | ||
| 10 | async function decommission() { | 18 | async function decommission() { |
| 11 | if (!host) return; | 19 | if (!host) return; |
| 12 | if (!confirm(`Decommission host ${host.name}? Its VMs will be drained and removed.`)) return; | 20 | if (!confirm(`Decommission host ${host.name}? Its VMs will be drained and removed.`)) return; |
| 13 | try { | 21 | const hostId = host.id; |
| 14 | await decommissionHost(host.id); | 22 | await action(() => decommissionHost(hostId)); |
| 15 | } catch (err) { | ||
| 16 | fleet.error = String(err); | ||
| 17 | } | ||
| 18 | } | 23 | } |
| 19 | </script> | 24 | </script> |
| 20 | 25 | ||
| @@ -30,7 +35,6 @@ | |||
| 30 | <tr><th>Status</th><td><span class="dot {host.online ? 'on' : 'off'}"></span>{host.status}{host.online ? '' : ' · offline'}</td></tr> | 35 | <tr><th>Status</th><td><span class="dot {host.online ? 'on' : 'off'}"></span>{host.status}{host.online ? '' : ' · offline'}</td></tr> |
| 31 | <tr><th>OS / Arch</th><td>{host.os} / {host.arch}</td></tr> | 36 | <tr><th>OS / Arch</th><td>{host.os} / {host.arch}</td></tr> |
| 32 | <tr><th>Provisioner</th><td>{host.provisioner}</td></tr> | 37 | <tr><th>Provisioner</th><td>{host.provisioner}</td></tr> |
| 33 | <tr><th>Overlay</th><td>{host.overlay}</td></tr> | ||
| 34 | <tr><th>Bridge CIDR</th><td>{host.bridge_cidr}</td></tr> | 38 | <tr><th>Bridge CIDR</th><td>{host.bridge_cidr}</td></tr> |
| 35 | <tr><th>Capacity</th><td>{host.capacity.vcpus}c / {host.capacity.mem_mb}MB / {host.capacity.disk_gb}GB</td></tr> | 39 | <tr><th>Capacity</th><td>{host.capacity.vcpus}c / {host.capacity.mem_mb}MB / {host.capacity.disk_gb}GB</td></tr> |
| 36 | <tr><th>Enrolled</th><td>{host.enrolled_at}</td></tr> | 40 | <tr><th>Enrolled</th><td>{host.enrolled_at}</td></tr> |
| @@ -93,20 +97,9 @@ | |||
| 93 | color: #8b919c; | 97 | color: #8b919c; |
| 94 | width: 140px; | 98 | width: 140px; |
| 95 | } | 99 | } |
| 100 | /* base .dot rule and .on/.off colors are global (+layout.svelte); this | ||
| 101 | page's dot just wants a bit more breathing room before the text. */ | ||
| 96 | .dot { | 102 | .dot { |
| 97 | width: 8px; | ||
| 98 | height: 8px; | ||
| 99 | border-radius: 50%; | ||
| 100 | display: inline-block; | ||
| 101 | margin-right: 4px; | 103 | margin-right: 4px; |
| 102 | } | 104 | } |
| 103 | .dot.on { | ||
| 104 | background: #34d399; | ||
| 105 | } | ||
| 106 | .dot.off { | ||
| 107 | background: #f87171; | ||
| 108 | } | ||
| 109 | .hint { | ||
| 110 | color: #6b7280; | ||
| 111 | } | ||
| 112 | </style> | 105 | </style> |
web/src/routes/vms/[id]/+page.svelte
| Old | New | ||
|---|---|---|---|
| @@ -1,44 +1,124 @@ | |||
| 1 | <script lang="ts"> | 1 | <script lang="ts"> |
| 2 | import { page } from '$app/state'; | 2 | import { page } from '$app/state'; |
| 3 | import { fleet, setPower, deleteVM, vmPhase, vmPower, vmIP, vmIsRunning } from '$lib/fleet.svelte'; | 3 | import Console from '$lib/Console.svelte'; |
| 4 | import { | ||
| 5 | fleet, | ||
| 6 | action, | ||
| 7 | setPower, | ||
| 8 | deleteVM, | ||
| 9 | restoreVM, | ||
| 10 | vmStatus, | ||
| 11 | teardownApprox, | ||
| 12 | vmPower, | ||
| 13 | vmIP, | ||
| 14 | vmIsRunning, | ||
| 15 | vmEvents, | ||
| 16 | eventLabel, | ||
| 17 | clock, | ||
| 18 | type VMEvent | ||
| 19 | } from '$lib/fleet.svelte'; | ||
| 4 | 20 | ||
| 5 | const id = $derived(page.params.id); | 21 | const id = $derived(page.params.id); |
| 6 | const vm = $derived(fleet.vms.find((v) => v.id === id)); | 22 | const vm = $derived(fleet.vms.find((v) => v.id === id)); |
| 7 | const host = $derived(vm ? fleet.hosts.find((h) => h.id === vm.host_id) : undefined); | 23 | const host = $derived(vm ? fleet.hosts.find((h) => h.id === vm.host_id) : undefined); |
| 24 | // Depend on the primitive lifecycle, not the whole vm object: fleet.vms is | ||
| 25 | // reassigned ~1/s over SSE (new object refs), so reading vm.lifecycle through | ||
| 26 | // the derived would refetch on every push. The string only changes on a real | ||
| 27 | // lifecycle transition. | ||
| 28 | const lifecycle = $derived(vm?.lifecycle ?? ''); | ||
| 29 | // tearingDown gates the cancel/undo affordance: the VM is quarantined for | ||
| 30 | // teardown (deleted). | ||
| 31 | const tearingDown = $derived(!!vm && vmStatus(vm) === 'deleting'); | ||
| 32 | // approx is the coarse "time left to undo" shown in the teardown callout. | ||
| 33 | const approx = $derived(vm ? teardownApprox(vm, clock.now) : ''); | ||
| 8 | 34 | ||
| 9 | async function power(p: 'running' | 'stopped') { | 35 | let events = $state<VMEvent[]>([]); |
| 10 | if (!vm) return; | 36 | let eventsFailed = $state(false); |
| 37 | |||
| 38 | // forId captures which VM the fetch was for: navigating A→B reuses this | ||
| 39 | // component, so A's in-flight request must not clobber B's newer results. | ||
| 40 | async function loadEvents(forId: string | undefined) { | ||
| 41 | if (!forId) return; | ||
| 11 | try { | 42 | try { |
| 12 | await setPower(vm.id, p); | 43 | const evs = await vmEvents(forId); |
| 13 | } catch (err) { | 44 | if (forId !== id) return; // navigated away mid-flight |
| 14 | fleet.error = String(err); | 45 | events = evs; |
| 46 | eventsFailed = false; | ||
| 47 | } catch { | ||
| 48 | if (forId !== id) return; | ||
| 49 | eventsFailed = true; | ||
| 15 | } | 50 | } |
| 16 | } | 51 | } |
| 17 | 52 | ||
| 53 | // Reload the timeline on mount and whenever the VM's lifecycle advances, so | ||
| 54 | // new events (delete, restore, reap…) appear as it progresses. | ||
| 55 | $effect(() => { | ||
| 56 | // track both id and lifecycle so a re-fetch fires when either changes. | ||
| 57 | void lifecycle; | ||
| 58 | // Clear stale history immediately on id change so the previous VM's | ||
| 59 | // events don't flash before the new fetch resolves. | ||
| 60 | events = []; | ||
| 61 | eventsFailed = false; | ||
| 62 | loadEvents(id); | ||
| 63 | }); | ||
| 64 | |||
| 65 | async function power(p: 'running' | 'stopped') { | ||
| 66 | if (!vm) return; | ||
| 67 | const vmId = vm.id; | ||
| 68 | await action(() => setPower(vmId, p)); | ||
| 69 | } | ||
| 70 | |||
| 18 | async function remove() { | 71 | async function remove() { |
| 19 | if (!vm) return; | 72 | if (!vm) return; |
| 20 | if (!confirm(`Delete VM ${vm.name}?`)) return; | 73 | if (!confirm(`Delete VM ${vm.name}?`)) return; |
| 21 | try { | 74 | const vmId = vm.id; |
| 22 | await deleteVM(vm.id); | 75 | await action(() => deleteVM(vmId)); |
| 23 | } catch (err) { | 76 | } |
| 24 | fleet.error = String(err); | 77 | |
| 25 | } | 78 | async function cancelDeletion() { |
| 79 | if (!vm) return; | ||
| 80 | const vmId = vm.id; | ||
| 81 | // The SSE stream flips the VM back to live on its own; no local poke. | ||
| 82 | await action(() => restoreVM(vmId)); | ||
| 26 | } | 83 | } |
| 27 | </script> | 84 | </script> |
| 28 | 85 | ||
| 29 | <p><a href="/">← fleet</a></p> | 86 | <p><a href="/">← fleet</a></p> |
| 30 | 87 | ||
| 88 | {#snippet timelineSection()} | ||
| 89 | <div class="timeline"> | ||
| 90 | <h3>Lifecycle</h3> | ||
| 91 | {#if eventsFailed} | ||
| 92 | <p class="hint">couldn't load history</p> | ||
| 93 | {:else if events.length === 0} | ||
| 94 | <p class="hint">No events recorded yet.</p> | ||
| 95 | {:else} | ||
| 96 | <table class="kv"> | ||
| 97 | <tbody> | ||
| 98 | {#each events as ev, i (ev.at + ev.action + i)} | ||
| 99 | <tr> | ||
| 100 | <th>{ev.at}</th> | ||
| 101 | <td>{eventLabel(ev)}</td> | ||
| 102 | </tr> | ||
| 103 | {/each} | ||
| 104 | </tbody> | ||
| 105 | </table> | ||
| 106 | {/if} | ||
| 107 | </div> | ||
| 108 | {/snippet} | ||
| 109 | |||
| 31 | {#if !vm} | 110 | {#if !vm} |
| 32 | <p class="hint">VM not found (it may have been deleted).</p> | 111 | <p class="hint">VM not found — it may have been destroyed. Its recorded lifecycle history:</p> |
| 112 | {@render timelineSection()} | ||
| 33 | {:else} | 113 | {:else} |
| 34 | <h2>{vm.name}</h2> | 114 | <h2>{vm.name}</h2> |
| 35 | <table class="kv"> | 115 | <table class="kv"> |
| 36 | <tbody> | 116 | <tbody> |
| 37 | <tr><th>ID</th><td>{vm.id}</td></tr> | 117 | <tr><th>ID</th><td>{vm.id}</td></tr> |
| 38 | <tr><th>Host</th><td>{#if host}<a href="/hosts/{host.id}">{host.name}</a>{:else}{vm.host_id}{/if}</td></tr> | 118 | <tr><th>Host</th><td>{#if host}<a href="/hosts/{host.id}">{host.name}</a>{:else}{vm.host_id}{/if}</td></tr> |
| 39 | <tr><th>Phase</th><td>{vmPhase(vm)}</td></tr> | 119 | <tr><th>Status</th><td>{vmStatus(vm)}</td></tr> |
| 40 | <tr><th>Power</th><td>{vmPower(vm)} (desired: {vm.power_state})</td></tr> | 120 | <tr><th>Power</th><td>{vmPower(vm)} (desired: {vm.power_state})</td></tr> |
| 41 | <tr><th>IP</th><td>{vmIP(vm)}</td></tr> | 121 | <tr><th>IP</th><td>{vmIP(vm)} <span class="hint">(host bridge, NAT — not reachable off-host)</span></td></tr> |
| 42 | <tr><th>Resources</th><td>{vm.vcpus}c / {vm.mem_mb}MB / {vm.disk_gb}GB</td></tr> | 122 | <tr><th>Resources</th><td>{vm.vcpus}c / {vm.mem_mb}MB / {vm.disk_gb}GB</td></tr> |
| 43 | <tr><th>Persistent</th><td>{vm.persistent}</td></tr> | 123 | <tr><th>Persistent</th><td>{vm.persistent}</td></tr> |
| 44 | <tr><th>Image</th><td class="wrap">{vm.image_url}</td></tr> | 124 | <tr><th>Image</th><td class="wrap">{vm.image_url}</td></tr> |
| @@ -47,18 +127,32 @@ | |||
| 47 | </tbody> | 127 | </tbody> |
| 48 | </table> | 128 | </table> |
| 49 | 129 | ||
| 50 | {#if vm.assigned_ip} | 130 | {#if tearingDown} |
| 51 | <p class="ssh">SSH: <code>ssh ubuntu@{vm.assigned_ip}</code> <span class="hint">(over the host's overlay)</span></p> | 131 | <div class="teardown-callout"> |
| 132 | <h3>This VM is being deleted</h3> | ||
| 133 | <p> | ||
| 134 | The guest is already stopped. You can still undo this{#if approx} — otherwise it's | ||
| 135 | destroyed automatically in {approx}{:else} — it's destroyed automatically once the | ||
| 136 | guest finishes shutting down{/if}. | ||
| 137 | </p> | ||
| 138 | <button class="restore" onclick={cancelDeletion}>Undo delete</button> | ||
| 139 | </div> | ||
| 140 | {:else} | ||
| 141 | <div class="actions"> | ||
| 142 | {#if vmIsRunning(vm)} | ||
| 143 | <button class="ghost" onclick={() => power('stopped')}>Stop</button> | ||
| 144 | {:else} | ||
| 145 | <button class="ghost" onclick={() => power('running')}>Start</button> | ||
| 146 | {/if} | ||
| 147 | <button class="danger" onclick={remove}>Delete</button> | ||
| 148 | </div> | ||
| 52 | {/if} | 149 | {/if} |
| 53 | 150 | ||
| 54 | <div class="actions"> | 151 | {#if !tearingDown} |
| 55 | {#if vmIsRunning(vm)} | 152 | <Console vmId={vm.id} /> |
| 56 | <button class="ghost" onclick={() => power('stopped')}>Stop</button> | 153 | {/if} |
| 57 | {:else} | 154 | |
| 58 | <button class="ghost" onclick={() => power('running')}>Start</button> | 155 | {@render timelineSection()} |
| 59 | {/if} | ||
| 60 | <button class="danger" onclick={remove}>Delete</button> | ||
| 61 | </div> | ||
| 62 | {/if} | 156 | {/if} |
| 63 | 157 | ||
| 64 | <style> | 158 | <style> |
| @@ -77,15 +171,46 @@ | |||
| 77 | .err { | 171 | .err { |
| 78 | color: #ffb4b4; | 172 | color: #ffb4b4; |
| 79 | } | 173 | } |
| 80 | .ssh code { | ||
| 81 | color: #8fe3a0; | ||
| 82 | } | ||
| 83 | .actions { | 174 | .actions { |
| 84 | display: flex; | 175 | display: flex; |
| 85 | gap: 0.4rem; | 176 | gap: 0.4rem; |
| 86 | margin-top: 0.8rem; | 177 | margin-top: 0.8rem; |
| 87 | } | 178 | } |
| 88 | .hint { | 179 | .teardown-callout { |
| 89 | color: #6b7280; | 180 | background: #2a2213; |
| 181 | border: 1px solid #7a5c12; | ||
| 182 | border-radius: 6px; | ||
| 183 | padding: 0.7rem 0.9rem; | ||
| 184 | margin-top: 0.9rem; | ||
| 185 | } | ||
| 186 | .teardown-callout h3 { | ||
| 187 | margin: 0 0 0.3rem; | ||
| 188 | font-size: 13px; | ||
| 189 | color: #f0b429; | ||
| 190 | } | ||
| 191 | .teardown-callout p { | ||
| 192 | margin: 0 0 0.6rem; | ||
| 193 | color: #d6c48a; | ||
| 194 | } | ||
| 195 | :global(button.restore) { | ||
| 196 | background: #2f9e44; | ||
| 197 | border-color: #2f9e44; | ||
| 198 | font-weight: 600; | ||
| 199 | } | ||
| 200 | :global(button.restore:hover) { | ||
| 201 | background: #37b24d; | ||
| 202 | } | ||
| 203 | .timeline { | ||
| 204 | margin-top: 1.2rem; | ||
| 205 | } | ||
| 206 | .timeline h3 { | ||
| 207 | margin: 0 0 0.2rem; | ||
| 208 | font-size: 13px; | ||
| 209 | } | ||
| 210 | .timeline .kv th { | ||
| 211 | color: #8b919c; | ||
| 212 | width: 220px; | ||
| 213 | font-weight: 400; | ||
| 214 | white-space: nowrap; | ||
| 90 | } | 215 | } |
| 91 | </style> | 216 | </style> |