a73x

d75b0342

fix(server): a UDP grant only goes to an agent that speaks it, and the HTTP server bounds a slow client

a73x   2026-08-12 19:24

Commit message
fix(server): a UDP grant only goes to an agent that speaks it, and the HTTP server bounds a slow client

Two server-side correctness/hardening fixes from the v0.0.1→v0.0.6 review.

A v0.0.5+ plane accepts protocol:"udp" with no agent-version floor, but a
below-v0.0.5 agent's converge loop calls net.Listen("tcp", …) unconditionally
and reports the exposure active regardless — a UDP grant handed there binds TCP
and reads published while carrying no datagrams, breaking ExposureActual's
"active means the host half of the pipe exists" contract. handleCreateExposure
now floors a UDP create on release.FirstDatagramExposures ("v0.0.5"), refusing
409 and naming the version + the upgrade endpoint, the exposure twin of the
certified-host-key create refusal (precsrRefusal). Only a connected, reporting
host is judged; a silent or offline one takes the grant as before. TCP is never
floored.

The plane's http.Server had no timeouts (Slowloris; gosec G112 never fired
because gosec is not an enabled linter). It now sets ReadHeaderTimeout (the
Slowloris defense) and IdleTimeout (reclaims idle keep-alives). ReadTimeout and
WriteTimeout stay off on purpose: each bounds the whole request, and this
handler carries the SSE fleet-event stream and the serial-console WebSocket,
both long-lived by design — a WriteTimeout would sever a live console and a
ReadTimeout cancels the request context at its deadline via net/http's
client-disconnect background read, cutting the SSE stream.

internal/server/api/exposures.go
Old New
@@ -8,6 +8,7 @@ import (
8 8
9 "github.com/a73x/eitri/internal/server/api/types" 9 "github.com/a73x/eitri/internal/server/api/types"
10 "github.com/a73x/eitri/internal/server/registry" 10 "github.com/a73x/eitri/internal/server/registry"
11 "github.com/a73x/eitri/internal/server/release"
11 "github.com/a73x/eitri/internal/server/store" 12 "github.com/a73x/eitri/internal/server/store"
12 ) 13 )
13 14
@@ -134,6 +135,23 @@ func (a *API) handleCreateExposure(w http.ResponseWriter, r *http.Request) {
134 return 135 return
135 } 136 }
136 protocol := exposureProtocol(req) 137 protocol := exposureProtocol(req)
138 // A UDP grant only goes to an agent that speaks it. An older agent's converge
139 // loop listens TCP unconditionally and reports the exposure active anyway, so
140 // the grant would read published and carry no datagrams. Refuse it here, where
141 // the operator can still upgrade the host — the exposure twin of the create's
142 // certified-host-key refusal. Only a connected, reporting host is judged: an
143 // offline or silent one takes the grant exactly as it would for any other
144 // reason it cannot serve one this moment (see api.go's precsrRefusal note).
145 if protocol == "udp" {
146 if hs, ok := a.reg.Get(vm.HostID); ok && hs.Online && !release.HonorsDatagramExposures(hs.AgentVersion) {
147 hostName := vm.HostID
148 if h, err := a.st.GetHost(vm.HostID); err == nil {
149 hostName = h.Name
150 }
151 http.Error(w, preDatagramRefusal(hostName, vm.HostID, hs.AgentVersion, a.URL(upgradeAgentPath(vm.HostID))), http.StatusConflict)
152 return
153 }
154 }
137 e, err := a.st.CreateExposure(vm.ID, req.GuestPort, req.HostPort, protocol) 155 e, err := a.st.CreateExposure(vm.ID, req.GuestPort, req.HostPort, protocol)
138 if err != nil { 156 if err != nil {
139 switch { 157 switch {
internal/server/api/exposures_test.go
Old New
@@ -8,6 +8,7 @@ import (
8 "testing" 8 "testing"
9 9
10 "github.com/a73x/eitri/internal/server/registry" 10 "github.com/a73x/eitri/internal/server/registry"
11 "github.com/a73x/eitri/internal/server/release"
11 "github.com/a73x/eitri/internal/server/store" 12 "github.com/a73x/eitri/internal/server/store"
12 "github.com/stretchr/testify/assert" 13 "github.com/stretchr/testify/assert"
13 "github.com/stretchr/testify/require" 14 "github.com/stretchr/testify/require"
@@ -102,6 +103,51 @@ func TestCreateExposureDefaultsToTCPAndTakesUDP(t *testing.T) {
102 assert.Equal(t, 409, resp.StatusCode) 103 assert.Equal(t, 409, resp.StatusCode)
103 } 104 }
104 105
106 // TestCreateExposureFloorsUDPBelowFirstDatagramAgent pins the one place a grant
107 // is refused for what the host runs rather than what was asked: a UDP exposure
108 // needs an agent that binds UDP. An older agent listens TCP unconditionally and
109 // reports the exposure active anyway, so the row would read published and carry
110 // no datagrams — refuse it here, where the host can still be upgraded. Only a
111 // connected, reporting host is judged, exactly as the certified-host-key create
112 // refusal (precsrRefusal) judges its floor.
113 func TestCreateExposureFloorsUDPBelowFirstDatagramAgent(t *testing.T) {
114 ts, _, _, reg, _ := newServer(t)
115 host := enroll(t, ts)
116 hostID := host["host_id"]
117 vmID := createTestVM(t, ts, hostID, "web-1")
118
119 udp := func(guestPort int) *http.Response {
120 return do(t, "POST", ts.URL+"/api/v1/vms/"+vmID+"/exposures", testPAT,
121 map[string]any{"guest_port": guestPort, "protocol": "udp"})
122 }
123
124 // enroll leaves the host known-but-silent (no report), the state of every
125 // host for a moment after a server restart: nothing says what it runs, so —
126 // like precsrRefusal — it is not judged and the UDP grant is taken.
127 require.Equal(t, 201, udp(50).StatusCode, "a silent host is not floored")
128
129 // An online agent below the datagram floor is refused, naming the version and
130 // the endpoint that fixes it.
131 reg.SetAgentVersion(hostID, "v0.0.4")
132 reg.UpdateReport(hostID, registry.Report{})
133 resp := udp(51)
134 require.Equal(t, 409, resp.StatusCode)
135 body, err := io.ReadAll(resp.Body)
136 require.NoError(t, err)
137 assert.Contains(t, string(body), release.FirstDatagramExposures, "the refusal names the floor version")
138 assert.Contains(t, string(body), "upgrade-agent", "and the endpoint that fixes it")
139
140 // A TCP grant to that same below-floor host is never floored.
141 resp = do(t, "POST", ts.URL+"/api/v1/vms/"+vmID+"/exposures", testPAT,
142 map[string]any{"guest_port": 80})
143 require.Equal(t, 201, resp.StatusCode, "a TCP grant is unaffected by the datagram floor")
144
145 // At the floor, the UDP grant is taken.
146 reg.SetAgentVersion(hostID, release.FirstDatagramExposures)
147 reg.UpdateReport(hostID, registry.Report{})
148 require.Equal(t, 201, udp(52).StatusCode, "an at-floor agent honors the protocol")
149 }
150
105 func TestCreateExposureRefusesAnotherProtocol(t *testing.T) { 151 func TestCreateExposureRefusesAnotherProtocol(t *testing.T) {
106 ts, _, _ := testServer(t) 152 ts, _, _ := testServer(t)
107 host := enroll(t, ts) 153 host := enroll(t, ts)
internal/server/api/upgrade.go
Old New
@@ -32,6 +32,25 @@ func precsrRefusal(hostName, hostID, agentVersion, upgradeURL string) string {
32 upgradeURL + " — then create the VM." 32 upgradeURL + " — then create the VM."
33 } 33 }
34 34
35 // preDatagramRefusal explains why a host cannot be granted a UDP exposure: its
36 // agent predates datagram proxies. A UDP grant handed to an older agent is bound
37 // as TCP by a converge loop that listens unconditionally, and reported active
38 // regardless — the row would read published and carry no datagrams ever. As in
39 // precsrRefusal, the two readings — a version below the floor, and no reported
40 // version at all — differ only in what is known, so they share the consequence
41 // and the fix; both describe a host that is connected and reporting.
42 func preDatagramRefusal(hostName, hostID, agentVersion, upgradeURL string) string {
43 known := fmt.Sprintf("host %s (%s) runs agent %s, which predates datagram exposures (%s)",
44 hostName, hostID, agentVersion, release.FirstDatagramExposures)
45 if agentVersion == "" {
46 known = fmt.Sprintf("host %s (%s) has reported no agent version, so nothing says it can carry a UDP exposure (%s)",
47 hostName, hostID, release.FirstDatagramExposures)
48 }
49 return known + ": a UDP grant there would be bound as TCP and reported active, publishing a port that carries no " +
50 "datagrams. Upgrade that host's agent — the console's upgrade button, or POST " + upgradeURL +
51 " — then publish the port."
52 }
53
35 // handleUpgradeAgent records a pending self-upgrade offer for one host's agent 54 // handleUpgradeAgent records a pending self-upgrade offer for one host's agent
36 // and pokes its snapshot stream. The human is the rollout controller: nothing 55 // and pokes its snapshot stream. The human is the rollout controller: nothing
37 // upgrades without this per-host click, so a bad release stops at one host. 56 // upgrades without this per-host click, so a bad release stops at one host.
internal/server/boot/boot.go
Old New
@@ -38,6 +38,41 @@ import (
38 "golang.org/x/crypto/ssh" 38 "golang.org/x/crypto/ssh"
39 ) 39 )
40 40
41 // httpServerReadHeaderTimeout bounds an unauthenticated client's request-header
42 // phase, the same defense the SSH gate's handshakeGrace and the sync service's
43 // helloGrace give their protocols: without it a client that opens a connection
44 // and dribbles (or never finishes) its request line + headers parks a goroutine
45 // and an fd indefinitely, and enough such connections — the Slowloris — starve
46 // the plane of accept slots. It governs only the header read, cleared once the
47 // headers are in, so it never touches the SSE event stream or the serial-console
48 // WebSocket that follow.
49 const httpServerReadHeaderTimeout = 20 * time.Second
50
51 // httpServerIdleTimeout reclaims a keep-alive connection that has gone quiet
52 // between requests. It applies only while a connection is idle — never during an
53 // in-flight request — so a live SSE stream or console session is untouched.
54 const httpServerIdleTimeout = 120 * time.Second
55
56 // httpServer builds the plane's HTTP server with the timeouts a public listener
57 // needs. Two of the four are deliberately left at zero: ReadTimeout and
58 // WriteTimeout each bound the WHOLE request, and this handler carries two
59 // long-lived-by-design responses — the SSE fleet-event stream (api/events.go,
60 // which writes for as long as a console tab is open) and the serial-console
61 // WebSocket (api/console.go). A WriteTimeout would sever a live console
62 // mid-session; a ReadTimeout cancels the request context at its deadline via
63 // net/http's client-disconnect background read, cutting the SSE stream the same
64 // way. The Slowloris is answered by ReadHeaderTimeout instead, which streaming
65 // does not feel. Front this listener with a reverse proxy for TLS and coarse
66 // body limits.
67 func httpServer(addr string, handler http.Handler) *http.Server {
68 return &http.Server{
69 Addr: addr,
70 Handler: handler,
71 ReadHeaderTimeout: httpServerReadHeaderTimeout,
72 IdleTimeout: httpServerIdleTimeout,
73 }
74 }
75
41 // RunCLI dispatches the eitri-server command line (everything after the binary 76 // RunCLI dispatches the eitri-server command line (everything after the binary
42 // name, --version excluded — that stays in cmd/eitri-server). It parses the 77 // name, --version excluded — that stays in cmd/eitri-server). It parses the
43 // -config flag and runs the control plane until SIGINT/SIGTERM. 78 // -config flag and runs the control plane until SIGINT/SIGTERM.
@@ -62,10 +97,11 @@ func run(cfgPath string) error {
62 return fmt.Errorf("config %s: %w", cfgPath, err) 97 return fmt.Errorf("config %s: %w", cfgPath, err)
63 } 98 }
64 99
65 // The key that seals every piece of key material this server holds, decoded 100 // The key that seals the on-disk host CA and gate host key — the only key
66 // once and handed to each place that seals or opens: the gate's key files 101 // material this server encrypts at rest — decoded once and handed to each
67 // (setupSSHGate), the API on the way into the store, and vmssh.SealedCAs on 102 // place that seals or opens: the gate's key files (setupSSHGate) and the host
68 // the way back out to the certificate signer. Load has already enforced it. 103 // CA (internal/server/sshca). Tenant user CAs are BYO public keys and seal
104 // nothing. Load has already enforced it.
69 kek, err := cfg.KEKBytes() 105 kek, err := cfg.KEKBytes()
70 if err != nil { 106 if err != nil {
71 return fmt.Errorf("config: %w", err) 107 return fmt.Errorf("config: %w", err)
@@ -291,7 +327,7 @@ func run(cfgPath string) error {
291 // from the live server without bouncing the process. 327 // from the live server without bouncing the process.
292 covsnap.Install(ctx) 328 covsnap.Install(ctx)
293 329
294 srv := &http.Server{Addr: cfg.HTTPListen, Handler: root} 330 srv := httpServer(cfg.HTTPListen, root)
295 go func() { 331 go func() {
296 slog.Info("http listening", "addr", cfg.HTTPListen) 332 slog.Info("http listening", "addr", cfg.HTTPListen)
297 if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { 333 if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
internal/server/boot/httpserver_test.go
Old New
@@ -0,0 +1,22 @@
1 package boot
2
3 import (
4 "net/http"
5 "testing"
6
7 "github.com/stretchr/testify/assert"
8 )
9
10 // TestHTTPServerBoundsSlowClientsWithoutCappingStreams pins the timeout posture
11 // of the plane's HTTP server: the header phase and idle keep-alives are bounded
12 // (Slowloris defense, fd reclamation), while ReadTimeout and WriteTimeout stay
13 // off so the SSE event stream and the serial-console WebSocket — both long-lived
14 // by design — are never cut at a deadline.
15 func TestHTTPServerBoundsSlowClientsWithoutCappingStreams(t *testing.T) {
16 srv := httpServer(":0", http.NewServeMux())
17
18 assert.Equal(t, httpServerReadHeaderTimeout, srv.ReadHeaderTimeout, "the Slowloris defense is on")
19 assert.Equal(t, httpServerIdleTimeout, srv.IdleTimeout, "idle keep-alives are reclaimed")
20 assert.Zero(t, srv.ReadTimeout, "a whole-request read deadline would cancel the SSE stream")
21 assert.Zero(t, srv.WriteTimeout, "a whole-request write deadline would sever a live console")
22 }
internal/server/release/release.go
Old New
@@ -167,6 +167,31 @@ func CertifiesGuestHostKeys(v string) bool {
167 return !before(p, floor) 167 return !before(p, floor)
168 } 168 }
169 169
170 // FirstDatagramExposures is the release whose agent began honoring an
171 // exposure's protocol. A UDP grant handed to anything older is bound as TCP by
172 // a converge loop that calls net.Listen("tcp", …) unconditionally, and reported
173 // active regardless — a row that reads published and carries no datagrams ever.
174 // So a UDP exposure is refused for a host below this floor, as a certified-key
175 // VM create is refused below FirstCertifiedHostKeys.
176 const FirstDatagramExposures = "v0.0.5"
177
178 // HonorsDatagramExposures reports whether an agent at version v binds a UDP
179 // exposure as UDP, ordering v against FirstDatagramExposures by the same rule
180 // Less publishes.
181 //
182 // An unparsable version — "dev", a "-dirty" tree, a malformed tag — does not,
183 // and neither does the empty version a host reports before it has said
184 // anything: the same conservative reading CertifiesGuestHostKeys takes.
185 func HonorsDatagramExposures(v string) bool {
186 p, ok := parse(v)
187 if !ok {
188 return false
189 }
190 // The floor is a release tag, so it parses by construction (pinned by test).
191 floor, _ := parse(FirstDatagramExposures)
192 return !before(p, floor)
193 }
194
170 // before compares two ordering tuples, the one place their fields are ranked. 195 // before compares two ordering tuples, the one place their fields are ranked.
171 func before(a, b [6]int) bool { 196 func before(a, b [6]int) bool {
172 for i := range a { 197 for i := range a {