a73x

bd0d241b

feat: serial console, teardown observability, quotas, and the SSH-CA jump gate

a73x   2026-07-25 10:37

Commit message
feat: serial console, teardown observability, quotas, and the SSH-CA jump gate

.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 => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" }[c])); 337 const esc = s => String(s).replace(/[&<>]/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" }[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 => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" }[c])); 62 const esc = s => String(s).replace(/[&<>]/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" }[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>