Public: anyone can read this; the crew can change it.
URGENT: the egress relay leaks upstream sockets - 40 open camera streams from the forge pod saturate the owner's Pi (~65 Mb/s) #3721
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/3719-urgent-the-egress-relay-leaks-upstream-s → main 5 commits fork 2 threads · 2 unresolved
14 files
changed +772
−70
README.md +14
| @@ -846,6 +846,15 @@ file), and if you named an agent, the sentence became issue #1, assigned. | ||
| 846 | 846 | names it exactly and a site admin has granted it: on the |
| 847 | 847 | app's settings page, or in the terminal (About `I`, then `e`, `g` |
| 848 | 848 | to grant, `x` to revoke). See `docs/APPS.md`. |
| 849 | - **Egress connections are bounded.** The proxy closes the upstream socket | |
| 850 | the moment the cell side closes, errors or stops, and the other way round. | |
| 851 | An app holds at most 8 open connections to one destination (the ninth | |
| 852 | gets a `429`, logged), and a connection that moves no bytes, either way, for 5 min is | |
| 853 | cut, so a tunnel quiet for longer than that (a websocket or SSE stream with | |
| 854 | very long gaps) needs a keepalive. Open connections per app and | |
| 855 | destination, with the bytes moved, are on the Capacity page | |
| 856 | (`/settings/capacity`, admins) and in the terminal: About `I`, then `c` | |
| 857 | (site admins). | |
| 849 | 858 | - **Production is the default branch.** The only way code reaches it is that |
| 850 | 859 | branch moving, and an agent cannot move it: the crew proposes through |
| 851 | 860 | pulls, a person merges. |
| @@ -1914,3 +1923,8 @@ same rule. A guard installed by hand without the prefix answers `refused` | ||
| 1914 | 1923 | until it is re-created with `forge: ` in front of its message. |
| 1915 | 1924 | |
| 1916 | 1925 | `reach` is a boards source for an admin only (anyone else gets 403, an agent too): `visitors` per day, `lcp_p75` and `inp_p75` per route per day, `server_p50` and `server_p95` per route per day (the beacon's time to first byte, the only per-route server timing the forge keeps), and `accounts_active_1d`, `accounts_active_7d` and `accounts_total` as of now. Filter by `metric`, group by `route` or a time bucket. Percentiles are nearest-rank within one route and day, so a `max` or `avg` over several days is a summary of daily percentiles, not a percentile of the period. Account activity comes from a session's last-seen time, so it is a count as of now, not a history. No dependency was added. |
| 1926 | ||
| 1927 | `GET /metrics` (site admin only; repository names are in the labels) serves | |
| 1928 | `forge_egress_connections`, `forge_egress_bytes_in` and `forge_egress_bytes_out` | |
| 1929 | per `repo` and `destination` as Prometheus text, so a leaked upstream socket | |
| 1930 | shows as a gauge that never falls (#3719). |
src/apps/contain.ts +1
| @@ -362,6 +362,7 @@ export function openCell( | ||
| 362 | 362 | { |
| 363 | 363 | hosts: spec.egress, |
| 364 | 364 | granted: spec.granted, |
| 365 | label: spec.label, | |
| 365 | 366 | origins, |
| 366 | 367 | report: |
| 367 | 368 | (spec.egressMode ?? egressMode()) === "report" |
src/apps/egress.ts +217 −66
| @@ -38,6 +38,14 @@ export interface EgressPolicy { | ||
| 38 | 38 | * refused. Private addresses are refused either way. |
| 39 | 39 | */ |
| 40 | 40 | readonly report?: ((host: string, port: number) => void) | undefined; |
| 41 | /** Whose proxy this is (`app:<slug>/<env>`), for the connection gauges. */ | |
| 42 | readonly label?: string | undefined; | |
| 43 | /** Most connections open to one destination at once; default 8. */ | |
| 44 | readonly maxPerDestination?: number | undefined; | |
| 45 | /** Close a connection that moved no bytes for this long; default 5 min. */ | |
| 46 | readonly idleMs?: number | undefined; | |
| 47 | /** Bytes held for a client that does not read before it is cut; default 4 MiB. */ | |
| 48 | readonly queueMax?: number | undefined; | |
| 41 | 49 | } |
| 42 | 50 | |
| 43 | 51 | export interface Egress { |
| @@ -45,6 +53,59 @@ export interface Egress { | ||
| 45 | 53 | } |
| 46 | 54 | |
| 47 | 55 | const HEAD_MAX = 16 * 1024; |
| 56 | const MAX_PER_DESTINATION = 8; | |
| 57 | const IDLE_MS = 5 * 60_000; | |
| 58 | /** Bytes held for a client that does not read before its upstream is cut. */ | |
| 59 | const QUEUE_MAX = 4 * 1024 * 1024; | |
| 60 | ||
| 61 | interface Tally { | |
| 62 | open: number; | |
| 63 | bytesIn: number; | |
| 64 | bytesOut: number; | |
| 65 | } | |
| 66 | ||
| 67 | /** | |
| 68 | * label -> `host:port` -> counts, for the connections open right now. A | |
| 69 | * destination's entry goes when its last connection closes (and the label | |
| 70 | * with its last destination), so it only ever holds what is live. | |
| 71 | */ | |
| 72 | const tallies = new Map<string, Map<string, Tally>>(); | |
| 73 | ||
| 74 | function tally(label: string, destination: string): Tally { | |
| 75 | let byDestination = tallies.get(label); | |
| 76 | if (byDestination === undefined) tallies.set(label, (byDestination = new Map())); | |
| 77 | let t = byDestination.get(destination); | |
| 78 | if (t === undefined) | |
| 79 | byDestination.set(destination, (t = { open: 0, bytesIn: 0, bytesOut: 0 })); | |
| 80 | return t; | |
| 81 | } | |
| 82 | ||
| 83 | /** One connection closed: drop its destination, then its label, when empty. */ | |
| 84 | function untally(label: string, destination: string): void { | |
| 85 | const byDestination = tallies.get(label); | |
| 86 | const t = byDestination?.get(destination); | |
| 87 | if (byDestination === undefined || t === undefined) return; | |
| 88 | if (--t.open > 0) return; | |
| 89 | byDestination.delete(destination); | |
| 90 | if (byDestination.size === 0) tallies.delete(label); | |
| 91 | } | |
| 92 | ||
| 93 | export interface EgressRow { | |
| 94 | readonly label: string; | |
| 95 | readonly destination: string; | |
| 96 | readonly open: number; | |
| 97 | readonly bytesIn: number; | |
| 98 | readonly bytesOut: number; | |
| 99 | } | |
| 100 | ||
| 101 | /** Every destination an app holds connections to now, busiest first. */ | |
| 102 | export function egressSnapshot(): EgressRow[] { | |
| 103 | const rows: EgressRow[] = []; | |
| 104 | for (const [label, byDestination] of tallies) | |
| 105 | for (const [destination, t] of byDestination) | |
| 106 | rows.push({ label, destination, open: t.open, bytesIn: t.bytesIn, bytesOut: t.bytesOut }); | |
| 107 | return rows.sort((a, b) => b.open - a.open || a.label.localeCompare(b.label)); | |
| 108 | } | |
| 48 | 109 | |
| 49 | 110 | /** The ports a declared host may be reached on when its entry names none. */ |
| 50 | 111 | export const WEB_PORTS: readonly number[] = [80, 443]; |
| @@ -165,8 +226,26 @@ interface Conn { | ||
| 165 | 226 | toPeer: Uint8Array[]; |
| 166 | 227 | toClient: Uint8Array[]; |
| 167 | 228 | done: boolean; |
| 229 | /** The destination's counts once dialled; the data path adds to it directly. */ | |
| 230 | tally?: Tally; | |
| 231 | /** When bytes last moved either way (ms). */ | |
| 232 | last: number; | |
| 233 | /** Frees the destination's slot and counts it closed; set once dialled. */ | |
| 234 | release: (() => void) | undefined; | |
| 168 | 235 | } |
| 169 | 236 | |
| 237 | /** Cut both sides now: the upstream must never outlive the client. */ | |
| 238 | function drop(client: Socket<Conn>): void { | |
| 239 | const conn = client.data; | |
| 240 | conn.done = true; | |
| 241 | conn.release?.(); | |
| 242 | conn.release = undefined; | |
| 243 | conn.peer?.terminate(); | |
| 244 | } | |
| 245 | ||
| 246 | const queued = (queue: Uint8Array[]): number => | |
| 247 | queue.reduce((n, c) => n + c.length, 0); | |
| 248 | ||
| 170 | 249 | /** Write what the socket takes now; keep the rest for its `drain`. */ |
| 171 | 250 | function send( |
| 172 | 251 | socket: { write(data: Uint8Array): number }, |
| @@ -295,31 +374,59 @@ async function open( | ||
| 295 | 374 | log(`egress reported: ${host}:${port} is not in forge.json "egress" (let through while egress is only reported)`); |
| 296 | 375 | policy.report?.(host.toLowerCase(), port); |
| 297 | 376 | } |
| 377 | const destination = `${host.toLowerCase()}:${port}`; | |
| 378 | const count = tally(policy.label ?? "", destination); | |
| 379 | const cap = policy.maxPerDestination ?? MAX_PER_DESTINATION; | |
| 380 | if (count.open >= cap) { | |
| 381 | log(`egress capped: ${policy.label ?? "app"} already has ${count.open} connections to ${destination}`); | |
| 382 | refuse( | |
| 383 | client, | |
| 384 | "429 Too Many Requests", | |
| 385 | `${destination} already has ${cap} open connections from this app`, | |
| 386 | ); | |
| 387 | return; | |
| 388 | } | |
| 389 | // The slot is taken before the dial so a burst cannot all pass the check. | |
| 390 | count.open++; | |
| 391 | client.data.tally = count; | |
| 392 | const label = policy.label ?? ""; | |
| 393 | client.data.release = () => untally(label, destination); | |
| 394 | const ended = () => { | |
| 395 | if (!client.data.done) { | |
| 396 | drop(client); | |
| 397 | client.end(); | |
| 398 | } | |
| 399 | }; | |
| 298 | 400 | try { |
| 299 | 401 | const peer = await Bun.connect<undefined>({ |
| 300 | 402 | hostname: verdict.address, |
| 301 | 403 | port, |
| 302 | 404 | socket: { |
| 303 | 405 | data(_peer, chunk) { |
| 304 | if (!client.data.done) send(client, client.data.toClient, chunk); | |
| 406 | if (client.data.done) return; | |
| 407 | client.data.last = Date.now(); | |
| 408 | count.bytesIn += chunk.length; | |
| 409 | send(client, client.data.toClient, chunk); | |
| 410 | if (queued(client.data.toClient) > (policy.queueMax ?? QUEUE_MAX)) { | |
| 411 | drop(client); | |
| 412 | client.terminate(); | |
| 413 | } | |
| 305 | 414 | }, |
| 306 | 415 | drain(peer) { |
| 307 | 416 | flush(peer, client.data.toPeer); |
| 308 | 417 | }, |
| 309 | close() { | |
| 310 | if (!client.data.done) { | |
| 311 | client.data.done = true; | |
| 312 | client.end(); | |
| 313 | } | |
| 314 | }, | |
| 315 | error() { | |
| 316 | if (!client.data.done) { | |
| 317 | client.data.done = true; | |
| 318 | client.end(); | |
| 319 | } | |
| 320 | }, | |
| 418 | close: ended, | |
| 419 | error: ended, | |
| 321 | 420 | }, |
| 322 | 421 | }); |
| 422 | // The client went away while the dial was in flight: nobody is left to | |
| 423 | // take this connection, so it must not stay open. | |
| 424 | if (client.data.done) { | |
| 425 | peer.terminate(); | |
| 426 | client.data.release?.(); | |
| 427 | client.data.release = undefined; | |
| 428 | return; | |
| 429 | } | |
| 323 | 430 | client.data.peer = peer; |
| 324 | 431 | if (tunnel) |
| 325 | 432 | send( |
| @@ -330,67 +437,111 @@ async function open( | ||
| 330 | 437 | // Everything after the head — including what arrived during the dial, |
| 331 | 438 | // kept with it — goes out now; from here `data` forwards directly. |
| 332 | 439 | const first = concat(prefix, client.data.head.subarray(end)); |
| 333 | if (first.length > 0) send(peer, client.data.toPeer, first); | |
| 440 | if (first.length > 0) { | |
| 441 | count.bytesOut += first.length; | |
| 442 | send(peer, client.data.toPeer, first); | |
| 443 | } | |
| 334 | 444 | } catch { |
| 445 | client.data.release?.(); | |
| 446 | client.data.release = undefined; | |
| 335 | 447 | refuse(client, "502 Bad Gateway", `${host}:${port} did not answer`); |
| 336 | 448 | } |
| 337 | 449 | } |
| 338 | 450 | |
| 339 | /** Listen at `path` and forward only what `policy` allows. */ | |
| 451 | /** | |
| 452 | * Listen at `path` and forward only what `policy` allows. `{ tcp }` (a | |
| 453 | * loopback port) exists for the tests, where a unix socket cannot be bound. | |
| 454 | */ | |
| 340 | 455 | export function startEgress( |
| 341 | path: string, | |
| 456 | path: string | { tcp: number }, | |
| 342 | 457 | policy: EgressPolicy, |
| 343 | 458 | log: (line: string) => void = () => {}, |
| 344 | 459 | ): Egress { |
| 345 | const server = Bun.listen<Conn>({ | |
| 346 | unix: path, | |
| 347 | socket: { | |
| 348 | open(client) { | |
| 349 | client.data = { | |
| 350 | head: new Uint8Array(0), | |
| 351 | peer: undefined, | |
| 352 | toPeer: [], | |
| 353 | toClient: [], | |
| 354 | done: false, | |
| 355 | }; | |
| 356 | }, | |
| 357 | data(client, chunk) { | |
| 358 | const conn = client.data; | |
| 359 | if (conn.peer !== undefined) { | |
| 360 | send(conn.peer, conn.toPeer, chunk); | |
| 361 | return; | |
| 362 | } | |
| 363 | if (conn.done) return; | |
| 364 | const before = conn.head.length; | |
| 365 | conn.head = concat(conn.head, chunk); | |
| 366 | // Waiting on the upstream dial: whatever else arrives is kept with | |
| 367 | // the head, and goes out after it. | |
| 368 | if (before > 0 && headEnd(conn.head.subarray(0, before)) !== -1) return; | |
| 369 | if (headEnd(conn.head) === -1) { | |
| 370 | if (conn.head.length > HEAD_MAX) | |
| 371 | refuse( | |
| 372 | client, | |
| 373 | "431 Request Header Fields Too Large", | |
| 374 | "head too large", | |
| 375 | ); | |
| 376 | return; | |
| 377 | } | |
| 378 | void open(client, policy, log); | |
| 379 | }, | |
| 380 | drain(client) { | |
| 381 | flush(client, client.data.toClient); | |
| 382 | }, | |
| 383 | close(client) { | |
| 384 | client.data.done = true; | |
| 385 | client.data.peer?.end(); | |
| 386 | }, | |
| 387 | error(client) { | |
| 388 | client.data.done = true; | |
| 389 | client.data.peer?.end(); | |
| 390 | }, | |
| 460 | const live = new Set<Socket<Conn>>(); | |
| 461 | const handlers = { | |
| 462 | open(client: Socket<Conn>) { | |
| 463 | live.add(client); | |
| 464 | client.data = { | |
| 465 | head: new Uint8Array(0), | |
| 466 | peer: undefined, | |
| 467 | toPeer: [], | |
| 468 | toClient: [], | |
| 469 | done: false, | |
| 470 | last: Date.now(), | |
| 471 | release: undefined, | |
| 472 | }; | |
| 391 | 473 | }, |
| 392 | }); | |
| 393 | return { close: () => server.stop(true) }; | |
| 474 | data(client: Socket<Conn>, chunk: Uint8Array) { | |
| 475 | const conn = client.data; | |
| 476 | conn.last = Date.now(); | |
| 477 | if (conn.peer !== undefined) { | |
| 478 | if (conn.done) return; | |
| 479 | if (conn.tally !== undefined) conn.tally.bytesOut += chunk.length; | |
| 480 | send(conn.peer, conn.toPeer, chunk); | |
| 481 | return; | |
| 482 | } | |
| 483 | if (conn.done) return; | |
| 484 | const before = conn.head.length; | |
| 485 | conn.head = concat(conn.head, chunk); | |
| 486 | // Waiting on the upstream dial: whatever else arrives is kept with | |
| 487 | // the head, and goes out after it. | |
| 488 | if (before > 0 && headEnd(conn.head.subarray(0, before)) !== -1) return; | |
| 489 | if (headEnd(conn.head) === -1) { | |
| 490 | if (conn.head.length > HEAD_MAX) | |
| 491 | refuse( | |
| 492 | client, | |
| 493 | "431 Request Header Fields Too Large", | |
| 494 | "head too large", | |
| 495 | ); | |
| 496 | return; | |
| 497 | } | |
| 498 | void open(client, policy, log); | |
| 499 | }, | |
| 500 | drain(client: Socket<Conn>) { | |
| 501 | client.data.last = Date.now(); | |
| 502 | flush(client, client.data.toClient); | |
| 503 | }, | |
| 504 | close(client: Socket<Conn>) { | |
| 505 | live.delete(client); | |
| 506 | drop(client); | |
| 507 | }, | |
| 508 | error(client: Socket<Conn>) { | |
| 509 | live.delete(client); | |
| 510 | drop(client); | |
| 511 | }, | |
| 512 | }; | |
| 513 | const server = | |
| 514 | typeof path === "string" | |
| 515 | ? Bun.listen<Conn>({ unix: path, socket: handlers }) | |
| 516 | : Bun.listen<Conn>({ hostname: "127.0.0.1", port: path.tcp, socket: handlers }); | |
| 517 | // A tunnel that moves nothing (a client that stopped reading, an upstream | |
| 518 | // that stopped sending) is cut, so it cannot hold an upstream open. | |
| 519 | const idleMs = policy.idleMs ?? IDLE_MS; | |
| 520 | const sweep = setInterval( | |
| 521 | () => { | |
| 522 | const now = Date.now(); | |
| 523 | for (const client of live) | |
| 524 | if (now - client.data.last > idleMs) { | |
| 525 | log(`egress idle: closed a connection quiet for ${idleMs} ms`); | |
| 526 | drop(client); | |
| 527 | client.terminate(); | |
| 528 | live.delete(client); | |
| 529 | } | |
| 530 | }, | |
| 531 | Math.max(50, Math.min(5_000, idleMs / 2)), | |
| 532 | ); | |
| 533 | sweep.unref(); | |
| 534 | return { | |
| 535 | close() { | |
| 536 | clearInterval(sweep); | |
| 537 | for (const client of live) { | |
| 538 | drop(client); | |
| 539 | client.terminate(); | |
| 540 | } | |
| 541 | live.clear(); | |
| 542 | server.stop(true); | |
| 543 | }, | |
| 544 | }; | |
| 394 | 545 | } |
| 395 | 546 | |
| 396 | 547 | /** |
| @@ -472,11 +623,11 @@ export function relay( | ||
| 472 | 623 | }, |
| 473 | 624 | close(client: Socket<Pair>) { |
| 474 | 625 | client.data.done = true; |
| 475 | client.data.peer?.end(); | |
| 626 | client.data.peer?.terminate(); | |
| 476 | 627 | }, |
| 477 | 628 | error(client: Socket<Pair>) { |
| 478 | 629 | client.data.done = true; |
| 479 | client.data.peer?.end(); | |
| 630 | client.data.peer?.terminate(); | |
| 480 | 631 | }, |
| 481 | 632 | }; |
| 482 | 633 | const server = |
src/apps/egress_metrics.ts added +26
| @@ -0,0 +1,26 @@ | ||
| 1 | /** | |
| 2 | * The egress tally as Prometheus text (#3719): one line per app and | |
| 3 | * destination for open connections and the bytes they have moved, so a leaked | |
| 4 | * upstream socket shows as a gauge that never falls. | |
| 5 | */ | |
| 6 | import type { EgressRow } from "./egress.ts"; | |
| 7 | ||
| 8 | /** A label value, escaped as the exposition format asks. */ | |
| 9 | function label(value: string): string { | |
| 10 | return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n"); | |
| 11 | } | |
| 12 | ||
| 13 | export function egressMetrics(rows: readonly EgressRow[]): string { | |
| 14 | const series: [string, string, (r: EgressRow) => number][] = [ | |
| 15 | ["forge_egress_connections", "Open egress connections.", (r) => r.open], | |
| 16 | ["forge_egress_bytes_in", "Bytes received from the destination over open connections.", (r) => r.bytesIn], | |
| 17 | ["forge_egress_bytes_out", "Bytes sent to the destination over open connections.", (r) => r.bytesOut], | |
| 18 | ]; | |
| 19 | const lines: string[] = []; | |
| 20 | for (const [name, help, value] of series) { | |
| 21 | lines.push(`# HELP ${name} ${help}`, `# TYPE ${name} gauge`); | |
| 22 | for (const r of rows) | |
| 23 | lines.push(`${name}{repo="${label(r.label)}",destination="${label(r.destination)}"} ${value(r)}`); | |
| 24 | } | |
| 25 | return `${lines.join("\n")}\n`; | |
| 26 | } |
src/http/api.ts +16
| @@ -427,6 +427,8 @@ import { passportRoutes } from "./passport_edge.ts"; | ||
| 427 | 427 | import type { Router } from "./router.ts"; |
| 428 | 428 | import { clientAddress, clientBucket } from "./peer.ts"; |
| 429 | 429 | import { manifestFor } from "../actions/here.ts"; |
| 430 | import { egressSnapshot } from "../apps/egress.ts"; | |
| 431 | import { egressMetrics } from "../apps/egress_metrics.ts"; | |
| 430 | 432 | import { |
| 431 | 433 | acceptReport, |
| 432 | 434 | dismissReport, |
| @@ -4821,6 +4823,20 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 4821 | 4823 | ), |
| 4822 | 4824 | ); |
| 4823 | 4825 | |
| 4826 | // Open egress connections per app and destination, as Prometheus text | |
| 4827 | // (#3719). Repository names are in the labels, so a site admin only. | |
| 4828 | router.add("GET", "/metrics", (ctx) => { | |
High: this admin-gated route has no test. Removing |
||
| 4829 | const denied = accountAdmin(ctx, "ro"); | |
| 4830 | if (denied) return denied; | |
| 4831 | return new Response(egressMetrics(egressSnapshot()), { | |
| 4832 | headers: { | |
| 4833 | "content-type": "text/plain; version=0.0.4; charset=utf-8", | |
| 4834 | "cache-control": "no-store", | |
| 4835 | vary: "authorization, cookie", | |
| 4836 | }, | |
| 4837 | }); | |
| 4838 | }); | |
| 4839 | ||
| 4824 | 4840 | // Explore (#607): the rows /explore shows, for the same one line. |
| 4825 | 4841 | router.add("GET", "/api/v1/explore", async (ctx) => { |
| 4826 | 4842 | const query = parseExploreQuery(ctx.url.searchParams.get("q") ?? ""); |
src/http/server.ts +2 −1
| @@ -2425,7 +2425,8 @@ async function answerGet( | ||
| 2425 | 2425 | // generic as `/:owner/:name` can never claim it — a route added there |
| 2426 | 2426 | // next year still can't bring the HTML 404 back for an API caller. |
| 2427 | 2427 | const isApiPath = url.pathname === "/api" || url.pathname.startsWith("/api/"); |
| 2428 | const matched = isApiPath | |
| 2428 | // `/metrics` is the API router's too: a scraper, not a page. | |
| 2429 | const matched = isApiPath || url.pathname === "/metrics" | |
| 2429 | 2430 | ? apiRouter.match(routeMethod, url.pathname) |
| 2430 | 2431 | : router.match(routeMethod, url.pathname); |
| 2431 | 2432 | if (!matched) { |
src/tui/screens/about.ts +6 −2
| @@ -32,7 +32,7 @@ import { truncate, wrap } from "../term/width.ts"; | ||
| 32 | 32 | import { badge, card } from "../ui/widgets.ts"; |
| 33 | 33 | import type { Session } from "../session.ts"; |
| 34 | 34 | import { PIN_MARK, ago, drawFacts, plural } from "./common.ts"; |
| 35 | import { egressScreen } from "./egress.ts"; | |
| 35 | import { connectionsScreen, egressScreen } from "./egress.ts"; | |
| 36 | 36 | import { forkOf, hostOf, liveOrigin, localRepo, sourceName } from "./where.ts"; |
| 37 | 37 | |
| 38 | 38 | /** |
| @@ -98,7 +98,7 @@ export function aboutScreen( | ||
| 98 | 98 | : []), |
| 99 | 99 | // A site admin also reaches the repository's private egress grants. |
| 100 | 100 | ...(f.user?.isAdmin === true && repo !== undefined |
| 101 | ? [binding(["e"], "egress")] | |
| 101 | ? [binding(["e"], "egress"), binding(["c"], "connections")] | |
| 102 | 102 | : []), |
| 103 | 103 | binding(["esc"], "close"), |
| 104 | 104 | ]; |
| @@ -209,6 +209,10 @@ export function aboutScreen( | ||
| 209 | 209 | ctx.push(egressScreen(f, ctx, repo)); |
| 210 | 210 | return true; |
| 211 | 211 | } |
| 212 | if (e.key === "c" && f.user?.isAdmin === true) { | |
| 213 | ctx.push(connectionsScreen(f, ctx)); | |
| 214 | return true; | |
| 215 | } | |
| 212 | 216 | if (e.key === "esc" || e.key === "q" || e.key === "I") { |
| 213 | 217 | ctx.pop(); |
| 214 | 218 | return true; |
src/tui/screens/egress.ts +37
| @@ -10,6 +10,8 @@ import { | ||
| 10 | 10 | revokeAppEgress, |
| 11 | 11 | type EgressRow, |
| 12 | 12 | } from "../../actions/egress_grants.ts"; |
| 13 | import { egressSnapshot } from "../../apps/egress.ts"; | |
| 14 | import { bytes } from "../../system/format.ts"; | |
| 13 | 15 | import type { Repo } from "../../core/repos.ts"; |
| 14 | 16 | import type { Ctx, Screen } from "../app/app.ts"; |
| 15 | 17 | import { binding } from "../app/keys.ts"; |
| @@ -74,3 +76,38 @@ export function egressScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 74 | 76 | }; |
| 75 | 77 | return screen; |
| 76 | 78 | } |
| 79 | ||
| 80 | /** | |
| 81 | * The egress connections open right now, per app and destination: the | |
| 82 | * Capacity page's card, for a site admin. Read again on every draw, so it | |
| 83 | * moves as connections do; anyone else is offered nothing. | |
| 84 | */ | |
| 85 | export function connectionsScreen(f: Session, ctx: Ctx): Screen { | |
| 86 | const state = new ListState(); | |
| 87 | const lines = (): ListRow[] => | |
| 88 | f.user?.isAdmin !== true | |
| 89 | ? [] | |
| 90 | : egressSnapshot().map((r) => ({ | |
| 91 | title: r.label === "" ? "(unlabelled)" : r.label, | |
| 92 | meta: `${r.destination} · ${r.open} open · ${bytes(r.bytesIn)} in · ${bytes(r.bytesOut)} out`, | |
| 93 | })); | |
| 94 | return { | |
| 95 | title: "Egress connections", | |
| 96 | keys: [binding(["j", "k"], "move cursor")], | |
| 97 | render(area, s) { | |
| 98 | const c = ctx.theme.c; | |
| 99 | const rows = lines(); | |
| 100 | state.clamp(rows.length); | |
| 101 | const [head, list] = split(area, "v", [len(1), fill(1)]); | |
| 102 | const note = | |
| 103 | rows.length === 0 | |
| 104 | ? "No app holds an egress connection open." | |
| 105 | : `${rows.length} destination${rows.length === 1 ? "" : "s"} in use`; | |
| 106 | s.text(head!.x, head!.y, truncate(note, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w); | |
| 107 | drawList(s, list!, c, state, rows); | |
| 108 | }, | |
| 109 | key(e) { | |
| 110 | return state.key(e, lines().length); | |
| 111 | }, | |
| 112 | }; | |
| 113 | } |
src/web/pages/capacity.ts +28 −1
| @@ -8,6 +8,8 @@ | ||
| 8 | 8 | |
| 9 | 9 | import { changeCapacity, listCapacity, type CapacityRow } from "../../actions/capacity.ts"; |
| 10 | 10 | import { CAPACITY, type CapacityKey } from "../../core/capacity.ts"; |
| 11 | import { bytes } from "../../system/format.ts"; | |
| 12 | import { egressSnapshot } from "../../apps/egress.ts"; | |
| 11 | 13 | import type { Ctx } from "../../http/context.ts"; |
| 12 | 14 | import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts"; |
| 13 | 15 | import { notFoundPage, signInFirst } from "../chrome.ts"; |
| @@ -155,7 +157,32 @@ export function capacityPage(ctx: Ctx): Response { | ||
| 155 | 157 | pad: false, |
| 156 | 158 | }, |
| 157 | 159 | ); |
| 158 | const body = html`${happened}${when( | |
| 160 | const open = egressSnapshot(); | |
| 161 | const connections = card( | |
| 162 | open.length === 0 | |
| 163 | ? html`<div class="card-body"> | |
| 164 | <p class="muted small" id="egress-open"> | |
| 165 | No app holds an egress connection open. | |
| 166 | </p> | |
| 167 | </div>` | |
| 168 | : html`<ul class="list" id="egress-open"> | |
| 169 | ${open.map((r) => | |
| 170 | row({ | |
| 171 | lead: icon("activity"), | |
| 172 | title: r.label === "" ? "(unlabelled)" : r.label, | |
| 173 | meta: html`<span class="mono">${r.destination}</span> · | |
| 174 | ${bytes(r.bytesIn)} in · ${bytes(r.bytesOut)} out`, | |
| 175 | trail: html`<span class="mono">${r.open} open</span>`, | |
| 176 | }), | |
| 177 | )} | |
| 178 | </ul>`, | |
| 179 | { | |
| 180 | head: html`${icon("activity")} | |
| 181 | <h2>Egress connections</h2>`, | |
| 182 | pad: false, | |
| 183 | }, | |
| 184 | ); | |
| 185 | const body = html`${happened}${connections}${when( | |
| 159 | 186 | reported, |
| 160 | 187 | () => html`<p class="muted small mb" id="egress-reported"> |
| 161 | 188 | ${icon("activity")} Egress is reported, not enforced: every new app, |
test/capacity.test.ts +34
| @@ -18,6 +18,7 @@ import { addToCrew, createAgent } from "../src/actions/crew.ts"; | ||
| 18 | 18 | import { assignIssue, openIssue } from "../src/actions/issues.ts"; |
| 19 | 19 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; |
| 20 | 20 | import { enableApp } from "../src/actions/apps.ts"; |
| 21 | import { startEgress } from "../src/apps/egress.ts"; | |
| 21 | 22 | import { AppsRuntime } from "../src/apps/runtime.ts"; |
| 22 | 23 | import { startChecks, type ChecksHandle } from "../src/checks/service.ts"; |
| 23 | 24 | import { |
| @@ -384,6 +385,39 @@ describe("report mode is loud to admins, and to nobody else", () => { | ||
| 384 | 385 | expect(shown.replace(/\s+/g, " ")).toContain("may reach any public host on ports 80 and 443"); |
| 385 | 386 | }); |
| 386 | 387 | |
| 388 | test("the page shows the egress connections an app holds open, and none once they close", async () => { | |
| 389 | const camera = Bun.listen<undefined>({ hostname: "127.0.0.1", port: 0, socket: { data() {} } }); | |
| 390 | const front = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } }); | |
| 391 | const listen = front.port; | |
| 392 | front.stop(true); | |
| 393 | const egress = startEgress( | |
| 394 | { tcp: listen }, | |
| 395 | { hosts: [], origins: [{ host: "127.0.0.1", port: camera.port }], label: "app:cam/main" }, | |
| 396 | ); | |
| 397 | try { | |
| 398 | let reply = ""; | |
| 399 | const client = await Bun.connect<undefined>({ | |
| 400 | hostname: "127.0.0.1", | |
| 401 | port: listen, | |
| 402 | socket: { data: (_s, c) => void (reply += new TextDecoder().decode(c)) }, | |
| 403 | }); | |
| 404 | client.write(`CONNECT 127.0.0.1:${camera.port} HTTP/1.1\r\n\r\n`); | |
| 405 | while (!reply.includes("200")) await Bun.sleep(10); | |
| 406 | const shown = (await page()).replace(/\s+/g, " "); | |
| 407 | expect(shown).toContain("app:cam/main"); | |
| 408 | expect(shown).toContain(`127.0.0.1:${camera.port}`); | |
| 409 | expect(shown).toContain("1 open"); | |
| 410 | expect(shown).toMatch(/\d+ B in · \d+ B out/); | |
| 411 | expect(shown).not.toContain("→ 127.0.0.1"); | |
| 412 | egress.close(); | |
| 413 | expect(await page()).toContain("No app holds an egress connection open."); | |
| 414 | client.terminate(); | |
| 415 | } finally { | |
| 416 | egress.close(); | |
| 417 | camera.stop(true); | |
| 418 | } | |
| 419 | }); | |
| 420 | ||
| 387 | 421 | test("reported: the public health check, a member and a stranger hear nothing of it", async () => { |
| 388 | 422 | const health = await fetch(`${servers.url}/healthz`); |
| 389 | 423 | expect(health.status).toBe(200); |
test/egress-admin.test.ts +38
| @@ -13,6 +13,7 @@ import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | ||
| 13 | 13 | import { listEgressGrants } from "../src/core/egress_grants.ts"; |
| 14 | 14 | import type { Repo } from "../src/core/repos.ts"; |
| 15 | 15 | import { createUser, type User } from "../src/core/users.ts"; |
| 16 | import { startEgress } from "../src/apps/egress.ts"; | |
| 16 | 17 | import { createForgeApp } from "../src/tui/forge.ts"; |
| 17 | 18 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; |
| 18 | 19 | import { quietLogs } from "./helpers/globals.ts"; |
| @@ -107,4 +108,41 @@ describe("egress grants in the terminal", () => { | ||
| 107 | 108 | h.tui.stop(); |
| 108 | 109 | } |
| 109 | 110 | }); |
| 111 | ||
| 112 | test("an admin sees the open egress connections from About; a non-admin does not", async () => { | |
| 113 | const camera = Bun.listen<undefined>({ hostname: "127.0.0.1", port: 0, socket: { data() {} } }); | |
| 114 | const probe = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } }); | |
| 115 | const listen = probe.port; | |
| 116 | probe.stop(true); | |
| 117 | const egress = startEgress( | |
| 118 | { tcp: listen }, | |
| 119 | { hosts: [], origins: [{ host: "127.0.0.1", port: camera.port }], label: "app:cam/main" }, | |
| 120 | ); | |
| 121 | let reply = ""; | |
| 122 | const client = await Bun.connect<undefined>({ | |
| 123 | hostname: "127.0.0.1", | |
| 124 | port: listen, | |
| 125 | socket: { data: (_s, c) => void (reply += new TextDecoder().decode(c)) }, | |
| 126 | }); | |
| 127 | client.write(`CONNECT 127.0.0.1:${camera.port} HTTP/1.1\r\n\r\n`); | |
| 128 | while (!reply.includes("200")) await Bun.sleep(10); | |
| 129 | const admin = boot(ada); | |
| 130 | const other = boot(bob); | |
| 131 | try { | |
| 132 | await admin.openAbout(); | |
| 133 | await admin.press("c"); | |
| 134 | await settleUntil(admin.tui, "app:cam/main"); | |
| 135 | expect(admin.text()).toContain(`127.0.0.1:${camera.port} · 1 open`); | |
| 136 | await other.openAbout(); | |
| 137 | await other.press("c"); | |
| 138 | expect(other.text()).not.toContain("Egress connections"); | |
| 139 | expect(other.text()).not.toContain("app:cam/main"); | |
| 140 | } finally { | |
| 141 | admin.tui.stop(); | |
| 142 | other.tui.stop(); | |
| 143 | client.terminate(); | |
| 144 | egress.close(); | |
| 145 | camera.stop(true); | |
| 146 | } | |
| 147 | }); | |
| 110 | 148 | }); |
test/egress-leak.test.ts added +231
| @@ -0,0 +1,231 @@ | ||
| 1 | /** | |
| 2 | * The egress proxy never leaves an upstream socket open after the cell side | |
| 3 | * is gone (#3719): a client that drops, a proxy that closes, a client that | |
| 4 | * stops reading, and a cap on connections to one destination. | |
| 5 | */ | |
| 6 | ||
| 7 | import { afterEach, describe, expect, test } from "bun:test"; | |
| 8 | import type { Socket } from "bun"; | |
| 9 | import { egressSnapshot, relay, startEgress } from "../src/apps/egress.ts"; | |
| 10 | ||
| 11 | const openNow = (label: string, destination?: string): number => | |
| 12 | egressSnapshot() | |
| 13 | .filter((r) => r.label === label && (destination === undefined || r.destination === destination)) | |
| 14 | .reduce((n, r) => n + r.open, 0); | |
| 15 | ||
| 16 | const pending: (() => void)[] = []; | |
| 17 | afterEach(() => { | |
| 18 | while (pending.length > 0) pending.pop()?.(); | |
| 19 | }); | |
| 20 | ||
| 21 | /** A fake MJPEG camera: streams forever, counts live connections. */ | |
| 22 | function camera(frame = 1024) { | |
| 23 | const open = new Set<Socket<undefined>>(); | |
| 24 | // Half-open allowed: like a real camera, it keeps streaming after the | |
| 25 | // other side has said it is done writing, until the socket is destroyed. | |
| 26 | const server = Bun.listen<undefined>({ | |
| 27 | hostname: "127.0.0.1", | |
| 28 | port: 0, | |
| 29 | allowHalfOpen: true, | |
| 30 | socket: { | |
| 31 | open(s) { | |
| 32 | open.add(s); | |
| 33 | s.data = undefined; | |
| 34 | }, | |
| 35 | data(s) { | |
| 36 | const timer = setInterval(() => { | |
| 37 | if (!open.has(s)) return clearInterval(timer); | |
| 38 | s.write("--frame\r\n" + "x".repeat(frame) + "\r\n"); | |
| 39 | }, 5); | |
| 40 | }, | |
| 41 | close(s) { | |
| 42 | open.delete(s); | |
| 43 | }, | |
| 44 | error(s) { | |
| 45 | open.delete(s); | |
| 46 | }, | |
| 47 | }, | |
| 48 | }); | |
| 49 | pending.push(() => server.stop(true)); | |
| 50 | return { port: server.port, count: () => open.size }; | |
| 51 | } | |
| 52 | ||
| 53 | async function until(check: () => boolean, ms = 2_000): Promise<boolean> { | |
| 54 | const end = Date.now() + ms; | |
| 55 | while (Date.now() < end) { | |
| 56 | if (check()) return true; | |
| 57 | await Bun.sleep(20); | |
| 58 | } | |
| 59 | return check(); | |
| 60 | } | |
| 61 | ||
| 62 | describe("egress leaves nothing open", () => { | |
| 63 | // Bun.listen with port 0 gives no handle back through `startEgress`, so | |
| 64 | // these tests listen on a fixed free port found first. | |
| 65 | async function freePort(): Promise<number> { | |
| 66 | const s = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } }); | |
| 67 | const p = s.port; | |
| 68 | s.stop(true); | |
| 69 | return p; | |
| 70 | } | |
| 71 | ||
| 72 | async function setup(extra: Record<string, unknown> = {}, frame = 1024, log?: (line: string) => void) { | |
| 73 | const cam = camera(frame); | |
| 74 | const listen = await freePort(); | |
| 75 | const egress = startEgress( | |
| 76 | { tcp: listen }, | |
| 77 | { | |
| 78 | hosts: [], | |
| 79 | origins: [{ host: "127.0.0.1", port: cam.port }], | |
| 80 | label: `t${listen}`, | |
| 81 | ...extra, | |
| 82 | }, | |
| 83 | log, | |
| 84 | ); | |
| 85 | pending.push(() => egress.close()); | |
| 86 | const stream = async (request = true, hold = false) => { | |
| 87 | const got = Promise.withResolvers<string>(); | |
| 88 | let text = ""; | |
| 89 | const sock = await Bun.connect<undefined>({ | |
| 90 | hostname: "127.0.0.1", | |
| 91 | port: listen, | |
| 92 | socket: { | |
| 93 | data(_s, chunk) { | |
| 94 | text += new TextDecoder().decode(chunk); | |
| 95 | if (text.includes("200 Connection Established")) got.resolve(text); | |
| 96 | }, | |
| 97 | close() {}, | |
| 98 | error() {}, | |
| 99 | }, | |
| 100 | }); | |
| 101 | sock.write(`CONNECT 127.0.0.1:${cam.port} HTTP/1.1\r\n\r\n${request ? "GET /\r\n\r\n" : ""}`); | |
| 102 | await got.promise; | |
| 103 | if (hold) sock.pause(); | |
| 104 | return sock; | |
| 105 | }; | |
| 106 | return { cam, egress, stream, listen, label: `t${listen}`, dest: `127.0.0.1:${cam.port}` }; | |
| 107 | } | |
| 108 | ||
| 109 | test("a client that disconnects mid-stream closes its upstream", async () => { | |
| 110 | const { cam, stream } = await setup(); | |
| 111 | const sock = await stream(); | |
| 112 | expect(await until(() => cam.count() === 1)).toBe(true); | |
| 113 | sock.terminate(); | |
| 114 | expect(await until(() => cam.count() === 0)).toBe(true); | |
| 115 | }); | |
| 116 | ||
| 117 | test("closing the proxy (the cell stops) leaves 0 of 20 streams open", async () => { | |
| 118 | const { cam, egress, stream, label } = await setup({ maxPerDestination: 20 }); | |
| 119 | const socks = await Promise.all(Array.from({ length: 20 }, () => stream())); | |
| 120 | expect(await until(() => cam.count() === 20)).toBe(true); | |
| 121 | expect(openNow(label)).toBe(20); | |
| 122 | egress.close(); | |
| 123 | expect(await until(() => cam.count() === 0)).toBe(true); | |
| 124 | expect(openNow(label)).toBe(0); | |
| 125 | for (const s of socks) s.terminate(); | |
| 126 | }); | |
| 127 | ||
| 128 | test("clients that drop right after connecting leave no upstream behind", async () => { | |
| 129 | const { cam, stream } = await setup({ maxPerDestination: 10 }); | |
| 130 | const socks = await Promise.all(Array.from({ length: 10 }, () => stream())); | |
| 131 | for (const s of socks) s.terminate(); | |
| 132 | expect(await until(() => cam.count() === 0, 3_000)).toBe(true); | |
| 133 | }); | |
| 134 | ||
| 135 | test("connections past the per-destination cap are refused, and counted", async () => { | |
| 136 | const lines: string[] = []; | |
| 137 | const { cam, stream, listen, label, dest } = await setup({ maxPerDestination: 2 }, 1024, (l) => lines.push(l)); | |
| 138 | const a = await stream(); | |
| 139 | const b = await stream(); | |
| 140 | expect(await until(() => cam.count() === 2)).toBe(true); | |
| 141 | let reply = ""; | |
| 142 | const third = await Bun.connect<undefined>({ | |
| 143 | hostname: "127.0.0.1", | |
| 144 | port: listen, | |
| 145 | socket: { | |
| 146 | data(_s, chunk) { | |
| 147 | reply += new TextDecoder().decode(chunk); | |
| 148 | }, | |
| 149 | }, | |
| 150 | }); | |
| 151 | third.write(`CONNECT 127.0.0.1:${cam.port} HTTP/1.1\r\n\r\n`); | |
| 152 | expect(await until(() => reply.startsWith("HTTP/1.1 429"))).toBe(true); | |
| 153 | expect(lines.some((l) => l.startsWith("egress capped:") && l.includes(dest))).toBe(true); | |
| 154 | expect(cam.count()).toBe(2); | |
| 155 | expect(openNow(label, dest)).toBe(2); | |
| 156 | const row = egressSnapshot().find((r) => r.label === label && r.destination === dest); | |
| 157 | expect(row?.open).toBe(2); | |
| 158 | expect(row?.bytesIn).toBeGreaterThan(0); | |
| 159 | a.terminate(); | |
| 160 | b.terminate(); | |
| 161 | expect(await until(() => openNow(label, dest) === 0)).toBe(true); | |
| 162 | // Nothing of a closed app stays in the tally. | |
| 163 | expect(egressSnapshot().some((r) => r.label === label)).toBe(false); | |
| 164 | }); | |
| 165 | ||
| 166 | test("a connection that moves no bytes is cut after the idle timeout", async () => { | |
| 167 | const { cam, stream } = await setup({ idleMs: 150 }); | |
| 168 | const sock = await stream(false); | |
| 169 | expect(await until(() => cam.count() === 1)).toBe(true); | |
| 170 | expect(await until(() => cam.count() === 0)).toBe(true); | |
| 171 | sock.terminate(); | |
| 172 | }); | |
| 173 | ||
| 174 | test("a client that stops reading is cut once its backlog passes the limit", async () => { | |
| 175 | const { cam, stream } = await setup({ queueMax: 64 * 1024 }, 32 * 1024); | |
| 176 | const sock = await stream(true, true); | |
| 177 | expect(await until(() => cam.count() === 0, 5_000)).toBe(true); | |
| 178 | sock.terminate(); | |
| 179 | }); | |
| 180 | }); | |
| 181 | ||
| 182 | describe("the inbound and loopback relay closes its target with the client", () => { | |
| 183 | test("a client that leaves mid-stream closes the connection it was relayed to", async () => { | |
| 184 | const cam = camera(); | |
| 185 | const find = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } }); | |
| 186 | const port = find.port; | |
| 187 | find.stop(true); | |
| 188 | const r = relay({ tcp: port }, { tcp: cam.port }); | |
| 189 | pending.push(() => r.close()); | |
| 190 | let seen = 0; | |
| 191 | const client = await Bun.connect<undefined>({ | |
| 192 | hostname: "127.0.0.1", | |
| 193 | port, | |
| 194 | socket: { data: (_s, c) => void (seen += c.length) }, | |
| 195 | }); | |
| 196 | client.write("go"); | |
| 197 | expect(await until(() => cam.count() === 1 && seen > 0)).toBe(true); | |
| 198 | client.terminate(); | |
| 199 | expect(await until(() => cam.count() === 0)).toBe(true); | |
| 200 | }); | |
| 201 | }); | |
| 202 | ||
| 203 | describe("a client that leaves while the dial is in flight", () => { | |
| 204 | test("strands no upstream and leaves nothing in the tally", async () => { | |
| 205 | const cam = camera(); | |
| 206 | const probe = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } }); | |
| 207 | const listen = probe.port; | |
| 208 | probe.stop(true); | |
| 209 | const label = `dial${listen}`; | |
| 210 | const egress = startEgress( | |
| 211 | { tcp: listen }, | |
| 212 | { hosts: [], origins: [{ host: "127.0.0.1", port: cam.port }], label, maxPerDestination: 1000 }, | |
| 213 | ); | |
| 214 | pending.push(() => egress.close()); | |
| 215 | // The dial is a few ticks long: leave at each of several moments inside it. | |
| 216 | const socks: Socket<undefined>[] = []; | |
| 217 | for (let i = 0; i < 60; i++) { | |
| 218 | const sock = await Bun.connect<undefined>({ | |
| 219 | hostname: "127.0.0.1", | |
| 220 | port: listen, | |
| 221 | socket: { data() {}, close() {}, error() {} }, | |
| 222 | }); | |
| 223 | sock.write(`CONNECT 127.0.0.1:${cam.port} HTTP/1.1\r\n\r\n`); | |
| 224 | socks.push(sock); | |
| 225 | if (i % 3 > 0) await Bun.sleep(i % 3 - 1); | |
| 226 | sock.terminate(); | |
| 227 | } | |
| 228 | expect(await until(() => cam.count() === 0, 3_000)).toBe(true); | |
| 229 | expect(await until(() => !egressSnapshot().some((r) => r.label === label), 3_000)).toBe(true); | |
| 230 | }); | |
| 231 | }); |
test/egress-metrics-route.test.ts added +107
| @@ -0,0 +1,107 @@ | ||
| 1 | /** | |
| 2 | * `GET /metrics` serves the open egress connections with repository names in | |
| 3 | * its labels, so it is a site admin's alone (#3719). | |
| 4 | */ | |
| 5 | ||
| 6 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 7 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { createApp, type App } from "../src/app.ts"; | |
| 11 | import { createAgent } from "../src/actions/crew.ts"; | |
| 12 | import { startEgress, type Egress } from "../src/apps/egress.ts"; | |
| 13 | import { createToken } from "../src/core/tokens.ts"; | |
| 14 | import { createUser, type User } from "../src/core/users.ts"; | |
| 15 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 16 | ||
| 17 | let dir: string; | |
| 18 | let app: App; | |
| 19 | let servers: Servers; | |
| 20 | let admin: string; | |
| 21 | let adminRo: string; | |
| 22 | let member: string; | |
| 23 | let agent: string; | |
| 24 | let memberCookie: string; | |
| 25 | let egress: Egress; | |
| 26 | let camera: { port: number; stop(close?: boolean): void }; | |
| 27 | let client: Awaited<ReturnType<typeof Bun.connect>>; | |
| 28 | ||
| 29 | beforeAll(async () => { | |
| 30 | dir = await mkdtemp(join(tmpdir(), "egress-metrics-")); | |
| 31 | app = createApp({ dataDir: dir, port: 0 }); | |
| 32 | servers = await serve(app); | |
| 33 | const mk = async (username: string, adminIfFirst = false): Promise<User> => { | |
| 34 | const made = await createUser(app.db, { | |
| 35 | origin: "seed", | |
| 36 | trusted: true, | |
| 37 | username, | |
| 38 | email: `${username}@forge.local`, | |
| 39 | password: "a-good-passphrase", | |
| 40 | adminIfFirst, | |
| 41 | }); | |
| 42 | if (!made.ok) throw new Error(made.error); | |
| 43 | return made.value; | |
| 44 | }; | |
| 45 | const alice = await mk("alice", true); | |
| 46 | const bob = await mk("bob"); | |
| 47 | const made = await createAgent(app, alice, { username: "scout" }); | |
| 48 | if (!made.ok) throw new Error(String(made.error)); | |
| 49 | admin = createToken(app.db, alice.id, "t", "admin").token; | |
| 50 | adminRo = createToken(app.db, alice.id, "t", "ro").token; | |
| 51 | member = createToken(app.db, bob.id, "t", "ro").token; | |
| 52 | agent = createToken(app.db, made.value.user.id, "t", "ro").token; | |
| 53 | const res = await fetch(`${servers.url}/signin`, { | |
| 54 | method: "POST", | |
| 55 | redirect: "manual", | |
| 56 | headers: { "content-type": "application/x-www-form-urlencoded", origin: servers.url }, | |
| 57 | body: new URLSearchParams({ username: "bob", password: "a-good-passphrase" }), | |
| 58 | }); | |
| 59 | memberCookie = (res.headers.get("set-cookie") ?? "").split(";")[0] ?? ""; | |
| 60 | ||
| 61 | camera = Bun.listen<undefined>({ hostname: "127.0.0.1", port: 0, socket: { data() {} } }); | |
| 62 | const probe = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } }); | |
| 63 | const listen = probe.port; | |
| 64 | probe.stop(true); | |
| 65 | egress = startEgress( | |
| 66 | { tcp: listen }, | |
| 67 | { hosts: [], origins: [{ host: "127.0.0.1", port: camera.port }], label: "app:cam/main" }, | |
| 68 | ); | |
| 69 | let reply = ""; | |
| 70 | client = await Bun.connect<undefined>({ | |
| 71 | hostname: "127.0.0.1", | |
| 72 | port: listen, | |
| 73 | socket: { data: (_s, c) => void (reply += new TextDecoder().decode(c)) }, | |
| 74 | }); | |
| 75 | client.write(`CONNECT 127.0.0.1:${camera.port} HTTP/1.1\r\n\r\n`); | |
| 76 | while (!reply.includes("200")) await Bun.sleep(10); | |
| 77 | }, 60_000); | |
| 78 | ||
| 79 | afterAll(async () => { | |
| 80 | client.terminate(); | |
| 81 | egress.close(); | |
| 82 | camera.stop(true); | |
| 83 | servers.stop(); | |
| 84 | app.close(); | |
| 85 | await rm(dir, { recursive: true, force: true }); | |
| 86 | }); | |
| 87 | ||
| 88 | const get = (headers: Record<string, string> = {}) => | |
| 89 | fetch(`${servers.url}/metrics`, { headers }); | |
| 90 | ||
| 91 | test("anonymous, a member and an agent are told it is not there", async () => { | |
| 92 | expect((await get()).status).toBe(404); | |
| 93 | expect((await get({ cookie: memberCookie })).status).toBe(404); | |
| 94 | expect((await get({ authorization: `Bearer ${member}` })).status).toBe(404); | |
| 95 | expect((await get({ authorization: `Bearer ${agent}` })).status).toBe(404); | |
| 96 | }); | |
| 97 | ||
| 98 | test("a site admin reads the open connections as Prometheus text", async () => { | |
| 99 | const res = await get({ authorization: `Bearer ${adminRo}` }); | |
| 100 | expect(res.status).toBe(200); | |
| 101 | expect(res.headers.get("content-type")).toStartWith("text/plain; version=0.0.4"); | |
| 102 | expect(res.headers.get("cache-control")).toBe("no-store"); | |
| 103 | expect(await res.text()).toContain( | |
| 104 | `forge_egress_connections{repo="app:cam/main",destination="127.0.0.1:${camera.port}"} 1`, | |
| 105 | ); | |
| 106 | expect((await get({ authorization: `Bearer ${admin}` })).status).toBe(200); | |
| 107 | }); |
test/egress-metrics.test.ts added +15
| @@ -0,0 +1,15 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { egressMetrics } from "../src/apps/egress_metrics.ts"; | |
| 3 | ||
| 4 | test("every destination gets its open count and bytes, labels escaped", () => { | |
| 5 | const text = egressMetrics([ | |
| 6 | { label: 'a/"b"', destination: "pi:8080", open: 3, bytesIn: 10, bytesOut: 2 }, | |
| 7 | ]); | |
| 8 | expect(text).toContain('forge_egress_connections{repo="a/\\"b\\"",destination="pi:8080"} 3'); | |
| 9 | expect(text).toContain('forge_egress_bytes_in{repo="a/\\"b\\"",destination="pi:8080"} 10'); | |
| 10 | expect(text).toContain('forge_egress_bytes_out{repo="a/\\"b\\"",destination="pi:8080"} 2'); | |
| 11 | }); | |
| 12 | ||
| 13 | test("no connections leaves only the headers", () => { | |
| 14 | expect(egressMetrics([])).not.toContain("{"); | |
| 15 | }); |
new above
offline
This branch is the leak the issue names: the client leaves while the dial is in flight. No test reaches it, because every leak test waits for the 200 first. Add one that drops the client before the upstream accepts and asserts the upstream closes and the tally is empty.