a73x

e523eae3

feat: single-paste join enrollment and reliability hardening

a73x   2026-07-25 09:48

Commit message
feat: single-paste join enrollment and reliability hardening

Makefile
Old New
@@ -131,16 +131,18 @@ shape-check:
131 # entrypoint — every main() in cmd/. Rooting at the binaries (NOT -test) is what 131 # 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 132 # 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 133 # 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. Three sanctioned exceptions, 134 # compile the tag-gated code so it is analysed too. Four sanctioned exceptions,
135 # all production code that only a CROSS-package test can reach (so none can be 135 # 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 136 # 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 137 # (terminal teardown that must not run in production — it would report every VM as
138 # vanished — used only by an integration test's cleanup), and store.Store.Close / 138 # vanished — used only by an integration test's cleanup), store.Store.Close /
139 # store.Store.Epoch (reachable only via the store's tests until the serial console 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). 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;
142 # the standalone accessor is exercised only by the store's own allocation tests).
141 deadcode: 143 deadcode:
142 @out=$$(go run golang.org/x/tools/cmd/deadcode@$(DEADCODE_VERSION) -tags=smoke,sandbox ./... \ 144 @out=$$(go run golang.org/x/tools/cmd/deadcode@$(DEADCODE_VERSION) -tags=smoke,sandbox ./... \
143 | { grep -vE '^internal/integration/|unreachable func: Engine\.Stop$$|unreachable func: Store\.Close$$|unreachable func: Store\.Epoch$$' || true; }); \ 145 | { grep -vE '^internal/integration/|unreachable func: Engine\.Stop$$|unreachable func: Store\.Close$$|unreachable func: Store\.Epoch$$|unreachable func: Store\.AllocatedByHost$$' || true; }); \
144 if [ -n "$$out" ]; then \ 146 if [ -n "$$out" ]; then \
145 echo "deadcode: unreachable from any cmd/ entrypoint (remove it, wire it in, or move it to a _test.go):"; \ 147 echo "deadcode: unreachable from any cmd/ entrypoint (remove it, wire it in, or move it to a _test.go):"; \
146 echo "$$out"; exit 1; \ 148 echo "$$out"; exit 1; \
cmd/eitri-agent/main.go
Old New
@@ -1,5 +1,5 @@
1 // eitri-agent: BYO-hardware agent. Enrolls the host and then runs the 1 // eitri-agent: BYO-hardware agent. Enrolls the host via "join <blob>" and
2 // reconcile + sync loop indefinitely. 2 // then runs the reconcile + sync loop indefinitely.
3 package main 3 package main
4 4
5 import ( 5 import (
@@ -28,21 +28,21 @@ import (
28 "github.com/a73x/eitri/internal/agent/seed" 28 "github.com/a73x/eitri/internal/agent/seed"
29 "github.com/a73x/eitri/internal/agent/state" 29 "github.com/a73x/eitri/internal/agent/state"
30 "github.com/a73x/eitri/internal/agent/syncclient" 30 "github.com/a73x/eitri/internal/agent/syncclient"
31 "github.com/a73x/eitri/internal/joinblob"
31 ) 32 )
32 33
33 func main() { 34 func main() {
34 stateDir := flag.String("state-dir", "/var/lib/eitri-agent", "agent state directory") 35 stateDir := flag.String("state-dir", "/var/lib/eitri-agent", "agent state directory")
35 chBin := flag.String("ch-bin", "cloud-hypervisor", "path to cloud-hypervisor binary") 36 chBin := flag.String("ch-bin", "cloud-hypervisor", "path to cloud-hypervisor binary")
36 firmware := flag.String("firmware", "/usr/share/eitri/hypervisor-fw", "path to hypervisor-fw") 37 firmware := flag.String("firmware", "/usr/share/eitri/hypervisor-fw", "path to hypervisor-fw")
37 server := flag.String("server", "", "HTTP server URL (e.g. http://localhost:8080)") 38 overlayKind := flag.String("overlay", "tailscale", "overlay kind: tailscale or none")
38 quicAddr := flag.String("quic-addr", "", "server QUIC address (e.g. localhost:8443)")
39 token := flag.String("token", "", "enrollment token")
40 overlayKind := flag.String("overlay", "tailscale", "overlay kind: tailscale or none")
41 overlayAuthkey := flag.String("overlay-authkey", "", "overlay auth key (optional, for initial enroll on dedicated hosts — requires --manage-overlay)") 39 overlayAuthkey := flag.String("overlay-authkey", "", "overlay auth key (optional, for initial enroll on dedicated hosts — requires --manage-overlay)")
42 manageOverlay := flag.Bool("manage-overlay", false, "allow agent to additively modify overlay route advertisement (opt-in; for dedicated hosts)") 40 manageOverlay := flag.Bool("manage-overlay", false, "allow agent to additively modify overlay route advertisement (opt-in; for dedicated hosts)")
43 noMasqIfaces := flag.String("no-masquerade-ifaces", "", "comma-separated interfaces to exclude from NAT masquerade (e.g. wg0)") 41 noMasqIfaces := flag.String("no-masquerade-ifaces", "", "comma-separated interfaces to exclude from NAT masquerade (e.g. wg0)")
44 tombstoneGrace := flag.Duration("tombstone-grace", 5*time.Minute, "quarantine period for tombstoned VMs before destroy") 42 tombstoneGrace := flag.Duration("tombstone-grace", 5*time.Minute, "quarantine period for tombstoned VMs before destroy")
45 vanishGrace := flag.Duration("vanish-grace", time.Hour, "quarantine period for VMs that vanished without a tombstone") 43 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")
45 imageCacheMaxGB := flag.Int64("image-cache-max-gb", 20, "evict least-recently-used cached base images beyond this size (0 = never evict)")
46 flag.Parse() 46 flag.Parse()
47 47
48 st, err := state.Open(*stateDir) 48 st, err := state.Open(*stateDir)
@@ -51,18 +51,26 @@ func main() {
51 os.Exit(1) 51 os.Exit(1)
52 } 52 }
53 53
54 if flag.Arg(0) == "enroll" { 54 if flag.Arg(0) == "join" {
55 runEnroll(st, *server, *quicAddr, *token, *stateDir, *overlayKind) 55 runJoin(st, flag.Arg(1), *overlayKind)
56 return 56 return
57 } 57 }
58 58
59 runAgent(st, *stateDir, *chBin, *firmware, *overlayKind, *overlayAuthkey, *noMasqIfaces, *manageOverlay, *tombstoneGrace, *vanishGrace) 59 runAgent(st, *stateDir, *chBin, *firmware, *overlayKind, *overlayAuthkey, *noMasqIfaces, *manageOverlay, *tombstoneGrace, *vanishGrace, *stepTimeout, *imageCacheMaxGB)
60 } 60 }
61 61
62 // runEnroll handles the "enroll" subcommand. 62 // runJoin handles the "join <blob>" subcommand: decode the join blob, enroll,
63 func runEnroll(st *state.Store, server, quicAddr, token, stateDir, overlayKind string) { 63 // and persist identity — pinning the server cert from the blob (the enroll
64 if server == "" || quicAddr == "" || token == "" { 64 // response's fingerprint is ignored, so the blob is the sole trust root).
65 fmt.Fprintln(os.Stderr, "enroll requires --server, --quic-addr, and --token") 65 func runJoin(st *state.Store, blob, overlayKind string) {
66 if blob == "" {
67 fmt.Fprintln(os.Stderr, "usage: eitri-agent join <join-blob>")
68 os.Exit(1)
69 }
70 f, err := joinblob.Decode(blob)
71 if err != nil {
72 // Never echo the blob itself — it carries a bearer token.
73 fmt.Fprintf(os.Stderr, "invalid join blob: %v\n", err)
66 os.Exit(1) 74 os.Exit(1)
67 } 75 }
68 76
@@ -70,9 +78,8 @@ func runEnroll(st *state.Store, server, quicAddr, token, stateDir, overlayKind s
70 if err != nil { 78 if err != nil {
71 hostname = "unknown" 79 hostname = "unknown"
72 } 80 }
73
74 body, err := json.Marshal(map[string]string{ 81 body, err := json.Marshal(map[string]string{
75 "token": token, 82 "token": f.Token,
76 "name": hostname, 83 "name": hostname,
77 "os": runtime.GOOS, 84 "os": runtime.GOOS,
78 "arch": runtime.GOARCH, 85 "arch": runtime.GOARCH,
@@ -84,24 +91,31 @@ func runEnroll(st *state.Store, server, quicAddr, token, stateDir, overlayKind s
84 os.Exit(1) 91 os.Exit(1)
85 } 92 }
86 93
87 resp, err := http.Post(server+"/api/v1/enroll", "application/json", bytes.NewReader(body)) 94 client := &http.Client{Timeout: 30 * time.Second}
95 resp, err := client.Post(f.HTTPURL+"/api/v1/enroll", "application/json", bytes.NewReader(body))
88 if err != nil { 96 if err != nil {
89 slog.Error("enroll request", "err", err) 97 slog.Error("enroll request", "err", err)
90 os.Exit(1) 98 os.Exit(1)
91 } 99 }
92 defer resp.Body.Close() 100 defer resp.Body.Close()
93 101 respBody, readErr := io.ReadAll(resp.Body)
94 respBody, _ := io.ReadAll(resp.Body) 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")
108 os.Exit(1)
109 }
95 if resp.StatusCode != http.StatusCreated { 110 if resp.StatusCode != http.StatusCreated {
96 fmt.Fprintf(os.Stderr, "enroll failed (HTTP %d): %s\n", resp.StatusCode, respBody) 111 fmt.Fprintf(os.Stderr, "enroll failed (HTTP %d): %s\n", resp.StatusCode, respBody)
97 os.Exit(1) 112 os.Exit(1)
98 } 113 }
99 114
100 var result struct { 115 var result struct {
101 HostID string `json:"host_id"` 116 HostID string `json:"host_id"`
102 Credential string `json:"credential"` 117 Credential string `json:"credential"`
103 BridgeCIDR string `json:"bridge_cidr"` 118 BridgeCIDR string `json:"bridge_cidr"`
104 ServerCertSHA256 string `json:"server_cert_sha256"`
105 } 119 }
106 if err := json.Unmarshal(respBody, &result); err != nil { 120 if err := json.Unmarshal(respBody, &result); err != nil {
107 slog.Error("parse enroll response", "err", err) 121 slog.Error("parse enroll response", "err", err)
@@ -112,14 +126,13 @@ func runEnroll(st *state.Store, server, quicAddr, token, stateDir, overlayKind s
112 HostID: result.HostID, 126 HostID: result.HostID,
113 Credential: result.Credential, 127 Credential: result.Credential,
114 BridgeCIDR: result.BridgeCIDR, 128 BridgeCIDR: result.BridgeCIDR,
115 ServerQUICAddr: quicAddr, 129 ServerQUICAddr: f.QUICAddr,
116 ServerCertSHA256: result.ServerCertSHA256, 130 ServerCertSHA256: f.CertFP, // authoritative; response fingerprint ignored
117 } 131 }
118 if err := st.SaveIdentity(id); err != nil { 132 if err := st.SaveIdentity(id); err != nil {
119 slog.Error("save identity", "err", err) 133 slog.Error("save identity", "err", err)
120 os.Exit(1) 134 os.Exit(1)
121 } 135 }
122
123 fmt.Printf("Enrolled: host_id=%s bridge_cidr=%s\n", result.HostID, result.BridgeCIDR) 136 fmt.Printf("Enrolled: host_id=%s bridge_cidr=%s\n", result.HostID, result.BridgeCIDR)
124 } 137 }
125 138
@@ -146,10 +159,10 @@ func splitComma(s string) []string {
146 } 159 }
147 160
148 // runAgent handles the normal (no subcommand) run mode. 161 // runAgent handles the normal (no subcommand) run mode.
149 func runAgent(st *state.Store, stateDir, chBin, firmware, overlayKind, overlayAuthkey, noMasqIfacesStr string, manageOverlay bool, tombstoneGrace, vanishGrace time.Duration) { 162 func runAgent(st *state.Store, stateDir, chBin, firmware, overlayKind, overlayAuthkey, noMasqIfacesStr string, manageOverlay bool, tombstoneGrace, vanishGrace, stepTimeout time.Duration, imageCacheMaxGB int64) {
150 id, ok := st.Identity() 163 id, ok := st.Identity()
151 if !ok { 164 if !ok {
152 fmt.Fprintln(os.Stderr, "not enrolled — run with 'enroll' subcommand first") 165 fmt.Fprintln(os.Stderr, "not enrolled — run with 'join <blob>' subcommand first")
153 os.Exit(1) 166 os.Exit(1)
154 } 167 }
155 168
@@ -211,6 +224,14 @@ func runAgent(st *state.Store, stateDir, chBin, firmware, overlayKind, overlayAu
211 224
212 prov := cloudhv.New(st, chBin, firmware, realRunner) 225 prov := cloudhv.New(st, chBin, firmware, realRunner)
213 cache := imagecache.New(st.ImagesDir(), realRunner) 226 cache := imagecache.New(st.ImagesDir(), realRunner)
227 // Clamp before shifting: GB<<30 overflows int64 for absurd flag values —
228 // same trap class cloudhv's maxDiskGB comment documents. 1 PiB is beyond
229 // any real cache; anything above disables eviction just like 0 would.
230 if imageCacheMaxGB < 0 || imageCacheMaxGB > 1<<20 {
231 slog.Warn("image-cache-max-gb out of range [0, 2^20]; disabling eviction", "value", imageCacheMaxGB)
232 imageCacheMaxGB = 0
233 }
234 cache.MaxBytes = imageCacheMaxGB << 30
214 235
215 engine := &reconcile.Engine{ 236 engine := &reconcile.Engine{
216 St: st, 237 St: st,
@@ -223,6 +244,7 @@ func runAgent(st *state.Store, stateDir, chBin, firmware, overlayKind, overlayAu
223 TombstoneGrace: tombstoneGrace, 244 TombstoneGrace: tombstoneGrace,
224 VanishGrace: vanishGrace, 245 VanishGrace: vanishGrace,
225 MaxCreateAttempts: 3, 246 MaxCreateAttempts: 3,
247 StepTimeout: stepTimeout,
226 } 248 }
227 249
228 // Compile-time interface satisfaction checks. 250 // Compile-time interface satisfaction checks.
cmd/eitri-server/main.go
Old New
@@ -11,6 +11,7 @@ import (
11 "os" 11 "os"
12 "time" 12 "time"
13 13
14 "github.com/a73x/eitri/internal/joinblob"
14 "github.com/a73x/eitri/internal/server/api" 15 "github.com/a73x/eitri/internal/server/api"
15 "github.com/a73x/eitri/internal/server/hub" 16 "github.com/a73x/eitri/internal/server/hub"
16 "github.com/a73x/eitri/internal/server/registry" 17 "github.com/a73x/eitri/internal/server/registry"
@@ -30,6 +31,16 @@ type config struct {
30 CIDRPool string `json:"cidr_pool"` 31 CIDRPool string `json:"cidr_pool"`
31 DefaultImageURL string `json:"default_image_url"` 32 DefaultImageURL string `json:"default_image_url"`
32 DefaultImageSHA string `json:"default_image_sha256"` 33 DefaultImageSHA string `json:"default_image_sha256"`
34 AdvertiseHTTP string `json:"advertise_http"`
35 AdvertiseQUIC string `json:"advertise_quic"`
36 // CredentialMaxAge optionally bounds host credential age (Go duration,
37 // e.g. "2160h" for 90 days). Empty/zero disables — revocation via
38 // POST /api/v1/hosts/{id}/revoke-credential is the primary mechanism;
39 // max-age forces periodic re-enrollment and is opt-in defense-in-depth.
40 CredentialMaxAge string `json:"credential_max_age"`
41 // AuditRetention bounds the audit_log age (Go duration; default "2160h" =
42 // 90 days; "0" disables pruning). Pruned at startup and daily.
43 AuditRetention string `json:"audit_retention"`
33 } 44 }
34 45
35 func main() { 46 func main() {
@@ -49,6 +60,10 @@ func main() {
49 slog.Error("admin_token and host_secret are required") 60 slog.Error("admin_token and host_secret are required")
50 os.Exit(1) 61 os.Exit(1)
51 } 62 }
63 if cfg.AdvertiseHTTP == "" || cfg.AdvertiseQUIC == "" {
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)")
65 os.Exit(1)
66 }
52 67
53 st, err := store.Open(cfg.DBPath, cfg.CIDRPool) 68 st, err := store.Open(cfg.DBPath, cfg.CIDRPool)
54 if err != nil { 69 if err != nil {
@@ -67,12 +82,79 @@ func main() {
67 os.Exit(1) 82 os.Exit(1)
68 } 83 }
69 84
85 // Log the identity agents pin — the operator verifies this out-of-band
86 // during the rotation ceremony (docs/cert-rotation.md step 3).
87 slog.Info("server cert", "fingerprint", certFP)
88
89 // Rotation nudge: the agent pin ignores expiry so nothing breaks at
90 // NotAfter, but a long-lived key is a widening forgery window. Warn while
91 // inside the renewal window — at startup AND daily, because servers here
92 // are long-lived daemons that can cross into the window (or past expiry)
93 // without ever restarting. See docs/cert-rotation.md for the ceremony.
94 warnIfRenewalDue := func() {
95 notAfter, due := transport.CertRenewalDue(certPEM, time.Now())
96 if !due {
97 return
98 }
99 if notAfter.IsZero() {
100 slog.Warn("server cert unparseable — inspect server.crt (docs/cert-rotation.md)")
101 return
102 }
103 slog.Warn("server cert renewal due — rotate and re-enroll agents (docs/cert-rotation.md)",
104 "not_after", notAfter.Format(time.RFC3339))
105 }
106 warnIfRenewalDue()
107
108 // Audit retention: bound the append-only log (default 90 days, 0 disables).
109 auditRetention := 90 * 24 * time.Hour
110 if cfg.AuditRetention != "" {
111 auditRetention, err = time.ParseDuration(cfg.AuditRetention)
112 if err != nil {
113 slog.Error("audit_retention invalid", "err", err)
114 os.Exit(1)
115 }
116 if auditRetention < 0 {
117 // Almost certainly a typo — refuse rather than silently keeping
118 // the audit log forever ("0" is the explicit disable spelling).
119 slog.Error("audit_retention must be >= 0", "value", cfg.AuditRetention)
120 os.Exit(1)
121 }
122 }
123 pruneAudit := func() {
124 if auditRetention <= 0 {
125 return
126 }
127 if n, err := st.PruneAudit(auditRetention); err != nil {
128 slog.Warn("audit prune failed", "err", err)
129 } else if n > 0 {
130 slog.Info("audit pruned", "rows", n, "retention", auditRetention)
131 }
132 }
133 pruneAudit()
134
135 // Daily housekeeping: cert-renewal nudge + audit retention.
136 go func() {
137 for range time.Tick(24 * time.Hour) {
138 warnIfRenewalDue()
139 pruneAudit()
140 }
141 }()
142
143 // Fail fast if the advertised addresses are non-empty but malformed (e.g. a
144 // URL with no scheme): otherwise every enroll-token mint would 500 at runtime.
145 if _, err := joinblob.Encode(cfg.AdvertiseHTTP, cfg.AdvertiseQUIC, "startup-probe", certFP); err != nil {
146 slog.Error("advertise_http/advertise_quic invalid", "err", err)
147 os.Exit(1)
148 }
149
70 reg := registry.New(time.Now) 150 reg := registry.New(time.Now)
71 h := hub.New() 151 h := hub.New()
72 152
73 a := api.New(api.Config{AdminToken: cfg.AdminToken, HostSecret: []byte(cfg.HostSecret), 153 a := api.New(api.Config{AdminToken: cfg.AdminToken, HostSecret: []byte(cfg.HostSecret),
74 DefaultImage: api.DefaultImage{URL: cfg.DefaultImageURL, SHA256: cfg.DefaultImageSHA}, 154 DefaultImage: api.DefaultImage{URL: cfg.DefaultImageURL, SHA256: cfg.DefaultImageSHA},
75 ServerCertSHA256: certFP}, 155 ServerCertSHA256: certFP,
156 AdvertiseHTTP: cfg.AdvertiseHTTP,
157 AdvertiseQUIC: cfg.AdvertiseQUIC},
76 st, reg, h) 158 st, reg, h)
77 159
78 tlsConf, err := transport.ServerTLS(certPEM, keyPEM) 160 tlsConf, err := transport.ServerTLS(certPEM, keyPEM)
@@ -86,7 +168,15 @@ func main() {
86 slog.Error("quic listen", "err", err) 168 slog.Error("quic listen", "err", err)
87 os.Exit(1) 169 os.Exit(1)
88 } 170 }
89 svc := syncsvc.New(st, reg, h, []byte(cfg.HostSecret)) 171 var maxCredAge time.Duration
172 if cfg.CredentialMaxAge != "" {
173 maxCredAge, err = time.ParseDuration(cfg.CredentialMaxAge)
174 if err != nil {
175 slog.Error("credential_max_age invalid", "err", err)
176 os.Exit(1)
177 }
178 }
179 svc := syncsvc.New(st, reg, h, []byte(cfg.HostSecret), maxCredAge)
90 go func() { 180 go func() {
91 slog.Info("quic listening", "addr", cfg.QUICListen) 181 slog.Info("quic listening", "addr", cfg.QUICListen)
92 if err := svc.Serve(context.Background(), lis); err != nil { 182 if err := svc.Serve(context.Background(), lis); err != nil {
docs/architecture.md
Old New
@@ -26,11 +26,11 @@ side effects on real infrastructure live in the agent.
26 | # | Invariant | Enforced by | 26 | # | Invariant | Enforced by |
27 |---|-----------|-------------| 27 |---|-----------|-------------|
28 | **R1** | The control plane and the data plane never import each other (even transitively). | `internal/arch` `TestControlAndDataPlaneAreDisjoint` (production, transitive) + `depguard` `server-no-agent` / `agent-no-server` (non-test files). | 28 | **R1** | The control plane and the data plane never import each other (even transitively). | `internal/arch` `TestControlAndDataPlaneAreDisjoint` (production, transitive) + `depguard` `server-no-agent` / `agent-no-server` (non-test files). |
29 | **R2** | No `internal/server` package shells out — the server is pure control plane. | `internal/arch` `TestServerNeverShellsOut` + `depguard` `server-no-exec`. | 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). |
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. | 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. |
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`. | 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`. |
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. | 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. |
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. | `internal/arch` `TestOnlyCloudhvImportsOsExecInDataPlane`. | 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). |
34 34
35 > The `internal/arch` tests shell out to `go list`, so Go's test cache can't see 35 > The `internal/arch` tests shell out to `go list`, so Go's test cache can't see
36 > edges changing elsewhere in the module. Always run them with `-count=1` 36 > edges changing elsewhere in the module. Always run them with `-count=1`
docs/cert-rotation.md
Old New
@@ -0,0 +1,51 @@
1 # Server certificate rotation
2
3 The server's QUIC identity is a self-signed ECDSA cert generated on first
4 start (`transport.GenerateServerCert`, 2-year validity) and persisted next to
5 the database (`server.crt` / `server.key`). Agents trust it by **fingerprint
6 pin** carried in the join blob — not by CA path and **not by expiry**: an
7 expired cert keeps working for already-enrolled agents. Rotation is therefore
8 never an emergency at `NotAfter`; it is hygiene that bounds how long a stolen
9 `server.key` stays useful.
10
11 The server logs its cert fingerprint at every startup, and warns — at startup
12 and daily thereafter — when the cert is within 90 days of expiry
13 (`transport.CertRenewalDue`).
14
15 ## Why rotate
16
17 - `server.key` compromise: anyone holding it can impersonate the server to
18 every agent pinning that cert's fingerprint, until every agent re-pins.
19 - The validity period (2 years) is the *scheduled* cadence; rotate immediately
20 on suspected key exposure.
21
22 ## Ceremony
23
24 Rotating the cert changes its fingerprint, which invalidates every agent's
25 pin. Each agent must re-enroll to pick up the new fingerprint.
26
27 1. Stop the server.
28 2. Move the old cert+key aside: `mv server.crt server.crt.old && mv
29 server.key server.key.old` (in the DB directory).
30 3. Start the server — it generates and persists a fresh 2-year cert and logs
31 the new fingerprint.
32 4. For each host: mint a join token (`POST /api/v1/enroll-tokens`), copy the
33 `join` blob, run `eitri-agent join <blob>` on the host, **then restart the
34 agent daemon** (e.g. `systemctl restart eitri-agent`). `join` only rewrites
35 the on-disk identity; the running daemon holds its identity in memory and
36 keeps pinning the old fingerprint until restarted. Running VMs are
37 untouched — they survive agent restarts by design, and the agent's
38 reconcile state is independent of its server identity.
39 5. Delete `server.crt.old` / `server.key.old` once every host has reconnected
40 (watch `GET /api/v1/hosts` for `online: true`).
41
42 Until a host re-enrolls, its agent logs `server cert pin mismatch` and backs
43 off — VMs keep running, but the host is dark to the control plane. Rotate
44 during a window where that is acceptable, host by host.
45
46 ## Out of scope (future)
47
48 - Overlap rotation (serving old+new certs simultaneously) — needs dual-cert
49 listener support.
50 - Pushing new pins over the existing authenticated channel (would remove the
51 re-enroll requirement).
docs/credential-revocation.md
Old New
@@ -0,0 +1,42 @@
1 # Host credential compromise & revocation
2
3 Each agent authenticates its QUIC session with a bearer credential:
4
5 ```
6 <host_id>.<generation>.<issued_unix>.<hmac-sha256>
7 ```
8
9 The HMAC (keyed by the server's `host_secret`) covers all three fields. The
10 `generation` is compared against the host row's `cred_generation` on every
11 Hello **and every report (~10s)**, so revocation takes effect on live
12 sessions within one tick.
13
14 ## Single credential leaked (the common case)
15
16 ```
17 curl -X POST -H "Authorization: Bearer $ADMIN_TOKEN" \
18 http://server:8080/api/v1/hosts/<host_id>/revoke-credential
19 ```
20
21 - Bumps that host's generation: the leaked credential is dead fleet-wide
22 within ~10s; every other host is untouched.
23 - The host's VMs keep running (the agent reconciles autonomously); the host
24 is dark to the control plane until re-enrolled.
25 - Re-enroll: mint a join token, `eitri-agent join <blob>` on the host,
26 restart the agent daemon.
27 - The revocation is recorded in the audit log (`host.credential.revoke`).
28
29 ## Server `host_secret` leaked (the disaster case)
30
31 Rotate `host_secret` in `server.json` and restart — this revokes **every**
32 host credential at once; re-enroll each host as above.
33
34 ## Optional max-age
35
36 `credential_max_age` in `server.json` (Go duration, e.g. `"2160h"`) rejects
37 credentials older than the window — at Hello and, like revocation, on every
38 report tick, so a live session cannot outlive the window. Off by default:
39 there is no automatic renewal channel yet, so expiry trades credential
40 lifetime against operator toil. Generation revocation is the primary
41 mechanism.
42
docs/openapi.json
Old New
@@ -1,6 +1,24 @@
1 { 1 {
2 "components": { 2 "components": {
3 "schemas": { 3 "schemas": {
4 "AuditEvent": {
5 "properties": {
6 "action": {
7 "type": "string"
8 },
9 "at": {
10 "format": "date-time",
11 "type": "string"
12 },
13 "detail": {}
14 },
15 "required": [
16 "action",
17 "at",
18 "detail"
19 ],
20 "type": "object"
21 },
4 "Capacity": { 22 "Capacity": {
5 "properties": { 23 "properties": {
6 "disk_gb": { 24 "disk_gb": {
@@ -125,11 +143,15 @@
125 }, 143 },
126 "EnrollTokenResponse": { 144 "EnrollTokenResponse": {
127 "properties": { 145 "properties": {
146 "join": {
147 "type": "string"
148 },
128 "token": { 149 "token": {
129 "type": "string" 150 "type": "string"
130 } 151 }
131 }, 152 },
132 "required": [ 153 "required": [
154 "join",
133 "token" 155 "token"
134 ], 156 ],
135 "type": "object" 157 "type": "object"
@@ -219,6 +241,17 @@
219 ], 241 ],
220 "type": "object" 242 "type": "object"
221 }, 243 },
244 "StreamTicketResponse": {
245 "properties": {
246 "ticket": {
247 "type": "string"
248 }
249 },
250 "required": [
251 "ticket"
252 ],
253 "type": "object"
254 },
222 "VM": { 255 "VM": {
223 "properties": { 256 "properties": {
224 "actual_power": { 257 "actual_power": {
@@ -305,6 +338,52 @@
305 }, 338 },
306 "openapi": "3.1.0", 339 "openapi": "3.1.0",
307 "paths": { 340 "paths": {
341 "/api/v1/audit": {
342 "get": {
343 "parameters": [
344 {
345 "description": "max rows to return (default 100, cap 1000)",
346 "in": "query",
347 "name": "limit",
348 "required": false,
349 "schema": {
350 "type": "string"
351 }
352 }
353 ],
354 "responses": {
355 "200": {
356 "content": {
357 "application/json": {
358 "schema": {
359 "items": {
360 "$ref": "#/components/schemas/AuditEvent"
361 },
362 "type": "array"
363 }
364 }
365 },
366 "description": "success"
367 },
368 "default": {
369 "content": {
370 "text/plain": {
371 "schema": {
372 "type": "string"
373 }
374 }
375 },
376 "description": "error (plain text)"
377 }
378 },
379 "security": [
380 {
381 "adminToken": []
382 }
383 ],
384 "summary": "Newest audit log rows."
385 }
386 },
308 "/api/v1/enroll": { 387 "/api/v1/enroll": {
309 "post": { 388 "post": {
310 "requestBody": { 389 "requestBody": {
@@ -339,7 +418,7 @@
339 "description": "error (plain text)" 418 "description": "error (plain text)"
340 } 419 }
341 }, 420 },
342 "summary": "Redeem a one-time enrollment token: a new host joins the fleet and receives its credential. Unauthenticated; the token is the proof." 421 "summary": "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."
343 } 422 }
344 }, 423 },
345 "/api/v1/enroll-tokens": { 424 "/api/v1/enroll-tokens": {
@@ -371,16 +450,16 @@
371 "adminToken": [] 450 "adminToken": []
372 } 451 }
373 ], 452 ],
374 "summary": "Mint a one-time host enrollment token." 453 "summary": "Mint a one-time host enrollment token plus the join blob agents consume."
375 } 454 }
376 }, 455 },
377 "/api/v1/events": { 456 "/api/v1/events": {
378 "get": { 457 "get": {
379 "parameters": [ 458 "parameters": [
380 { 459 {
381 "description": "admin token", 460 "description": "one-time stream ticket",
382 "in": "query", 461 "in": "query",
383 "name": "token", 462 "name": "ticket",
384 "required": false, 463 "required": false,
385 "schema": { 464 "schema": {
386 "type": "string" 465 "type": "string"
@@ -482,6 +561,73 @@
482 "summary": "Decommission a host: tombstone its VMs and drain gracefully (202)." 561 "summary": "Decommission a host: tombstone its VMs and drain gracefully (202)."
483 } 562 }
484 }, 563 },
564 "/api/v1/hosts/{id}/revoke-credential": {
565 "post": {
566 "parameters": [
567 {
568 "in": "path",
569 "name": "id",
570 "required": true,
571 "schema": {
572 "type": "string"
573 }
574 }
575 ],
576 "responses": {
577 "204": {
578 "description": "success"
579 },
580 "default": {
581 "content": {
582 "text/plain": {
583 "schema": {
584 "type": "string"
585 }
586 }
587 },
588 "description": "error (plain text)"
589 }
590 },
591 "security": [
592 {
593 "adminToken": []
594 }
595 ],
596 "summary": "Revoke a host's outstanding credential by bumping its generation; the host stays dark until re-enrolled."
597 }
598 },
599 "/api/v1/stream-tickets": {
600 "post": {
601 "responses": {
602 "201": {
603 "content": {
604 "application/json": {
605 "schema": {
606 "$ref": "#/components/schemas/StreamTicketResponse"
607 }
608 }
609 },
610 "description": "success"
611 },
612 "default": {
613 "content": {
614 "text/plain": {
615 "schema": {
616 "type": "string"
617 }
618 }
619 },
620 "description": "error (plain text)"
621 }
622 },
623 "security": [
624 {
625 "adminToken": []
626 }
627 ],
628 "summary": "Mint a one-time short-TTL ticket for the SSE stream — the only credential that ever rides in a URL."
629 }
630 },
485 "/api/v1/vms": { 631 "/api/v1/vms": {
486 "get": { 632 "get": {
487 "responses": { 633 "responses": {
docs/shape.html
Old New
@@ -64,7 +64,8 @@
64 "internal/agent/reconcile", 64 "internal/agent/reconcile",
65 "internal/agent/seed", 65 "internal/agent/seed",
66 "internal/agent/state", 66 "internal/agent/state",
67 "internal/agent/syncclient" 67 "internal/agent/syncclient",
68 "internal/joinblob"
68 ] 69 ]
69 }, 70 },
70 { 71 {
@@ -80,6 +81,7 @@
80 "plane": "binaries", 81 "plane": "binaries",
81 "synopsis": "eitri-server: single-node control plane (Phase 1: static admin token, no TLS termination here — front with a reverse proxy for TLS).", 82 "synopsis": "eitri-server: single-node control plane (Phase 1: static admin token, no TLS termination here — front with a reverse proxy for TLS).",
82 "imports": [ 83 "imports": [
84 "internal/joinblob",
83 "internal/server/api", 85 "internal/server/api",
84 "internal/server/hub", 86 "internal/server/hub",
85 "internal/server/registry", 87 "internal/server/registry",
@@ -183,6 +185,12 @@
183 "imports": [] 185 "imports": []
184 }, 186 },
185 { 187 {
188 "importPath": "internal/joinblob",
189 "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.",
191 "imports": []
192 },
193 {
186 "importPath": "internal/pb", 194 "importPath": "internal/pb",
187 "plane": "wire", 195 "plane": "wire",
188 "synopsis": "", 196 "synopsis": "",
@@ -193,6 +201,7 @@
193 "plane": "control", 201 "plane": "control",
194 "synopsis": "Package api implements the admin REST API and the unauthenticated enrollment endpoint.", 202 "synopsis": "Package api implements the admin REST API and the unauthenticated enrollment endpoint.",
195 "imports": [ 203 "imports": [
204 "internal/joinblob",
196 "internal/server/api/types", 205 "internal/server/api/types",
197 "internal/server/hosttoken", 206 "internal/server/hosttoken",
198 "internal/server/hub", 207 "internal/server/hub",
@@ -218,7 +227,7 @@
218 { 227 {
219 "importPath": "internal/server/hosttoken", 228 "importPath": "internal/server/hosttoken",
220 "plane": "control", 229 "plane": "control",
221 "synopsis": "Package hosttoken mints and verifies host credentials: \"\u003chost_id\u003e.\u003chex hmac-sha256\u003e\".", 230 "synopsis": "Package hosttoken mints and verifies generation-versioned host credentials.",
222 "imports": [] 231 "imports": []
223 }, 232 },
224 { 233 {
docs/shape.json
Old New
@@ -13,7 +13,8 @@
13 "internal/agent/reconcile", 13 "internal/agent/reconcile",
14 "internal/agent/seed", 14 "internal/agent/seed",
15 "internal/agent/state", 15 "internal/agent/state",
16 "internal/agent/syncclient" 16 "internal/agent/syncclient",
17 "internal/joinblob"
17 ] 18 ]
18 }, 19 },
19 { 20 {
@@ -29,6 +30,7 @@
29 "plane": "binaries", 30 "plane": "binaries",
30 "synopsis": "eitri-server: single-node control plane (Phase 1: static admin token, no TLS termination here — front with a reverse proxy for TLS).", 31 "synopsis": "eitri-server: single-node control plane (Phase 1: static admin token, no TLS termination here — front with a reverse proxy for TLS).",
31 "imports": [ 32 "imports": [
33 "internal/joinblob",
32 "internal/server/api", 34 "internal/server/api",
33 "internal/server/hub", 35 "internal/server/hub",
34 "internal/server/registry", 36 "internal/server/registry",
@@ -132,6 +134,12 @@
132 "imports": [] 134 "imports": []
133 }, 135 },
134 { 136 {
137 "importPath": "internal/joinblob",
138 "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.",
140 "imports": []
141 },
142 {
135 "importPath": "internal/pb", 143 "importPath": "internal/pb",
136 "plane": "wire", 144 "plane": "wire",
137 "synopsis": "", 145 "synopsis": "",
@@ -142,6 +150,7 @@
142 "plane": "control", 150 "plane": "control",
143 "synopsis": "Package api implements the admin REST API and the unauthenticated enrollment endpoint.", 151 "synopsis": "Package api implements the admin REST API and the unauthenticated enrollment endpoint.",
144 "imports": [ 152 "imports": [
153 "internal/joinblob",
145 "internal/server/api/types", 154 "internal/server/api/types",
146 "internal/server/hosttoken", 155 "internal/server/hosttoken",
147 "internal/server/hub", 156 "internal/server/hub",
@@ -167,7 +176,7 @@
167 { 176 {
168 "importPath": "internal/server/hosttoken", 177 "importPath": "internal/server/hosttoken",
169 "plane": "control", 178 "plane": "control",
170 "synopsis": "Package hosttoken mints and verifies host credentials: \"\u003chost_id\u003e.\u003chex hmac-sha256\u003e\".", 179 "synopsis": "Package hosttoken mints and verifies generation-versioned host credentials.",
171 "imports": [] 180 "imports": []
172 }, 181 },
173 { 182 {
internal/agent/cloudhv/cloudhv.go
Old New
@@ -70,10 +70,46 @@ func (p *Provisioner) buildArgs(spec state.VMSpec) []string {
70 } 70 }
71 } 71 }
72 72
73 // maxDiskGB caps a VM disk at 1 PiB (2^20 GiB) — far beyond any real host,
74 // and small enough that DiskGB<<30 can never overflow int64 (2^50 max).
75 const maxDiskGB = 1 << 20
76
77 // permanentError marks provisioning failures no retry can fix (the disk
78 // guards); reconcile checks the Permanent() marker and terminal-fails
79 // immediately instead of burning its retry budget.
80 type permanentError struct{ err error }
81
82 func (e permanentError) Error() string { return e.err.Error() }
83 func (e permanentError) Unwrap() error { return e.err }
84 func (e permanentError) Permanent() bool { return true }
85
86 func permanentf(format string, args ...any) error {
87 return permanentError{err: fmt.Errorf(format, args...)}
88 }
89
73 // PrepareDisk creates the VM disk by making a reflink copy of basePath 90 // PrepareDisk creates the VM disk by making a reflink copy of basePath
74 // (instant on XFS/btrfs; silent full-copy fallback on ext4) and then 91 // (instant on XFS/btrfs; silent full-copy fallback on ext4) and then
75 // truncating it to spec.DiskGB gigabytes. 92 // truncating it to spec.DiskGB gigabytes.
93 //
94 // truncate -s sets an EXACT size, so a target smaller than the base image
95 // would silently chop the guest filesystem. PrepareDisk refuses to shrink:
96 // spec.DiskGB must be in [1, maxDiskGB] and cover the base image. The range
97 // check runs first so the byte computation is overflow-safe (a naive
98 // DiskGB<<30 wraps to a small positive value for e.g. 2^34+10, bypassing the
99 // guard). Pinned by TestPrepareDiskRefusesToShrinkBaseImage and
100 // TestPrepareDiskShrinkGuardEdgeCases.
76 func (p *Provisioner) PrepareDisk(ctx context.Context, spec state.VMSpec, basePath string) error { 101 func (p *Provisioner) PrepareDisk(ctx context.Context, spec state.VMSpec, basePath string) error {
102 base, err := os.Stat(basePath)
103 if err != nil {
104 return fmt.Errorf("stat base image %s: %w", basePath, err)
105 }
106 if spec.DiskGB < 1 || spec.DiskGB > maxDiskGB {
107 return permanentf("disk_gb %d out of range [1, %d]", spec.DiskGB, int64(maxDiskGB))
108 }
109 if targetBytes := spec.DiskGB << 30; targetBytes < base.Size() {
110 return permanentf("disk_gb %d (%d bytes) is smaller than base image %s (%d bytes) — shrinking would corrupt the guest",
111 spec.DiskGB, targetBytes, basePath, base.Size())
112 }
77 diskPath := p.st.DiskPath(spec.VMID) 113 diskPath := p.st.DiskPath(spec.VMID)
78 // Ensure VM directory exists. 114 // Ensure VM directory exists.
79 if err := os.MkdirAll(p.st.VMDir(spec.VMID), 0o700); err != nil { 115 if err := os.MkdirAll(p.st.VMDir(spec.VMID), 0o700); err != nil {
@@ -97,12 +133,18 @@ func (p *Provisioner) pidPath(vmID string) string {
97 // Boot spawns a cloud-hypervisor process for spec. The process is placed in 133 // Boot spawns a cloud-hypervisor process for spec. The process is placed in
98 // its own session (Setsid) so it survives an agent restart. A goroutine calls 134 // its own session (Setsid) so it survives an agent restart. A goroutine calls
99 // cmd.Wait to reap the child when it exits. 135 // cmd.Wait to reap the child when it exits.
100 func (p *Provisioner) Boot(ctx context.Context, vmID string, spec state.VMSpec) error { 136 //
137 // The ctx parameter is deliberately NOT wired to the process: the VM's
138 // lifetime must not be tied to the agent's (exec.CommandContext SIGKILLs the
139 // child on ctx cancel, which would hard-power-off every VM on a graceful
140 // agent stop). Stopping a VM is exclusively the job of Shutdown/Kill, driven
141 // by the reconcile loop. Pinned by TestBootedVMSurvivesCtxCancellation.
142 func (p *Provisioner) Boot(_ context.Context, vmID string, spec state.VMSpec) error {
101 // Remove stale socket from a previous run. 143 // Remove stale socket from a previous run.
102 _ = os.Remove(p.st.SocketPath(vmID)) 144 _ = os.Remove(p.st.SocketPath(vmID))
103 145
104 args := p.buildArgs(spec) 146 args := p.buildArgs(spec)
105 cmd := exec.CommandContext(ctx, p.chBin, args...) 147 cmd := exec.Command(p.chBin, args...)
106 cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} 148 cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
107 149
108 // Guest console output (serial) goes to serial.log (via --serial file=…). 150 // Guest console output (serial) goes to serial.log (via --serial file=…).
internal/agent/cloudhv/cloudhv_test.go
Old New
@@ -2,11 +2,15 @@ package cloudhv
2 2
3 import ( 3 import (
4 "context" 4 "context"
5 "errors"
5 "net" 6 "net"
6 "net/http" 7 "net/http"
8 "os"
9 "path/filepath"
7 "strings" 10 "strings"
8 "sync/atomic" 11 "sync/atomic"
9 "testing" 12 "testing"
13 "time"
10 14
11 "github.com/a73x/eitri/internal/agent/state" 15 "github.com/a73x/eitri/internal/agent/state"
12 "github.com/stretchr/testify/assert" 16 "github.com/stretchr/testify/assert"
@@ -35,6 +39,18 @@ func TestBuildArgs(t *testing.T) {
35 assert.Contains(t, joined, "tap=eit-vm1,mac="+MAC("vm1")) 39 assert.Contains(t, joined, "tap=eit-vm1,mac="+MAC("vm1"))
36 } 40 }
37 41
42 // 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.
44 func sparseFile(t *testing.T, size int64) string {
45 t.Helper()
46 path := filepath.Join(t.TempDir(), "base.raw")
47 f, err := os.Create(path)
48 require.NoError(t, err)
49 require.NoError(t, f.Truncate(size))
50 require.NoError(t, f.Close())
51 return path
52 }
53
38 func TestPrepareDiskUsesReflinkAndResizes(t *testing.T) { 54 func TestPrepareDiskUsesReflinkAndResizes(t *testing.T) {
39 var cmds []string 55 var cmds []string
40 run := func(ctx context.Context, name string, args ...string) (string, error) { 56 run := func(ctx context.Context, name string, args ...string) (string, error) {
@@ -43,14 +59,91 @@ func TestPrepareDiskUsesReflinkAndResizes(t *testing.T) {
43 } 59 }
44 st, _ := state.Open(t.TempDir()) 60 st, _ := state.Open(t.TempDir())
45 p := New(st, "ch", "fw", run) 61 p := New(st, "ch", "fw", run)
62 base := sparseFile(t, 1<<20) // 1 MiB base, well under the 10G target
46 require.NoError(t, p.PrepareDisk(context.Background(), 63 require.NoError(t, p.PrepareDisk(context.Background(),
47 state.VMSpec{VMID: "vm1", DiskGB: 10}, "/cache/abc.raw")) 64 state.VMSpec{VMID: "vm1", DiskGB: 10}, base))
48 joined := strings.Join(cmds, "\n") 65 joined := strings.Join(cmds, "\n")
49 // reflink=auto: instant on XFS/btrfs, silent full-copy fallback on ext4 (spec) 66 // reflink=auto: instant on XFS/btrfs, silent full-copy fallback on ext4 (spec)
50 assert.Contains(t, joined, "cp --reflink=auto /cache/abc.raw "+st.DiskPath("vm1")) 67 assert.Contains(t, joined, "cp --reflink=auto "+base+" "+st.DiskPath("vm1"))
51 assert.Contains(t, joined, "truncate -s 10G "+st.DiskPath("vm1")) 68 assert.Contains(t, joined, "truncate -s 10G "+st.DiskPath("vm1"))
52 } 69 }
53 70
71 // TestPrepareDiskRefusesToShrinkBaseImage pins the never-shrink guard:
72 // truncate -s sets an EXACT size, so a DiskGB smaller than the base image
73 // would silently corrupt the guest filesystem. PrepareDisk must refuse
74 // before running any command.
75 func TestPrepareDiskRefusesToShrinkBaseImage(t *testing.T) {
76 var cmds []string
77 run := func(ctx context.Context, name string, args ...string) (string, error) {
78 cmds = append(cmds, name+" "+strings.Join(args, " "))
79 return "", nil
80 }
81 st, _ := state.Open(t.TempDir())
82 p := New(st, "ch", "fw", run)
83 base := sparseFile(t, 2<<30) // sparse 2 GiB base
84 err := p.PrepareDisk(context.Background(),
85 state.VMSpec{VMID: "vm1", DiskGB: 1}, base)
86 require.Error(t, err, "shrinking below the base image must be rejected")
87 assert.Contains(t, err.Error(), "smaller than base image")
88 assert.Empty(t, cmds, "no command may run once the shrink is detected")
89 }
90
91 // TestPrepareDiskFailsOnMissingBaseImage: a stat failure on the base image is
92 // a real error (cp would fail anyway) and must surface before any command.
93 func TestPrepareDiskFailsOnMissingBaseImage(t *testing.T) {
94 var cmds []string
95 run := func(ctx context.Context, name string, args ...string) (string, error) {
96 cmds = append(cmds, name+" "+strings.Join(args, " "))
97 return "", nil
98 }
99 st, _ := state.Open(t.TempDir())
100 p := New(st, "ch", "fw", run)
101 err := p.PrepareDisk(context.Background(),
102 state.VMSpec{VMID: "vm1", DiskGB: 10}, "/nonexistent/base.raw")
103 require.Error(t, err)
104 assert.Empty(t, cmds)
105 }
106
107 // TestBootedVMSurvivesCtxCancellation pins the VM-lifetime contract: the
108 // cloud-hypervisor process must NOT die when the context passed to Boot is
109 // cancelled. The agent's root context is cancelled on every graceful agent
110 // stop (SIGINT/SIGTERM in main), and VMs are meant to survive agent restarts
111 // (that is why Boot uses Setsid). Killing the VM is exclusively the job of
112 // the reconcile Shutdown/Kill path.
113 func TestBootedVMSurvivesCtxCancellation(t *testing.T) {
114 st, err := state.Open(t.TempDir())
115 require.NoError(t, err)
116
117 vmID := "vm-survive-test"
118 // Ensure the VM directory exists (Boot writes ch.log + pidfile inside it).
119 require.NoError(t, st.SaveVM(state.Record{Spec: state.VMSpec{VMID: vmID}}))
120
121 // Fake cloud-hypervisor: ignores its CLI args and sleeps. exec replaces the
122 // shell, so the pidfile PID is the sleep itself and the cleanup Kill reaps
123 // it directly (no orphaned child if the shell wouldn't exec its tail).
124 fakeCH := filepath.Join(t.TempDir(), "fake-ch")
125 require.NoError(t, os.WriteFile(fakeCH, []byte("#!/bin/sh\nexec sleep 60\n"), 0o755))
126
127 p := New(st, fakeCH, "fw", nil)
128 ctx, cancel := context.WithCancel(context.Background())
129 require.NoError(t, p.Boot(ctx, vmID, state.VMSpec{VMID: vmID, VCPUs: 1, MemMB: 128}))
130 t.Cleanup(func() { _ = p.Kill(context.Background(), vmID) })
131 require.True(t, p.Running(vmID), "process must be alive right after Boot")
132
133 cancel()
134
135 // The process must still be alive well after cancellation. Poll instead of
136 // a single sleep so a kill-on-cancel regression fails fast and reliably
137 // (SIGKILL from exec.CommandContext lands and is reaped within
138 // milliseconds, so 300ms is orders-of-magnitude margin).
139 deadline := time.Now().Add(300 * time.Millisecond)
140 for time.Now().Before(deadline) {
141 require.True(t, p.Running(vmID),
142 "cloud-hypervisor process died after ctx cancellation — VM lifetime must not be tied to the agent's context")
143 time.Sleep(50 * time.Millisecond)
144 }
145 }
146
54 // TestShutdownFallsBackToSIGTERMOn500 verifies fix 3: a non-2xx HTTP response 147 // TestShutdownFallsBackToSIGTERMOn500 verifies fix 3: a non-2xx HTTP response
55 // from the cloud-hypervisor socket is treated as failure and the SIGTERM 148 // from the cloud-hypervisor socket is treated as failure and the SIGTERM
56 // fallback path is taken. 149 // fallback path is taken.
@@ -116,3 +209,73 @@ func TestShutdownSucceedsOn204(t *testing.T) {
116 p := New(st, "ch", "fw", nil) 209 p := New(st, "ch", "fw", nil)
117 assert.NoError(t, p.Shutdown(context.Background(), vmID)) 210 assert.NoError(t, p.Shutdown(context.Background(), vmID))
118 } 211 }
212
213 // TestPrepareDiskShrinkGuardEdgeCases pins the guard's arithmetic: an exact
214 // fit is allowed (truncate to same size is a no-op), and an absurd DiskGB
215 // that would overflow a byte computation (DiskGB<<30) must still be caught —
216 // 2^34+10 wraps to a small positive byte count if computed naively.
217 func TestPrepareDiskShrinkGuardEdgeCases(t *testing.T) {
218 newP := func(t *testing.T, cmds *[]string) *Provisioner {
219 run := func(ctx context.Context, name string, args ...string) (string, error) {
220 *cmds = append(*cmds, name)
221 return "", nil
222 }
223 st, _ := state.Open(t.TempDir())
224 return New(st, "ch", "fw", run)
225 }
226
227 t.Run("exact fit is allowed", func(t *testing.T) {
228 var cmds []string
229 p := newP(t, &cmds)
230 base := sparseFile(t, 2<<30) // exactly 2 GiB
231 require.NoError(t, p.PrepareDisk(context.Background(),
232 state.VMSpec{VMID: "vm1", DiskGB: 2}, base))
233 assert.NotEmpty(t, cmds)
234 })
235
236 t.Run("overflow-sized disk_gb does not bypass the guard", func(t *testing.T) {
237 var cmds []string
238 p := newP(t, &cmds)
239 base := sparseFile(t, 2<<30)
240 // (1<<34)+10 << 30 wraps to +10 GiB... no: ((1<<34)+10)*2^30 mod 2^64
241 // wraps to 10 GiB-ish positive — either way the request is absurd and
242 // must not run cp/truncate with a nonsense size. DiskGB=2^34+10 > any
243 // real disk; the guard must reject or the size math must be exact.
244 err := p.PrepareDisk(context.Background(),
245 state.VMSpec{VMID: "vm1", DiskGB: (1 << 34) + 10}, base)
246 if err == nil {
247 // Accepting it is only sound if the target genuinely covers the
248 // base, which it does mathematically (2^34+10 GiB >> 2 GiB) — but
249 // then truncate would run with a size beyond off_t. Reject instead.
250 t.Fatal("absurd disk_gb accepted; overflow in the guard arithmetic")
251 }
252 assert.Empty(t, cmds, "no command may run for an absurd disk_gb")
253 })
254 }
255
256 // TestDiskGuardErrorsArePermanent pins that the never-shrink and range
257 // guards mark their errors with the reconcile-consumed Permanent() marker:
258 // no retry can ever fix a disk_gb below the base image.
259 func TestDiskGuardErrorsArePermanent(t *testing.T) {
260 st, _ := state.Open(t.TempDir())
261 p := New(st, "ch", "fw", func(ctx context.Context, name string, args ...string) (string, error) {
262 return "", nil
263 })
264 isPermanent := func(err error) bool {
265 var m interface{ Permanent() bool }
266 return errors.As(err, &m) && m.Permanent()
267 }
268
269 base := sparseFile(t, 2<<30)
270 err := p.PrepareDisk(context.Background(), state.VMSpec{VMID: "v", DiskGB: 1}, base)
271 require.Error(t, err)
272 assert.True(t, isPermanent(err), "shrink guard error must be permanent")
273
274 err = p.PrepareDisk(context.Background(), state.VMSpec{VMID: "v", DiskGB: (1 << 34) + 10}, base)
275 require.Error(t, err)
276 assert.True(t, isPermanent(err), "range guard error must be permanent")
277
278 err = p.PrepareDisk(context.Background(), state.VMSpec{VMID: "v", DiskGB: 10}, "/nonexistent/base.raw")
279 require.Error(t, err)
280 assert.False(t, isPermanent(err), "stat failure may be transient (NFS blip, cache re-fetch)")
281 }
internal/agent/imagecache/imagecache.go
Old New
@@ -1,6 +1,9 @@
1 // Package imagecache downloads, verifies, and raw-converts base images. 1 // Package imagecache downloads, verifies, and raw-converts base images.
2 // Layout: <dir>/<sha256>.raw — keyed by checksum (spec). LRU eviction is 2 // Layout: <dir>/<sha256>.raw — keyed by checksum (spec). The cache is
3 // deferred (Phase 1 hosts pin one or two images). 3 // size-capped LRU: a hit refreshes the file's mtime, and after every
4 // successful Ensure the oldest images beyond MaxBytes are evicted (never the
5 // one just ensured). Eviction is safe for running VMs: PrepareDisk copies
6 // (reflink) the base, so nothing references it after create.
4 package imagecache 7 package imagecache
5 8
6 import ( 9 import (
@@ -9,10 +12,12 @@ import (
9 "encoding/hex" 12 "encoding/hex"
10 "fmt" 13 "fmt"
11 "io" 14 "io"
15 "log/slog"
12 "net/http" 16 "net/http"
13 "os" 17 "os"
14 "path/filepath" 18 "path/filepath"
15 "regexp" 19 "regexp"
20 "sort"
16 "time" 21 "time"
17 22
18 "github.com/a73x/eitri/internal/agent/exec" 23 "github.com/a73x/eitri/internal/agent/exec"
@@ -34,6 +39,14 @@ type Cache struct {
34 dir string 39 dir string
35 run exec.Runner 40 run exec.Runner
36 http httpDoer 41 http httpDoer
42
43 // MaxBytes caps the summed size of cached images; 0 disables eviction.
44 // Best-effort: the just-ensured image is never evicted, even if it alone
45 // exceeds the cap. Accounting uses APPARENT size (fi.Size), which
46 // overstates sparse raw images — the error direction is safe (evicts too
47 // eagerly, never too late), but a huge-virtual-size image can pin the
48 // cache over cap; use block-based accounting if that ever bites.
49 MaxBytes int64
37 } 50 }
38 51
39 // New returns a cache rooted at dir. Its HTTP client carries a generous timeout 52 // New returns a cache rooted at dir. Its HTTP client carries a generous timeout
@@ -51,6 +64,10 @@ func (c *Cache) Ensure(ctx context.Context, url, sha string) (string, error) {
51 64
52 final := filepath.Join(c.dir, sha+".raw") 65 final := filepath.Join(c.dir, sha+".raw")
53 if _, err := os.Stat(final); err == nil { 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)
54 return final, nil 71 return final, nil
55 } 72 }
56 req, err := http.NewRequestWithContext(ctx, "GET", url, nil) 73 req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
@@ -90,5 +107,49 @@ func (c *Cache) Ensure(ctx context.Context, url, sha string) (string, error) {
90 os.Remove(converting) 107 os.Remove(converting)
91 return "", fmt.Errorf("imagecache: rename to final: %w", err) 108 return "", fmt.Errorf("imagecache: rename to final: %w", err)
92 } 109 }
110 c.evict(final)
93 return final, nil 111 return final, nil
94 } 112 }
113
114 // evict removes least-recently-used .raw images until the cache fits
115 // MaxBytes, never touching keep (the image the current create is about to
116 // use). Failures are logged-by-omission best-effort: eviction must never
117 // fail an Ensure that already succeeded.
118 func (c *Cache) evict(keep string) {
119 if c.MaxBytes <= 0 {
120 return
121 }
122 entries, err := filepath.Glob(filepath.Join(c.dir, "*.raw"))
123 if err != nil {
124 return
125 }
126 type img struct {
127 path string
128 size int64
129 mtime time.Time
130 }
131 var imgs []img
132 var total int64
133 for _, p := range entries {
134 fi, err := os.Stat(p)
135 if err != nil {
136 continue
137 }
138 total += fi.Size()
139 if p != keep {
140 imgs = append(imgs, img{p, fi.Size(), fi.ModTime()})
141 }
142 }
143 sort.Slice(imgs, func(i, j int) bool { return imgs[i].mtime.Before(imgs[j].mtime) })
144 for _, im := range imgs {
145 if total <= c.MaxBytes {
146 return
147 }
148 if os.Remove(im.path) == nil {
149 total -= im.size
150 // The one observable trace of eviction: cap-thrash (an oversized
151 // image forcing constant re-downloads) is invisible without it.
152 slog.Info("imagecache: evicted", "path", im.path, "size", im.size)
153 }
154 }
155 }
internal/agent/imagecache/imagecache_test.go
Old New
@@ -1,6 +1,7 @@
1 package imagecache 1 package imagecache
2 2
3 import ( 3 import (
4 "bytes"
4 "context" 5 "context"
5 "crypto/sha256" 6 "crypto/sha256"
6 "encoding/hex" 7 "encoding/hex"
@@ -10,6 +11,7 @@ import (
10 "os" 11 "os"
11 "path/filepath" 12 "path/filepath"
12 "testing" 13 "testing"
14 "time"
13 15
14 "github.com/a73x/eitri/internal/agent/exec" 16 "github.com/a73x/eitri/internal/agent/exec"
15 "github.com/stretchr/testify/assert" 17 "github.com/stretchr/testify/assert"
@@ -126,3 +128,79 @@ func TestEnsureNoStrayFilesAfterSuccess(t *testing.T) {
126 assert.Len(t, entries, 1, "only the final .raw file should remain in cache dir") 128 assert.Len(t, entries, 1, "only the final .raw file should remain in cache dir")
127 assert.Equal(t, sum+".raw", entries[0].Name()) 129 assert.Equal(t, sum+".raw", entries[0].Name())
128 } 130 }
131
132 // ensureBytes downloads body into c and returns the cached path.
133 func ensureBytes(t *testing.T, c *Cache, body []byte) string {
134 t.Helper()
135 ts, sha := serve(t, body)
136 p, err := c.Ensure(context.Background(), ts.URL+"/img", sha)
137 require.NoError(t, err)
138 return p
139 }
140
141 // backdate pushes a file's mtime into the past to force LRU ordering.
142 func backdate(t *testing.T, path string, d time.Duration) {
143 t.Helper()
144 old := time.Now().Add(-d)
145 require.NoError(t, os.Chtimes(path, old, old))
146 }
147
148 // TestEnsureEvictsLRUBeyondCap pins eviction: with MaxBytes set, the
149 // least-recently-used image beyond the cap is removed after a successful
150 // Ensure; the freshly-ensured image always survives.
151 func TestEnsureEvictsLRUBeyondCap(t *testing.T) {
152 dir := t.TempDir()
153 c := New(dir, fakeRunner(t))
154 c.MaxBytes = 150
155
156 a := ensureBytes(t, c, bytes.Repeat([]byte("a"), 100))
157 backdate(t, a, time.Hour)
158 b := ensureBytes(t, c, bytes.Repeat([]byte("b"), 100)) // 200 > 150 → evict a
159
160 _, errA := os.Stat(a)
161 assert.True(t, os.IsNotExist(errA), "LRU image beyond the cap must be evicted")
162 _, errB := os.Stat(b)
163 assert.NoError(t, errB, "just-ensured image must never be evicted")
164 }
165
166 // TestEnsureCapZeroDisablesEviction pins the default: MaxBytes 0 keeps
167 // everything (existing behavior).
168 func TestEnsureCapZeroDisablesEviction(t *testing.T) {
169 dir := t.TempDir()
170 c := New(dir, fakeRunner(t))
171
172 a := ensureBytes(t, c, bytes.Repeat([]byte("a"), 100))
173 backdate(t, a, time.Hour)
174 _ = ensureBytes(t, c, bytes.Repeat([]byte("b"), 100))
175 _, err := os.Stat(a)
176 assert.NoError(t, err, "no eviction when MaxBytes is 0")
177 }
178
179 // TestCacheHitRefreshesRecency pins the LRU signal: a cache hit touches the
180 // file so frequently-used images sort as recent.
181 func TestCacheHitRefreshesRecency(t *testing.T) {
182 dir := t.TempDir()
183 c := New(dir, fakeRunner(t))
184
185 body := bytes.Repeat([]byte("a"), 50)
186 a := ensureBytes(t, c, body)
187 backdate(t, a, time.Hour)
188 before, _ := os.Stat(a)
189
190 _ = ensureBytes(t, c, body) // same sha → cache hit
191 after, err := os.Stat(a)
192 require.NoError(t, err)
193 assert.True(t, after.ModTime().After(before.ModTime()), "hit must refresh mtime")
194 }
195
196 // TestEvictionNeverRemovesEnsuredImageEvenOverCap pins the best-effort cap:
197 // the just-ensured image survives even when it alone exceeds MaxBytes.
198 func TestEvictionNeverRemovesEnsuredImageEvenOverCap(t *testing.T) {
199 dir := t.TempDir()
200 c := New(dir, fakeRunner(t))
201 c.MaxBytes = 10
202
203 a := ensureBytes(t, c, bytes.Repeat([]byte("a"), 100))
204 _, err := os.Stat(a)
205 assert.NoError(t, err, "cap is best-effort; the image in use survives")
206 }
internal/agent/netenv/netenv.go
Old New
@@ -7,6 +7,9 @@ import (
7 "context" 7 "context"
8 "fmt" 8 "fmt"
9 "net/netip" 9 "net/netip"
10 "os"
11 "path/filepath"
12 "strconv"
10 "strings" 13 "strings"
11 14
12 "github.com/a73x/eitri/internal/agent/exec" 15 "github.com/a73x/eitri/internal/agent/exec"
@@ -20,6 +23,10 @@ const Bridge = "eitri0"
20 type Net struct { 23 type Net struct {
21 run exec.Runner 24 run exec.Runner
22 cidr netip.Prefix 25 cidr netip.Prefix
26 // isTap reports whether an existing link is a tun/tap device. Injectable
27 // for tests; production checks /sys/class/net/<name>/tun_flags, which
28 // exists only for tun/tap links — no error-string parsing.
29 isTap func(name string) bool
23 } 30 }
24 31
25 // New constructs a Net. cidr must be a valid IPv4 prefix (e.g. "10.77.1.0/24"). 32 // New constructs a Net. cidr must be a valid IPv4 prefix (e.g. "10.77.1.0/24").
@@ -31,9 +38,38 @@ func New(run exec.Runner, cidr string) (*Net, error) {
31 if !p.Addr().Is4() { 38 if !p.Addr().Is4() {
32 return nil, fmt.Errorf("bridge CIDR must be IPv4, got %s", cidr) 39 return nil, fmt.Errorf("bridge CIDR must be IPv4, got %s", cidr)
33 } 40 }
34 return &Net{run: run, cidr: p}, nil 41 return &Net{run: run, cidr: p, isTap: sysfsIsTap}, nil
35 } 42 }
36 43
44 // sysfsIsTap reports whether name is an L2 TAP link: /sys/class/net/<name>/
45 // tun_flags exists exactly for tun-driver links, and bit 0x0002 (IFF_TAP)
46 // distinguishes a TAP from an L3 TUN (which cannot join a bridge and would
47 // fail later with a raw RTNETLINK error).
48 func sysfsIsTap(name string) bool { return sysfsIsTapAt("/sys/class/net", name) }
49
50 // sysfsIsTapAt is sysfsIsTap with an injectable sysfs root (for tests).
51 func sysfsIsTapAt(root, name string) bool {
52 raw, err := os.ReadFile(filepath.Join(root, name, "tun_flags"))
53 if err != nil {
54 return false
55 }
56 flags, err := strconv.ParseUint(strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(string(raw)), "0x")), 16, 64)
57 if err != nil {
58 return false
59 }
60 const iffTap = 0x0002
61 return flags&iffTap != 0
62 }
63
64 // tapConflictError marks the name-collision failure permanent: a foreign
65 // device squatting on the tap name never resolves by retrying (reconcile's
66 // Permanent() marker — burns the budget at once when that lands).
67 type tapConflictError struct{ err error }
68
69 func (e tapConflictError) Error() string { return e.err.Error() }
70 func (e tapConflictError) Unwrap() error { return e.err }
71 func (e tapConflictError) Permanent() bool { return true }
72
37 // Gateway returns the host-side IP (.1) on the bridge, as a bare address string. 73 // Gateway returns the host-side IP (.1) on the bridge, as a bare address string.
38 func (n *Net) Gateway() string { return n.cidr.Masked().Addr().Next().String() } 74 func (n *Net) Gateway() string { return n.cidr.Masked().Addr().Next().String() }
39 75
@@ -161,9 +197,23 @@ func (n *Net) EnsureBridge(ctx context.Context, noMasqIfaces []string) error {
161 197
162 // CreateTap creates a TAP device and attaches it to the eitri0 bridge. 198 // CreateTap creates a TAP device and attaches it to the eitri0 bridge.
163 // tap should be the value from state.TapName(vmID). 199 // tap should be the value from state.TapName(vmID).
200 //
201 // Idempotent by existence check, not error-string parsing (same rationale as
202 // EnsureBridge): re-running `ip tuntap add` on a live tap fails with
203 // "ioctl(TUNSETIFF): Device or resource busy" — a message the tolerated()
204 // allow-list can never chase across iproute2 versions. A create-retry hitting
205 // the previous attempt's tap must not mask the retry's real error. Pinned by
206 // TestCreateTapIdempotentWhenTapExists.
164 func (n *Net) CreateTap(ctx context.Context, tap string) error { 207 func (n *Net) CreateTap(ctx context.Context, tap string) error {
165 if _, err := n.best(ctx, "ip", "tuntap", "add", "dev", tap, "mode", "tap"); err != nil { 208 if _, err := n.run(ctx, "ip", "link", "show", "dev", tap); err != nil {
166 return err 209 if _, err := n.best(ctx, "ip", "tuntap", "add", "dev", tap, "mode", "tap"); err != nil {
210 return err
211 }
212 } else if !n.isTap(tap) {
213 // Name collision or stale device: silently enslaving a non-tap would
214 // surface later as an illegible cloud-hypervisor failure. Fail loudly
215 // here instead (pinned by TestCreateTapRejectsNonTapDevice).
216 return tapConflictError{err: fmt.Errorf("link %s exists but is not a TAP device — name collision or stale interface; remove it or rename the VM", tap)}
167 } 217 }
168 if _, err := n.best(ctx, "ip", "link", "set", tap, "master", Bridge); err != nil { 218 if _, err := n.best(ctx, "ip", "link", "set", tap, "master", Bridge); err != nil {
169 return err 219 return err
internal/agent/netenv/netenv_test.go
Old New
@@ -3,6 +3,8 @@ package netenv
3 import ( 3 import (
4 "context" 4 "context"
5 "errors" 5 "errors"
6 "fmt"
7 "os"
6 "strings" 8 "strings"
7 "testing" 9 "testing"
8 10
@@ -89,7 +91,11 @@ func TestEnsureBridgeIdempotentOnRestart(t *testing.T) {
89 } 91 }
90 92
91 func TestTapLifecycle(t *testing.T) { 93 func TestTapLifecycle(t *testing.T) {
92 run, calls := recorder(nil, nil) 94 // Fresh tap: the existence probe fails, so the add runs.
95 errs := map[string]error{
96 "ip link show dev eit-abc123": errors.New(`Device "eit-abc123" does not exist.`),
97 }
98 run, calls := recorder(nil, errs)
93 n, _ := New(run, "10.77.1.0/24") 99 n, _ := New(run, "10.77.1.0/24")
94 require.NoError(t, n.CreateTap(context.Background(), "eit-abc123")) 100 require.NoError(t, n.CreateTap(context.Background(), "eit-abc123"))
95 all := joinCalls(calls) 101 all := joinCalls(calls)
@@ -213,7 +219,12 @@ func TestCreateTapToleratesAlreadyExists(t *testing.T) {
213 key := "ip tuntap add dev eit-x mode tap" 219 key := "ip tuntap add dev eit-x mode tap"
214 run, _ := recorder( 220 run, _ := recorder(
215 map[string]string{key: tc.out}, 221 map[string]string{key: tc.out},
216 map[string]error{key: errors.New(tc.errText)}, 222 map[string]error{
223 key: errors.New(tc.errText),
224 // TOCTOU window: probe says absent, add still races an
225 // exists-style failure — the tolerance must still apply.
226 "ip link show dev eit-x": errors.New(`Device "eit-x" does not exist.`),
227 },
217 ) 228 )
218 n, _ := New(run, "10.77.1.0/24") 229 n, _ := New(run, "10.77.1.0/24")
219 require.NoError(t, n.CreateTap(context.Background(), "eit-x"), 230 require.NoError(t, n.CreateTap(context.Background(), "eit-x"),
@@ -225,7 +236,10 @@ func TestCreateTapToleratesAlreadyExists(t *testing.T) {
225 // CreateTap must still surface a genuine (non-tolerated) error. 236 // CreateTap must still surface a genuine (non-tolerated) error.
226 func TestCreateTapPropagatesRealError(t *testing.T) { 237 func TestCreateTapPropagatesRealError(t *testing.T) {
227 key := "ip tuntap add dev eit-x mode tap" 238 key := "ip tuntap add dev eit-x mode tap"
228 run, _ := recorder(nil, map[string]error{key: errors.New("Operation not permitted")}) 239 run, _ := recorder(nil, map[string]error{
240 key: errors.New("Operation not permitted"),
241 "ip link show dev eit-x": errors.New(`Device "eit-x" does not exist.`),
242 })
229 n, _ := New(run, "10.77.1.0/24") 243 n, _ := New(run, "10.77.1.0/24")
230 err := n.CreateTap(context.Background(), "eit-x") 244 err := n.CreateTap(context.Background(), "eit-x")
231 require.Error(t, err) 245 require.Error(t, err)
@@ -248,3 +262,105 @@ func TestDeleteTapToleratesMissingDevice(t *testing.T) {
248 n3, _ := New(run3, "10.77.1.0/24") 262 n3, _ := New(run3, "10.77.1.0/24")
249 require.Error(t, n3.DeleteTap(context.Background(), "eit-x")) 263 require.Error(t, n3.DeleteTap(context.Background(), "eit-x"))
250 } 264 }
265
266 // TestCreateTapIdempotentWhenTapExists is the regression test for the sandbox
267 // failure: a create-retry re-ran CreateTap on a tap left by the previous
268 // attempt (UP, enslaved to eitri0), and `ip tuntap add` failed with
269 // "ioctl(TUNSETIFF): Device or resource busy" — which is NOT in the tolerated
270 // string list, so the retry's real root-cause error (disk guard) was masked
271 // by a tap artifact. Like EnsureBridge, CreateTap must existence-check the
272 // link and skip the add instead of parsing error strings.
273 func TestCreateTapIdempotentWhenTapExists(t *testing.T) {
274 out := map[string]string{
275 "ip link show dev eit-busy": "4: eit-busy: <NO-CARRIER,BROADCAST,MULTICAST,UP> master eitri0 state DOWN",
276 }
277 // If the code ever regresses to an unconditional add, fail it the
278 // real-world way.
279 errs := map[string]error{
280 "ip tuntap add dev eit-busy mode tap": errors.New("ioctl(TUNSETIFF): Device or resource busy"),
281 }
282 run, calls := recorder(out, errs)
283 n, err := New(run, "10.77.1.0/24")
284 require.NoError(t, err)
285 n.isTap = func(string) bool { return true } // the leftover IS a real tap
286 require.NoError(t, n.CreateTap(context.Background(), "eit-busy"),
287 "CreateTap on an existing tap must succeed")
288
289 all := joinCalls(calls)
290 assert.NotContains(t, all, "ip tuntap add", "tap exists → no add")
291 assert.Contains(t, all, "ip link set eit-busy master eitri0", "enslave stays (idempotent)")
292 assert.Contains(t, all, "ip link set eit-busy up", "up stays (idempotent)")
293 }
294
295 // TestCreateTapRejectsNonTapDevice pins the collision guard: when a link
296 // with the tap's name exists but is NOT a tap (stale dummy/veth or a name
297 // collision), CreateTap must fail loudly naming the conflict — silently
298 // enslaving it would surface later as an illegible cloud-hypervisor error.
299 func TestCreateTapRejectsNonTapDevice(t *testing.T) {
300 out := map[string]string{
301 "ip link show dev eit-clash": "5: eit-clash: <BROADCAST> state DOWN", // exists
302 }
303 run, calls := recorder(out, nil)
304 n, err := New(run, "10.77.1.0/24")
305 require.NoError(t, err)
306 n.isTap = func(tap string) bool { return false } // not a tun/tap device
307
308 err = n.CreateTap(context.Background(), "eit-clash")
309 require.Error(t, err)
310 assert.Contains(t, err.Error(), "not a TAP device")
311 var p interface{ Permanent() bool }
312 assert.True(t, errors.As(err, &p) && p.Permanent(), "conflict never self-resolves — must be permanent")
313 all := joinCalls(calls)
314 assert.NotContains(t, all, "master", "must not enslave a conflicting device")
315 assert.NotContains(t, all, "ip tuntap add", "guard path must not attempt a create")
316 }
317
318 // TestCreateTapAcceptsExistingRealTap pins the counterpart: an existing
319 // genuine tap passes the type check and is (idempotently) enslaved.
320 func TestCreateTapAcceptsExistingRealTap(t *testing.T) {
321 out := map[string]string{
322 "ip link show dev eit-ok": "5: eit-ok: <NO-CARRIER,BROADCAST,MULTICAST,UP> master eitri0",
323 }
324 run, calls := recorder(out, nil)
325 n, err := New(run, "10.77.1.0/24")
326 require.NoError(t, err)
327 n.isTap = func(tap string) bool { return true }
328
329 require.NoError(t, n.CreateTap(context.Background(), "eit-ok"))
330 all := joinCalls(calls)
331 assert.NotContains(t, all, "ip tuntap add")
332 assert.Contains(t, all, "ip link set eit-ok master eitri0")
333 }
334
335 // TestSysfsIsTap exercises the production sysfs probe against a fabricated
336 // /sys-like tree: an L2 TAP (IFF_TAP set), an L3 TUN (bit clear), a
337 // non-tun-driver link (no tun_flags), and an absent link.
338 func TestSysfsIsTap(t *testing.T) {
339 // The probe reads /sys/class/net/<name>/tun_flags; point it at a temp
340 // tree via a tiny indirection so the test needs no root or real devices.
341 root := t.TempDir()
342 write := func(name, flags string) {
343 dir := root + "/" + name
344 require.NoError(t, os.MkdirAll(dir, 0o755))
345 require.NoError(t, os.WriteFile(dir+"/tun_flags", []byte(flags), 0o644))
346 }
347 write("eit-tap", "0x0002") // IFF_TAP
348 write("eit-tun", "0x0001") // IFF_TUN (L3)
349 require.NoError(t, os.MkdirAll(root+"/eth0", 0o755)) // no tun_flags
350
351 isTap := func(name string) bool { return sysfsIsTapAt(root, name) }
352 assert.True(t, isTap("eit-tap"), "IFF_TAP link is a tap")
353 assert.False(t, isTap("eit-tun"), "IFF_TUN link is not an L2 tap")
354 assert.False(t, isTap("eth0"), "non-tun link is not a tap")
355 assert.False(t, isTap("absent"), "missing link is not a tap")
356 }
357
358 // TestTapConflictErrorIsPermanent pins the marker + unwrap on the conflict error.
359 func TestTapConflictErrorIsPermanent(t *testing.T) {
360 inner := fmt.Errorf("boom")
361 e := tapConflictError{err: inner}
362 assert.Equal(t, "boom", e.Error())
363 assert.Equal(t, inner, e.Unwrap())
364 var p interface{ Permanent() bool }
365 assert.True(t, errors.As(error(e), &p) && p.Permanent())
366 }
internal/agent/reconcile/reconcile.go
Old New
@@ -19,6 +19,7 @@ package reconcile
19 import ( 19 import (
20 "context" 20 "context"
21 "encoding/json" 21 "encoding/json"
22 "errors"
22 "time" 23 "time"
23 24
24 "github.com/a73x/eitri/internal/agent/seed" 25 "github.com/a73x/eitri/internal/agent/seed"
@@ -77,6 +78,17 @@ type Engine struct {
77 78
78 // MaxCreateAttempts is the maximum number of create attempts before terminal failed. 79 // MaxCreateAttempts is the maximum number of create attempts before terminal failed.
79 MaxCreateAttempts int 80 MaxCreateAttempts int
81
82 // 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.
84 // A wedged operation (disk prep, seed build, image fetch) then fails with
85 // the ctx error instead of freezing the reconcile loop forever; the next
86 // tick retries, and ctx-expiry failures are refunded from the create retry
87 // budget (see failCreate). Convergence across ticks is guaranteed because
88 // completed image downloads are durably cached per-sha. Set comfortably
89 // above the longest legitimate operation (imagecache's HTTP client allows
90 // 10m for a first-time image download).
91 StepTimeout time.Duration
80 } 92 }
81 93
82 // Step reconciles the desired snapshot against actual state and returns an 94 // Step reconciles the desired snapshot against actual state and returns an
@@ -85,6 +97,15 @@ type Engine struct {
85 // The algorithm is level-triggered: every call re-examines full state and 97 // The algorithm is level-triggered: every call re-examines full state and
86 // drives toward desired. Idempotent under repeated identical snapshots. 98 // drives toward desired. Idempotent under repeated identical snapshots.
87 func (e *Engine) Step(ctx context.Context, snap *pb.DesiredStateSnapshot) *pb.ActualStateReport { 99 func (e *Engine) Step(ctx context.Context, snap *pb.DesiredStateSnapshot) *pb.ActualStateReport {
100 // Watchdog: bound the whole step so one wedged operation cannot freeze the
101 // reconcile loop (and with it all reports for this host) forever. Pinned by
102 // TestStepTimeoutBoundsSlowOperations.
103 if e.StepTimeout > 0 {
104 var cancel context.CancelFunc
105 ctx, cancel = context.WithTimeout(ctx, e.StepTimeout)
106 defer cancel()
107 }
108
88 rep := &pb.ActualStateReport{} 109 rep := &pb.ActualStateReport{}
89 110
90 // ── 1. Epoch fence ─────────────────────────────────────────────────────── 111 // ── 1. Epoch fence ───────────────────────────────────────────────────────
@@ -183,6 +204,11 @@ func (e *Engine) Step(ctx context.Context, snap *pb.DesiredStateSnapshot) *pb.Ac
183 204
184 if now.Sub(*rec.QuarantinedAt) >= grace { 205 if now.Sub(*rec.QuarantinedAt) >= grace {
185 // Grace expired: destroy the VM. 206 // Grace expired: destroy the VM.
207 // NOTE: this may run with an expired step ctx (watchdog). Safe today
208 // because cloudhv.Kill ignores ctx (SIGKILL via pidfile) and
209 // Shutdown falls back to a ctx-free SIGTERM — a future Provisioner
210 // that honors ctx here would skip the destroy until a later tick,
211 // which the level-triggered loop tolerates but delays.
186 _ = e.Prov.Kill(ctx, id) 212 _ = e.Prov.Kill(ctx, id)
187 _ = e.Net.DeleteTap(ctx, state.TapName(id)) 213 _ = e.Net.DeleteTap(ctx, state.TapName(id))
188 _ = e.St.DeleteVM(id) 214 _ = e.St.DeleteVM(id)
@@ -228,6 +254,15 @@ func (e *Engine) Step(ctx context.Context, snap *pb.DesiredStateSnapshot) *pb.Ac
228 // create attempts to create a new VM from desired state d. 254 // create attempts to create a new VM from desired state d.
229 // rec is the existing (potentially stale) record, ok indicates whether one exists. 255 // rec is the existing (potentially stale) record, ok indicates whether one exists.
230 func (e *Engine) create(ctx context.Context, d *pb.VMDesired, rec state.Record, ok bool, rep *pb.ActualStateReport) { 256 func (e *Engine) create(ctx context.Context, d *pb.VMDesired, rec state.Record, ok bool, rep *pb.ActualStateReport) {
257 // A fired step watchdog is the STEP's failure, not this VM's: don't start
258 // an attempt (which would burn retry budget) with a dead context. Report
259 // and let the next tick — with a fresh budget — do the work. Pinned by
260 // TestWatchdogExpiryDoesNotBurnCreateAttempts.
261 if err := ctx.Err(); err != nil {
262 addReport(rep, d.VmId, rec.IP, "stopped", "creating", "step timeout: "+err.Error())
263 return
264 }
265
231 // Fix 2: if the desired spec differs from the stored spec, the user edited the 266 // Fix 2: if the desired spec differs from the stored spec, the user edited the
232 // VM definition. Reset CreateAttempts so the new spec gets a fresh retry budget 267 // VM definition. Reset CreateAttempts so the new spec gets a fresh retry budget
233 // instead of being permanently terminal-failed due to the old spec's failures. 268 // instead of being permanently terminal-failed due to the old spec's failures.
@@ -328,8 +363,31 @@ func (e *Engine) create(ctx context.Context, d *pb.VMDesired, rec state.Record,
328 addReport(rep, d.VmId, rec.IP, power, "ready", "") 363 addReport(rep, d.VmId, rec.IP, power, "ready", "")
329 } 364 }
330 365
331 // failCreate records a failed create attempt and appends a report row. 366 // permanent reports whether err (anywhere in its chain) carries the
367 // consumer-owned permanence marker — the provisioner's way of saying no
368 // retry can ever succeed (e.g. the disk-shrink guard). Consumer-side
369 // interface per the R5 convention: reconcile declares it, cloudhv implements.
370 func permanent(err error) bool {
371 var p interface{ Permanent() bool }
372 return errors.As(err, &p) && p.Permanent()
373 }
374
375 // failCreate records a failed create attempt and appends a report row. When
376 // the step context has expired, the failure belongs to the watchdog, not the
377 // VM: the attempt is refunded so wedged steps can never drive a healthy VM to
378 // terminal failed (see TestWatchdogExpiryDoesNotBurnCreateAttempts). A
379 // Permanent() error spends the whole budget at once — retrying a permanent
380 // misconfiguration only wastes tap/image work across three ticks (pinned by
381 // TestPermanentCreateErrorFailsTerminallyInOneAttempt). Ordering: the ctx
382 // refund wins over permanence — a permanent error surfacing under an expired
383 // ctx is refunded this tick and, being deterministic, terminal-fails on the
384 // next tick's fresh ctx. Keeps the watchdog invariant unconditional.
332 func (e *Engine) failCreate(ctx context.Context, rec state.Record, err error, rep *pb.ActualStateReport) { 385 func (e *Engine) failCreate(ctx context.Context, rec state.Record, err error, rep *pb.ActualStateReport) {
386 if ctx.Err() != nil {
387 rec.CreateAttempts-- // refund: the step died mid-operation
388 } else if permanent(err) {
389 rec.CreateAttempts = e.MaxCreateAttempts // terminal now; retry cannot succeed
390 }
333 rec.LastError = err.Error() 391 rec.LastError = err.Error()
334 _ = e.St.SaveVM(rec) 392 _ = e.St.SaveVM(rec)
335 393
@@ -362,8 +420,16 @@ func (e *Engine) converge(ctx context.Context, d *pb.VMDesired, rec state.Record
362 420
363 // Persistent lost VM. 421 // Persistent lost VM.
364 if d.PowerState == "running" { 422 if d.PowerState == "running" {
365 // Restart: tap dies on reboot, recreate it. 423 // Restart: tap dies on reboot, recreate it. A CreateTap failure
366 _ = e.Net.CreateTap(ctx, state.TapName(d.VmId)) 424 // (e.g. a foreign device squatting on the name) is reported with
425 // ITS message — letting Boot fail instead yields an illegible
426 // cloud-hypervisor error for the same root cause.
427 if err := e.Net.CreateTap(ctx, state.TapName(d.VmId)); err != nil {
428 rec.LastError = err.Error()
429 _ = e.St.SaveVM(rec)
430 addReport(rep, d.VmId, rec.IP, "stopped", "failed", rec.LastError)
431 return
432 }
367 if err := e.Prov.Boot(ctx, d.VmId, rec.Spec); err != nil { 433 if err := e.Prov.Boot(ctx, d.VmId, rec.Spec); err != nil {
368 rec.LastError = err.Error() 434 rec.LastError = err.Error()
369 _ = e.St.SaveVM(rec) 435 _ = e.St.SaveVM(rec)
internal/agent/reconcile/reconcile_test.go
Old New
@@ -2,6 +2,7 @@ package reconcile
2 2
3 import ( 3 import (
4 "context" 4 "context"
5 "errors"
5 "net/netip" 6 "net/netip"
6 "testing" 7 "testing"
7 "time" 8 "time"
@@ -17,18 +18,20 @@ import (
17 // ---- fakes ---- 18 // ---- fakes ----
18 19
19 type fakeProv struct { 20 type fakeProv struct {
20 running map[string]bool 21 running map[string]bool
21 prepared []string 22 prepCalls int // total PrepareDisk invocations, including failed ones
22 booted []string 23 prepared []string
23 shutdown []string 24 booted []string
24 killed []string 25 shutdown []string
25 prepErr error 26 killed []string
26 bootErr error // one-shot: consumed and cleared on first Boot call 27 prepErr error
28 bootErr error // one-shot: consumed and cleared on first Boot call
27 } 29 }
28 30
29 func newFakeProv() *fakeProv { return &fakeProv{running: map[string]bool{}} } 31 func newFakeProv() *fakeProv { return &fakeProv{running: map[string]bool{}} }
30 32
31 func (f *fakeProv) PrepareDisk(_ context.Context, s state.VMSpec, _ string) error { 33 func (f *fakeProv) PrepareDisk(_ context.Context, s state.VMSpec, _ string) error {
34 f.prepCalls++ // counted BEFORE the error short-circuit: total invocations
32 if f.prepErr != nil { 35 if f.prepErr != nil {
33 return f.prepErr 36 return f.prepErr
34 } 37 }
@@ -63,7 +66,10 @@ type fakeNet struct {
63 cidr string 66 cidr string
64 } 67 }
65 68
66 func (f *fakeNet) CreateTap(_ context.Context, t string) error { f.taps = append(f.taps, t); return nil } 69 func (f *fakeNet) CreateTap(_ context.Context, t string) error {
70 f.taps = append(f.taps, t)
71 return nil
72 }
67 func (f *fakeNet) DeleteTap(_ context.Context, t string) error { 73 func (f *fakeNet) DeleteTap(_ context.Context, t string) error {
68 f.deleted = append(f.deleted, t) 74 f.deleted = append(f.deleted, t)
69 return nil 75 return nil
@@ -349,3 +355,115 @@ func TestFenceReportIncludesQuarantinedVMs(t *testing.T) {
349 // The quarantined VM must NOT appear in rep.Vms (it is quarantined, not active). 355 // The quarantined VM must NOT appear in rep.Vms (it is quarantined, not active).
350 assert.Nil(t, findVM(rep, "vm1"), "quarantined VM must not appear in Vms on fence path") 356 assert.Nil(t, findVM(rep, "vm1"), "quarantined VM must not appear in Vms on fence path")
351 } 357 }
358
359 // TestStepTimeoutBoundsSlowOperations pins the Step watchdog: a wedged
360 // operation inside Step (here: an image fetch that never returns until its
361 // context is cancelled) must not block the reconcile loop forever. With
362 // StepTimeout set, Step's context expires, the create fails with the ctx
363 // error, and Step returns so the next tick can retry.
364 func TestStepTimeoutBoundsSlowOperations(t *testing.T) {
365 f := setup(t)
366 f.eng.StepTimeout = 50 * time.Millisecond
367 f.eng.Images = func(ctx context.Context, url, sha string) (string, error) {
368 <-ctx.Done() // wedged until the watchdog fires
369 return "", ctx.Err()
370 }
371
372 done := make(chan *pb.ActualStateReport, 1)
373 go func() { done <- f.eng.Step(context.Background(), snap(1, vm("vm1"))) }()
374
375 select {
376 case rep := <-done:
377 row := findVM(rep, "vm1")
378 require.NotNil(t, row)
379 assert.Equal(t, "creating", row.Phase, "first failed attempt stays in creating (retry budget)")
380 assert.Contains(t, row.LastError, "context deadline exceeded")
381 case <-time.After(2 * time.Second):
382 t.Fatal("Step did not return: a wedged operation blocked the reconcile loop (no watchdog)")
383 }
384 }
385
386 // TestStepTimeoutZeroDisablesWatchdog: the zero value must not impose any
387 // deadline (all pre-existing behavior and tests rely on unbounded Step).
388 func TestStepTimeoutZeroDisablesWatchdog(t *testing.T) {
389 f := setup(t)
390 sawDeadline := false
391 f.eng.Images = func(ctx context.Context, url, sha string) (string, error) {
392 _, sawDeadline = ctx.Deadline()
393 return "/cache/x.raw", nil
394 }
395 rep := f.eng.Step(context.Background(), snap(1, vm("vm1")))
396 require.NotNil(t, findVM(rep, "vm1"))
397 assert.False(t, sawDeadline, "StepTimeout==0 must not set a deadline")
398 }
399
400 // TestWatchdogExpiryDoesNotBurnCreateAttempts pins that a fired step watchdog
401 // is the STEP's failure, not any VM's: records must not lose retry budget when
402 // the ctx has expired (three wedged steps would otherwise terminal-fail
403 // perfectly healthy VMs — including ones that never got a turn because an
404 // earlier VM in the randomized map order consumed the whole budget).
405 func TestWatchdogExpiryDoesNotBurnCreateAttempts(t *testing.T) {
406 f := setup(t)
407 f.eng.StepTimeout = 50 * time.Millisecond
408 f.eng.Images = func(ctx context.Context, url, sha string) (string, error) {
409 <-ctx.Done() // every fetch wedges until the watchdog fires
410 return "", ctx.Err()
411 }
412
413 _ = f.eng.Step(context.Background(), snap(1, vm("vm1"), vm("vm2")))
414
415 recs, err := f.st.LoadVMs()
416 require.NoError(t, err)
417 for id, rec := range recs {
418 assert.Zero(t, rec.CreateAttempts,
419 "vm %s: watchdog expiry must not burn the retry budget", id)
420 }
421 }
422
423 // permErr is a test error carrying the consumer-owned permanence marker.
424 type permErr struct{ msg string }
425
426 func (e permErr) Error() string { return e.msg }
427 func (e permErr) Permanent() bool { return true }
428
429 // TestPermanentCreateErrorFailsTerminallyInOneAttempt pins fast-fail: an
430 // error marked Permanent() must not burn the retry budget across ticks —
431 // the first attempt goes straight to terminal failed, and subsequent steps
432 // do not retry the provisioner.
433 func TestPermanentCreateErrorFailsTerminallyInOneAttempt(t *testing.T) {
434 f := setup(t)
435 f.prov.prepErr = permErr{"disk_gb 1 is smaller than base image"}
436
437 rep := f.eng.Step(context.Background(), snap(1, vm("vm1")))
438 row := findVM(rep, "vm1")
439 require.NotNil(t, row)
440 assert.Equal(t, "failed", row.Phase, "permanent error must be terminal on attempt 1")
441 assert.Contains(t, row.LastError, "smaller than base image")
442
443 require.Equal(t, 1, f.prov.prepCalls, "exactly one PrepareDisk invocation")
444 rep = f.eng.Step(context.Background(), snap(1, vm("vm1")))
445 row = findVM(rep, "vm1")
446 require.NotNil(t, row)
447 assert.Equal(t, "failed", row.Phase)
448 assert.Equal(t, 1, f.prov.prepCalls, "no further provisioner attempts after a permanent failure")
449 }
450
451 // TestTransientCreateErrorStillRetries pins the counterpart: unmarked errors
452 // keep the existing bounded-retry behavior (phase creating until the budget
453 // is spent).
454 func TestTransientCreateErrorStillRetries(t *testing.T) {
455 f := setup(t)
456 f.prov.prepErr = errors.New("cp: reflink failed") // unmarked -> transient
457
458 for i := 1; i < f.eng.MaxCreateAttempts; i++ {
459 rep := f.eng.Step(context.Background(), snap(1, vm("vm1")))
460 row := findVM(rep, "vm1")
461 require.NotNil(t, row)
462 assert.Equal(t, "creating", row.Phase, "attempt %d stays in the retry budget", i)
463 }
464 rep := f.eng.Step(context.Background(), snap(1, vm("vm1")))
465 row := findVM(rep, "vm1")
466 require.NotNil(t, row)
467 assert.Equal(t, "failed", row.Phase, "budget spent -> terminal failed")
468 assert.Equal(t, f.eng.MaxCreateAttempts, f.prov.prepCalls, "one invocation per budgeted attempt")
469 }
internal/agent/syncclient/client_test.go
Old New
@@ -81,7 +81,7 @@ func (h *serverHarness) start(addr string) {
81 h.addr = lis.Addr().String() 81 h.addr = lis.Addr().String()
82 ctx, cancel := context.WithCancel(context.Background()) 82 ctx, cancel := context.WithCancel(context.Background())
83 h.cancel = cancel 83 h.cancel = cancel
84 svc := syncsvc.New(h.st, h.reg, h.hub, h.secret) 84 svc := syncsvc.New(h.st, h.reg, h.hub, h.secret, 0)
85 go svc.Serve(ctx, lis) //nolint:errcheck 85 go svc.Serve(ctx, lis) //nolint:errcheck
86 } 86 }
87 87
@@ -97,9 +97,9 @@ func (h *serverHarness) stop() {
97 // enroll creates a host and returns a valid credential for it. 97 // enroll creates a host and returns a valid credential for it.
98 func (h *serverHarness) enroll() (hostID, cred string) { 98 func (h *serverHarness) enroll() (hostID, cred string) {
99 tok, _ := h.st.CreateEnrollmentToken() 99 tok, _ := h.st.CreateEnrollmentToken()
100 host, err := h.st.RedeemEnrollmentToken(tok, "h", "linux", "amd64", "cloudhv", "") 100 host, err := h.st.RedeemEnrollmentToken(tok, "h", "linux", "amd64", "cloudhv", "", "")
101 require.NoError(h.t, err) 101 require.NoError(h.t, err)
102 return host.ID, hosttoken.Mint(h.secret, host.ID) 102 return host.ID, hosttoken.Mint(h.secret, host.ID, host.CredGeneration, time.Now())
103 } 103 }
104 104
105 func newClient(t *testing.T, addr, fp, hostID, cred string) *Client { 105 func newClient(t *testing.T, addr, fp, hostID, cred string) *Client {
internal/arch/arch_test.go
Old New
@@ -154,18 +154,14 @@ func TestDomainDoesNotImportTransportStack(t *testing.T) {
154 } 154 }
155 155
156 // R2: the server is a pure control plane. It expresses intent as desired state 156 // R2: the server is a pure control plane. It expresses intent as desired state
157 // and never actuates VMs itself, so no package under internal/server may shell 157 // and never actuates VMs itself, so no package under internal/server may reach
158 // out. A direct import of os/exec is the canonical signal of a violation. 158 // os/exec — directly OR through an internal wrapper package (checking only
159 // direct imports would let internal/util/somewrapper smuggle a shell-out in).
159 func TestServerNeverShellsOut(t *testing.T) { 160 func TestServerNeverShellsOut(t *testing.T) {
160 g := directImports(t) 161 g := directImports(t)
161 for pkg, deps := range g { 162 for pkg, offenders := range execViolations(g, module, "internal/server/", nil) {
162 if !has(pkg, "internal/server/") { 163 for _, o := range offenders {
163 continue 164 t.Errorf("control-plane package %s reaches os/exec via %s — the server never shells out", short(pkg), short(o))
164 }
165 for _, d := range deps {
166 if d == "os/exec" {
167 t.Errorf("control-plane package %s must not import os/exec — the server never shells out", short(pkg))
168 }
169 } 165 }
170 } 166 }
171 } 167 }
@@ -173,20 +169,16 @@ func TestServerNeverShellsOut(t *testing.T) {
173 // R6: all external process execution in the data plane funnels through 169 // R6: all external process execution in the data plane funnels through
174 // agent/exec.Runner, which keeps the host-touching packages mockable and 170 // agent/exec.Runner, which keeps the host-touching packages mockable and
175 // auditable. The sole exception is agent/cloudhv, which launches the 171 // auditable. The sole exception is agent/cloudhv, which launches the
176 // long-lived cloud-hypervisor process directly (exec.CommandContext) rather 172 // long-lived cloud-hypervisor process directly (exec.Command) rather than
177 // than through the one-shot Runner. Every other agent package must use Runner 173 // through the one-shot Runner. Every other agent package must use Runner and
178 // and must not import os/exec. 174 // must not reach os/exec — directly or through an internal wrapper (reaching
175 // it via the sanctioned cloudhv is fine).
179 func TestOnlyCloudhvImportsOsExecInDataPlane(t *testing.T) { 176 func TestOnlyCloudhvImportsOsExecInDataPlane(t *testing.T) {
180 g := directImports(t) 177 g := directImports(t)
181 allowed := module + "/internal/agent/cloudhv" 178 allowed := map[string]bool{module + "/internal/agent/cloudhv": true}
182 for pkg, deps := range g { 179 for pkg, offenders := range execViolations(g, module, "internal/agent/", allowed) {
183 if !has(pkg, "internal/agent/") || pkg == allowed { 180 for _, o := range offenders {
184 continue 181 t.Errorf("data-plane package %s reaches os/exec via %s — use agent/exec.Runner instead", short(pkg), short(o))
185 }
186 for _, d := range deps {
187 if d == "os/exec" {
188 t.Errorf("data-plane package %s must not import os/exec — use agent/exec.Runner instead", short(pkg))
189 }
190 } 182 }
191 } 183 }
192 } 184 }
internal/arch/doc.go
Old New
@@ -1,6 +1,6 @@
1 // Package arch holds executable architecture fitness functions for the Eitri 1 // Package arch holds executable architecture fitness functions for the Eitri
2 // module. It contains no production code — only tests (arch_test.go) that read 2 // module. It contains no production code — only _test.go files that read the
3 // the real package import graph via `go list` and fail the build when a 3 // real package import graph via `go list` and fail the build when a
4 // documented architectural invariant is violated. 4 // documented architectural invariant is violated.
5 // 5 //
6 // The invariants and their rationale are documented in docs/architecture.md. 6 // The invariants and their rationale are documented in docs/architecture.md.
internal/arch/execwalk_test.go
Old New
@@ -0,0 +1,109 @@
1 package arch
2
3 import (
4 "sort"
5 "strings"
6 "testing"
7 )
8
9 // TestExecViolationsDetectsWrappers pins the transitive os/exec detector on a
10 // fake graph: a control-plane package must be flagged whether it imports
11 // os/exec directly OR reaches it through an internal wrapper package (the
12 // loophole a direct-import check misses). Allowed packages (cloudhv for R6)
13 // are sanctioned exec users: reaching os/exec *via* them is fine, and they are
14 // themselves exempt.
15 func TestExecViolationsDetectsWrappers(t *testing.T) {
16 const m = "github.com/a73x/eitri"
17 graph := map[string][]string{
18 m + "/internal/server/api": {m + "/internal/util/execwrap", "net/http"},
19 m + "/internal/util/execwrap": {"os/exec", "fmt"},
20 m + "/internal/server/store": {m + "/internal/transport", "database/sql"},
21 m + "/internal/transport": {"crypto/tls"},
22 m + "/internal/server/hub": {"os/exec"}, // direct violation
23 m + "/internal/agent/reconcile": {m + "/internal/agent/cloudhv"},
24 m + "/internal/agent/cloudhv": {"os/exec", m + "/internal/util/spawnhelper"}, // sanctioned (allowed)
25 m + "/internal/util/spawnhelper": {"os/exec"},
26 // multi-hop: guarded -> clean intermediate -> wrapper -> os/exec
27 m + "/internal/server/syncsvc": {m + "/internal/util/clean"},
28 m + "/internal/util/clean": {m + "/internal/util/execwrap"},
29 }
30
31 t.Run("server: direct and wrapper violations found, clean pkg not flagged", func(t *testing.T) {
32 v := execViolations(graph, m, "internal/server/", nil)
33 if got := v[m+"/internal/server/api"]; len(got) != 1 || got[0] != m+"/internal/util/execwrap" {
34 t.Errorf("api must be flagged via internal/util/execwrap, got %v", got)
35 }
36 if got := v[m+"/internal/server/hub"]; len(got) != 1 || got[0] != m+"/internal/server/hub" {
37 t.Errorf("hub must be flagged as a direct importer, got %v", got)
38 }
39 if got := v[m+"/internal/server/syncsvc"]; len(got) != 1 || got[0] != m+"/internal/util/execwrap" {
40 t.Errorf("syncsvc must be flagged via the multi-hop chain, got %v", got)
41 }
42 if len(v[m+"/internal/server/store"]) != 0 {
43 t.Errorf("store is clean but was flagged: %v", v[m+"/internal/server/store"])
44 }
45 })
46
47 t.Run("agent: allowed package is exempt and does not taint importers", func(t *testing.T) {
48 allowed := map[string]bool{m + "/internal/agent/cloudhv": true}
49 v := execViolations(graph, m, "internal/agent/", allowed)
50 // Includes the documented design decision: spawnhelper (a non-allowed
51 // exec user) is reachable ONLY through cloudhv, so it is sanctioned by
52 // extension — code in the plane can only reach it via cloudhv's API.
53 if len(v) != 0 {
54 t.Errorf("no agent violations expected (cloudhv and its subtree sanctioned), got %v", v)
55 }
56 })
57 }
58
59 // execViolations returns, for every package whose module-relative path has the
60 // given prefix, the set of packages through which os/exec becomes reachable:
61 // the package itself (direct import) and/or any transitively-reached internal
62 // package that imports os/exec. Walking never descends into (or flags)
63 // packages in allowed — their exec use is sanctioned (R6: agent/cloudhv).
64 //
65 // This closes the wrapper loophole: a direct-import check misses an internal
66 // package that wraps os/exec and is imported by the guarded plane.
67 func execViolations(graph map[string][]string, module, prefix string, allowed map[string]bool) map[string][]string {
68 importsExec := func(pkg string) bool {
69 for _, d := range graph[pkg] {
70 if d == "os/exec" {
71 return true
72 }
73 }
74 return false
75 }
76
77 out := map[string][]string{}
78 for pkg := range graph {
79 rel := strings.TrimPrefix(pkg, module+"/")
80 if !strings.HasPrefix(rel, prefix) || allowed[pkg] {
81 continue
82 }
83 var offenders []string
84 if importsExec(pkg) {
85 offenders = append(offenders, pkg)
86 }
87 // Walk transitive internal deps, skipping sanctioned packages.
88 seen := map[string]bool{}
89 var walk func(string)
90 walk = func(p string) {
91 for _, d := range graph[p] {
92 if !strings.HasPrefix(d, module+"/") || seen[d] || allowed[d] {
93 continue
94 }
95 seen[d] = true
96 if importsExec(d) {
97 offenders = append(offenders, d)
98 }
99 walk(d)
100 }
101 }
102 walk(pkg)
103 if len(offenders) > 0 {
104 sort.Strings(offenders)
105 out[pkg] = offenders
106 }
107 }
108 return out
109 }
internal/joinblob/joinblob.go
Old New
@@ -0,0 +1,96 @@
1 // Package joinblob encodes and decodes the single-paste enrollment token
2 // ("join blob") an agent uses to enroll: it carries the server's HTTP base URL,
3 // its QUIC address, a one-shot enrollment token, and the server's TLS cert
4 // fingerprint for out-of-band pinning. Dependency-free leaf shared by the
5 // server (encode) and the agent (decode).
6 package joinblob
7
8 import (
9 "encoding/base64"
10 "encoding/json"
11 "fmt"
12 "net"
13 "net/url"
14 "regexp"
15 "strings"
16 )
17
18 // Prefix marks a join blob: human-recognizable and greppable.
19 const Prefix = "eitri_join_"
20
21 // version is the current blob format version.
22 const version = 1
23
24 // sha256Hex matches exactly 64 lowercase hex characters.
25 var sha256Hex = regexp.MustCompile(`^[a-f0-9]{64}$`)
26
27 // Fields is the decoded content of a join blob.
28 type Fields struct {
29 HTTPURL string // server HTTP base URL, including scheme (e.g. http://host:8080)
30 QUICAddr string // server QUIC address, host:port
31 Token string // one-shot enrollment token
32 CertFP string // server cert SHA-256 fingerprint (64 lowercase hex)
33 }
34
35 // wire is the JSON inside the base64url body. Terse keys keep the paste short.
36 type wire struct {
37 V int `json:"v"`
38 H string `json:"h"`
39 Q string `json:"q"`
40 T string `json:"t"`
41 F string `json:"f"`
42 }
43
44 // Encode validates its inputs and returns a prefixed join blob.
45 func Encode(httpURL, quicAddr, token, certFP string) (string, error) {
46 if err := validate(httpURL, quicAddr, token, certFP); err != nil {
47 return "", err
48 }
49 raw, err := json.Marshal(wire{V: version, H: httpURL, Q: quicAddr, T: token, F: certFP})
50 if err != nil {
51 return "", fmt.Errorf("marshal join blob: %w", err)
52 }
53 return Prefix + base64.RawURLEncoding.EncodeToString(raw), nil
54 }
55
56 // Decode parses and validates a join blob. Surrounding whitespace/newlines
57 // (common from copy-paste) are trimmed first.
58 func Decode(blob string) (Fields, error) {
59 rest, ok := strings.CutPrefix(strings.TrimSpace(blob), Prefix)
60 if !ok {
61 return Fields{}, fmt.Errorf("not a join blob: missing %q prefix", Prefix)
62 }
63 raw, err := base64.RawURLEncoding.DecodeString(rest)
64 if err != nil {
65 return Fields{}, fmt.Errorf("join blob is not valid base64url: %w", err)
66 }
67 var w wire
68 if err := json.Unmarshal(raw, &w); err != nil {
69 return Fields{}, fmt.Errorf("join blob JSON malformed: %w", err)
70 }
71 if w.V != version {
72 return Fields{}, fmt.Errorf("unsupported join blob version %d (want %d)", w.V, version)
73 }
74 if err := validate(w.H, w.Q, w.T, w.F); err != nil {
75 return Fields{}, err
76 }
77 return Fields{HTTPURL: w.H, QUICAddr: w.Q, Token: w.T, CertFP: w.F}, nil
78 }
79
80 // validate enforces field rules with a distinct error per field.
81 func validate(httpURL, quicAddr, token, certFP string) error {
82 u, err := url.Parse(httpURL)
83 if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.Path != "" {
84 return fmt.Errorf("http url %q must be an absolute http(s) URL with no path (no trailing slash)", httpURL)
85 }
86 if _, _, err := net.SplitHostPort(quicAddr); err != nil {
87 return fmt.Errorf("quic addr %q must be host:port", quicAddr)
88 }
89 if token == "" {
90 return fmt.Errorf("enrollment token is empty")
91 }
92 if !sha256Hex.MatchString(certFP) {
93 return fmt.Errorf("cert fingerprint must be 64 lowercase hex chars")
94 }
95 return nil
96 }
internal/joinblob/joinblob_test.go
Old New
@@ -0,0 +1,83 @@
1 package joinblob
2
3 import (
4 "encoding/base64"
5 "strings"
6 "testing"
7 )
8
9 const goodFP = "18f2977d77dfea1b74aee14533bd21c34f789139e949c57023b7364894b7e5e9"
10
11 func TestEncodeDecodeRoundTrip(t *testing.T) {
12 blob, err := Encode("http://192.168.0.190:8080", "192.168.0.190:8443", "tok123", goodFP)
13 if err != nil {
14 t.Fatalf("Encode: %v", err)
15 }
16 if !strings.HasPrefix(blob, Prefix) {
17 t.Fatalf("blob %q missing prefix %q", blob, Prefix)
18 }
19 got, err := Decode(blob)
20 if err != nil {
21 t.Fatalf("Decode: %v", err)
22 }
23 want := Fields{HTTPURL: "http://192.168.0.190:8080", QUICAddr: "192.168.0.190:8443", Token: "tok123", CertFP: goodFP}
24 if got != want {
25 t.Fatalf("round-trip mismatch:\n got %+v\nwant %+v", got, want)
26 }
27 }
28
29 func TestDecodeTrimsWhitespace(t *testing.T) {
30 blob, _ := Encode("http://h:8080", "h:8443", "t", goodFP)
31 if _, err := Decode(" " + blob + "\n"); err != nil {
32 t.Fatalf("Decode with surrounding whitespace: %v", err)
33 }
34 }
35
36 func TestEncodeRejectsBadInputs(t *testing.T) {
37 cases := map[string][4]string{
38 "http missing scheme": {"192.168.0.190:8080", "h:8443", "t", goodFP},
39 "quic not host:port": {"http://h:8080", "hostonly", "t", goodFP},
40 "empty token": {"http://h:8080", "h:8443", "", goodFP},
41 "short fp": {"http://h:8080", "h:8443", "t", "abc"},
42 "uppercase fp": {"http://h:8080", "h:8443", "t", strings.ToUpper(goodFP)},
43 "http with path": {"http://h:8080/foo", "h:8443", "t", goodFP},
44 }
45 for name, in := range cases {
46 if _, err := Encode(in[0], in[1], in[2], in[3]); err == nil {
47 t.Errorf("%s: expected error, got nil", name)
48 }
49 }
50 }
51
52 func TestDecodeRejectsWrongVersion(t *testing.T) {
53 // A well-formed blob whose version is not 1 must be rejected. Built by hand
54 // because Encode only ever emits the current version.
55 raw := `{"v":2,"h":"http://h:8080","q":"h:8443","t":"t","f":"` + goodFP + `"}`
56 blob := Prefix + base64.RawURLEncoding.EncodeToString([]byte(raw))
57 if _, err := Decode(blob); err == nil {
58 t.Fatal("expected version-mismatch error, got nil")
59 }
60 }
61
62 func TestDecodeRejectsTrailingSlash(t *testing.T) {
63 raw := `{"v":1,"h":"http://h:8080/","q":"h:8443","t":"t","f":"` + goodFP + `"}`
64 blob := Prefix + base64.RawURLEncoding.EncodeToString([]byte(raw))
65 if _, err := Decode(blob); err == nil {
66 t.Fatal("expected trailing-slash rejection, got nil")
67 }
68 }
69
70 func TestDecodeRejectsMalformed(t *testing.T) {
71 good, _ := Encode("http://h:8080", "h:8443", "t", goodFP)
72 cases := map[string]string{
73 "no prefix": strings.TrimPrefix(good, Prefix),
74 "bad base64": Prefix + "!!!not-base64!!!",
75 "bad json": Prefix + "YWJj", // base64url of "abc"
76 "empty string": "",
77 }
78 for name, blob := range cases {
79 if _, err := Decode(blob); err == nil {
80 t.Errorf("%s: expected error, got nil", name)
81 }
82 }
83 }
internal/server/api/api.go
Old New
@@ -6,13 +6,16 @@ import (
6 "crypto/sha256" 6 "crypto/sha256"
7 "crypto/subtle" 7 "crypto/subtle"
8 "database/sql" 8 "database/sql"
9 "encoding/hex"
9 "encoding/json" 10 "encoding/json"
10 "errors" 11 "errors"
12 "log/slog"
11 "net/http" 13 "net/http"
12 "regexp" 14 "regexp"
13 "strings" 15 "strings"
14 "time" 16 "time"
15 17
18 "github.com/a73x/eitri/internal/joinblob"
16 "github.com/a73x/eitri/internal/server/api/types" 19 "github.com/a73x/eitri/internal/server/api/types"
17 "github.com/a73x/eitri/internal/server/hosttoken" 20 "github.com/a73x/eitri/internal/server/hosttoken"
18 "github.com/a73x/eitri/internal/server/hub" 21 "github.com/a73x/eitri/internal/server/hub"
@@ -40,20 +43,25 @@ type Config struct {
40 HostSecret []byte 43 HostSecret []byte
41 DefaultImage DefaultImage 44 DefaultImage DefaultImage
42 ServerCertSHA256 string 45 ServerCertSHA256 string
46 AdvertiseHTTP string // HTTP base URL agents use to reach this server (scheme+host+port)
47 AdvertiseQUIC string // QUIC host:port agents use to reach this server
43 } 48 }
44 49
45 // API is the HTTP handler container. 50 // API is the HTTP handler container.
46 type API struct { 51 type API struct {
47 cfg Config 52 cfg Config
48 st *store.Store 53 st *store.Store
49 reg *registry.Registry 54 reg *registry.Registry
50 hub *hub.Hub 55 hub *hub.Hub
51 notif *notifier 56 notif *notifier
57 enrolls *ipLimiter // per-client-bucket brake on the unauthenticated enroll endpoint (v4: address, v6: /64)
58 tickets *ticketStore // one-time SSE stream tickets
52 } 59 }
53 60
54 // New constructs an API. 61 // New constructs an API.
55 func New(cfg Config, st *store.Store, reg *registry.Registry, h *hub.Hub) *API { 62 func New(cfg Config, st *store.Store, reg *registry.Registry, h *hub.Hub) *API {
56 return &API{cfg: cfg, st: st, reg: reg, hub: h, notif: newNotifier()} 63 return &API{cfg: cfg, st: st, reg: reg, hub: h, notif: newNotifier(),
64 enrolls: newIPLimiter(time.Now), tickets: newTicketStore(time.Now)}
57 } 65 }
58 66
59 // Handler returns the ServeMux with all routes registered from the declared 67 // Handler returns the ServeMux with all routes registered from the declared
@@ -174,7 +182,42 @@ func decodeJSON(w http.ResponseWriter, r *http.Request, v any) bool {
174 // validOverlays is the set of accepted overlay values at enrollment. 182 // validOverlays is the set of accepted overlay values at enrollment.
175 var validOverlays = map[string]bool{"tailscale": true, "none": true} 183 var validOverlays = map[string]bool{"tailscale": true, "none": true}
176 184
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.
187 // BEST-EFFORT: rows written here can be lost on a crash between the audited
188 // operation and this insert. The one row that must be durable — host.enroll —
189 // is written inside the redeem transaction by the store, not here.
190 func (a *API) audit(action string, detail map[string]string) {
191 raw, _ := json.Marshal(detail)
192 if err := a.st.AppendAudit(action, string(raw)); err != nil {
193 slog.Warn("audit append failed", "action", action, "err", err)
194 }
195 }
196
197 // truncate bounds attacker-controlled strings before they reach the audit
198 // log (the name in a denied enroll is unauthenticated input).
199 func truncate(s string, n int) string {
200 if len(s) > n {
201 return s[:n]
202 }
203 return s
204 }
205
206 // tokenHashPrefix returns the first 8 hex chars of sha256(tok) — enough to
207 // correlate mint/redeem audit rows without ever storing the secret.
208 func tokenHashPrefix(tok string) string {
209 sum := sha256.Sum256([]byte(tok))
210 return hex.EncodeToString(sum[:])[:8]
211 }
212
177 func (a *API) handleEnroll(w http.ResponseWriter, r *http.Request) { 213 func (a *API) handleEnroll(w http.ResponseWriter, r *http.Request) {
214 if !a.enrolls.allow(bucketKey(clientIP(r))) {
215 httpError(w, "rate limited", http.StatusTooManyRequests)
216 return
217 }
218 // Unauthenticated endpoint: bound the body so junk can't bloat memory or
219 // the denied-audit trail. Legitimate enroll bodies are well under 1 KiB.
220 r.Body = http.MaxBytesReader(w, r.Body, 16<<10)
178 var req types.EnrollRequest 221 var req types.EnrollRequest
179 if !decodeJSON(w, r, &req) { 222 if !decodeJSON(w, r, &req) {
180 return 223 return
@@ -187,12 +230,16 @@ func (a *API) handleEnroll(w http.ResponseWriter, r *http.Request) {
187 httpError(w, "overlay must be one of: tailscale, none", http.StatusBadRequest) 230 httpError(w, "overlay must be one of: tailscale, none", http.StatusBadRequest)
188 return 231 return
189 } 232 }
190 host, err := a.st.RedeemEnrollmentToken(req.Token, req.Name, req.OS, req.Arch, req.Provisioner, req.Overlay) 233 host, err := a.st.RedeemEnrollmentToken(req.Token, req.Name, req.OS, req.Arch, req.Provisioner, req.Overlay, clientIP(r))
191 if err != nil { 234 if err != nil {
235 a.audit("host.enroll.denied", map[string]string{
236 "remote": clientIP(r), "name": truncate(req.Name, 64),
237 "token_hash_prefix": tokenHashPrefix(req.Token)})
192 httpError(w, "forbidden", http.StatusForbidden) 238 httpError(w, "forbidden", http.StatusForbidden)
193 return 239 return
194 } 240 }
195 cred := hosttoken.Mint(a.cfg.HostSecret, host.ID) 241 // host.enroll is audited durably inside the redeem transaction.
242 cred := hosttoken.Mint(a.cfg.HostSecret, host.ID, host.CredGeneration, time.Now())
196 writeJSON(w, http.StatusCreated, types.EnrollResponse{ 243 writeJSON(w, http.StatusCreated, types.EnrollResponse{
197 BridgeCIDR: host.BridgeCIDR, 244 BridgeCIDR: host.BridgeCIDR,
198 Credential: cred, 245 Credential: cred,
@@ -208,7 +255,14 @@ func (a *API) handleCreateEnrollToken(w http.ResponseWriter, r *http.Request) {
208 httpError(w, "internal error", http.StatusInternalServerError) 255 httpError(w, "internal error", http.StatusInternalServerError)
209 return 256 return
210 } 257 }
211 writeJSON(w, http.StatusCreated, types.EnrollTokenResponse{Token: tok}) 258 a.audit("enroll-token.mint", map[string]string{
259 "remote": clientIP(r), "token_hash_prefix": tokenHashPrefix(tok)})
260 join, err := joinblob.Encode(a.cfg.AdvertiseHTTP, a.cfg.AdvertiseQUIC, tok, a.cfg.ServerCertSHA256)
261 if err != nil {
262 httpError(w, "internal error", http.StatusInternalServerError)
263 return
264 }
265 writeJSON(w, http.StatusCreated, types.EnrollTokenResponse{Token: tok, Join: join})
212 } 266 }
213 267
214 // --- hosts --- 268 // --- hosts ---
@@ -240,22 +294,26 @@ func toHostResponse(h store.Host, st registry.HostState, ok bool, alloc store.Al
240 } 294 }
241 295
242 // snapshotHosts builds the wire host list (durable host rows merged with live 296 // snapshotHosts builds the wire host list (durable host rows merged with live
243 // registry state and server-computed allocation). Shared by GET /hosts and SSE. 297 // registry state and server-computed allocation) for GET /hosts.
244 func (a *API) snapshotHosts() ([]types.Host, error) { 298 func (a *API) snapshotHosts() ([]types.Host, error) {
245 hosts, err := a.st.ListHosts() 299 // Single-tx read: hosts and alloc must not mix two epochs (same property
246 if err != nil { 300 // the SSE stream needs; the unused vms read is cheap on these small
247 return nil, err 301 // control-plane tables).
248 } 302 hosts, alloc, _, err := a.st.Snapshot()
249 alloc, err := a.st.AllocatedByHost()
250 if err != nil { 303 if err != nil {
251 return nil, err 304 return nil, err
252 } 305 }
306 return a.buildHostResponses(hosts, alloc), nil
307 }
308
309 // buildHostResponses merges durable host rows with live registry state.
310 func (a *API) buildHostResponses(hosts []store.Host, alloc map[string]store.Alloc) []types.Host {
253 out := make([]types.Host, len(hosts)) 311 out := make([]types.Host, len(hosts))
254 for i, h := range hosts { 312 for i, h := range hosts {
255 st, ok := a.reg.Get(h.ID) 313 st, ok := a.reg.Get(h.ID)
256 out[i] = toHostResponse(h, st, ok, alloc[h.ID]) 314 out[i] = toHostResponse(h, st, ok, alloc[h.ID])
257 } 315 }
258 return out, nil 316 return out
259 } 317 }
260 318
261 func (a *API) handleListHosts(w http.ResponseWriter, r *http.Request) { 319 func (a *API) handleListHosts(w http.ResponseWriter, r *http.Request) {
@@ -293,12 +351,18 @@ func toVMResponse(vm store.VM, actualPower, phase string) types.VM {
293 } 351 }
294 352
295 // snapshotVMs builds the wire VM list (durable VM rows merged with live 353 // snapshotVMs builds the wire VM list (durable VM rows merged with live
296 // actual-state from the registry). Shared by GET /vms and the SSE stream. 354 // actual-state from the registry). Used by GET /vms; the SSE stream uses
355 // buildVMResponses over a single-tx store.Snapshot instead.
297 func (a *API) snapshotVMs() ([]types.VM, error) { 356 func (a *API) snapshotVMs() ([]types.VM, error) {
298 vms, err := a.st.ListVMs() 357 vms, err := a.st.ListVMs()
299 if err != nil { 358 if err != nil {
300 return nil, err 359 return nil, err
301 } 360 }
361 return a.buildVMResponses(vms), nil
362 }
363
364 // buildVMResponses merges durable VM rows with live registry actual-state.
365 func (a *API) buildVMResponses(vms []store.VM) []types.VM {
302 out := make([]types.VM, len(vms)) 366 out := make([]types.VM, len(vms))
303 for i, vm := range vms { 367 for i, vm := range vms {
304 var actualPower, phase string 368 var actualPower, phase string
@@ -313,7 +377,7 @@ func (a *API) snapshotVMs() ([]types.VM, error) {
313 } 377 }
314 out[i] = toVMResponse(vm, actualPower, phase) 378 out[i] = toVMResponse(vm, actualPower, phase)
315 } 379 }
316 return out, nil 380 return out
317 } 381 }
318 382
319 func (a *API) handleListVMs(w http.ResponseWriter, r *http.Request) { 383 func (a *API) handleListVMs(w http.ResponseWriter, r *http.Request) {
@@ -373,6 +437,12 @@ func validateCreateVM(req *types.CreateVMRequest) (string, int) {
373 if !sha256Hex.MatchString(req.ImageSHA256) { 437 if !sha256Hex.MatchString(req.ImageSHA256) {
374 return "invalid image_sha256", http.StatusBadRequest 438 return "invalid image_sha256", http.StatusBadRequest
375 } 439 }
440 // Resource floors (post-defaults, so a zero has already become the default).
441 // Negative values are never meaningful; a too-small disk_gb is also rejected
442 // by the agent's never-shrink guard, but nonsense should fail at create time.
443 if req.VCPUs < 1 || req.MemMB < 1 || req.DiskGB < 1 {
444 return "vcpus, mem_mb and disk_gb must each be >= 1", http.StatusBadRequest
445 }
376 return "", 0 446 return "", 0
377 } 447 }
378 448
internal/server/api/api_test.go
Old New
@@ -9,6 +9,7 @@ import (
9 "testing" 9 "testing"
10 "time" 10 "time"
11 11
12 "github.com/a73x/eitri/internal/joinblob"
12 "github.com/a73x/eitri/internal/server/hub" 13 "github.com/a73x/eitri/internal/server/hub"
13 "github.com/a73x/eitri/internal/server/registry" 14 "github.com/a73x/eitri/internal/server/registry"
14 "github.com/a73x/eitri/internal/server/store" 15 "github.com/a73x/eitri/internal/server/store"
@@ -137,6 +138,9 @@ func testServer(t *testing.T) (*httptest.Server, *store.Store, *hub.Hub) {
137 DefaultImage: DefaultImage{ 138 DefaultImage: DefaultImage{
138 URL: "https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img", 139 URL: "https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img",
139 SHA256: strings.Repeat("a", 64)}, 140 SHA256: strings.Repeat("a", 64)},
141 AdvertiseHTTP: "http://127.0.0.1:8080",
142 AdvertiseQUIC: "127.0.0.1:8443",
143 ServerCertSHA256: strings.Repeat("c", 64),
140 }, st, registry.New(time.Now), h) 144 }, st, registry.New(time.Now), h)
141 ts := httptest.NewServer(a.Handler()) 145 ts := httptest.NewServer(a.Handler())
142 t.Cleanup(ts.Close) 146 t.Cleanup(ts.Close)
@@ -322,6 +326,38 @@ func TestCreateVMNameValidation(t *testing.T) {
322 } 326 }
323 } 327 }
324 328
329 // TestCreateVMResourceValidation pins the resource floors: post-defaults,
330 // vcpus/mem_mb/disk_gb must each be >= 1. Zero means "use the default"; a
331 // negative value is never meaningful (a tiny disk_gb would also truncate the
332 // base image on the agent — the agent-side never-shrink guard is the backstop,
333 // this rejects nonsense at create time).
334 func TestCreateVMResourceValidation(t *testing.T) {
335 ts, _, _ := testServer(t)
336 out := enroll(t, ts)
337
338 tests := []struct {
339 name string
340 body map[string]any
341 wantStatus int
342 }{
343 {"negative disk_gb", map[string]any{"disk_gb": -1}, 400},
344 {"negative vcpus", map[string]any{"vcpus": -2}, 400},
345 {"negative mem_mb", map[string]any{"mem_mb": -512}, 400},
346 {"zero values take defaults", map[string]any{"disk_gb": 0, "vcpus": 0, "mem_mb": 0}, 201},
347 {"minimal explicit values", map[string]any{"disk_gb": 1, "vcpus": 1, "mem_mb": 1}, 201},
348 }
349 for _, tc := range tests {
350 t.Run(tc.name, func(t *testing.T) {
351 body := map[string]any{"host_id": out["host_id"]}
352 for k, v := range tc.body {
353 body[k] = v
354 }
355 resp := do(t, "POST", ts.URL+"/api/v1/vms", "admintok", body)
356 assert.Equal(t, tc.wantStatus, resp.StatusCode)
357 })
358 }
359 }
360
325 func TestCreateVMSSHKeyValidation(t *testing.T) { 361 func TestCreateVMSSHKeyValidation(t *testing.T) {
326 ts, _, _ := testServer(t) 362 ts, _, _ := testServer(t)
327 out := enroll(t, ts) 363 out := enroll(t, ts)
@@ -407,3 +443,158 @@ func TestCreateVMImageSHAAdmission(t *testing.T) {
407 assert.Equal(t, 201, resp.StatusCode) 443 assert.Equal(t, 201, resp.StatusCode)
408 }) 444 })
409 } 445 }
446
447 func TestMintEnrollTokenReturnsJoinBlob(t *testing.T) {
448 ts, _, _ := testServer(t)
449 resp := do(t, "POST", ts.URL+"/api/v1/enroll-tokens", "admintok", nil)
450 if resp.StatusCode != http.StatusCreated {
451 t.Fatalf("mint status = %d", resp.StatusCode)
452 }
453 var out struct {
454 Token string `json:"token"`
455 Join string `json:"join"`
456 }
457 require.NoError(t, json.NewDecoder(resp.Body).Decode(&out))
458 if out.Token == "" || out.Join == "" {
459 t.Fatalf("expected token and join, got %+v", out)
460 }
461 f, err := joinblob.Decode(out.Join)
462 if err != nil {
463 t.Fatalf("join blob decode: %v", err)
464 }
465 if f.Token != out.Token {
466 t.Errorf("join token %q != response token %q", f.Token, out.Token)
467 }
468 // The blob must carry back exactly the configured advertise addresses and
469 // cert fingerprint (see testServer's api.Config) — not merely non-empty.
470 assert.Equal(t, "http://127.0.0.1:8080", f.HTTPURL)
471 assert.Equal(t, "127.0.0.1:8443", f.QUICAddr)
472 assert.Equal(t, strings.Repeat("c", 64), f.CertFP)
473 }
474
475 // TestEnrollmentIsAudited pins the audit trail: minting a token, a successful
476 // enroll, and a denied enroll each leave a durable audit row (secrets appear
477 // only as hash prefixes, never verbatim).
478 func TestEnrollmentIsAudited(t *testing.T) {
479 ts, st, _ := testServer(t)
480
481 resp := do(t, "POST", ts.URL+"/api/v1/enroll-tokens", "admintok", nil)
482 require.Equal(t, 201, resp.StatusCode)
483 var tok map[string]string
484 json.NewDecoder(resp.Body).Decode(&tok)
485
486 resp = do(t, "POST", ts.URL+"/api/v1/enroll", "", map[string]string{
487 "token": tok["token"], "name": "host-a", "os": "linux", "arch": "amd64", "provisioner": "cloudhv"})
488 require.Equal(t, 201, resp.StatusCode)
489
490 resp = do(t, "POST", ts.URL+"/api/v1/enroll", "", map[string]string{
491 "token": "bogus", "name": "evil", "os": "linux", "arch": "amd64", "provisioner": "cloudhv"})
492 require.Equal(t, 403, resp.StatusCode)
493
494 rows, err := st.ListAudit(10)
495 require.NoError(t, err)
496 require.Len(t, rows, 3)
497 assert.Equal(t, "host.enroll.denied", rows[0].Action)
498 assert.Equal(t, "host.enroll", rows[1].Action)
499 assert.Contains(t, rows[1].Detail, "host-a")
500 assert.Equal(t, "enroll-token.mint", rows[2].Action)
501 for _, r := range rows {
502 assert.NotContains(t, r.Detail, tok["token"], "raw token must never reach the audit log")
503 }
504 }
505
506 // TestEnrollRateLimited pins the per-IP limiter on the unauthenticated enroll
507 // endpoint: a burst beyond the limit returns 429 without touching the store.
508 func TestEnrollRateLimited(t *testing.T) {
509 ts, _, _ := testServer(t)
510
511 var got429 bool
512 for i := 0; i < enrollBurst+3; i++ {
513 resp := do(t, "POST", ts.URL+"/api/v1/enroll", "", map[string]string{
514 "token": "bogus", "name": "x", "os": "linux", "arch": "amd64", "provisioner": "cloudhv"})
515 if resp.StatusCode == http.StatusTooManyRequests {
516 got429 = true
517 break
518 }
519 require.Equal(t, 403, resp.StatusCode, "pre-limit attempts fail auth, not rate limit")
520 }
521 assert.True(t, got429, "burst beyond enrollBurst must yield 429")
522 }
523
524 // TestRevokeCredentialEndpoint pins per-host revocation: POST
525 // /api/v1/hosts/{id}/revoke-credential bumps the generation (204), leaves an
526 // audit row, requires admin auth, and 404s unknown hosts.
527 func TestRevokeCredentialEndpoint(t *testing.T) {
528 ts, st, _ := testServer(t)
529 out := enroll(t, ts)
530
531 resp := do(t, "POST", ts.URL+"/api/v1/hosts/"+out["host_id"]+"/revoke-credential", "admintok", nil)
532 require.Equal(t, 204, resp.StatusCode)
533
534 h, err := st.GetHost(out["host_id"])
535 require.NoError(t, err)
536 assert.Equal(t, int64(2), h.CredGeneration)
537
538 rows, err := st.ListAudit(3)
539 require.NoError(t, err)
540 require.NotEmpty(t, rows)
541 assert.Equal(t, "host.credential.revoke", rows[0].Action)
542 assert.Contains(t, rows[0].Detail, out["host_id"])
543
544 resp = do(t, "POST", ts.URL+"/api/v1/hosts/deadbeef/revoke-credential", "admintok", nil)
545 assert.Equal(t, 404, resp.StatusCode)
546
547 resp = do(t, "POST", ts.URL+"/api/v1/hosts/"+out["host_id"]+"/revoke-credential", "wrong", nil)
548 assert.Equal(t, 401, resp.StatusCode)
549 }
550
551 // TestEnrollMintsGenerationCredential pins that the enroll response carries a
552 // v2 credential bound to the host's current generation.
553 func TestEnrollMintsGenerationCredential(t *testing.T) {
554 ts, _, _ := testServer(t)
555 out := enroll(t, ts)
556 parts := strings.Split(out["credential"], ".")
557 require.Len(t, parts, 4, "v2 credential is host_id.gen.issued.hmac")
558 assert.Equal(t, out["host_id"], parts[0])
559 assert.Equal(t, "1", parts[1], "fresh enrollment mints generation 1")
560 }
561
562 // TestAuditEndpoint pins the forensic read API: GET /api/v1/audit returns
563 // newest-first rows (detail as embedded JSON), honors ?limit, requires admin.
564 func TestAuditEndpoint(t *testing.T) {
565 ts, _, _ := testServer(t)
566 enroll(t, ts) // produces mint + enroll audit rows
567
568 resp := do(t, "GET", ts.URL+"/api/v1/audit", "admintok", nil)
569 require.Equal(t, 200, resp.StatusCode)
570 var rows []struct {
571 At time.Time `json:"at"`
572 Action string `json:"action"`
573 Detail json.RawMessage `json:"detail"`
574 }
575 require.NoError(t, json.NewDecoder(resp.Body).Decode(&rows))
576 require.Len(t, rows, 2)
577 assert.Equal(t, "host.enroll", rows[0].Action, "newest first")
578 assert.Equal(t, "enroll-token.mint", rows[1].Action)
579 assert.False(t, rows[0].At.IsZero())
580 assert.Contains(t, string(rows[0].Detail), "host-a")
581
582 resp = do(t, "GET", ts.URL+"/api/v1/audit?limit=1", "admintok", nil)
583 require.Equal(t, 200, resp.StatusCode)
584 rows = nil
585 require.NoError(t, json.NewDecoder(resp.Body).Decode(&rows))
586 assert.Len(t, rows, 1)
587
588 resp = do(t, "GET", ts.URL+"/api/v1/audit", "", nil)
589 assert.Equal(t, 401, resp.StatusCode)
590 }
591
592 // TestAuditEndpointLimitValidation pins the explicit reject-not-clamp
593 // contract for ?limit.
594 func TestAuditEndpointLimitValidation(t *testing.T) {
595 ts, _, _ := testServer(t)
596 for _, bad := range []string{"0", "1001", "-3", "abc"} {
597 resp := do(t, "GET", ts.URL+"/api/v1/audit?limit="+bad, "admintok", nil)
598 assert.Equal(t, 400, resp.StatusCode, "limit=%s must be rejected", bad)
599 }
600 }
internal/server/api/decommission_api_test.go
Old New
@@ -24,9 +24,12 @@ func apiServer(t *testing.T) (*httptest.Server, *API, *store.Store) {
24 require.NoError(t, err) 24 require.NoError(t, err)
25 t.Cleanup(func() { st.Close() }) 25 t.Cleanup(func() { st.Close() })
26 a := New(Config{ 26 a := New(Config{
27 AdminToken: "admintok", 27 AdminToken: "admintok",
28 HostSecret: []byte("hostsecret"), 28 HostSecret: []byte("hostsecret"),
29 DefaultImage: DefaultImage{URL: "http://img", SHA256: strings.Repeat("a", 64)}, 29 DefaultImage: DefaultImage{URL: "http://img", SHA256: strings.Repeat("a", 64)},
30 AdvertiseHTTP: "http://127.0.0.1:8080",
31 AdvertiseQUIC: "127.0.0.1:8443",
32 ServerCertSHA256: strings.Repeat("c", 64),
30 }, st, registry.New(time.Now), hub.New()) 33 }, st, registry.New(time.Now), hub.New())
31 ts := httptest.NewServer(a.Handler()) 34 ts := httptest.NewServer(a.Handler())
32 t.Cleanup(ts.Close) 35 t.Cleanup(ts.Close)
@@ -79,7 +82,7 @@ func TestEventsStreamSendsSnapshot(t *testing.T) {
79 82
80 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 83 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
81 defer cancel() 84 defer cancel()
82 req, _ := http.NewRequestWithContext(ctx, "GET", ts.URL+"/api/v1/events?token=admintok", nil) 85 req, _ := http.NewRequestWithContext(ctx, "GET", ts.URL+"/api/v1/events?ticket="+mintTicket(t, ts.URL, "admintok"), nil)
83 resp, err := http.DefaultClient.Do(req) 86 resp, err := http.DefaultClient.Do(req)
84 require.NoError(t, err) 87 require.NoError(t, err)
85 defer resp.Body.Close() 88 defer resp.Body.Close()
@@ -107,6 +110,6 @@ func TestEventsStreamSendsSnapshot(t *testing.T) {
107 110
108 func TestEventsRejectsBadToken(t *testing.T) { 111 func TestEventsRejectsBadToken(t *testing.T) {
109 ts, _, _ := apiServer(t) 112 ts, _, _ := apiServer(t)
110 resp := do(t, "GET", ts.URL+"/api/v1/events?token=wrong", "", nil) 113 resp := do(t, "GET", ts.URL+"/api/v1/events?ticket=wrong", "", nil)
111 assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) 114 assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
112 } 115 }
internal/server/api/events.go
Old New
@@ -7,6 +7,7 @@ import (
7 "errors" 7 "errors"
8 "fmt" 8 "fmt"
9 "net/http" 9 "net/http"
10 "strconv"
10 "time" 11 "time"
11 12
12 "github.com/a73x/eitri/internal/server/api/types" 13 "github.com/a73x/eitri/internal/server/api/types"
@@ -24,20 +25,73 @@ func (a *API) handleDecommissionHost(w http.ResponseWriter, r *http.Request) {
24 httpError(w, "internal error", http.StatusInternalServerError) 25 httpError(w, "internal error", http.StatusInternalServerError)
25 return 26 return
26 } 27 }
28 a.audit("host.decommission", map[string]string{"host_id": id, "remote": clientIP(r)})
27 a.notif.notify() 29 a.notif.notify()
28 w.WriteHeader(http.StatusAccepted) 30 w.WriteHeader(http.StatusAccepted)
29 } 31 }
30 32
33 // handleRevokeCredential bumps the host's credential generation, revoking its
34 // outstanding credential WITHOUT rotating the fleet secret. The agent's live
35 // session is closed by syncsvc within one report tick; the host stays dark
36 // until the operator re-enrolls it with a fresh join blob.
37 func (a *API) handleRevokeCredential(w http.ResponseWriter, r *http.Request) {
38 id := r.PathValue("id")
39 // Audit is written inside the bump transaction (a security action must
40 // not be able to happen unrecorded).
41 _, err := a.st.BumpCredGeneration(id, clientIP(r))
42 switch {
43 case errors.Is(err, sql.ErrNoRows):
44 httpError(w, "host not found", http.StatusNotFound)
45 return
46 case err != nil:
47 httpError(w, "internal error", http.StatusInternalServerError)
48 return
49 }
50 w.WriteHeader(http.StatusNoContent)
51 }
52
53 // handleListAudit returns the newest audit rows (default 100, ?limit=N caps
54 // at 1000). Completes the forensic story: rows were previously reachable only
55 // by opening the SQLite file.
56 func (a *API) handleListAudit(w http.ResponseWriter, r *http.Request) {
57 limit := 100
58 if v := r.URL.Query().Get("limit"); v != "" {
59 n, err := strconv.Atoi(v)
60 if err != nil || n < 1 || n > 1000 {
61 httpError(w, "limit must be 1..1000", http.StatusBadRequest)
62 return
63 }
64 limit = n
65 }
66 rows, err := a.st.ListAudit(limit)
67 if err != nil {
68 httpError(w, "internal error", http.StatusInternalServerError)
69 return
70 }
71 out := make([]types.AuditEvent, len(rows))
72 for i, e := range rows {
73 detail := json.RawMessage(e.Detail)
74 if !json.Valid(detail) { // defensive: never emit invalid JSON
75 detail, _ = json.Marshal(e.Detail)
76 }
77 out[i] = types.AuditEvent{At: e.At, Action: e.Action, Detail: detail}
78 }
79 writeJSON(w, http.StatusOK, out)
80 }
81
82 // handleMintStreamTicket issues a one-time short-TTL ticket for the SSE
83 // stream (admin-authenticated; the ticket is the only thing that ever
84 // appears in a URL).
85 func (a *API) handleMintStreamTicket(w http.ResponseWriter, r *http.Request) {
86 writeJSON(w, http.StatusCreated, types.StreamTicketResponse{Ticket: a.tickets.mint()})
87 }
88
31 // handleEvents streams the fleet snapshot as Server-Sent Events. It pushes on 89 // handleEvents streams the fleet snapshot as Server-Sent Events. It pushes on
32 // every desired-state change (via the notifier) and re-checks on a 1s tick to 90 // every desired-state change (via the notifier) and re-checks on a 1s tick to
33 // catch agent-reported actual-state changes, sending only when the snapshot 91 // catch agent-reported actual-state changes, sending only when the snapshot
34 // actually changed. A periodic comment keeps the connection alive. 92 // actually changed. A periodic comment keeps the connection alive.
35 func (a *API) handleEvents(w http.ResponseWriter, r *http.Request) { 93 func (a *API) handleEvents(w http.ResponseWriter, r *http.Request) {
36 if a.cfg.AdminToken == "" { 94 if !a.tickets.consume(r.URL.Query().Get("ticket")) {
37 http.Error(w, "unauthorized", http.StatusUnauthorized)
38 return
39 }
40 if !constantTimeTokenMatch(r.URL.Query().Get("token"), a.cfg.AdminToken) {
41 http.Error(w, "unauthorized", http.StatusUnauthorized) 95 http.Error(w, "unauthorized", http.StatusUnauthorized)
42 return 96 return
43 } 97 }
@@ -96,15 +150,16 @@ func (a *API) handleEvents(w http.ResponseWriter, r *http.Request) {
96 } 150 }
97 } 151 }
98 152
99 // marshalSnapshot builds and JSON-encodes the current fleet snapshot. 153 // marshalSnapshot builds and JSON-encodes the current fleet snapshot. The
154 // store reads happen in one transaction (store.Snapshot) so hosts, allocation
155 // and VMs can never mix state from two different epochs.
100 func (a *API) marshalSnapshot() ([]byte, error) { 156 func (a *API) marshalSnapshot() ([]byte, error) {
101 hosts, err := a.snapshotHosts() 157 hosts, alloc, vms, err := a.st.Snapshot()
102 if err != nil {
103 return nil, err
104 }
105 vms, err := a.snapshotVMs()
106 if err != nil { 158 if err != nil {
107 return nil, err 159 return nil, err
108 } 160 }
109 return json.Marshal(types.StateSnapshot{Hosts: hosts, VMs: vms}) 161 return json.Marshal(types.StateSnapshot{
162 Hosts: a.buildHostResponses(hosts, alloc),
163 VMs: a.buildVMResponses(vms),
164 })
110 } 165 }
internal/server/api/events_test.go
Old New
@@ -0,0 +1,95 @@
1 package api
2
3 import (
4 "bufio"
5 "context"
6 "encoding/json"
7 "net/http"
8 "strings"
9 "testing"
10 "time"
11
12 "github.com/stretchr/testify/assert"
13 "github.com/stretchr/testify/require"
14 )
15
16 // mintTicket mints a one-time stream ticket via the admin API.
17 func mintTicket(t *testing.T, ts string, token string) string {
18 t.Helper()
19 resp := do(t, "POST", ts+"/api/v1/stream-tickets", token, nil)
20 require.Equal(t, 201, resp.StatusCode)
21 var out map[string]string
22 require.NoError(t, json.NewDecoder(resp.Body).Decode(&out))
23 require.NotEmpty(t, out["ticket"])
24 return out["ticket"]
25 }
26
27 // readFirstSSEEvent connects to the events URL and returns true when a
28 // "event: state" frame arrives before the deadline.
29 func readFirstSSEEvent(t *testing.T, url string) (int, bool) {
30 t.Helper()
31 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
32 defer cancel()
33 req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
34 resp, err := http.DefaultClient.Do(req)
35 require.NoError(t, err)
36 defer resp.Body.Close()
37 if resp.StatusCode != 200 {
38 return resp.StatusCode, false
39 }
40 sc := bufio.NewScanner(resp.Body)
41 for sc.Scan() {
42 if strings.HasPrefix(sc.Text(), "event: state") {
43 return 200, true
44 }
45 }
46 return 200, false
47 }
48
49 // TestStreamTicketFlow pins the SSE auth model: the long-lived admin token
50 // never rides in a URL. A one-time short-TTL ticket is minted over an
51 // authenticated POST; the events stream consumes it; replay fails; the old
52 // ?token= path is gone.
53 func TestStreamTicketFlow(t *testing.T) {
54 ts, _, _ := testServer(t)
55
56 t.Run("mint requires admin", func(t *testing.T) {
57 resp := do(t, "POST", ts.URL+"/api/v1/stream-tickets", "wrong", nil)
58 assert.Equal(t, 401, resp.StatusCode)
59 })
60
61 tick := mintTicket(t, ts.URL, "admintok")
62
63 t.Run("valid ticket streams", func(t *testing.T) {
64 code, gotState := readFirstSSEEvent(t, ts.URL+"/api/v1/events?ticket="+tick)
65 assert.Equal(t, 200, code)
66 assert.True(t, gotState, "first state frame must arrive")
67 })
68
69 t.Run("replay rejected", func(t *testing.T) {
70 code, _ := readFirstSSEEvent(t, ts.URL+"/api/v1/events?ticket="+tick)
71 assert.Equal(t, 401, code, "a consumed ticket must be rejected")
72 })
73
74 t.Run("garbage ticket rejected", func(t *testing.T) {
75 code, _ := readFirstSSEEvent(t, ts.URL+"/api/v1/events?ticket=nope")
76 assert.Equal(t, 401, code)
77 })
78
79 t.Run("admin token in query string is rejected", func(t *testing.T) {
80 code, _ := readFirstSSEEvent(t, ts.URL+"/api/v1/events?token=admintok")
81 assert.Equal(t, 401, code, "an admin token in the URL must not authenticate the stream")
82 })
83 }
84
85 // TestStreamTicketExpiry pins the TTL with an injected clock.
86 func TestStreamTicketExpiry(t *testing.T) {
87 now := time.Unix(1_750_000_000, 0)
88 tk := newTicketStore(func() time.Time { return now })
89 tick := tk.mint()
90 now = now.Add(streamTicketTTL + time.Second)
91 assert.False(t, tk.consume(tick), "expired ticket must not be consumable")
92 fresh := tk.mint()
93 assert.True(t, tk.consume(fresh))
94 assert.False(t, tk.consume(fresh), "one-time: second consume fails")
95 }
internal/server/api/ratelimit.go
Old New
@@ -0,0 +1,110 @@
1 package api
2
3 import (
4 "net"
5 "net/http"
6 "net/netip"
7 "sync"
8 "time"
9 )
10
11 // enrollBurst is the per-IP bucket capacity for POST /api/v1/enroll — the
12 // only unauthenticated endpoint. Enrollment is operator-paced (one paste per
13 // host), so a small burst with a slow refill comfortably covers legitimate
14 // use while blunting token-guessing.
15 const enrollBurst = 5
16
17 // enrollRefillEvery is how often one token drips back into a bucket.
18 const enrollRefillEvery = 30 * time.Second
19
20 // maxLimiterEntries caps the bucket map so an address-spoofing flood cannot
21 // grow it without bound; when full, entries idle past a full refill are
22 // pruned, and if nothing is prunable the request is allowed (fail open — the
23 // limiter is a brake, not the auth boundary).
24 const maxLimiterEntries = 10_000
25
26 // ipLimiter is a per-IP token bucket. now is injectable for tests.
27 type ipLimiter struct {
28 mu sync.Mutex
29 buckets map[string]*bucket
30 now func() time.Time
31 }
32
33 type bucket struct {
34 tokens float64
35 lastSeen time.Time
36 }
37
38 func newIPLimiter(now func() time.Time) *ipLimiter {
39 return &ipLimiter{buckets: map[string]*bucket{}, now: now}
40 }
41
42 // allow reports whether ip may proceed, consuming one token if so.
43 func (l *ipLimiter) allow(ip string) bool {
44 l.mu.Lock()
45 defer l.mu.Unlock()
46 now := l.now()
47
48 b := l.buckets[ip]
49 if b == nil {
50 if len(l.buckets) >= maxLimiterEntries && !l.prune(now) {
51 return true // fail open; see maxLimiterEntries
52 }
53 b = &bucket{tokens: enrollBurst}
54 l.buckets[ip] = b
55 } else {
56 refill := float64(now.Sub(b.lastSeen)) / float64(enrollRefillEvery)
57 b.tokens = min(enrollBurst, b.tokens+refill)
58 }
59 b.lastSeen = now
60 if b.tokens < 1 {
61 return false
62 }
63 b.tokens--
64 return true
65 }
66
67 // prune drops buckets idle past a full refill; reports whether any were freed.
68 func (l *ipLimiter) prune(now time.Time) bool {
69 idle := time.Duration(enrollBurst) * enrollRefillEvery
70 freed := false
71 for ip, b := range l.buckets {
72 if now.Sub(b.lastSeen) > idle {
73 delete(l.buckets, ip)
74 freed = true
75 }
76 }
77 return freed
78 }
79
80 // bucketKey normalizes a client IP into its rate-limit bucket: IPv4 (and
81 // v4-mapped v6) per address; IPv6 by /64, since a single host trivially owns
82 // billions of v6 addresses and per-address buckets would under-limit it.
83 // Unparseable input is used raw (still a stable key).
84 func bucketKey(ip string) string {
85 addr, err := netip.ParseAddr(ip)
86 if err != nil {
87 return ip
88 }
89 if addr.Is4() || addr.Is4In6() {
90 return addr.Unmap().String()
91 }
92 p, err := addr.Prefix(64)
93 if err != nil {
94 // Unreachable for a parsed addr (BitLen is always 128 here); kept as
95 // belt-and-braces so a refactor can't turn this into a panic.
96 return ip
97 }
98 return p.String()
99 }
100
101 // clientIP extracts the bare IP from r.RemoteAddr. Deployments fronted by a
102 // reverse proxy see the proxy's address here — enroll rate limiting then
103 // applies to the proxy as a whole, which is still a meaningful brake.
104 func clientIP(r *http.Request) string {
105 host, _, err := net.SplitHostPort(r.RemoteAddr)
106 if err != nil {
107 return r.RemoteAddr
108 }
109 return host
110 }
internal/server/api/ratelimit_test.go
Old New
@@ -0,0 +1,81 @@
1 package api
2
3 import (
4 "fmt"
5 "testing"
6 "time"
7
8 "github.com/stretchr/testify/assert"
9 )
10
11 // TestLimiterRefillAndCap pins the token-bucket math against a fake clock:
12 // a drained bucket refills one token per enrollRefillEvery, capped at burst.
13 func TestLimiterRefillAndCap(t *testing.T) {
14 now := time.Unix(1_750_000_000, 0)
15 l := newIPLimiter(func() time.Time { return now })
16
17 for i := 0; i < enrollBurst; i++ {
18 assert.True(t, l.allow("10.0.0.1"), "burst request %d", i)
19 }
20 assert.False(t, l.allow("10.0.0.1"), "burst exhausted")
21
22 now = now.Add(enrollRefillEvery)
23 assert.True(t, l.allow("10.0.0.1"), "one token refilled")
24 assert.False(t, l.allow("10.0.0.1"), "only one refilled")
25
26 // A long idle period refills to the cap, not beyond.
27 now = now.Add(24 * time.Hour)
28 for i := 0; i < enrollBurst; i++ {
29 assert.True(t, l.allow("10.0.0.1"), "cap request %d", i)
30 }
31 assert.False(t, l.allow("10.0.0.1"), "cap enforced")
32 }
33
34 // TestLimiterPruneAndFailOpen pins the capacity behavior: at the entry cap,
35 // idle buckets are pruned to admit new IPs; when nothing is prunable the
36 // limiter fails OPEN (it is a brake, not the auth boundary).
37 func TestLimiterPruneAndFailOpen(t *testing.T) {
38 now := time.Unix(1_750_000_000, 0)
39 l := newIPLimiter(func() time.Time { return now })
40
41 for i := 0; i < maxLimiterEntries; i++ {
42 l.allow(fmt.Sprintf("10.%d.%d.%d", i>>16&0xff, i>>8&0xff, i&0xff))
43 }
44 assert.Len(t, l.buckets, maxLimiterEntries)
45
46 // Nothing is idle yet: a new IP must still be allowed (fail open) and
47 // must not grow the map.
48 assert.True(t, l.allow("192.0.2.1"), "fail open when full and nothing prunable")
49 assert.Len(t, l.buckets, maxLimiterEntries, "fail-open must not grow the map")
50
51 // After everything goes idle past a full refill, pruning admits new IPs.
52 now = now.Add(time.Duration(enrollBurst)*enrollRefillEvery + time.Second)
53 assert.True(t, l.allow("192.0.2.2"))
54 assert.Len(t, l.buckets, 1, "idle buckets pruned, new bucket tracked")
55 }
56
57 // TestBucketKeyGroupsIPv6BySlash64 pins the bucket-key normalization: IPv4
58 // (and v4-mapped-v6) keep per-address buckets; IPv6 collapses to the /64 so a
59 // single host's billions of addresses share one bucket; garbage stays raw.
60 func TestBucketKeyGroupsIPv6BySlash64(t *testing.T) {
61 assert.Equal(t, "203.0.113.9", bucketKey("203.0.113.9"))
62 assert.Equal(t, "203.0.113.9", bucketKey("::ffff:203.0.113.9"), "v4-mapped stays per-address")
63 a := bucketKey("2001:db8:1:2:aaaa:bbbb:cccc:dddd")
64 b := bucketKey("2001:db8:1:2:1111:2222:3333:4444")
65 c := bucketKey("2001:db8:1:3::1")
66 assert.Equal(t, a, b, "same /64 must share a bucket")
67 assert.NotEqual(t, a, c, "different /64 must not")
68 assert.Equal(t, "not-an-ip", bucketKey("not-an-ip"))
69 }
70
71 // TestLimiterSharesBucketAcrossSameSlash64 pins end behavior: burst spent
72 // from one v6 address exhausts the whole /64.
73 func TestLimiterSharesBucketAcrossSameSlash64(t *testing.T) {
74 now := time.Unix(1_750_000_000, 0)
75 l := newIPLimiter(func() time.Time { return now })
76 for i := 0; i < enrollBurst; i++ {
77 assert.True(t, l.allow(bucketKey("2001:db8::1")))
78 }
79 assert.False(t, l.allow(bucketKey("2001:db8::2")), "same /64: bucket shared")
80 assert.True(t, l.allow(bucketKey("2001:db9::1")), "different /64: fresh bucket")
81 }
internal/server/api/routes.go
Old New
@@ -11,7 +11,8 @@ import (
11 type AuthTier int 11 type AuthTier int
12 12
13 const ( 13 const (
14 AuthPublic AuthTier = iota // no auth (public material) 14 AuthPublic AuthTier = iota // no auth (public material / rate-limited)
15 AuthTicket // one-time short-TTL ticket in ?ticket= (browser streams)
15 AuthAdmin // Authorization: Bearer <admin token> 16 AuthAdmin // Authorization: Bearer <admin token>
16 ) 17 )
17 18
@@ -56,19 +57,21 @@ var routeTable = []Route{
56 Request: (*types.EnrollRequest)(nil), 57 Request: (*types.EnrollRequest)(nil),
57 Response: (*types.EnrollResponse)(nil), 58 Response: (*types.EnrollResponse)(nil),
58 Success: http.StatusCreated, 59 Success: http.StatusCreated,
59 Doc: "Redeem a one-time enrollment token: a new host joins the fleet and receives its credential. Unauthenticated; the token is the proof.", 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.",
60 handler: (*API).handleEnroll, 61 handler: (*API).handleEnroll,
61 }, 62 },
62 // SSE live status. EventSource cannot set headers, so this endpoint 63 // SSE live status. EventSource cannot set headers, so the stream
63 // authenticates via a ?token= query param instead of the admin middleware. 64 // authenticates with a one-time short-TTL ticket minted via the
65 // admin-authenticated POST /api/v1/stream-tickets — the long-lived admin
66 // token never rides in a URL (proxy/access logs).
64 { 67 {
65 Method: "GET", 68 Method: "GET",
66 Path: "/api/v1/events", 69 Path: "/api/v1/events",
67 Auth: AuthPublic, 70 Auth: AuthTicket,
68 Kind: KindSSE, 71 Kind: KindSSE,
69 Response: (*types.StateSnapshot)(nil), 72 Response: (*types.StateSnapshot)(nil),
70 Success: http.StatusOK, 73 Success: http.StatusOK,
71 Query: []QueryParam{{Name: "token", Doc: "admin token"}}, 74 Query: []QueryParam{{Name: "ticket", Doc: "one-time stream ticket"}},
72 Doc: "Live fleet state stream (Server-Sent Events); each 'state' event carries a StateSnapshot.", 75 Doc: "Live fleet state stream (Server-Sent Events); each 'state' event carries a StateSnapshot.",
73 handler: (*API).handleEvents, 76 handler: (*API).handleEvents,
74 }, 77 },
@@ -81,7 +84,7 @@ var routeTable = []Route{
81 Kind: KindJSON, 84 Kind: KindJSON,
82 Response: (*types.EnrollTokenResponse)(nil), 85 Response: (*types.EnrollTokenResponse)(nil),
83 Success: http.StatusCreated, 86 Success: http.StatusCreated,
84 Doc: "Mint a one-time host enrollment token.", 87 Doc: "Mint a one-time host enrollment token plus the join blob agents consume.",
85 handler: (*API).handleCreateEnrollToken, 88 handler: (*API).handleCreateEnrollToken,
86 }, 89 },
87 { 90 {
@@ -104,6 +107,36 @@ var routeTable = []Route{
104 handler: (*API).handleDecommissionHost, 107 handler: (*API).handleDecommissionHost,
105 }, 108 },
106 { 109 {
110 Method: "POST",
111 Path: "/api/v1/hosts/{id}/revoke-credential",
112 Auth: AuthAdmin,
113 Kind: KindJSON,
114 Success: http.StatusNoContent,
115 Doc: "Revoke a host's outstanding credential by bumping its generation; the host stays dark until re-enrolled.",
116 handler: (*API).handleRevokeCredential,
117 },
118 {
119 Method: "GET",
120 Path: "/api/v1/audit",
121 Auth: AuthAdmin,
122 Kind: KindJSON,
123 Response: []types.AuditEvent(nil),
124 Success: http.StatusOK,
125 Query: []QueryParam{{Name: "limit", Doc: "max rows to return (default 100, cap 1000)"}},
126 Doc: "Newest audit log rows.",
127 handler: (*API).handleListAudit,
128 },
129 {
130 Method: "POST",
131 Path: "/api/v1/stream-tickets",
132 Auth: AuthAdmin,
133 Kind: KindJSON,
134 Response: (*types.StreamTicketResponse)(nil),
135 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.",
137 handler: (*API).handleMintStreamTicket,
138 },
139 {
107 Method: "GET", 140 Method: "GET",
108 Path: "/api/v1/vms", 141 Path: "/api/v1/vms",
109 Auth: AuthAdmin, 142 Auth: AuthAdmin,
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 = 9 37 const wantRoutes = 12
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/testdata/audit-event.golden.json
Old New
@@ -0,0 +1,8 @@
1 {
2 "at": "2026-07-27T12:02:00Z",
3 "action": "host.enroll",
4 "detail": {
5 "host_id": "h-1234",
6 "remote": "203.0.113.7"
7 }
8 }
internal/server/api/testdata/enroll-token-response.golden.json
Old New
@@ -1,3 +1,4 @@
1 { 1 {
2 "join": "eitri-join-blob-base64url",
2 "token": "tok-secret-01" 3 "token": "tok-secret-01"
3 } 4 }
internal/server/api/testdata/stream-ticket-response.golden.json
Old New
@@ -0,0 +1,3 @@
1 {
2 "ticket": "ticket-opaque-01"
3 }
internal/server/api/ticket.go
Old New
@@ -0,0 +1,58 @@
1 package api
2
3 import (
4 "sync"
5 "time"
6
7 "github.com/a73x/eitri/internal/server/store"
8 )
9
10 // streamTicketTTL bounds how long a minted stream ticket stays redeemable.
11 // It only needs to cover the gap between the SPA's mint call and the
12 // EventSource connect — one minute is generous.
13 const streamTicketTTL = time.Minute
14
15 // ticketStore holds one-time SSE stream tickets in memory. Tickets are
16 // ephemeral session bootstrap — a server restart just means the client mints
17 // a fresh one on its next reconnect — so no durability is needed. now is
18 // injectable for tests.
19 type ticketStore struct {
20 mu sync.Mutex
21 tickets map[string]time.Time // ticket → expiry
22 now func() time.Time
23 }
24
25 func newTicketStore(now func() time.Time) *ticketStore {
26 return &ticketStore{tickets: map[string]time.Time{}, now: now}
27 }
28
29 // mint issues a fresh one-time ticket, pruning expired ones while it holds
30 // the lock (mints are operator-paced; the map stays tiny).
31 func (t *ticketStore) mint() string {
32 t.mu.Lock()
33 defer t.mu.Unlock()
34 now := t.now()
35 for k, exp := range t.tickets {
36 if now.After(exp) {
37 delete(t.tickets, k)
38 }
39 }
40 tick := store.RandHex(16)
41 t.tickets[tick] = now.Add(streamTicketTTL)
42 return tick
43 }
44
45 // consume redeems a ticket exactly once; expired or unknown tickets fail.
46 // The map lookup is not constant-time by design: tickets are 128-bit
47 // crypto-random, single-use, and 60s-TTL, so timing attacks are academic —
48 // unlike the long-lived admin token, which does get constantTimeTokenMatch.
49 func (t *ticketStore) consume(tick string) bool {
50 t.mu.Lock()
51 defer t.mu.Unlock()
52 exp, ok := t.tickets[tick]
53 if !ok {
54 return false
55 }
56 delete(t.tickets, tick) // one-time, even when expired
57 return !t.now().After(exp)
58 }
internal/server/api/types/types.go
Old New
@@ -6,6 +6,7 @@
6 package types 6 package types
7 7
8 import ( 8 import (
9 "encoding/json"
9 "time" 10 "time"
10 ) 11 )
11 12
@@ -117,6 +118,7 @@ type EnrollResponse struct {
117 118
118 // EnrollTokenResponse answers POST /api/v1/enroll-tokens. 119 // EnrollTokenResponse answers POST /api/v1/enroll-tokens.
119 type EnrollTokenResponse struct { 120 type EnrollTokenResponse struct {
121 Join string `json:"join"`
120 Token string `json:"token"` 122 Token string `json:"token"`
121 } 123 }
122 124
@@ -125,3 +127,16 @@ type CreateVMResponse struct {
125 ID string `json:"id"` 127 ID string `json:"id"`
126 Name string `json:"name"` 128 Name string `json:"name"`
127 } 129 }
130
131 // StreamTicketResponse answers POST /api/v1/stream-tickets.
132 type StreamTicketResponse struct {
133 Ticket string `json:"ticket"`
134 }
135
136 // 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).
138 type AuditEvent struct {
139 At time.Time `json:"at"`
140 Action string `json:"action"`
141 Detail json.RawMessage `json:"detail"`
142 }
internal/server/api/wire_golden_test.go
Old New
@@ -129,6 +129,7 @@ func TestWireGolden(t *testing.T) {
129 }) 129 })
130 130
131 goldenCheck(t, "enroll-token-response", types.EnrollTokenResponse{ 131 goldenCheck(t, "enroll-token-response", types.EnrollTokenResponse{
132 Join: "eitri-join-blob-base64url",
132 Token: "tok-secret-01", 133 Token: "tok-secret-01",
133 }) 134 })
134 135
@@ -136,4 +137,14 @@ func TestWireGolden(t *testing.T) {
136 ID: "v-5678", 137 ID: "v-5678",
137 Name: "sandbox-abc123", 138 Name: "sandbox-abc123",
138 }) 139 })
140
141 goldenCheck(t, "stream-ticket-response", types.StreamTicketResponse{
142 Ticket: "ticket-opaque-01",
143 })
144
145 goldenCheck(t, "audit-event", types.AuditEvent{
146 At: base.Add(2 * time.Minute),
147 Action: "host.enroll",
148 Detail: json.RawMessage(`{"host_id":"h-1234","remote":"203.0.113.7"}`),
149 })
139 } 150 }
internal/server/hosttoken/hosttoken.go
Old New
@@ -1,34 +1,69 @@
1 // Package hosttoken mints and verifies host credentials: "<host_id>.<hex hmac-sha256>". 1 // Package hosttoken mints and verifies generation-versioned host credentials.
2 // Phase 1 has no revocation list (single-tenant; rotate the server secret to revoke all). 2 //
3 // Host IDs are hex strings and must not contain '.'; a dotted input fails verification 3 // Format:
4 // safely because the signature is computed over the full ID and will never match the 4 //
5 // truncated parse produced by strings.Cut. 5 // "<host_id>.<generation>.<issued_unix>.<hex hmac-sha256>"
6 //
7 // The HMAC covers the three dotted fields, binding identity, credential
8 // generation, and issue time. Generation enables PER-HOST revocation: the
9 // server compares the credential's generation against the host row's
10 // cred_generation and rejects stale ones — bumping the row revokes that one
11 // host without rotating the fleet-wide secret. issued_unix enables an
12 // optional max-age policy (enforced by the caller; this package only signs
13 // and parses).
14 //
15 // Host IDs are hex strings and must not contain '.'; a dotted input fails
16 // verification safely because the signature is computed over the exact
17 // parsed fields and will never match.
6 package hosttoken 18 package hosttoken
7 19
8 import ( 20 import (
9 "crypto/hmac" 21 "crypto/hmac"
10 "crypto/sha256" 22 "crypto/sha256"
11 "encoding/hex" 23 "encoding/hex"
24 "fmt"
25 "strconv"
12 "strings" 26 "strings"
27 "time"
13 ) 28 )
14 29
15 func sign(secret []byte, hostID string) string { 30 // Claims is the verified content of a credential.
31 type Claims struct {
32 HostID string
33 Generation int64
34 IssuedAt time.Time
35 }
36
37 func sign(secret []byte, payload string) string {
16 m := hmac.New(sha256.New, secret) 38 m := hmac.New(sha256.New, secret)
17 m.Write([]byte(hostID)) 39 m.Write([]byte(payload))
18 return hex.EncodeToString(m.Sum(nil)) 40 return hex.EncodeToString(m.Sum(nil))
19 } 41 }
20 42
21 func Mint(secret []byte, hostID string) string { 43 // Mint returns a signed credential for hostID at the given generation.
22 return hostID + "." + sign(secret, hostID) 44 func Mint(secret []byte, hostID string, generation int64, issuedAt time.Time) string {
45 payload := fmt.Sprintf("%s.%d.%d", hostID, generation, issuedAt.Unix())
46 return payload + "." + sign(secret, payload)
23 } 47 }
24 48
25 func Verify(secret []byte, cred string) (hostID string, ok bool) { 49 // Verify parses and authenticates cred: the four signed fields
26 id, sig, found := strings.Cut(cred, ".") 50 // host_id.generation.issued_unix.hmac. Anything else fails.
27 if !found || id == "" { 51 func Verify(secret []byte, cred string) (Claims, bool) {
28 return "", false 52 parts := strings.Split(cred, ".")
53 if len(parts) != 4 || parts[0] == "" {
54 return Claims{}, false
55 }
56 gen, err := strconv.ParseInt(parts[1], 10, 64)
57 if err != nil {
58 return Claims{}, false
59 }
60 issued, err := strconv.ParseInt(parts[2], 10, 64)
61 if err != nil {
62 return Claims{}, false
29 } 63 }
30 if !hmac.Equal([]byte(sig), []byte(sign(secret, id))) { 64 payload := strings.Join(parts[:3], ".")
31 return "", false 65 if !hmac.Equal([]byte(parts[3]), []byte(sign(secret, payload))) {
66 return Claims{}, false
32 } 67 }
33 return id, true 68 return Claims{HostID: parts[0], Generation: gen, IssuedAt: time.Unix(issued, 0)}, true
34 } 69 }
internal/server/hosttoken/hosttoken_generation_test.go
Old New
@@ -0,0 +1,59 @@
1 package hosttoken
2
3 import (
4 "strings"
5 "testing"
6 "time"
7
8 "github.com/stretchr/testify/assert"
9 "github.com/stretchr/testify/require"
10 )
11
12 // TestMintVerifyRoundTrip pins the credential contract:
13 // host_id.gen.issued_unix.hmac, HMAC over the three dotted fields.
14 func TestMintVerifyRoundTrip(t *testing.T) {
15 secret := []byte("s3cret")
16 issued := time.Unix(1_750_000_000, 0)
17 cred := Mint(secret, "abc123", 3, issued)
18
19 claims, ok := Verify(secret, cred)
20 require.True(t, ok)
21 assert.Equal(t, "abc123", claims.HostID)
22 assert.Equal(t, int64(3), claims.Generation)
23 assert.True(t, claims.IssuedAt.Equal(issued))
24 }
25
26 // TestVerifyRejectsTampering: flipping any field invalidates the HMAC, and a
27 // wrong secret never verifies.
28 func TestVerifyRejectsTampering(t *testing.T) {
29 secret := []byte("s3cret")
30 cred := Mint(secret, "abc123", 1, time.Unix(1_750_000_000, 0))
31 parts := strings.Split(cred, ".")
32 require.Len(t, parts, 4)
33
34 for i, repl := range []string{"otherhost", "9", "1750000001"} {
35 mut := make([]string, 4)
36 copy(mut, parts)
37 mut[i] = repl
38 _, ok := Verify(secret, strings.Join(mut, "."))
39 assert.False(t, ok, "tampered field %d must fail", i)
40 }
41 _, ok := Verify([]byte("wrong"), cred)
42 assert.False(t, ok)
43 }
44
45 // TestVerifyRejectsMalformed: anything that is not exactly the four signed
46 // fields is rejected.
47 func TestVerifyRejectsMalformed(t *testing.T) {
48 secret := []byte("s3cret")
49 for _, cred := range []string{
50 "", "abc123", "a.b.c", // too few parts
51 "abc123.deadbeef", // two parts
52 "a.b.c.d.e", // too many parts
53 "abc123.x.170.deadbeef", // non-numeric generation
54 "abc123.1.x.deadbeef", // non-numeric issued_unix
55 } {
56 _, ok := Verify(secret, cred)
57 assert.False(t, ok, "must reject %q", cred)
58 }
59 }
internal/server/hosttoken/hosttoken_test.go
Old New
@@ -2,20 +2,22 @@ package hosttoken
2 2
3 import ( 3 import (
4 "testing" 4 "testing"
5 "time"
6
5 "github.com/stretchr/testify/assert" 7 "github.com/stretchr/testify/assert"
6 ) 8 )
7 9
8 func TestMintedCredentialVerifiesAndRecoversHostID(t *testing.T) { 10 func TestMintedCredentialVerifiesAndRecoversHostID(t *testing.T) {
9 secret := []byte("server-secret") 11 secret := []byte("server-secret")
10 cred := Mint(secret, "host-123") 12 cred := Mint(secret, "host-123", 1, time.Unix(1_750_000_000, 0))
11 hostID, ok := Verify(secret, cred) 13 claims, ok := Verify(secret, cred)
12 assert.True(t, ok) 14 assert.True(t, ok)
13 assert.Equal(t, "host-123", hostID) 15 assert.Equal(t, "host-123", claims.HostID)
14 } 16 }
15 17
16 func TestVerifyRejectsTamperedAndWrongSecret(t *testing.T) { 18 func TestVerifyRejectsTamperedAndWrongSecret(t *testing.T) {
17 secret := []byte("server-secret") 19 secret := []byte("server-secret")
18 cred := Mint(secret, "host-123") 20 cred := Mint(secret, "host-123", 1, time.Unix(1_750_000_000, 0))
19 _, ok := Verify([]byte("other"), cred) 21 _, ok := Verify([]byte("other"), cred)
20 assert.False(t, ok, "wrong secret") 22 assert.False(t, ok, "wrong secret")
21 _, ok = Verify(secret, cred+"x") 23 _, ok = Verify(secret, cred+"x")
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
@@ -9,11 +9,13 @@ import (
9 "crypto/sha256" 9 "crypto/sha256"
10 "database/sql" 10 "database/sql"
11 "encoding/hex" 11 "encoding/hex"
12 "encoding/json"
12 "errors" 13 "errors"
13 "fmt" 14 "fmt"
14 "net/netip" 15 "net/netip"
15 "os" 16 "os"
16 "path/filepath" 17 "path/filepath"
18 "strconv"
17 "strings" 19 "strings"
18 "time" 20 "time"
19 21
@@ -42,7 +44,11 @@ type Store struct {
42 44
43 type Host struct { 45 type Host struct {
44 ID, Name, OS, Arch, Provisioner, Overlay, BridgeCIDR, Status string 46 ID, Name, OS, Arch, Provisioner, Overlay, BridgeCIDR, Status string
45 EnrolledAt time.Time 47 // CredGeneration is the host's current credential generation. Credentials
48 // minted at an older generation are rejected — bumping it revokes that
49 // one host's outstanding credential without rotating the fleet secret.
50 CredGeneration int64
51 EnrolledAt time.Time
46 } 52 }
47 53
48 type VM struct { 54 type VM struct {
@@ -70,7 +76,8 @@ CREATE TABLE IF NOT EXISTS hosts (
70 overlay TEXT NOT NULL DEFAULT 'tailscale', 76 overlay TEXT NOT NULL DEFAULT 'tailscale',
71 bridge_cidr TEXT NOT NULL, 77 bridge_cidr TEXT NOT NULL,
72 status TEXT NOT NULL DEFAULT 'enrolled', 78 status TEXT NOT NULL DEFAULT 'enrolled',
73 enrolled_at DATETIME NOT NULL 79 enrolled_at DATETIME NOT NULL,
80 cred_generation INTEGER NOT NULL DEFAULT 1
74 ); 81 );
75 82
76 CREATE TABLE IF NOT EXISTS enrollment_tokens ( 83 CREATE TABLE IF NOT EXISTS enrollment_tokens (
@@ -107,6 +114,19 @@ CREATE TABLE IF NOT EXISTS freed_cidrs (
107 bridge_cidr TEXT PRIMARY KEY 114 bridge_cidr TEXT PRIMARY KEY
108 ); 115 );
109 116
117 -- append-only operational audit trail (enrollment, decommission). Read via
118 -- 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
120 -- config). The at column is second-precision UTC RFC3339, which makes
121 -- lexicographic comparison chronological -- PruneAudit's DELETE relies on
122 -- every writer keeping that format.
123 CREATE TABLE IF NOT EXISTS audit_log (
124 id INTEGER PRIMARY KEY AUTOINCREMENT,
125 at DATETIME NOT NULL,
126 action TEXT NOT NULL,
127 detail TEXT NOT NULL DEFAULT ''
128 );
129
110 INSERT INTO meta(key, value) VALUES ('epoch', '0') ON CONFLICT DO NOTHING; 130 INSERT INTO meta(key, value) VALUES ('epoch', '0') ON CONFLICT DO NOTHING;
111 INSERT INTO meta(key, value) VALUES ('next_cidr_index', '1') ON CONFLICT DO NOTHING; 131 INSERT INTO meta(key, value) VALUES ('next_cidr_index', '1') ON CONFLICT DO NOTHING;
112 ` 132 `
@@ -180,7 +200,11 @@ func (s *Store) CreateEnrollmentToken() (string, error) {
180 return tok, nil 200 return tok, nil
181 } 201 }
182 202
183 func (s *Store) RedeemEnrollmentToken(tok, name, osName, arch, provisioner, overlay string) (Host, error) { 203 // RedeemEnrollmentToken atomically consumes tok and creates the host row.
204 // 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
206 // without its durable audit record.
207 func (s *Store) RedeemEnrollmentToken(tok, name, osName, arch, provisioner, overlay, remote string) (Host, error) {
184 if overlay == "" { 208 if overlay == "" {
185 overlay = "tailscale" 209 overlay = "tailscale"
186 } 210 }
@@ -248,6 +272,19 @@ func (s *Store) RedeemEnrollmentToken(tok, name, osName, arch, provisioner, over
248 return Host{}, fmt.Errorf("insert host: %w", err) 272 return Host{}, fmt.Errorf("insert host: %w", err)
249 } 273 }
250 274
275 // Audit in the SAME tx: the durable record of who enrolled cannot be lost
276 // once the enrollment itself commits. The token appears only as a hash
277 // prefix (matches the enrollment_tokens.token_hash the mint row logs).
278 tokSum := sha256.Sum256([]byte(tok))
279 detail, _ := json.Marshal(map[string]string{
280 "host_id": id, "name": name, "os": osName, "arch": arch,
281 "remote": remote, "token_hash_prefix": hex.EncodeToString(tokSum[:])[:8],
282 })
283 if _, err := tx.Exec(`INSERT INTO audit_log(at, action, detail) VALUES (?, ?, ?)`,
284 now.Format(time.RFC3339), "host.enroll", string(detail)); err != nil {
285 return Host{}, fmt.Errorf("audit enroll: %w", err)
286 }
287
251 if err := tx.Commit(); err != nil { 288 if err := tx.Commit(); err != nil {
252 return Host{}, err 289 return Host{}, err
253 } 290 }
@@ -260,8 +297,9 @@ func (s *Store) RedeemEnrollmentToken(tok, name, osName, arch, provisioner, over
260 Provisioner: provisioner, 297 Provisioner: provisioner,
261 Overlay: overlay, 298 Overlay: overlay,
262 BridgeCIDR: bridgeCIDR, 299 BridgeCIDR: bridgeCIDR,
263 Status: "enrolled", 300 Status: "enrolled",
264 EnrolledAt: now, 301 CredGeneration: 1,
302 EnrolledAt: now,
265 }, nil 303 }, nil
266 } 304 }
267 305
@@ -269,8 +307,8 @@ func (s *Store) GetHost(id string) (Host, error) {
269 var h Host 307 var h Host
270 var enrolledAt string 308 var enrolledAt string
271 err := s.db.QueryRow( 309 err := s.db.QueryRow(
272 `SELECT id, name, os, arch, provisioner, overlay, bridge_cidr, status, enrolled_at FROM hosts WHERE id=?`, id, 310 `SELECT id, name, os, arch, provisioner, overlay, bridge_cidr, status, enrolled_at, cred_generation FROM hosts WHERE id=?`, id,
273 ).Scan(&h.ID, &h.Name, &h.OS, &h.Arch, &h.Provisioner, &h.Overlay, &h.BridgeCIDR, &h.Status, &enrolledAt) 311 ).Scan(&h.ID, &h.Name, &h.OS, &h.Arch, &h.Provisioner, &h.Overlay, &h.BridgeCIDR, &h.Status, &enrolledAt, &h.CredGeneration)
274 if err != nil { 312 if err != nil {
275 return Host{}, err 313 return Host{}, err
276 } 314 }
@@ -278,8 +316,43 @@ func (s *Store) GetHost(id string) (Host, error) {
278 return h, nil 316 return h, nil
279 } 317 }
280 318
281 func (s *Store) ListHosts() ([]Host, error) { 319 // BumpCredGeneration increments the host's credential generation, revoking
282 rows, err := s.db.Query(`SELECT id, name, os, arch, provisioner, overlay, bridge_cidr, status, enrolled_at FROM hosts`) 320 // every credential minted at the previous generation, and returns the new
321 // value. The host.credential.revoke audit row is written in the SAME
322 // transaction — a security action must not be able to happen unrecorded.
323 // Errors (sql.ErrNoRows via %w) if the host does not exist.
324 func (s *Store) BumpCredGeneration(id, remote string) (int64, error) {
325 tx, err := s.db.Begin()
326 if err != nil {
327 return 0, err
328 }
329 defer tx.Rollback()
330 var gen int64
331 if err := tx.QueryRow(
332 `UPDATE hosts SET cred_generation = cred_generation + 1 WHERE id=? RETURNING cred_generation`, id,
333 ).Scan(&gen); err != nil {
334 return 0, fmt.Errorf("bump cred_generation for %s: %w", id, err)
335 }
336 detail, _ := json.Marshal(map[string]string{
337 "host_id": id, "new_generation": strconv.FormatInt(gen, 10), "remote": remote,
338 })
339 if _, err := tx.Exec(`INSERT INTO audit_log(at, action, detail) VALUES (?, ?, ?)`,
340 time.Now().UTC().Format(time.RFC3339), "host.credential.revoke", string(detail)); err != nil {
341 return 0, fmt.Errorf("audit revoke: %w", err)
342 }
343 return gen, tx.Commit()
344 }
345
346 // querier is the subset of *sql.DB / *sql.Tx the list helpers need, so the
347 // same scan logic serves both the standalone reads and Snapshot's single-tx read.
348 type querier interface {
349 Query(query string, args ...any) (*sql.Rows, error)
350 }
351
352 func (s *Store) ListHosts() ([]Host, error) { return listHosts(s.db) }
353
354 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`)
283 if err != nil { 356 if err != nil {
284 return nil, err 357 return nil, err
285 } 358 }
@@ -288,7 +361,7 @@ func (s *Store) ListHosts() ([]Host, error) {
288 for rows.Next() { 361 for rows.Next() {
289 var h Host 362 var h Host
290 var enrolledAt string 363 var enrolledAt string
291 if err := rows.Scan(&h.ID, &h.Name, &h.OS, &h.Arch, &h.Provisioner, &h.Overlay, &h.BridgeCIDR, &h.Status, &enrolledAt); err != nil { 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 {
292 return nil, err 365 return nil, err
293 } 366 }
294 h.EnrolledAt, _ = time.Parse(time.RFC3339, enrolledAt) 367 h.EnrolledAt, _ = time.Parse(time.RFC3339, enrolledAt)
@@ -406,8 +479,10 @@ type Alloc struct{ VCPUs, MemMB, DiskGB int64 }
406 479
407 // AllocatedByHost returns, per host, the resources allocated to its live 480 // AllocatedByHost returns, per host, the resources allocated to its live
408 // (non-tombstoned) VMs. Hosts with no live VMs are absent from the map. 481 // (non-tombstoned) VMs. Hosts with no live VMs are absent from the map.
409 func (s *Store) AllocatedByHost() (map[string]Alloc, error) { 482 func (s *Store) AllocatedByHost() (map[string]Alloc, error) { return allocatedByHost(s.db) }
410 rows, err := s.db.Query(` 483
484 func allocatedByHost(q querier) (map[string]Alloc, error) {
485 rows, err := q.Query(`
411 SELECT host_id, COALESCE(SUM(vcpus),0), COALESCE(SUM(mem_mb),0), COALESCE(SUM(disk_gb),0) 486 SELECT host_id, COALESCE(SUM(vcpus),0), COALESCE(SUM(mem_mb),0), COALESCE(SUM(disk_gb),0)
412 FROM vms WHERE deleted_at IS NULL GROUP BY host_id`) 487 FROM vms WHERE deleted_at IS NULL GROUP BY host_id`)
413 if err != nil { 488 if err != nil {
@@ -426,6 +501,58 @@ func (s *Store) AllocatedByHost() (map[string]Alloc, error) {
426 return out, rows.Err() 501 return out, rows.Err()
427 } 502 }
428 503
504 // AuditEntry is one row of the append-only audit trail.
505 type AuditEntry struct {
506 At time.Time
507 Action string
508 Detail string
509 }
510
511 // AppendAudit records an audit event. detail is a small JSON blob; keep
512 // secrets out (hash prefixes, not tokens).
513 func (s *Store) AppendAudit(action, detail string) error {
514 _, err := s.db.Exec(`INSERT INTO audit_log(at, action, detail) VALUES (?, ?, ?)`,
515 time.Now().UTC().Format(time.RFC3339), action, detail)
516 return err
517 }
518
519 // ListAudit returns up to limit audit entries, newest first.
520 func (s *Store) ListAudit(limit int) ([]AuditEntry, error) {
521 rows, err := s.db.Query(`SELECT at, action, detail FROM audit_log ORDER BY id DESC LIMIT ?`, limit)
522 if err != nil {
523 return nil, err
524 }
525 defer rows.Close()
526 var out []AuditEntry
527 for rows.Next() {
528 var e AuditEntry
529 var at string
530 if err := rows.Scan(&at, &e.Action, &e.Detail); err != nil {
531 return nil, err
532 }
533 e.At, _ = time.Parse(time.RFC3339, at)
534 out = append(out, e)
535 }
536 return out, rows.Err()
537 }
538
539 // PruneAudit deletes audit rows older than olderThan and reports how many
540 // were removed. Retention keeps the append-only log bounded; the caller
541 // (eitri-server) runs it at startup and daily.
542 func (s *Store) PruneAudit(olderThan time.Duration) (int64, error) {
543 // Guard the contract, not just the caller: a zero/negative window would
544 // compute a now-or-future cutoff and wipe the entire forensic trail.
545 if olderThan <= 0 {
546 return 0, nil
547 }
548 cutoff := time.Now().UTC().Add(-olderThan).Format(time.RFC3339)
549 res, err := s.db.Exec(`DELETE FROM audit_log WHERE at < ?`, cutoff)
550 if err != nil {
551 return 0, err
552 }
553 return res.RowsAffected()
554 }
555
429 // HostVMCount returns the number of VM rows for a host (live + tombstoned). 556 // HostVMCount returns the number of VM rows for a host (live + tombstoned).
430 // Rows are hard-deleted only after the agent acks destroy, so a count of 0 means 557 // Rows are hard-deleted only after the agent acks destroy, so a count of 0 means
431 // the host is fully drained. 558 // the host is fully drained.
@@ -532,8 +659,10 @@ func scanVM(rows *sql.Rows) (VM, error) {
532 return vm, nil 659 return vm, nil
533 } 660 }
534 661
535 func (s *Store) ListVMs() ([]VM, error) { 662 func (s *Store) ListVMs() ([]VM, error) { return listVMs(s.db) }
536 rows, err := s.db.Query( 663
664 func listVMs(q querier) ([]VM, error) {
665 rows, err := q.Query(
537 `SELECT id, host_id, name, image_url, image_sha256, cloud_init, ssh_authorized_key, 666 `SELECT id, host_id, name, image_url, image_sha256, cloud_init, ssh_authorized_key,
538 vcpus, mem_mb, disk_gb, persistent, power_state, status, last_error, assigned_ip, 667 vcpus, mem_mb, disk_gb, persistent, power_state, status, last_error, assigned_ip,
539 created_at, deleted_at FROM vms`, 668 created_at, deleted_at FROM vms`,
@@ -553,6 +682,31 @@ func (s *Store) ListVMs() ([]VM, error) {
553 return vms, rows.Err() 682 return vms, rows.Err()
554 } 683 }
555 684
685 // Snapshot reads hosts, per-host allocation, and VMs in a single read
686 // transaction, so the trio is mutually consistent — a concurrent desired-state
687 // mutation between the reads cannot produce a payload mixing two epochs.
688 // The SSE stream builds its fleet snapshot from this.
689 func (s *Store) Snapshot() ([]Host, map[string]Alloc, []VM, error) {
690 tx, err := s.db.Begin()
691 if err != nil {
692 return nil, nil, nil, err
693 }
694 defer tx.Rollback()
695 hosts, err := listHosts(tx)
696 if err != nil {
697 return nil, nil, nil, err
698 }
699 alloc, err := allocatedByHost(tx)
700 if err != nil {
701 return nil, nil, nil, err
702 }
703 vms, err := listVMs(tx)
704 if err != nil {
705 return nil, nil, nil, err
706 }
707 return hosts, alloc, vms, tx.Commit()
708 }
709
556 func (s *Store) DesiredForHost(hostID string) (uint64, []VM, error) { 710 func (s *Store) DesiredForHost(hostID string) (uint64, []VM, error) {
557 tx, err := s.db.Begin() 711 tx, err := s.db.Begin()
558 if err != nil { 712 if err != nil {
internal/server/store/store_test.go
Old New
@@ -3,6 +3,7 @@ package store
3 import ( 3 import (
4 "path/filepath" 4 "path/filepath"
5 "testing" 5 "testing"
6 "time"
6 7
7 "github.com/stretchr/testify/assert" 8 "github.com/stretchr/testify/assert"
8 "github.com/stretchr/testify/require" 9 "github.com/stretchr/testify/require"
@@ -20,7 +21,7 @@ func enrollHost(t *testing.T, s *Store) Host {
20 t.Helper() 21 t.Helper()
21 tok, err := s.CreateEnrollmentToken() 22 tok, err := s.CreateEnrollmentToken()
22 require.NoError(t, err) 23 require.NoError(t, err)
23 h, err := s.RedeemEnrollmentToken(tok, "host-a", "linux", "amd64", "cloudhv", "") 24 h, err := s.RedeemEnrollmentToken(tok, "host-a", "linux", "amd64", "cloudhv", "", "")
24 require.NoError(t, err) 25 require.NoError(t, err)
25 return h 26 return h
26 } 27 }
@@ -28,15 +29,15 @@ func enrollHost(t *testing.T, s *Store) Host {
28 func TestEnrollmentAssignsSequentialCIDRsAndIsOneTimeUse(t *testing.T) { 29 func TestEnrollmentAssignsSequentialCIDRsAndIsOneTimeUse(t *testing.T) {
29 s := newStore(t) 30 s := newStore(t)
30 tok1, _ := s.CreateEnrollmentToken() 31 tok1, _ := s.CreateEnrollmentToken()
31 h1, err := s.RedeemEnrollmentToken(tok1, "a", "linux", "amd64", "cloudhv", "") 32 h1, err := s.RedeemEnrollmentToken(tok1, "a", "linux", "amd64", "cloudhv", "", "")
32 require.NoError(t, err) 33 require.NoError(t, err)
33 assert.Equal(t, "10.77.1.0/24", h1.BridgeCIDR) 34 assert.Equal(t, "10.77.1.0/24", h1.BridgeCIDR)
34 35
35 _, err = s.RedeemEnrollmentToken(tok1, "b", "linux", "amd64", "cloudhv", "") 36 _, err = s.RedeemEnrollmentToken(tok1, "b", "linux", "amd64", "cloudhv", "", "")
36 assert.Error(t, err, "token must be one-time use") 37 assert.Error(t, err, "token must be one-time use")
37 38
38 tok2, _ := s.CreateEnrollmentToken() 39 tok2, _ := s.CreateEnrollmentToken()
39 h2, _ := s.RedeemEnrollmentToken(tok2, "b", "linux", "amd64", "cloudhv", "") 40 h2, _ := s.RedeemEnrollmentToken(tok2, "b", "linux", "amd64", "cloudhv", "", "")
40 assert.Equal(t, "10.77.2.0/24", h2.BridgeCIDR) 41 assert.Equal(t, "10.77.2.0/24", h2.BridgeCIDR)
41 } 42 }
42 43
@@ -110,7 +111,7 @@ func TestEnrollmentCIDRWorksForNonSlash16Pools(t *testing.T) {
110 require.NoError(t, err) 111 require.NoError(t, err)
111 defer s.Close() 112 defer s.Close()
112 tok, _ := s.CreateEnrollmentToken() 113 tok, _ := s.CreateEnrollmentToken()
113 h, err := s.RedeemEnrollmentToken(tok, "a", "linux", "amd64", "cloudhv", "") 114 h, err := s.RedeemEnrollmentToken(tok, "a", "linux", "amd64", "cloudhv", "", "")
114 require.NoError(t, err) 115 require.NoError(t, err)
115 assert.Equal(t, "192.168.5.0/24", h.BridgeCIDR, "1st /24 within the pool, 0th reserved") 116 assert.Equal(t, "192.168.5.0/24", h.BridgeCIDR, "1st /24 within the pool, 0th reserved")
116 } 117 }
@@ -120,11 +121,11 @@ func TestEnrollmentFailsWhenPoolExhausted(t *testing.T) {
120 require.NoError(t, err) 121 require.NoError(t, err)
121 defer s.Close() 122 defer s.Close()
122 tok1, _ := s.CreateEnrollmentToken() 123 tok1, _ := s.CreateEnrollmentToken()
123 h, err := s.RedeemEnrollmentToken(tok1, "a", "linux", "amd64", "cloudhv", "") 124 h, err := s.RedeemEnrollmentToken(tok1, "a", "linux", "amd64", "cloudhv", "", "")
124 require.NoError(t, err) 125 require.NoError(t, err)
125 assert.Equal(t, "10.9.9.0/24", h.BridgeCIDR) 126 assert.Equal(t, "10.9.9.0/24", h.BridgeCIDR)
126 tok2, _ := s.CreateEnrollmentToken() 127 tok2, _ := s.CreateEnrollmentToken()
127 _, err = s.RedeemEnrollmentToken(tok2, "b", "linux", "amd64", "cloudhv", "") 128 _, err = s.RedeemEnrollmentToken(tok2, "b", "linux", "amd64", "cloudhv", "", "")
128 assert.ErrorContains(t, err, "exhausted") 129 assert.ErrorContains(t, err, "exhausted")
129 } 130 }
130 131
@@ -140,13 +141,13 @@ func TestRedeemEnrollmentToken_OverlayPersistedAndDefaultsTailscale(t *testing.T
140 s := newStore(t) 141 s := newStore(t)
141 tok, _ := s.CreateEnrollmentToken() 142 tok, _ := s.CreateEnrollmentToken()
142 // Explicit overlay="none" must be persisted. 143 // Explicit overlay="none" must be persisted.
143 h, err := s.RedeemEnrollmentToken(tok, "host-b", "linux", "amd64", "cloudhv", "none") 144 h, err := s.RedeemEnrollmentToken(tok, "host-b", "linux", "amd64", "cloudhv", "none", "")
144 require.NoError(t, err) 145 require.NoError(t, err)
145 assert.Equal(t, "none", h.Overlay, "overlay must be 'none' as requested") 146 assert.Equal(t, "none", h.Overlay, "overlay must be 'none' as requested")
146 147
147 // Empty overlay → defaults to "tailscale". 148 // Empty overlay → defaults to "tailscale".
148 tok2, _ := s.CreateEnrollmentToken() 149 tok2, _ := s.CreateEnrollmentToken()
149 h2, err := s.RedeemEnrollmentToken(tok2, "host-c", "linux", "amd64", "cloudhv", "") 150 h2, err := s.RedeemEnrollmentToken(tok2, "host-c", "linux", "amd64", "cloudhv", "", "")
150 require.NoError(t, err) 151 require.NoError(t, err)
151 assert.Equal(t, "tailscale", h2.Overlay, "empty overlay must default to 'tailscale'") 152 assert.Equal(t, "tailscale", h2.Overlay, "empty overlay must default to 'tailscale'")
152 } 153 }
@@ -154,7 +155,7 @@ func TestRedeemEnrollmentToken_OverlayPersistedAndDefaultsTailscale(t *testing.T
154 func TestListHosts_ReturnsOverlay(t *testing.T) { 155 func TestListHosts_ReturnsOverlay(t *testing.T) {
155 s := newStore(t) 156 s := newStore(t)
156 tok, _ := s.CreateEnrollmentToken() 157 tok, _ := s.CreateEnrollmentToken()
157 _, err := s.RedeemEnrollmentToken(tok, "host-x", "linux", "amd64", "cloudhv", "none") 158 _, err := s.RedeemEnrollmentToken(tok, "host-x", "linux", "amd64", "cloudhv", "none", "")
158 require.NoError(t, err) 159 require.NoError(t, err)
159 hosts, err := s.ListHosts() 160 hosts, err := s.ListHosts()
160 require.NoError(t, err) 161 require.NoError(t, err)
@@ -181,3 +182,185 @@ func TestServerCertLoadOrCreatePersists(t *testing.T) {
181 require.NoError(t, err) 182 require.NoError(t, err)
182 require.NotEmpty(t, key) 183 require.NotEmpty(t, key)
183 } 184 }
185
186 // TestSnapshotReadsAllThreeConsistently pins the Snapshot contract: one call
187 // returns hosts, per-host allocation, and VMs equal to the individual reads —
188 // but sourced from a single read transaction so the trio can never mix state
189 // from two different epochs (the SSE stream builds its payload from this).
190 func TestSnapshotReadsAllThreeConsistently(t *testing.T) {
191 s := newStore(t)
192 h := enrollHost(t, s)
193 require.NoError(t, s.CreateVM(VM{ID: "vm1", HostID: h.ID, Name: "one",
194 ImageURL: "http://x/i", ImageSHA256: "abc", VCPUs: 2, MemMB: 1024, DiskGB: 10, PowerState: "running"}))
195 require.NoError(t, s.CreateVM(VM{ID: "vm2", HostID: h.ID, Name: "two",
196 ImageURL: "http://x/i", ImageSHA256: "abc", VCPUs: 1, MemMB: 512, DiskGB: 5, PowerState: "stopped"}))
197
198 hosts, alloc, vms, err := s.Snapshot()
199 require.NoError(t, err)
200
201 wantHosts, err := s.ListHosts()
202 require.NoError(t, err)
203 wantAlloc, err := s.AllocatedByHost()
204 require.NoError(t, err)
205 wantVMs, err := s.ListVMs()
206 require.NoError(t, err)
207
208 assert.Equal(t, wantHosts, hosts)
209 assert.Equal(t, wantAlloc, alloc)
210 assert.Equal(t, wantVMs, vms)
211 assert.Equal(t, Alloc{VCPUs: 3, MemMB: 1536, DiskGB: 15}, alloc[h.ID])
212 }
213
214 // TestSnapshotIsAtomicUnderConcurrentWrites pins the single-tx property
215 // itself: alloc is derivable from vms, so any snapshot whose alloc disagrees
216 // with its own vms mixed two epochs. A mutator hammers create/tombstone while
217 // the main goroutine snapshots; the old three-call implementation released
218 // the sole connection between reads and fails this with high probability.
219 func TestSnapshotIsAtomicUnderConcurrentWrites(t *testing.T) {
220 s := newStore(t)
221 h := enrollHost(t, s)
222
223 done := make(chan struct{})
224 go func() {
225 defer close(done)
226 for i := 0; i < 300; i++ {
227 id := RandHex(8)
228 _ = s.CreateVM(VM{ID: id, HostID: h.ID, Name: "vm-" + id,
229 ImageURL: "http://x/i", ImageSHA256: "abc",
230 VCPUs: 1, MemMB: 256, DiskGB: 1, PowerState: "running"})
231 _ = s.TombstoneVM(id)
232 }
233 }()
234
235 for i := 0; i < 100; i++ {
236 _, alloc, vms, err := s.Snapshot()
237 require.NoError(t, err)
238 derived := map[string]Alloc{}
239 for _, vm := range vms {
240 if vm.DeletedAt == nil {
241 a := derived[vm.HostID]
242 a.VCPUs += vm.VCPUs
243 a.MemMB += vm.MemMB
244 a.DiskGB += vm.DiskGB
245 derived[vm.HostID] = a
246 }
247 }
248 require.Equal(t, derived, alloc,
249 "snapshot %d: alloc disagrees with its own vms — reads mixed two epochs", i)
250 }
251 <-done
252 }
253
254 // TestAuditLogRoundTrip pins the audit trail contract: AppendAudit writes a
255 // timestamped action+detail row; ListAudit returns newest-first with a limit.
256 func TestAuditLogRoundTrip(t *testing.T) {
257 s := newStore(t)
258 require.NoError(t, s.AppendAudit("enroll-token.mint", `{"token_hash_prefix":"abcd1234"}`))
259 require.NoError(t, s.AppendAudit("host.enroll", `{"host_id":"h1","name":"host-a"}`))
260
261 rows, err := s.ListAudit(10)
262 require.NoError(t, err)
263 require.Len(t, rows, 2)
264 assert.Equal(t, "host.enroll", rows[0].Action, "newest first")
265 assert.Contains(t, rows[0].Detail, "h1")
266 assert.False(t, rows[0].At.IsZero())
267
268 one, err := s.ListAudit(1)
269 require.NoError(t, err)
270 require.Len(t, one, 1)
271 assert.Equal(t, "host.enroll", one[0].Action)
272 }
273
274 // TestRedeemWritesAuditRowAtomically pins audit durability: the host.enroll
275 // audit row is written inside the SAME transaction as the redeem, so an
276 // enrolled host can never exist without its durable audit record.
277 func TestRedeemWritesAuditRowAtomically(t *testing.T) {
278 s := newStore(t)
279 tok, err := s.CreateEnrollmentToken()
280 require.NoError(t, err)
281 h, err := s.RedeemEnrollmentToken(tok, "host-a", "linux", "amd64", "cloudhv", "", "192.0.2.9")
282 require.NoError(t, err)
283
284 rows, err := s.ListAudit(5)
285 require.NoError(t, err)
286 require.NotEmpty(t, rows)
287 assert.Equal(t, "host.enroll", rows[0].Action)
288 assert.Contains(t, rows[0].Detail, h.ID)
289 assert.Contains(t, rows[0].Detail, "192.0.2.9")
290 assert.NotContains(t, rows[0].Detail, tok, "raw token must never reach the audit log")
291 }
292
293 // TestCredGenerationLifecycle pins per-host credential revocation: hosts
294 // enroll at generation 1; BumpCredGeneration invalidates outstanding
295 // credentials by incrementing the row; GetHost/ListHosts expose it.
296 func TestCredGenerationLifecycle(t *testing.T) {
297 s := newStore(t)
298 h := enrollHost(t, s)
299 assert.Equal(t, int64(1), h.CredGeneration, "fresh enrollment starts at generation 1")
300
301 got, err := s.GetHost(h.ID)
302 require.NoError(t, err)
303 assert.Equal(t, int64(1), got.CredGeneration)
304
305 gen, err := s.BumpCredGeneration(h.ID, "192.0.2.7")
306 require.NoError(t, err)
307 assert.Equal(t, int64(2), gen)
308
309 got, err = s.GetHost(h.ID)
310 require.NoError(t, err)
311 assert.Equal(t, int64(2), got.CredGeneration)
312
313 _, err = s.BumpCredGeneration("no-such-host", "")
314 assert.Error(t, err, "unknown host must error")
315 }
316
317
318 // TestBumpCredGenerationAuditsAtomically pins that the revoke audit row is
319 // written in the same transaction as the bump.
320 func TestBumpCredGenerationAuditsAtomically(t *testing.T) {
321 s := newStore(t)
322 h := enrollHost(t, s)
323 _, err := s.BumpCredGeneration(h.ID, "192.0.2.7")
324 require.NoError(t, err)
325 rows, err := s.ListAudit(1)
326 require.NoError(t, err)
327 require.Len(t, rows, 1)
328 assert.Equal(t, "host.credential.revoke", rows[0].Action)
329 assert.Contains(t, rows[0].Detail, h.ID)
330 assert.Contains(t, rows[0].Detail, "192.0.2.7")
331 }
332
333 // TestPruneAuditRemovesOnlyOldRows pins retention: rows older than the
334 // window are deleted, newer rows survive, and the count is reported.
335 func TestPruneAuditRemovesOnlyOldRows(t *testing.T) {
336 s := newStore(t)
337 // Insert directly so the timestamps are controlled.
338 old := time.Now().UTC().Add(-100 * 24 * time.Hour).Format(time.RFC3339)
339 _, err := s.db.Exec(`INSERT INTO audit_log(at, action, detail) VALUES (?, 'old.event', '{}')`, old)
340 require.NoError(t, err)
341 require.NoError(t, s.AppendAudit("new.event", "{}"))
342
343 n, err := s.PruneAudit(90 * 24 * time.Hour)
344 require.NoError(t, err)
345 assert.Equal(t, int64(1), n, "exactly the old row pruned")
346
347 rows, err := s.ListAudit(10)
348 require.NoError(t, err)
349 require.Len(t, rows, 1)
350 assert.Equal(t, "new.event", rows[0].Action)
351 }
352
353 // TestPruneAuditGuardsNonPositiveWindow pins the store-level contract: a
354 // zero/negative retention must never delete anything.
355 func TestPruneAuditGuardsNonPositiveWindow(t *testing.T) {
356 s := newStore(t)
357 require.NoError(t, s.AppendAudit("keep.me", "{}"))
358 for _, d := range []time.Duration{0, -time.Hour} {
359 n, err := s.PruneAudit(d)
360 require.NoError(t, err)
361 assert.Zero(t, n)
362 }
363 rows, err := s.ListAudit(5)
364 require.NoError(t, err)
365 assert.Len(t, rows, 1, "nothing may be deleted by a non-positive window")
366 }
internal/server/syncsvc/syncsvc.go
Old New
@@ -30,23 +30,29 @@ type Service struct {
30 reg *registry.Registry 30 reg *registry.Registry
31 hub *hub.Hub 31 hub *hub.Hub
32 secret []byte 32 secret []byte
33 // maxCredAge, when non-zero, rejects credentials whose issued-at is older.
34 // Zero disables the age check (default: expiry without an auto-renewal
35 // channel would force periodic re-enrolls; per-host generation revocation
36 // is the primary mechanism, max-age is opt-in defense-in-depth).
37 maxCredAge time.Duration
33 // writeTimeout bounds each down-stream snapshot write (see defaultWriteTimeout). 38 // writeTimeout bounds each down-stream snapshot write (see defaultWriteTimeout).
34 writeTimeout time.Duration 39 writeTimeout time.Duration
35 } 40 }
36 41
37 // New constructs a Service with the production-default down-stream write timeout. 42 // New constructs a Service with the production-default down-stream write
38 func New(st *store.Store, reg *registry.Registry, h *hub.Hub, secret []byte) *Service { 43 // timeout. maxCredAge zero disables the credential age check.
39 return newWithWriteTimeout(st, reg, h, secret, defaultWriteTimeout) 44 func New(st *store.Store, reg *registry.Registry, h *hub.Hub, secret []byte, maxCredAge time.Duration) *Service {
45 return newWithWriteTimeout(st, reg, h, secret, maxCredAge, defaultWriteTimeout)
40 } 46 }
41 47
42 // newWithWriteTimeout constructs a Service with an explicit down-stream write 48 // newWithWriteTimeout constructs a Service with an explicit down-stream write
43 // timeout. A zero timeout falls back to defaultWriteTimeout. Tests use this to 49 // timeout. A zero timeout falls back to defaultWriteTimeout. Tests use this to
44 // inject a short timeout; New keeps the public signature unchanged. 50 // inject a short timeout; New keeps the public signature unchanged.
45 func newWithWriteTimeout(st *store.Store, reg *registry.Registry, h *hub.Hub, secret []byte, writeTimeout time.Duration) *Service { 51 func newWithWriteTimeout(st *store.Store, reg *registry.Registry, h *hub.Hub, secret []byte, maxCredAge, writeTimeout time.Duration) *Service {
46 if writeTimeout <= 0 { 52 if writeTimeout <= 0 {
47 writeTimeout = defaultWriteTimeout 53 writeTimeout = defaultWriteTimeout
48 } 54 }
49 return &Service{st: st, reg: reg, hub: h, secret: secret, writeTimeout: writeTimeout} 55 return &Service{st: st, reg: reg, hub: h, secret: secret, maxCredAge: maxCredAge, writeTimeout: writeTimeout}
50 } 56 }
51 57
52 // Serve accepts QUIC connections until ctx is cancelled. 58 // Serve accepts QUIC connections until ctx is cancelled.
@@ -80,15 +86,26 @@ func (s *Service) handleConn(ctx context.Context, conn quic.Connection) {
80 // Auth: the Hello carries a Bearer host credential (string credential = 9). 86 // Auth: the Hello carries a Bearer host credential (string credential = 9).
81 cred := h.GetCredential() 87 cred := h.GetCredential()
82 // host_id in Hello is advisory; the authenticated identity comes from the credential (hosttoken.Verify), so a spoofed Hello.HostId cannot mislead us. 88 // host_id in Hello is advisory; the authenticated identity comes from the credential (hosttoken.Verify), so a spoofed Hello.HostId cannot mislead us.
83 hostID, ok := hosttoken.Verify(s.secret, cred) 89 claims, ok := hosttoken.Verify(s.secret, cred)
84 if !ok { 90 if !ok {
85 _ = conn.CloseWithError(transport.CodeAuthRejected, "invalid host credential") 91 _ = conn.CloseWithError(transport.CodeAuthRejected, "invalid host credential")
86 return 92 return
87 } 93 }
88 if _, err := s.st.GetHost(hostID); err != nil { 94 hostID := claims.HostID
95 if s.maxCredAge > 0 && time.Since(claims.IssuedAt) > s.maxCredAge {
96 _ = conn.CloseWithError(transport.CodeAuthRejected, "credential expired — re-enroll this host")
97 return
98 }
99 hostRow, err := s.st.GetHost(hostID)
100 if err != nil {
89 _ = conn.CloseWithError(transport.CodeAuthRejected, "host not found") 101 _ = conn.CloseWithError(transport.CodeAuthRejected, "host not found")
90 return 102 return
91 } 103 }
104 // Per-host revocation: a credential minted at an older generation is dead.
105 if claims.Generation != hostRow.CredGeneration {
106 _ = conn.CloseWithError(transport.CodeAuthRejected, "credential revoked — re-enroll this host")
107 return
108 }
92 slog.Info("agent connected", "host", hostID, "provisioner", h.GetProvisioner(), "last_seen_epoch", h.GetLastSeenEpoch()) 109 slog.Info("agent connected", "host", hostID, "provisioner", h.GetProvisioner(), "last_seen_epoch", h.GetLastSeenEpoch())
93 110
94 // Down-stream: server opens it; first write makes it visible to the agent. 111 // Down-stream: server opens it; first write makes it visible to the agent.
@@ -128,6 +145,24 @@ func (s *Service) handleConn(ctx context.Context, conn quic.Connection) {
128 return 145 return
129 } 146 }
130 if rep := msg.GetReport(); rep != nil { 147 if rep := msg.GetReport(); rep != nil {
148 // Re-check the credential each report so a revoke (or max-age
149 // expiry) lands within one tick (~10s) instead of waiting for the
150 // session to drop naturally.
151 row, err := s.st.GetHost(hostID)
152 if err != nil {
153 // Transient store failure — NOT an auth verdict. Close with a
154 // plain code so the agent retries on its normal 5s backoff
155 // instead of the permanent-auth 60s path.
156 slog.Warn("credential re-check failed; dropping session", "host", hostID, "err", err)
157 _ = conn.CloseWithError(0, "credential re-check unavailable")
158 return
159 }
160 expired := s.maxCredAge > 0 && time.Since(claims.IssuedAt) > s.maxCredAge
161 if row.CredGeneration != claims.Generation || expired {
162 slog.Info("credential no longer valid mid-session; closing", "host", hostID, "expired", expired)
163 _ = conn.CloseWithError(transport.CodeAuthRejected, "credential revoked or expired — re-enroll this host")
164 return
165 }
131 s.applyReport(hostID, rep) 166 s.applyReport(hostID, rep)
132 } 167 }
133 select { 168 select {
internal/server/syncsvc/syncsvc_test.go
Old New
@@ -41,7 +41,7 @@ func setupWithWriteTimeout(t *testing.T, writeTimeout time.Duration) *fixture {
41 require.NoError(t, err) 41 require.NoError(t, err)
42 t.Cleanup(func() { st.Close() }) 42 t.Cleanup(func() { st.Close() })
43 tok, _ := st.CreateEnrollmentToken() 43 tok, _ := st.CreateEnrollmentToken()
44 host, err := st.RedeemEnrollmentToken(tok, "h", "linux", "amd64", "cloudhv", "") 44 host, err := st.RedeemEnrollmentToken(tok, "h", "linux", "amd64", "cloudhv", "", "")
45 require.NoError(t, err) 45 require.NoError(t, err)
46 46
47 reg := registry.New(time.Now) 47 reg := registry.New(time.Now)
@@ -50,7 +50,7 @@ func setupWithWriteTimeout(t *testing.T, writeTimeout time.Duration) *fixture {
50 50
51 addr, fp, _ := startTestServer(t, st, reg, h, secret, writeTimeout) 51 addr, fp, _ := startTestServer(t, st, reg, h, secret, writeTimeout)
52 return &fixture{st: st, reg: reg, hub: h, addr: addr, fp: fp, secret: secret, 52 return &fixture{st: st, reg: reg, hub: h, addr: addr, fp: fp, secret: secret,
53 host: host, cred: hosttoken.Mint(secret, host.ID)} 53 host: host, cred: hosttoken.Mint(secret, host.ID, host.CredGeneration, time.Now())}
54 } 54 }
55 55
56 // startTestServer listens on 127.0.0.1:0 (random UDP port) and returns the addr, 56 // startTestServer listens on 127.0.0.1:0 (random UDP port) and returns the addr,
@@ -66,7 +66,7 @@ func startTestServer(t *testing.T, st *store.Store, reg *registry.Registry, h *h
66 require.NoError(t, err) 66 require.NoError(t, err)
67 lis, err := quic.ListenAddr("127.0.0.1:0", tlsConf, &quic.Config{MaxIdleTimeout: 5 * time.Second}) 67 lis, err := quic.ListenAddr("127.0.0.1:0", tlsConf, &quic.Config{MaxIdleTimeout: 5 * time.Second})
68 require.NoError(t, err) 68 require.NoError(t, err)
69 svc := newWithWriteTimeout(st, reg, h, secret, writeTimeout) 69 svc := newWithWriteTimeout(st, reg, h, secret, 0, writeTimeout)
70 ctx, cancel := context.WithCancel(context.Background()) 70 ctx, cancel := context.WithCancel(context.Background())
71 go svc.Serve(ctx, lis) //nolint:errcheck 71 go svc.Serve(ctx, lis) //nolint:errcheck
72 stop = func() { cancel(); lis.Close() } 72 stop = func() { cancel(); lis.Close() }
@@ -349,3 +349,121 @@ func TestWrongCertPinRejected(t *testing.T) {
349 assert.Contains(t, strings.ToLower(err.Error()), "fingerprint", 349 assert.Contains(t, strings.ToLower(err.Error()), "fingerprint",
350 "expected cert fingerprint mismatch, got: %v", err) 350 "expected cert fingerprint mismatch, got: %v", err)
351 } 351 }
352
353 // TestStaleGenerationCredentialRejected pins per-host revocation: after
354 // BumpCredGeneration, a credential minted at the old generation is rejected
355 // with CodeAuthRejected, while a freshly-minted one connects.
356 func TestStaleGenerationCredentialRejected(t *testing.T) {
357 f := setup(t)
358
359 oldCred := hosttoken.Mint(f.secret, f.host.ID, f.host.CredGeneration, time.Now())
360 _, err := f.st.BumpCredGeneration(f.host.ID, "")
361 require.NoError(t, err)
362
363 _, err = dial(t, f.addr, f.fp, f.host.ID, oldCred)
364 require.Error(t, err, "revoked-generation credential must be rejected")
365 var appErr *quic.ApplicationError
366 require.ErrorAs(t, err, &appErr)
367 assert.Equal(t, quic.ApplicationErrorCode(transport.CodeAuthRejected), appErr.ErrorCode)
368
369 fresh := hosttoken.Mint(f.secret, f.host.ID, f.host.CredGeneration+1, time.Now())
370 c, err := dial(t, f.addr, f.fp, f.host.ID, fresh)
371 require.NoError(t, err, "current-generation credential must connect")
372 c.conn.CloseWithError(0, "")
373 }
374
375 // TestExpiredCredentialRejectedWhenMaxAgeSet pins the optional max-age
376 // policy: with maxAge configured, a credential older than maxAge is rejected;
377 // with maxAge zero (default) age is ignored.
378 func TestExpiredCredentialRejectedWhenMaxAgeSet(t *testing.T) {
379 st, err := store.Open(t.TempDir()+"/db", "10.77.0.0/16")
380 require.NoError(t, err)
381 t.Cleanup(func() { st.Close() })
382 tok, _ := st.CreateEnrollmentToken()
383 host, err := st.RedeemEnrollmentToken(tok, "h", "linux", "amd64", "cloudhv", "", "")
384 require.NoError(t, err)
385
386 reg := registry.New(time.Now)
387 h := hub.New()
388 secret := []byte("s3cret")
389
390 certPEM, keyPEM, err := transport.GenerateServerCert()
391 require.NoError(t, err)
392 fp, err := transport.CertFingerprint(certPEM)
393 require.NoError(t, err)
394 tlsConf, err := transport.ServerTLS(certPEM, keyPEM)
395 require.NoError(t, err)
396 lis, err := quic.ListenAddr("127.0.0.1:0", tlsConf, &quic.Config{MaxIdleTimeout: 5 * time.Second})
397 require.NoError(t, err)
398 svc := New(st, reg, h, secret, 30*24*time.Hour) // maxAge 30d
399 ctx, cancel := context.WithCancel(context.Background())
400 go svc.Serve(ctx, lis) //nolint:errcheck
401 t.Cleanup(func() { cancel(); lis.Close() })
402 addr := lis.Addr().String()
403
404 stale := hosttoken.Mint(secret, host.ID, host.CredGeneration, time.Now().Add(-31*24*time.Hour))
405 _, err = dial(t, addr, fp, host.ID, stale)
406 require.Error(t, err, "over-max-age credential must be rejected")
407 var appErr *quic.ApplicationError
408 require.ErrorAs(t, err, &appErr)
409 assert.Equal(t, quic.ApplicationErrorCode(transport.CodeAuthRejected), appErr.ErrorCode)
410
411 fresh := hosttoken.Mint(secret, host.ID, host.CredGeneration, time.Now())
412 c, err := dial(t, addr, fp, host.ID, fresh)
413 require.NoError(t, err, "fresh credential must connect")
414 c.conn.CloseWithError(0, "")
415 }
416
417 // TestMaxAgeEnforcedMidSession pins that a live session does not outlive
418 // credential_max_age: the per-report re-check closes it once the credential's
419 // issued-at falls out of the window.
420 func TestMaxAgeEnforcedMidSession(t *testing.T) {
421 st, err := store.Open(t.TempDir()+"/db", "10.77.0.0/16")
422 require.NoError(t, err)
423 t.Cleanup(func() { st.Close() })
424 tok, _ := st.CreateEnrollmentToken()
425 host, err := st.RedeemEnrollmentToken(tok, "h", "linux", "amd64", "cloudhv", "", "")
426 require.NoError(t, err)
427
428 reg := registry.New(time.Now)
429 h := hub.New()
430 secret := []byte("s3cret")
431 certPEM, keyPEM, err := transport.GenerateServerCert()
432 require.NoError(t, err)
433 fp, err := transport.CertFingerprint(certPEM)
434 require.NoError(t, err)
435 tlsConf, err := transport.ServerTLS(certPEM, keyPEM)
436 require.NoError(t, err)
437 lis, err := quic.ListenAddr("127.0.0.1:0", tlsConf, &quic.Config{MaxIdleTimeout: 5 * time.Second})
438 require.NoError(t, err)
439 // Timing is truncation-aware: Mint stores issued_unix at SECOND
440 // granularity, so the observed age can read up to 0.999s older than
441 // real. maxAge 3s with a 1.5s-old credential leaves >1s margin on the
442 // Hello side (worst case 2.499s < 3s), and the 1.8s sleep pushes the
443 // real age to 3.3s — past maxAge even before truncation inflation.
444 svc := New(st, reg, h, secret, 3*time.Second)
445 ctx, cancel := context.WithCancel(context.Background())
446 go svc.Serve(ctx, lis) //nolint:errcheck
447 t.Cleanup(func() { cancel(); lis.Close() })
448
449 cred := hosttoken.Mint(secret, host.ID, host.CredGeneration, time.Now().Add(-1500*time.Millisecond))
450 c, err := dial(t, lis.Addr().String(), fp, host.ID, cred)
451 require.NoError(t, err, "not-yet-expired credential connects")
452 defer c.conn.CloseWithError(0, "")
453
454 time.Sleep(1800 * time.Millisecond) // credential ages past maxCredAge
455
456 c.send(t, &pb.AgentMessage{Msg: &pb.AgentMessage_Report{Report: &pb.ActualStateReport{}}})
457 // The server must close the connection with CodeAuthRejected; the next
458 // read on the down-stream surfaces it.
459 var msg pb.ServerMessage
460 readErr := transport.ReadMsg(c.down, &msg, transport.DefaultMaxFrame)
461 if readErr == nil {
462 // First read may deliver the initial snapshot; the close lands next.
463 readErr = transport.ReadMsg(c.down, &msg, transport.DefaultMaxFrame)
464 }
465 require.Error(t, readErr)
466 var appErr *quic.ApplicationError
467 require.ErrorAs(t, readErr, &appErr)
468 assert.Equal(t, quic.ApplicationErrorCode(transport.CodeAuthRejected), appErr.ErrorCode)
469 }
internal/shape/classify.go
Old New
@@ -30,7 +30,8 @@ func classify(rel string) Plane {
30 case strings.HasPrefix(rel, "internal/agent"): 30 case strings.HasPrefix(rel, "internal/agent"):
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 return PlaneWire 35 return PlaneWire
35 case strings.HasPrefix(rel, "cmd/"): 36 case strings.HasPrefix(rel, "cmd/"):
36 return PlaneBinaries 37 return PlaneBinaries
internal/transport/tlsconf.go
Old New
@@ -35,9 +35,13 @@ func GenerateServerCert() (certPEM, keyPEM []byte, err error) {
35 SerialNumber: big.NewInt(1), 35 SerialNumber: big.NewInt(1),
36 Subject: pkix.Name{CommonName: "eitri-server"}, 36 Subject: pkix.Name{CommonName: "eitri-server"},
37 NotBefore: time.Now().Add(-time.Hour), 37 NotBefore: time.Now().Add(-time.Hour),
38 NotAfter: time.Now().AddDate(10, 0, 0), 38 // 2 years, not 10: the agent pin ignores expiry (an expired cert never
39 KeyUsage: x509.KeyUsageDigitalSignature, 39 // breaks the fleet), so long validity only widens the forgery window
40 ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, 40 // if server.key leaks. CertRenewalDue warns 90 days out; rotation
41 // runbook: docs/cert-rotation.md.
42 NotAfter: time.Now().AddDate(2, 0, 0),
43 KeyUsage: x509.KeyUsageDigitalSignature,
44 ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
41 } 45 }
42 der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) 46 der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
43 if err != nil { 47 if err != nil {
@@ -62,6 +66,27 @@ func CertFingerprint(certPEM []byte) (string, error) {
62 return hex.EncodeToString(sum[:]), nil 66 return hex.EncodeToString(sum[:]), nil
63 } 67 }
64 68
69 // renewalWindow is how long before NotAfter CertRenewalDue starts reporting
70 // true. 90 days gives the operator a comfortable rotation runway.
71 const renewalWindow = 90 * 24 * time.Hour
72
73 // CertRenewalDue reports the cert's NotAfter and whether rotation is due (now
74 // is within renewalWindow of expiry, or past it). An unparseable cert reports
75 // due — fail loud so the operator looks at it. Note: the agent pin
76 // (VerifyConnection) ignores expiry, so an expired cert never breaks the
77 // running fleet; this only drives the operator-facing warning cadence.
78 func CertRenewalDue(certPEM []byte, now time.Time) (notAfter time.Time, due bool) {
79 block, _ := pem.Decode(certPEM)
80 if block == nil {
81 return time.Time{}, true
82 }
83 cert, err := x509.ParseCertificate(block.Bytes)
84 if err != nil {
85 return time.Time{}, true
86 }
87 return cert.NotAfter, now.After(cert.NotAfter.Add(-renewalWindow))
88 }
89
65 // ServerTLS builds the server's tls.Config from PEM cert+key with ALPN set. 90 // ServerTLS builds the server's tls.Config from PEM cert+key with ALPN set.
66 func ServerTLS(certPEM, keyPEM []byte) (*tls.Config, error) { 91 func ServerTLS(certPEM, keyPEM []byte) (*tls.Config, error) {
67 cert, err := tls.X509KeyPair(certPEM, keyPEM) 92 cert, err := tls.X509KeyPair(certPEM, keyPEM)
internal/transport/tlsconf_test.go
Old New
@@ -3,7 +3,9 @@ package transport
3 import ( 3 import (
4 "crypto/tls" 4 "crypto/tls"
5 "crypto/x509" 5 "crypto/x509"
6 "encoding/pem"
6 "testing" 7 "testing"
8 "time"
7 9
8 "github.com/stretchr/testify/assert" 10 "github.com/stretchr/testify/assert"
9 "github.com/stretchr/testify/require" 11 "github.com/stretchr/testify/require"
@@ -46,3 +48,50 @@ func TestServerTLSHasALPN(t *testing.T) {
46 require.NoError(t, err) 48 require.NoError(t, err)
47 assert.Contains(t, sc.NextProtos, ALPN) 49 assert.Contains(t, sc.NextProtos, ALPN)
48 } 50 }
51
52 // TestGeneratedCertValidityIsTwoYears pins the cert lifetime at ~2 years. The
53 // pin (VerifyConnection) ignores expiry, so a longer validity buys nothing —
54 // it only widens the forgery window if server.key leaks. Two years sets the
55 // rotation cadence the renewal warning drives.
56 func TestGeneratedCertValidityIsTwoYears(t *testing.T) {
57 certPEM, _, err := GenerateServerCert()
58 require.NoError(t, err)
59 block, _ := pem.Decode(certPEM)
60 require.NotNil(t, block)
61 cert, err := x509.ParseCertificate(block.Bytes)
62 require.NoError(t, err)
63
64 lifetime := cert.NotAfter.Sub(cert.NotBefore)
65 assert.LessOrEqual(t, lifetime, 2*365*24*time.Hour+31*24*time.Hour,
66 "validity must be ~2 years, not the old 10")
67 assert.Greater(t, lifetime, 365*24*time.Hour, "validity must exceed 1 year")
68 }
69
70 // TestCertRenewalDue pins the renewal-warning helper: due when now is within
71 // 90 days of NotAfter (or past it), not due before that, and an unparseable
72 // cert reports due (fail-loud: an operator should look at a broken cert).
73 func TestCertRenewalDue(t *testing.T) {
74 certPEM, _, err := GenerateServerCert()
75 require.NoError(t, err)
76 block, _ := pem.Decode(certPEM)
77 require.NotNil(t, block)
78 cert, err := x509.ParseCertificate(block.Bytes)
79 require.NoError(t, err)
80
81 notAfter, due := CertRenewalDue(certPEM, cert.NotAfter.Add(-180*24*time.Hour))
82 assert.False(t, due, "6 months out must not be due")
83 assert.Equal(t, cert.NotAfter, notAfter)
84
85 _, due = CertRenewalDue(certPEM, cert.NotAfter.Add(-30*24*time.Hour))
86 assert.True(t, due, "30 days out must be due")
87
88 _, due = CertRenewalDue(certPEM, cert.NotAfter.Add(24*time.Hour))
89 assert.True(t, due, "past expiry must be due")
90
91 _, due = CertRenewalDue([]byte("not a cert"), time.Now())
92 assert.True(t, due, "non-PEM input must report due (fail loud)")
93
94 junkDER := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: []byte("junk")})
95 _, due = CertRenewalDue(junkDER, time.Now())
96 assert.True(t, due, "valid PEM with garbage DER must report due (fail loud)")
97 }
web/src/lib/api-types.ts
Old New
@@ -4,6 +4,54 @@
4 */ 4 */
5 5
6 export interface paths { 6 export interface paths {
7 "/api/v1/audit": {
8 parameters: {
9 query?: never;
10 header?: never;
11 path?: never;
12 cookie?: never;
13 };
14 /** Newest audit log rows. */
15 get: {
16 parameters: {
17 query?: {
18 /** @description max rows to return (default 100, cap 1000) */
19 limit?: string;
20 };
21 header?: never;
22 path?: never;
23 cookie?: never;
24 };
25 requestBody?: never;
26 responses: {
27 /** @description success */
28 200: {
29 headers: {
30 [name: string]: unknown;
31 };
32 content: {
33 "application/json": components["schemas"]["AuditEvent"][];
34 };
35 };
36 /** @description error (plain text) */
37 default: {
38 headers: {
39 [name: string]: unknown;
40 };
41 content: {
42 "text/plain": string;
43 };
44 };
45 };
46 };
47 put?: never;
48 post?: never;
49 delete?: never;
50 options?: never;
51 head?: never;
52 patch?: never;
53 trace?: never;
54 };
7 "/api/v1/enroll": { 55 "/api/v1/enroll": {
8 parameters: { 56 parameters: {
9 query?: never; 57 query?: never;
@@ -13,7 +61,7 @@ export interface paths {
13 }; 61 };
14 get?: never; 62 get?: never;
15 put?: never; 63 put?: never;
16 /** Redeem a one-time enrollment token: a new host joins the fleet and receives its credential. Unauthenticated; the token is the proof. */ 64 /** 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. */
17 post: { 65 post: {
18 parameters: { 66 parameters: {
19 query?: never; 67 query?: never;
@@ -62,7 +110,7 @@ export interface paths {
62 }; 110 };
63 get?: never; 111 get?: never;
64 put?: never; 112 put?: never;
65 /** Mint a one-time host enrollment token. */ 113 /** Mint a one-time host enrollment token plus the join blob agents consume. */
66 post: { 114 post: {
67 parameters: { 115 parameters: {
68 query?: never; 116 query?: never;
@@ -109,8 +157,8 @@ export interface paths {
109 get: { 157 get: {
110 parameters: { 158 parameters: {
111 query?: { 159 query?: {
112 /** @description admin token */ 160 /** @description one-time stream ticket */
113 token?: string; 161 ticket?: string;
114 }; 162 };
115 header?: never; 163 header?: never;
116 path?: never; 164 path?: never;
@@ -236,6 +284,96 @@ export interface paths {
236 patch?: never; 284 patch?: never;
237 trace?: never; 285 trace?: never;
238 }; 286 };
287 "/api/v1/hosts/{id}/revoke-credential": {
288 parameters: {
289 query?: never;
290 header?: never;
291 path?: never;
292 cookie?: never;
293 };
294 get?: never;
295 put?: never;
296 /** Revoke a host's outstanding credential by bumping its generation; the host stays dark until re-enrolled. */
297 post: {
298 parameters: {
299 query?: never;
300 header?: never;
301 path: {
302 id: string;
303 };
304 cookie?: never;
305 };
306 requestBody?: never;
307 responses: {
308 /** @description success */
309 204: {
310 headers: {
311 [name: string]: unknown;
312 };
313 content?: never;
314 };
315 /** @description error (plain text) */
316 default: {
317 headers: {
318 [name: string]: unknown;
319 };
320 content: {
321 "text/plain": string;
322 };
323 };
324 };
325 };
326 delete?: never;
327 options?: never;
328 head?: never;
329 patch?: never;
330 trace?: never;
331 };
332 "/api/v1/stream-tickets": {
333 parameters: {
334 query?: never;
335 header?: never;
336 path?: never;
337 cookie?: never;
338 };
339 get?: never;
340 put?: never;
341 /** Mint a one-time short-TTL ticket for the SSE stream — the only credential that ever rides in a URL. */
342 post: {
343 parameters: {
344 query?: never;
345 header?: never;
346 path?: never;
347 cookie?: never;
348 };
349 requestBody?: never;
350 responses: {
351 /** @description success */
352 201: {
353 headers: {
354 [name: string]: unknown;
355 };
356 content: {
357 "application/json": components["schemas"]["StreamTicketResponse"];
358 };
359 };
360 /** @description error (plain text) */
361 default: {
362 headers: {
363 [name: string]: unknown;
364 };
365 content: {
366 "text/plain": string;
367 };
368 };
369 };
370 };
371 delete?: never;
372 options?: never;
373 head?: never;
374 patch?: never;
375 trace?: never;
376 };
239 "/api/v1/vms": { 377 "/api/v1/vms": {
240 parameters: { 378 parameters: {
241 query?: never; 379 query?: never;
@@ -396,6 +534,12 @@ export interface paths {
396 export type webhooks = Record<string, never>; 534 export type webhooks = Record<string, never>;
397 export interface components { 535 export interface components {
398 schemas: { 536 schemas: {
537 AuditEvent: {
538 action: string;
539 /** Format: date-time */
540 at: string;
541 detail: unknown;
542 };
399 Capacity: { 543 Capacity: {
400 disk_gb: number; 544 disk_gb: number;
401 mem_mb: number; 545 mem_mb: number;
@@ -434,6 +578,7 @@ export interface components {
434 server_cert_sha256: string; 578 server_cert_sha256: string;
435 }; 579 };
436 EnrollTokenResponse: { 580 EnrollTokenResponse: {
581 join: string;
437 token: string; 582 token: string;
438 }; 583 };
439 Host: { 584 Host: {
@@ -458,6 +603,9 @@ export interface components {
458 hosts: components["schemas"]["Host"][]; 603 hosts: components["schemas"]["Host"][];
459 vms: components["schemas"]["VM"][]; 604 vms: components["schemas"]["VM"][];
460 }; 605 };
606 StreamTicketResponse: {
607 ticket: string;
608 };
461 VM: { 609 VM: {
462 actual_power: string; 610 actual_power: string;
463 assigned_ip: string; 611 assigned_ip: string;
web/src/lib/fleet.svelte.ts
Old New
@@ -25,6 +25,11 @@ export const fleet = $state({
25 25
26 let es: EventSource | null = null; 26 let es: EventSource | null = null;
27 27
28 // sseParseError marks that fleet.error came from the SSE stream itself (not a
29 // user action), so the next well-formed event self-heals it — action errors
30 // stay sticky until dismissed.
31 let sseParseError = false;
32
28 function authHeaders(): HeadersInit { 33 function authHeaders(): HeadersInit {
29 return { Authorization: `Bearer ${fleet.token}`, 'Content-Type': 'application/json' }; 34 return { Authorization: `Bearer ${fleet.token}`, 'Content-Type': 'application/json' };
30 } 35 }
@@ -49,28 +54,85 @@ export function setToken(t: string) {
49 connect(); 54 connect();
50 } 55 }
51 56
52 /** connect opens the SSE stream; on error it falls back to a one-shot refresh. */ 57 let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
53 export function connect() { 58
59 // connectGen guards against overlapping async connect() calls: only the
60 // newest invocation may install its EventSource — a stale one closes its
61 // orphan instead of clobbering (or later killing) the current stream.
62 let connectGen = 0;
63
64 /** connect mints a one-time stream ticket (so the admin token never rides in
65 * a URL) and opens the SSE stream. Tickets are single-use, so the browser's
66 * built-in EventSource retry cannot work — on error we close the stream and
67 * reconnect ourselves with a fresh ticket. */
68 export async function connect() {
69 es?.close(); // close first: clearing the token must also stop the stream
70 if (reconnectTimer) {
71 clearTimeout(reconnectTimer);
72 reconnectTimer = null;
73 }
54 if (!fleet.token) return; 74 if (!fleet.token) return;
55 es?.close(); 75 const gen = ++connectGen;
56 es = new EventSource(`/api/v1/events?token=${encodeURIComponent(fleet.token)}`); 76 let ticket: string;
57 es.addEventListener('state', (e) => { 77 try {
78 const r = await (await req('POST', '/api/v1/stream-tickets')).json();
79 if (typeof r.ticket !== 'string' || !r.ticket) throw new Error('malformed ticket response');
80 ticket = r.ticket;
81 } catch {
82 if (gen !== connectGen) return; // superseded while minting
83 fleet.connected = false;
84 scheduleReconnect();
85 return;
86 }
87 if (gen !== connectGen) return; // superseded while minting — drop the ticket
88 const mine = new EventSource(`/api/v1/events?ticket=${encodeURIComponent(ticket)}`);
89 es = mine;
90 mine.addEventListener('state', (e) => {
58 try { 91 try {
59 const snap = JSON.parse((e as MessageEvent).data); 92 const snap = JSON.parse((e as MessageEvent).data);
60 fleet.hosts = snap.hosts ?? []; 93 fleet.hosts = snap.hosts ?? [];
61 fleet.vms = snap.vms ?? []; 94 fleet.vms = snap.vms ?? [];
62 fleet.connected = true; 95 fleet.connected = true;
63 fleet.error = ''; 96 // Deliberately does NOT clear action errors: state events arrive
97 // ~1/s, so auto-clearing here made action failures flash for under
98 // a second — invisible in practice. They persist until dismissed
99 // (dismissError) or the next successful refresh() (page load /
100 // token re-set). Only the stream's OWN parse errors self-heal.
101 if (sseParseError) {
102 fleet.error = '';
103 sseParseError = false;
104 }
64 } catch (err) { 105 } catch (err) {
65 fleet.error = String(err); 106 fleet.error = String(err);
107 sseParseError = true;
66 } 108 }
67 }); 109 });
68 es.onerror = () => { 110 mine.onerror = () => {
111 // Only the CURRENT stream may trigger reconnect churn — an orphaned
112 // EventSource from a superseded connect() closes itself and stops.
113 mine.close();
114 if (es !== mine) return;
69 fleet.connected = false; 115 fleet.connected = false;
70 // EventSource auto-reconnects; surface the state but keep last data. 116 // The ticket is consumed: the built-in retry would replay a dead URL.
117 // Reconnect with a fresh ticket instead; keep last data.
118 scheduleReconnect();
71 }; 119 };
72 } 120 }
73 121
122 /** scheduleReconnect arms a single delayed reconnect (fresh ticket). */
123 function scheduleReconnect() {
124 if (reconnectTimer) return;
125 reconnectTimer = setTimeout(() => {
126 reconnectTimer = null;
127 void connect();
128 }, 3000);
129 }
130
131 /** dismissError clears the sticky error banner. */
132 export function dismissError() {
133 fleet.error = '';
134 }
135
74 /** refresh does a one-shot fetch (used before SSE connects or as a fallback). */ 136 /** refresh does a one-shot fetch (used before SSE connects or as a fallback). */
75 export async function refresh() { 137 export async function refresh() {
76 try { 138 try {
@@ -102,9 +164,9 @@ export async function decommissionHost(id: string) {
102 await req('DELETE', `/api/v1/hosts/${id}`); 164 await req('DELETE', `/api/v1/hosts/${id}`);
103 } 165 }
104 166
105 export async function createEnrollToken(): Promise<string> { 167 export async function createJoinBlob(): Promise<string> {
106 const r = await (await req('POST', '/api/v1/enroll-tokens')).json(); 168 const r = await (await req('POST', '/api/v1/enroll-tokens')).json();
107 return r.token; 169 return r.join;
108 } 170 }
109 171
110 export function vmsForHost(id: string): VM[] { 172 export function vmsForHost(id: string): VM[] {
web/src/routes/+layout.svelte
Old New
@@ -1,7 +1,7 @@
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 } from '$lib/fleet.svelte'; 4 import { fleet, setToken, connect, refresh, dismissError } from '$lib/fleet.svelte';
5 let { children } = $props(); 5 let { children } = $props();
6 let tokenInput = $state(''); 6 let tokenInput = $state('');
7 7
@@ -18,6 +18,20 @@
18 setToken(tokenInput); 18 setToken(tokenInput);
19 refresh(); 19 refresh();
20 } 20 }
21
22 // Dismissing the banner removes the focused button from the DOM, which
23 // drops keyboard focus to <body>. Land it somewhere useful instead: the
24 // fleet filter when present (overview), else the first main link/control.
25 function dismissAndRefocus() {
26 dismissError();
27 // An open modal owns focus: dismissing the banner above it must not
28 // send keyboard focus behind the overlay.
29 const target =
30 document.querySelector<HTMLElement>('.modal input, .modal select, .modal button') ??
31 document.getElementById('fleet-filter') ??
32 document.querySelector<HTMLElement>('main a, main button, main input');
33 target?.focus();
34 }
21 </script> 35 </script>
22 36
23 <svelte:head> 37 <svelte:head>
@@ -40,7 +54,10 @@
40 </header> 54 </header>
41 55
42 {#if fleet.error} 56 {#if fleet.error}
43 <div class="error">{fleet.error}</div> 57 <div class="error" role="alert">
58 <span>{fleet.error}</span>
59 <button type="button" class="dismiss" onclick={dismissAndRefocus} aria-label="dismiss error">✕</button>
60 </div>
44 {/if} 61 {/if}
45 62
46 <main> 63 <main>
@@ -157,6 +174,19 @@
157 color: #ffb4b4; 174 color: #ffb4b4;
158 padding: 0.5rem 1rem; 175 padding: 0.5rem 1rem;
159 border-bottom: 1px solid #5a2025; 176 border-bottom: 1px solid #5a2025;
177 display: flex;
178 align-items: center;
179 justify-content: space-between;
180 gap: 0.6rem;
181 }
182 .error .dismiss {
183 background: transparent;
184 border: 1px solid #5a2025;
185 color: #ffb4b4;
186 padding: 0.1rem 0.45rem;
187 }
188 .error .dismiss:hover {
189 background: #5a2025;
160 } 190 }
161 .hint { 191 .hint {
162 color: #6b7280; 192 color: #6b7280;
web/src/routes/+page.svelte
Old New
@@ -5,7 +5,7 @@
5 setPower, 5 setPower,
6 deleteVM, 6 deleteVM,
7 decommissionHost, 7 decommissionHost,
8 createEnrollToken, 8 createJoinBlob,
9 vmsForHost, 9 vmsForHost,
10 vmPhase, 10 vmPhase,
11 vmPower, 11 vmPower,
@@ -16,8 +16,31 @@
16 16
17 let showCreate = $state(false); 17 let showCreate = $state(false);
18 let advanced = $state(false); 18 let advanced = $state(false);
19 let enrollToken = $state(''); 19 let joinBlob = $state('');
20 let busy = $state(''); 20 let busy = $state('');
21 let filter = $state('');
22
23 // Case-insensitive substring match over the row's visible fields, so a
24 // large fleet can be narrowed live from one box.
25 const needle = $derived(filter.trim().toLowerCase());
26 const shownHosts = $derived(
27 fleet.hosts.filter(
28 (h) =>
29 !needle ||
30 `${h.name} ${h.bridge_cidr} ${h.status} ${h.online ? 'online' : 'offline'}`
31 .toLowerCase()
32 .includes(needle)
33 )
34 );
35 const shownVMs = $derived(
36 fleet.vms.filter((v) => {
37 if (!needle) return true;
38 const hostName = fleet.hosts.find((h) => h.id === v.host_id)?.name ?? '';
39 return `${v.name} ${hostName} ${vmPhase(v)} ${vmPower(v)} ${vmIP(v)}`
40 .toLowerCase()
41 .includes(needle);
42 })
43 );
21 44
22 let form = $state<CreateVMRequest>({ host_id: '' }); 45 let form = $state<CreateVMRequest>({ host_id: '' });
23 46
@@ -75,35 +98,48 @@
75 98
76 async function addHost() { 99 async function addHost() {
77 try { 100 try {
78 enrollToken = await createEnrollToken(); 101 joinBlob = await createJoinBlob();
79 } catch (err) { 102 } catch (err) {
80 fleet.error = String(err); 103 fleet.error = String(err);
81 } 104 }
82 } 105 }
83 </script> 106 </script>
84 107
108 <div class="row">
109 <input
110 id="fleet-filter"
111 class="filter"
112 type="search"
113 placeholder="filter hosts & VMs…"
114 bind:value={filter}
115 aria-label="filter hosts and VMs"
116 />
117 </div>
118
85 <section> 119 <section>
86 <div class="row"> 120 <div class="row">
87 <h2>Hosts ({fleet.hosts.length})</h2> 121 <h2>Hosts ({needle ? `${shownHosts.length}/${fleet.hosts.length}` : fleet.hosts.length})</h2>
88 <button class="ghost" onclick={addHost}>+ Add host</button> 122 <button class="ghost" onclick={addHost}>+ Add host</button>
89 </div> 123 </div>
90 124
91 {#if enrollToken} 125 {#if joinBlob}
92 <div class="enroll"> 126 <div class="enroll">
93 Run on the new host: 127 Run on the new host:
94 <code>eitri-agent --server &lt;url&gt; --quic-addr &lt;addr&gt; --token {enrollToken} enroll</code> 128 <code>eitri-agent --state-dir /var/lib/eitri-agent join {joinBlob}</code>
95 </div> 129 </div>
96 {/if} 130 {/if}
97 131
98 {#if fleet.hosts.length === 0} 132 {#if fleet.hosts.length === 0}
99 <p class="hint">No hosts enrolled yet.</p> 133 <p class="hint">No hosts enrolled yet.</p>
134 {:else if shownHosts.length === 0}
135 <p class="hint">No hosts match “{filter}”.</p>
100 {:else} 136 {:else}
101 <table> 137 <table>
102 <thead> 138 <thead>
103 <tr><th>Name</th><th>Status</th><th>VMs</th><th>CIDR</th><th>Used / total (vCPU · mem · disk)</th><th></th></tr> 139 <tr><th>Name</th><th>Status</th><th>VMs</th><th>CIDR</th><th>Used / total (vCPU · mem · disk)</th><th></th></tr>
104 </thead> 140 </thead>
105 <tbody> 141 <tbody>
106 {#each fleet.hosts as h (h.id)} 142 {#each shownHosts as h (h.id)}
107 <tr> 143 <tr>
108 <td><a href="/hosts/{h.id}">{h.name}</a></td> 144 <td><a href="/hosts/{h.id}">{h.name}</a></td>
109 <td> 145 <td>
@@ -133,19 +169,21 @@
133 169
134 <section> 170 <section>
135 <div class="row"> 171 <div class="row">
136 <h2>VMs ({fleet.vms.length})</h2> 172 <h2>VMs ({needle ? `${shownVMs.length}/${fleet.vms.length}` : fleet.vms.length})</h2>
137 <button onclick={openCreate} disabled={fleet.hosts.length === 0}>+ Create VM</button> 173 <button onclick={openCreate} disabled={fleet.hosts.length === 0}>+ Create VM</button>
138 </div> 174 </div>
139 175
140 {#if fleet.vms.length === 0} 176 {#if fleet.vms.length === 0}
141 <p class="hint">No VMs.</p> 177 <p class="hint">No VMs.</p>
178 {:else if shownVMs.length === 0}
179 <p class="hint">No VMs match “{filter}”.</p>
142 {:else} 180 {:else}
143 <table> 181 <table>
144 <thead> 182 <thead>
145 <tr><th>Name</th><th>Host</th><th>Phase</th><th>Power</th><th>IP</th><th></th></tr> 183 <tr><th>Name</th><th>Host</th><th>Phase</th><th>Power</th><th>IP</th><th></th></tr>
146 </thead> 184 </thead>
147 <tbody> 185 <tbody>
148 {#each fleet.vms as v (v.id)} 186 {#each shownVMs as v (v.id)}
149 <tr> 187 <tr>
150 <td><a href="/vms/{v.id}">{v.name}</a></td> 188 <td><a href="/vms/{v.id}">{v.name}</a></td>
151 <td>{fleet.hosts.find((h) => h.id === v.host_id)?.name ?? v.host_id.slice(0, 8)}</td> 189 <td>{fleet.hosts.find((h) => h.id === v.host_id)?.name ?? v.host_id.slice(0, 8)}</td>
@@ -299,4 +337,8 @@
299 .hint { 337 .hint {
300 color: #6b7280; 338 color: #6b7280;
301 } 339 }
340 .filter {
341 margin-top: 0.8rem;
342 width: 260px;
343 }
302 </style> 344 </style>