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

Merged opened this in answer to

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/3719-urgent-the-egress-relay-leaks-upstream-s → main 5 commits fork 2 threads · 2 unresolved

  • Merged. scout/3719-urgent-the-egress-relay-leaks-upstream-s was merged into main as 364aa0c.

Review

Sign in to review this change.
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.
846846 names it exactly and a site admin has granted it: on the
847847 app's settings page, or in the terminal (About `I`, then `e`, `g`
848848 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).
849858 - **Production is the default branch.** The only way code reaches it is that
850859 branch moving, and an agent cannot move it: the crew proposes through
851860 pulls, a person merges.
@@ -1914,3 +1923,8 @@ same rule. A guard installed by hand without the prefix answers `refused`
19141923 until it is re-created with `forge: ` in front of its message.
19151924
19161925 `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(
362362 {
363363 hosts: spec.egress,
364364 granted: spec.granted,
365 label: spec.label,
365366 origins,
366367 report:
367368 (spec.egressMode ?? egressMode()) === "report"
src/apps/egress.ts +217 −66
@@ -38,6 +38,14 @@ export interface EgressPolicy {
3838 * refused. Private addresses are refused either way.
3939 */
4040 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;
4149 }
4250
4351 export interface Egress {
@@ -45,6 +53,59 @@ export interface Egress {
4553 }
4654
4755 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 }
48109
49110 /** The ports a declared host may be reached on when its entry names none. */
50111 export const WEB_PORTS: readonly number[] = [80, 443];
@@ -165,8 +226,26 @@ interface Conn {
165226 toPeer: Uint8Array[];
166227 toClient: Uint8Array[];
167228 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;
168235 }
169236
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
170249 /** Write what the socket takes now; keep the rest for its `drain`. */
171250 function send(
172251 socket: { write(data: Uint8Array): number },
@@ -295,31 +374,59 @@ async function open(
295374 log(`egress reported: ${host}:${port} is not in forge.json "egress" (let through while egress is only reported)`);
296375 policy.report?.(host.toLowerCase(), port);
297376 }
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 };
298400 try {
299401 const peer = await Bun.connect<undefined>({
300402 hostname: verdict.address,
301403 port,
302404 socket: {
303405 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 }
305414 },
306415 drain(peer) {
307416 flush(peer, client.data.toPeer);
308417 },
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,
321420 },
322421 });
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) {

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.

425 peer.terminate();
426 client.data.release?.();
427 client.data.release = undefined;
428 return;
429 }
323430 client.data.peer = peer;
324431 if (tunnel)
325432 send(
@@ -330,67 +437,111 @@ async function open(
330437 // Everything after the head — including what arrived during the dial,
331438 // kept with it — goes out now; from here `data` forwards directly.
332439 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 }
334444 } catch {
445 client.data.release?.();
446 client.data.release = undefined;
335447 refuse(client, "502 Bad Gateway", `${host}:${port} did not answer`);
336448 }
337449 }
338450
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 */
340455 export function startEgress(
341 path: string,
456 path: string | { tcp: number },
342457 policy: EgressPolicy,
343458 log: (line: string) => void = () => {},
344459 ): 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 };
391473 },
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 };
394545 }
395546
396547 /**
@@ -472,11 +623,11 @@ export function relay(
472623 },
473624 close(client: Socket<Pair>) {
474625 client.data.done = true;
475 client.data.peer?.end();
626 client.data.peer?.terminate();
476627 },
477628 error(client: Socket<Pair>) {
478629 client.data.done = true;
479 client.data.peer?.end();
630 client.data.peer?.terminate();
480631 },
481632 };
482633 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";
427427 import type { Router } from "./router.ts";
428428 import { clientAddress, clientBucket } from "./peer.ts";
429429 import { manifestFor } from "../actions/here.ts";
430 import { egressSnapshot } from "../apps/egress.ts";
431 import { egressMetrics } from "../apps/egress_metrics.ts";
430432 import {
431433 acceptReport,
432434 dismissReport,
@@ -4821,6 +4823,20 @@ export function apiRoutes(router: Router<Handler>): void {
48214823 ),
48224824 );
48234825
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 accountAdmin would leak every app's repository name and destinations, and no test would fail. Test it for anonymous, non-admin, agent-without-scope and admin.

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
48244840 // Explore (#607): the rows /explore shows, for the same one line.
48254841 router.add("GET", "/api/v1/explore", async (ctx) => {
48264842 const query = parseExploreQuery(ctx.url.searchParams.get("q") ?? "");
src/http/server.ts +2 −1
@@ -2425,7 +2425,8 @@ async function answerGet(
24252425 // generic as `/:owner/:name` can never claim it — a route added there
24262426 // next year still can't bring the HTML 404 back for an API caller.
24272427 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"
24292430 ? apiRouter.match(routeMethod, url.pathname)
24302431 : router.match(routeMethod, url.pathname);
24312432 if (!matched) {
src/tui/screens/about.ts +6 −2
@@ -32,7 +32,7 @@ import { truncate, wrap } from "../term/width.ts";
3232 import { badge, card } from "../ui/widgets.ts";
3333 import type { Session } from "../session.ts";
3434 import { PIN_MARK, ago, drawFacts, plural } from "./common.ts";
35 import { egressScreen } from "./egress.ts";
35 import { connectionsScreen, egressScreen } from "./egress.ts";
3636 import { forkOf, hostOf, liveOrigin, localRepo, sourceName } from "./where.ts";
3737
3838 /**
@@ -98,7 +98,7 @@ export function aboutScreen(
9898 : []),
9999 // A site admin also reaches the repository's private egress grants.
100100 ...(f.user?.isAdmin === true && repo !== undefined
101 ? [binding(["e"], "egress")]
101 ? [binding(["e"], "egress"), binding(["c"], "connections")]
102102 : []),
103103 binding(["esc"], "close"),
104104 ];
@@ -209,6 +209,10 @@ export function aboutScreen(
209209 ctx.push(egressScreen(f, ctx, repo));
210210 return true;
211211 }
212 if (e.key === "c" && f.user?.isAdmin === true) {
213 ctx.push(connectionsScreen(f, ctx));
214 return true;
215 }
212216 if (e.key === "esc" || e.key === "q" || e.key === "I") {
213217 ctx.pop();
214218 return true;
src/tui/screens/egress.ts +37
@@ -10,6 +10,8 @@ import {
1010 revokeAppEgress,
1111 type EgressRow,
1212 } from "../../actions/egress_grants.ts";
13 import { egressSnapshot } from "../../apps/egress.ts";
14 import { bytes } from "../../system/format.ts";
1315 import type { Repo } from "../../core/repos.ts";
1416 import type { Ctx, Screen } from "../app/app.ts";
1517 import { binding } from "../app/keys.ts";
@@ -74,3 +76,38 @@ export function egressScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
7476 };
7577 return screen;
7678 }
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 @@
88
99 import { changeCapacity, listCapacity, type CapacityRow } from "../../actions/capacity.ts";
1010 import { CAPACITY, type CapacityKey } from "../../core/capacity.ts";
11 import { bytes } from "../../system/format.ts";
12 import { egressSnapshot } from "../../apps/egress.ts";
1113 import type { Ctx } from "../../http/context.ts";
1214 import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts";
1315 import { notFoundPage, signInFirst } from "../chrome.ts";
@@ -155,7 +157,32 @@ export function capacityPage(ctx: Ctx): Response {
155157 pad: false,
156158 },
157159 );
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(
159186 reported,
160187 () => html`<p class="muted small mb" id="egress-reported">
161188 ${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";
1818 import { assignIssue, openIssue } from "../src/actions/issues.ts";
1919 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
2020 import { enableApp } from "../src/actions/apps.ts";
21 import { startEgress } from "../src/apps/egress.ts";
2122 import { AppsRuntime } from "../src/apps/runtime.ts";
2223 import { startChecks, type ChecksHandle } from "../src/checks/service.ts";
2324 import {
@@ -384,6 +385,39 @@ describe("report mode is loud to admins, and to nobody else", () => {
384385 expect(shown.replace(/\s+/g, " ")).toContain("may reach any public host on ports 80 and 443");
385386 });
386387
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
387421 test("reported: the public health check, a member and a stranger hear nothing of it", async () => {
388422 const health = await fetch(`${servers.url}/healthz`);
389423 expect(health.status).toBe(200);
test/egress-admin.test.ts +38
@@ -13,6 +13,7 @@ import { commitToBranch, createRepo } from "../src/actions/repos.ts";
1313 import { listEgressGrants } from "../src/core/egress_grants.ts";
1414 import type { Repo } from "../src/core/repos.ts";
1515 import { createUser, type User } from "../src/core/users.ts";
16 import { startEgress } from "../src/apps/egress.ts";
1617 import { createForgeApp } from "../src/tui/forge.ts";
1718 import { headlessTerminal } from "../src/tui/term/terminal.ts";
1819 import { quietLogs } from "./helpers/globals.ts";
@@ -107,4 +108,41 @@ describe("egress grants in the terminal", () => {
107108 h.tui.stop();
108109 }
109110 });
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 });
110148 });
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs