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
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 { |