ef2f020d
fix(agent): traffic on a published port cannot starve fleet management
a73x 2026-08-09 16:42
Commit message
internal/agent/exposeproxy/exposeproxy.go
| Old | New | ||
|---|---|---|---|
| @@ -18,19 +18,45 @@ | |||
| 18 | // Nothing here is persisted. Listeners are rebuilt from the first snapshot | 18 | // Nothing here is persisted. Listeners are rebuilt from the first snapshot |
| 19 | // after the agent starts, the same way the consoles are; connections in flight | 19 | // after the agent starts, the same way the consoles are; connections in flight |
| 20 | // across an agent restart drop, and reconnecting works. | 20 | // across an agent restart drop, and reconnecting works. |
| 21 | // | ||
| 22 | // Every published port serves under a cap, because the agent that carries this | ||
| 23 | // proxy is also the thing that runs the guests: traffic on a published port | ||
| 24 | // must never be able to starve fleet management of file descriptors. | ||
| 21 | package exposeproxy | 25 | package exposeproxy |
| 22 | 26 | ||
| 23 | import ( | 27 | import ( |
| 28 | "errors" | ||
| 24 | "fmt" | 29 | "fmt" |
| 25 | "io" | 30 | "io" |
| 26 | "log/slog" | 31 | "log/slog" |
| 27 | "net" | 32 | "net" |
| 28 | "strconv" | 33 | "strconv" |
| 29 | "sync" | 34 | "sync" |
| 35 | "sync/atomic" | ||
| 36 | "syscall" | ||
| 37 | "time" | ||
| 30 | 38 | ||
| 31 | "github.com/a73x/eitri/internal/pb" | 39 | "github.com/a73x/eitri/internal/pb" |
| 32 | ) | 40 | ) |
| 33 | 41 | ||
| 42 | // maxConnsPerExposure is how many connections one published port may hold open | ||
| 43 | // at once. Each costs two descriptors — the caller's and the dial into the | ||
| 44 | // guest — so an exposure sitting at its cap spends 512 of the 65536 the shipped | ||
| 45 | // unit grants the agent: a host could serve dozens of published ports, all of | ||
| 46 | // them saturated, and still leave the agent's own descriptors (hypervisor | ||
| 47 | // children, disks, consoles, the sync tunnel) untouched. 256 at once is far | ||
| 48 | // more than a service behind a published port meets in real use, and a caller | ||
| 49 | // past it is closed immediately — a refusal the caller can see beats a proxy | ||
| 50 | // wedged in a way nobody can diagnose. | ||
| 51 | const maxConnsPerExposure = 256 | ||
| 52 | |||
| 53 | // acceptRetryFloor and acceptRetryCeiling bound the pause an accept loop takes | ||
| 54 | // when the process is momentarily out of descriptors. | ||
| 55 | const ( | ||
| 56 | acceptRetryFloor = 5 * time.Millisecond | ||
| 57 | acceptRetryCeiling = time.Second | ||
| 58 | ) | ||
| 59 | |||
| 34 | // Manager runs one listener per active exposure. | 60 | // Manager runs one listener per active exposure. |
| 35 | type Manager struct { | 61 | type Manager struct { |
| 36 | // addr answers a VM's current guest address, or "" when this host does not | 62 | // addr answers a VM's current guest address, or "" when this host does not |
| @@ -39,6 +65,11 @@ type Manager struct { | |||
| 39 | // goroutines; it must be safe for concurrent use. | 65 | // goroutines; it must be safe for concurrent use. |
| 40 | addr func(vmID string) string | 66 | addr func(vmID string) string |
| 41 | 67 | ||
| 68 | // maxConns is maxConnsPerExposure, held per-Manager so a test can serve the | ||
| 69 | // same behaviour with a handful of connections instead of hundreds. Written | ||
| 70 | // once at construction and only read after, so it needs no lock. | ||
| 71 | maxConns int64 | ||
| 72 | |||
| 42 | mu sync.Mutex | 73 | mu sync.Mutex |
| 43 | live map[string]*exposure | 74 | live map[string]*exposure |
| 44 | } | 75 | } |
| @@ -46,15 +77,21 @@ type Manager struct { | |||
| 46 | // NewManager returns a Manager that resolves each connection's destination | 77 | // NewManager returns a Manager that resolves each connection's destination |
| 47 | // through addr. | 78 | // through addr. |
| 48 | func NewManager(addr func(vmID string) string) *Manager { | 79 | func NewManager(addr func(vmID string) string) *Manager { |
| 49 | return &Manager{addr: addr, live: map[string]*exposure{}} | 80 | return &Manager{addr: addr, maxConns: maxConnsPerExposure, live: map[string]*exposure{}} |
| 50 | } | 81 | } |
| 51 | 82 | ||
| 52 | // exposure is one published port's running state: the spec key it was bound | 83 | // exposure is one published port's running state: the spec key it was bound |
| 53 | // for, its listener (nil when the bind failed), and what the OS said if it did. | 84 | // for, its listener (nil when the bind failed), what the OS said if it did, and |
| 85 | // how many connections it is holding open right now. | ||
| 86 | // | ||
| 87 | // The count lives here rather than with the listener so that an exposure whose | ||
| 88 | // accept loop died and was rebound keeps counting what is still draining | ||
| 89 | // through it: the descriptors those connections hold are still spent. | ||
| 54 | type exposure struct { | 90 | type exposure struct { |
| 55 | key string | 91 | key string |
| 56 | ln net.Listener | 92 | ln net.Listener |
| 57 | reason string | 93 | reason string |
| 94 | conns atomic.Int64 | ||
| 58 | } | 95 | } |
| 59 | 96 | ||
| 60 | // close tears down the listener, which is what ends its accept goroutine. | 97 | // close tears down the listener, which is what ends its accept goroutine. |
| @@ -112,7 +149,7 @@ func (m *Manager) Converge(desired []*pb.ExposureDesired) []*pb.ExposureActual { | |||
| 112 | continue | 149 | continue |
| 113 | } | 150 | } |
| 114 | ex.ln, ex.reason = ln, "" | 151 | ex.ln, ex.reason = ln, "" |
| 115 | go m.accept(d.GetId(), ln, d.GetVmId(), d.GetGuestPort()) | 152 | go m.accept(d.GetId(), ex, ln, d.GetVmId(), d.GetGuestPort()) |
| 116 | } | 153 | } |
| 117 | out = append(out, &pb.ExposureActual{Id: d.GetId(), State: "active"}) | 154 | out = append(out, &pb.ExposureActual{Id: d.GetId(), State: "active"}) |
| 118 | } | 155 | } |
| @@ -139,26 +176,71 @@ func (m *Manager) StopAll() { | |||
| 139 | // The pointer-identity guard is what keeps the normal exits quiet: on a | 176 | // The pointer-identity guard is what keeps the normal exits quiet: on a |
| 140 | // close-driven exit the entry is already gone, already nil, or already holds a | 177 | // close-driven exit the entry is already gone, already nil, or already holds a |
| 141 | // newer listener, so only the loop that owns the current listener writes here. | 178 | // newer listener, so only the loop that owns the current listener writes here. |
| 142 | func (m *Manager) accept(id string, ln net.Listener, vmID string, guestPort uint32) { | 179 | // |
| 180 | // A descriptor shortage is the one accept error this does not die on. Handing | ||
| 181 | // the listener back would unbind a working port and ask the next converge to | ||
| 182 | // find a descriptor for a fresh one — exactly what the process has none of — | ||
| 183 | // so the loop pauses and keeps the port, and the backlog keeps callers waiting | ||
| 184 | // rather than refusing them. Everything else is a broken listener and heals the | ||
| 185 | // only way a broken listener can, by being rebound. | ||
| 186 | func (m *Manager) accept(id string, ex *exposure, ln net.Listener, vmID string, guestPort uint32) { | ||
| 187 | var pause time.Duration | ||
| 188 | capped := false | ||
| 143 | for { | 189 | for { |
| 144 | conn, err := ln.Accept() | 190 | conn, err := ln.Accept() |
| 145 | if err != nil { | 191 | if err != nil { |
| 192 | if outOfDescriptors(err) { | ||
| 193 | if pause == 0 { | ||
| 194 | pause = acceptRetryFloor | ||
| 195 | slog.Warn("exposure accept out of descriptors, retrying", "exposure", id, "err", err) | ||
| 196 | } else { | ||
| 197 | pause = min(pause*2, acceptRetryCeiling) | ||
| 198 | } | ||
| 199 | time.Sleep(pause) | ||
| 200 | continue | ||
| 201 | } | ||
| 146 | m.mu.Lock() | 202 | m.mu.Lock() |
| 147 | if ex, ok := m.live[id]; ok && ex.ln == ln { | 203 | if cur, ok := m.live[id]; ok && cur.ln == ln { |
| 148 | ex.close() | 204 | cur.close() |
| 149 | ex.reason = "accept: " + err.Error() | 205 | cur.reason = "accept: " + err.Error() |
| 150 | // The reason travels in the next report, which nobody watching | 206 | // The reason travels in the next report, which nobody watching |
| 151 | // the host sees; fd exhaustion and a broken listener belong in | 207 | // the host sees; a broken listener belongs in the agent's log |
| 152 | // the agent's log too. | 208 | // too. |
| 153 | slog.Warn("exposure accept loop died", "exposure", id, "err", err) | 209 | slog.Warn("exposure accept loop died", "exposure", id, "err", err) |
| 154 | } | 210 | } |
| 155 | m.mu.Unlock() | 211 | m.mu.Unlock() |
| 156 | return | 212 | return |
| 157 | } | 213 | } |
| 158 | go m.pipe(conn, vmID, guestPort) | 214 | pause = 0 |
| 215 | |||
| 216 | // Increment first and give it back if the cap says no: counting only | ||
| 217 | // after the decision would let a burst of simultaneous accepts each read | ||
| 218 | // a stale count and all pass. | ||
| 219 | if ex.conns.Add(1) > m.maxConns { | ||
| 220 | ex.conns.Add(-1) | ||
| 221 | conn.Close() | ||
| 222 | if !capped { | ||
| 223 | capped = true | ||
| 224 | slog.Warn("exposure at its connection cap, refusing", "exposure", id, "cap", m.maxConns) | ||
| 225 | } | ||
| 226 | continue | ||
| 227 | } | ||
| 228 | capped = false | ||
| 229 | go func() { | ||
| 230 | defer ex.conns.Add(-1) | ||
| 231 | m.pipe(conn, vmID, guestPort) | ||
| 232 | }() | ||
| 159 | } | 233 | } |
| 160 | } | 234 | } |
| 161 | 235 | ||
| 236 | // outOfDescriptors reports whether an accept failed because the process (EMFILE) | ||
| 237 | // or the machine (ENFILE) has no descriptor to give it. The runtime already | ||
| 238 | // retries the other transient accept errors — an interrupted call, a caller that | ||
| 239 | // went away between SYN and accept — so those never reach here. | ||
| 240 | func outOfDescriptors(err error) bool { | ||
| 241 | return errors.Is(err, syscall.EMFILE) || errors.Is(err, syscall.ENFILE) | ||
| 242 | } | ||
| 243 | |||
| 162 | // pipe connects one accepted connection to the guest. No address (a guest still | 244 | // pipe connects one accepted connection to the guest. No address (a guest still |
| 163 | // leasing) or a guest that will not answer closes immediately: the host half of | 245 | // leasing) or a guest that will not answer closes immediately: the host half of |
| 164 | // the pipe exists, the guest half does not, and waiting would only hold the | 246 | // the pipe exists, the guest half does not, and waiting would only hold the |
internal/agent/exposeproxy/exposeproxy_test.go
| Old | New | ||
|---|---|---|---|
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | "io" | 4 | "io" |
| 5 | "net" | 5 | "net" |
| 6 | "strconv" | 6 | "strconv" |
| 7 | "syscall" | ||
| 7 | "testing" | 8 | "testing" |
| 8 | "time" | 9 | "time" |
| 9 | 10 | ||
| @@ -317,6 +318,174 @@ func TestRevokeDrainsRatherThanCuts(t *testing.T) { | |||
| 317 | assert.Equal(t, "two", exchange("two"), "closing a listener drains: only new dials die") | 318 | assert.Equal(t, "two", exchange("two"), "closing a listener drains: only new dials die") |
| 318 | } | 319 | } |
| 319 | 320 | ||
| 321 | // newHoldingGuest is a guest that echoes for as long as a caller keeps | ||
| 322 | // speaking, so a test can hold connections open across the proxy. | ||
| 323 | func newHoldingGuest(t *testing.T) *fakeGuest { | ||
| 324 | t.Helper() | ||
| 325 | ln, err := net.Listen("tcp", "127.0.0.1:0") | ||
| 326 | require.NoError(t, err) | ||
| 327 | t.Cleanup(func() { ln.Close() }) | ||
| 328 | go func() { | ||
| 329 | for { | ||
| 330 | c, err := ln.Accept() | ||
| 331 | if err != nil { | ||
| 332 | return | ||
| 333 | } | ||
| 334 | go func() { | ||
| 335 | defer c.Close() | ||
| 336 | _, _ = io.Copy(c, c) | ||
| 337 | }() | ||
| 338 | } | ||
| 339 | }() | ||
| 340 | return &fakeGuest{ln: ln, addr: "127.0.0.1", port: portOf(t, ln.Addr())} | ||
| 341 | } | ||
| 342 | |||
| 343 | // hold opens a connection through the proxy and proves it is spliced through to | ||
| 344 | // the guest, which is also what makes the exposure's count of it deterministic: | ||
| 345 | // a byte came back, so the connection was counted before its pipe started. | ||
| 346 | func hold(t *testing.T, addr string) net.Conn { | ||
| 347 | t.Helper() | ||
| 348 | c, err := net.Dial("tcp", addr) | ||
| 349 | require.NoError(t, err) | ||
| 350 | t.Cleanup(func() { c.Close() }) | ||
| 351 | require.NoError(t, c.SetDeadline(time.Now().Add(5*time.Second))) | ||
| 352 | require.Equal(t, "ping", exchange(t, c, "ping")) | ||
| 353 | return c | ||
| 354 | } | ||
| 355 | |||
| 356 | // exchange writes msg on an open connection and reads the echo back. | ||
| 357 | func exchange(t *testing.T, c net.Conn, msg string) string { | ||
| 358 | t.Helper() | ||
| 359 | _, err := c.Write([]byte(msg)) | ||
| 360 | require.NoError(t, err) | ||
| 361 | buf := make([]byte, len(msg)) | ||
| 362 | _, err = io.ReadFull(c, buf) | ||
| 363 | require.NoError(t, err) | ||
| 364 | return string(buf) | ||
| 365 | } | ||
| 366 | |||
| 367 | // waitConns waits for an exposure's held-connection count to reach want. The | ||
| 368 | // increment is synchronous with the accept, but the decrement happens when the | ||
| 369 | // pipe ends, which is a goroutine finishing on its own time. | ||
| 370 | func waitConns(t *testing.T, m *Manager, id string, want int64) { | ||
| 371 | t.Helper() | ||
| 372 | var got int64 | ||
| 373 | for i := 0; i < 200; i++ { | ||
| 374 | m.mu.Lock() | ||
| 375 | ex, ok := m.live[id] | ||
| 376 | m.mu.Unlock() | ||
| 377 | require.True(t, ok, "no exposure %q", id) | ||
| 378 | if got = ex.conns.Load(); got == want { | ||
| 379 | return | ||
| 380 | } | ||
| 381 | time.Sleep(10 * time.Millisecond) | ||
| 382 | } | ||
| 383 | t.Fatalf("exposure %q holds %d connections, want %d", id, got, want) | ||
| 384 | } | ||
| 385 | |||
| 386 | func TestExposureRefusesConnectionsPastItsCap(t *testing.T) { | ||
| 387 | g := newHoldingGuest(t) | ||
| 388 | m := newTestManager(t, map[string]string{"vm1": g.addr}) | ||
| 389 | m.maxConns = 2 | ||
| 390 | m.Converge([]*pb.ExposureDesired{desired("e1", "vm1", g.port, 0)}) | ||
| 391 | addr := boundPort(t, m, "e1") | ||
| 392 | |||
| 393 | first, second := hold(t, addr), hold(t, addr) | ||
| 394 | |||
| 395 | // The listener still accepts — the refusal is a close, not a bind that went | ||
| 396 | // away — and the caller learns immediately rather than waiting on a promise. | ||
| 397 | over, err := net.Dial("tcp", addr) | ||
| 398 | require.NoError(t, err) | ||
| 399 | defer over.Close() | ||
| 400 | require.NoError(t, over.SetDeadline(time.Now().Add(5*time.Second))) | ||
| 401 | out, err := io.ReadAll(over) | ||
| 402 | require.NoError(t, err) | ||
| 403 | assert.Empty(t, out, "a connection past the cap is closed at once") | ||
| 404 | |||
| 405 | assert.Equal(t, "still", exchange(t, first, "still"), "a refusal must not disturb the connections under the cap") | ||
| 406 | assert.Equal(t, "serving", exchange(t, second, "serving")) | ||
| 407 | } | ||
| 408 | |||
| 409 | func TestTheCapReleasesWhenAConnectionEnds(t *testing.T) { | ||
| 410 | g := newHoldingGuest(t) | ||
| 411 | m := newTestManager(t, map[string]string{"vm1": g.addr}) | ||
| 412 | m.maxConns = 1 | ||
| 413 | m.Converge([]*pb.ExposureDesired{desired("e1", "vm1", g.port, 0)}) | ||
| 414 | addr := boundPort(t, m, "e1") | ||
| 415 | |||
| 416 | first := hold(t, addr) | ||
| 417 | require.NoError(t, first.Close()) | ||
| 418 | waitConns(t, m, "e1", 0) | ||
| 419 | |||
| 420 | // The cap is a level, not a ratchet: the slot the closed connection held is | ||
| 421 | // the slot this one takes. | ||
| 422 | assert.Equal(t, "after", exchange(t, hold(t, addr), "after")) | ||
| 423 | } | ||
| 424 | |||
| 425 | // scriptedListener hands an accept loop exactly the sequence a test writes to | ||
| 426 | // it — the errors a real listener only produces under a load a test cannot | ||
| 427 | // stage. | ||
| 428 | type scriptedListener struct { | ||
| 429 | next chan acceptResult | ||
| 430 | addr net.Addr | ||
| 431 | } | ||
| 432 | |||
| 433 | type acceptResult struct { | ||
| 434 | conn net.Conn | ||
| 435 | err error | ||
| 436 | } | ||
| 437 | |||
| 438 | func (l *scriptedListener) Accept() (net.Conn, error) { | ||
| 439 | r := <-l.next | ||
| 440 | return r.conn, r.err | ||
| 441 | } | ||
| 442 | func (l *scriptedListener) Close() error { return nil } | ||
| 443 | func (l *scriptedListener) Addr() net.Addr { return l.addr } | ||
| 444 | |||
| 445 | func TestAcceptKeepsThePortThroughADescriptorShortage(t *testing.T) { | ||
| 446 | g := newFakeGuest(t) | ||
| 447 | m := newTestManager(t, map[string]string{"vm1": g.addr}) | ||
| 448 | |||
| 449 | ln := &scriptedListener{next: make(chan acceptResult), addr: g.ln.Addr()} | ||
| 450 | ex := &exposure{key: "k", ln: ln} | ||
| 451 | m.mu.Lock() | ||
| 452 | m.live["e1"] = ex | ||
| 453 | m.mu.Unlock() | ||
| 454 | go m.accept("e1", ex, ln, "vm1", g.port) | ||
| 455 | |||
| 456 | // Out of descriptors is not a broken listener. Handing the port back would | ||
| 457 | // only ask the next converge to find a descriptor the process does not have. | ||
| 458 | ln.next <- acceptResult{err: syscall.EMFILE} | ||
| 459 | ln.next <- acceptResult{err: syscall.EMFILE} | ||
| 460 | |||
| 461 | client, accepted := net.Pipe() | ||
| 462 | defer client.Close() | ||
| 463 | ln.next <- acceptResult{conn: accepted} | ||
| 464 | require.NoError(t, client.SetDeadline(time.Now().Add(5*time.Second))) | ||
| 465 | _, err := client.Write([]byte("hello")) | ||
| 466 | require.NoError(t, err) | ||
| 467 | buf := make([]byte, len("echo:hello")) | ||
| 468 | _, err = io.ReadFull(client, buf) | ||
| 469 | require.NoError(t, err) | ||
| 470 | assert.Equal(t, "echo:hello", string(buf), "the loop kept its listener and went on serving") | ||
| 471 | |||
| 472 | // A listener that is genuinely broken still ends the loop and gives the | ||
| 473 | // exposure back its listener-less state for the next converge to heal. | ||
| 474 | ln.next <- acceptResult{err: net.ErrClosed} | ||
| 475 | var reason string | ||
| 476 | for i := 0; i < 200; i++ { | ||
| 477 | m.mu.Lock() | ||
| 478 | gone, r := ex.ln == nil, ex.reason | ||
| 479 | m.mu.Unlock() | ||
| 480 | if gone { | ||
| 481 | reason = r | ||
| 482 | break | ||
| 483 | } | ||
| 484 | time.Sleep(10 * time.Millisecond) | ||
| 485 | } | ||
| 486 | assert.Contains(t, reason, "accept:") | ||
| 487 | } | ||
| 488 | |||
| 320 | func TestStopAllClosesEveryListener(t *testing.T) { | 489 | func TestStopAllClosesEveryListener(t *testing.T) { |
| 321 | g := newFakeGuest(t) | 490 | g := newFakeGuest(t) |
| 322 | m := NewManager(func(string) string { return g.addr }) | 491 | m := NewManager(func(string) string { return g.addr }) |
scripts/eitri-agent.service
| Old | New | ||
|---|---|---|---|
| @@ -33,6 +33,14 @@ RestartSec=5 | |||
| 33 | # running VM on a mere agent stop. Only the agent itself may be signalled — | 33 | # running VM on a mere agent stop. Only the agent itself may be signalled — |
| 34 | # VMs survive agent restarts by design. | 34 | # VMs survive agent restarts by design. |
| 35 | KillMode=process | 35 | KillMode=process |
| 36 | # The agent's descriptor budget, stated rather than inherited from whatever the | ||
| 37 | # distro's default happens to be. Published guest ports are proxied inside this | ||
| 38 | # process, two descriptors per connection, and each exposure serves at most 256 | ||
| 39 | # at once: dozens of saturated published ports fit here with room to spare, and | ||
| 40 | # what is left over is what the agent runs the fleet with — hypervisor children, | ||
| 41 | # disk images, consoles, the sync tunnel. Traffic on a published port must never | ||
| 42 | # be able to starve fleet management. | ||
| 43 | LimitNOFILE=65536 | ||
| 36 | # Root is deliberate and scoped to THIS unit (eitri-server and eitri-oidc run | 44 | # Root is deliberate and scoped to THIS unit (eitri-server and eitri-oidc run |
| 37 | # as dedicated users): the agent opens /dev/kvm and /dev/net/tun, creates the | 45 | # as dedicated users): the agent opens /dev/kvm and /dev/net/tun, creates the |
| 38 | # bridge and taps, sets net.ipv4.ip_forward, installs the nftables masquerade, | 46 | # bridge and taps, sets net.ipv4.ip_forward, installs the nftables masquerade, |
scripts/eitri-server.service
| Old | New | ||
|---|---|---|---|
| @@ -23,6 +23,12 @@ ExecStart=/usr/local/bin/eitri-server --config /etc/eitri/server.json | |||
| 23 | StateDirectory=eitri | 23 | StateDirectory=eitri |
| 24 | Restart=on-failure | 24 | Restart=on-failure |
| 25 | RestartSec=5 | 25 | RestartSec=5 |
| 26 | # Same stated budget as the agent, for the same reason: what this process holds | ||
| 27 | # open is decided by how many people are using the fleet, not by the box. Every | ||
| 28 | # gate session is a pair of connections and every open console is a stream that | ||
| 29 | # lives as long as the browser tab does, so the descriptor count follows the | ||
| 30 | # fleet's use and should not sit on a distro default nobody chose. | ||
| 31 | LimitNOFILE=65536 | ||
| 26 | NoNewPrivileges=yes | 32 | NoNewPrivileges=yes |
| 27 | ProtectSystem=strict | 33 | ProtectSystem=strict |
| 28 | ProtectHome=yes | 34 | ProtectHome=yes |