@sproutboat/wire 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # @sproutboat/wire
2
2
 
3
+ ## 0.12.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 9e9638d: An egress service for `fetch()` (baronunread/sproutboat#252). `@sproutboat/wire/egress` runs as its own process (`SB_EGRESS_TOKEN=... bun egress.ts --port 8070`), listens on loopback, requires a token on every call, and performs the vetted fetch itself. A broker given `SB_EGRESS_URL` and `SB_EGRESS_TOKEN` sends `fetch()` through it instead of connecting directly, so on a host where the broker's own unit has no network, sprouts can still reach the internet. The address checks and `SB_EGRESS_ALLOW` behave the same in both modes; refusals read the same; compressed bodies and redirects come back as a direct fetch would see them.
8
+
9
+ `fetch()` also moves on to the next resolved address when one can't be connected to, such as an IPv6 answer on a host with no IPv6 route. It only does so on failures where nothing was sent.
10
+
3
11
  ## 0.11.0
4
12
 
5
13
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sproutboat/wire",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {
@@ -17,7 +17,8 @@
17
17
  "exports": {
18
18
  ".": "./src/index.ts",
19
19
  "./broker": "./src/broker.ts",
20
- "./package.json": "./package.json"
20
+ "./package.json": "./package.json",
21
+ "./egress": "./src/egress.ts"
21
22
  },
22
23
  "scripts": {
23
24
  "test": "bun test ./"
package/src/broker.ts CHANGED
@@ -24,9 +24,8 @@ import { open } from "node:fs/promises";
24
24
  import { dirname, join, normalize, resolve } from "node:path";
25
25
  import { parseArgs } from "node:util";
26
26
  import { resolveAssetKey, type AssetManifest } from "@sproutboat/assets";
27
- import { egressAllowList, egressRefusal } from "@sproutboat/runtime";
28
- import { lookup } from "node:dns/promises";
29
- import { isIP } from "node:net";
27
+ import { egressAllowList } from "@sproutboat/runtime";
28
+ import { EGRESS_ERROR_HEADER, EGRESS_TOKEN_HEADER, EGRESS_URL_HEADER, resolveAll, vettedFetch } from "./egress";
30
29
  import { isSafeInteger } from "./json";
31
30
  import { isBoolean, isString, jsonObject, parseJsonValue, type JsonObject, type JsonValue } from "./json";
32
31
 
@@ -124,6 +123,10 @@ export type BrokerOptions = {
124
123
  /** #174 — private addresses `fetch()` may reach anyway. Defaults to
125
124
  * `SB_EGRESS_ALLOW` (`*` or comma-separated exact addresses). */
126
125
  egressAllow?: string[];
126
+ /** #252 — send `fetch()` through the egress service (egress.ts) instead of
127
+ * connecting directly. Defaults to `SB_EGRESS_URL` / `SB_EGRESS_TOKEN`. */
128
+ egressUrl?: string;
129
+ egressToken?: string;
127
130
  };
128
131
 
129
132
  type SqlParam = string | number | null;
@@ -261,8 +264,10 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
261
264
  const replayed = new Map<string, Frame>();
262
265
  const serviceHosts = opts.services ?? {};
263
266
  const doFetch = opts.fetchImpl ?? fetch;
264
- const resolveHost = opts.resolveImpl ?? ((hostname: string) => lookup(hostname, { all: true }));
267
+ const resolveHost = opts.resolveImpl ?? resolveAll;
265
268
  const egressAllow = opts.egressAllow ?? egressAllowList(process.env.SB_EGRESS_ALLOW);
269
+ const egressUrl = opts.egressUrl ?? process.env.SB_EGRESS_URL ?? "";
270
+ const egressToken = opts.egressToken ?? process.env.SB_EGRESS_TOKEN ?? "";
266
271
 
267
272
  const dbPath = opts.db ?? ":memory:";
268
273
  const inMemory = dbPath === ":memory:" || dbPath === "";
@@ -1179,6 +1184,21 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
1179
1184
  ? new Uint8Array(Buffer.from(str(msg.body), "latin1"))
1180
1185
  : str(msg.body);
1181
1186
 
1187
+ /** #252 — the egress service vets and fetches; its refusal reads as ours. */
1188
+ async function viaEgressService(url: URL, init: RequestInit): Promise<Response> {
1189
+ const headers = new Headers(init.headers);
1190
+ headers.set(EGRESS_URL_HEADER, url.href);
1191
+ headers.set(EGRESS_TOKEN_HEADER, egressToken);
1192
+ const res = await doFetch(egressUrl, { ...init, headers });
1193
+ if (res.headers.get(EGRESS_ERROR_HEADER)) {
1194
+ // SAFETY: the egress service answers a refusal with { error: string }.
1195
+ const { error } = (await res.json()) as { error: string };
1196
+ throw new Error(error);
1197
+ }
1198
+ if (res.status === 401) throw new Error("the egress service rejected this broker's token");
1199
+ return res;
1200
+ }
1201
+
1182
1202
  async function proxyFetch(msg: Frame): Promise<Upstream> {
1183
1203
  let url: URL;
1184
1204
  try {
@@ -1194,37 +1214,16 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
1194
1214
  if (Array.isArray(pair) && pair.length === 2) headers.set(str(pair[0]), str(pair[1]));
1195
1215
  }
1196
1216
  }
1197
- // #174 — resolve, vet every address, then connect to the vetted one, never
1198
- // the name: handing the name to fetch() would resolve it again, and DNS
1199
- // rebinding lives in that gap. TLS still verifies against the name.
1200
- const hostname = url.hostname.replace(/^\[|\]$/g, "");
1201
- let addresses: Array<{ address: string }>;
1202
- try {
1203
- addresses = await resolveHost(hostname);
1204
- } catch {
1205
- throw new Error(`could not resolve ${url.hostname}`);
1206
- }
1207
- let refusal: string | null = null;
1208
- const vetted = addresses.find(({ address }) => {
1209
- const why = egressRefusal(url.hostname, address, egressAllow);
1210
- refusal ??= why;
1211
- return why === null;
1212
- });
1213
- if (!vetted) throw new Error(refusal ?? `could not resolve ${url.hostname}`);
1214
- const pinned = new URL(url);
1215
- pinned.hostname = isIP(vetted.address) === 6 ? `[${vetted.address}]` : vetted.address;
1216
- headers.set("host", url.host);
1217
-
1218
1217
  const method = str(msg.method || "GET").toUpperCase();
1219
- const init: RequestInit & { tls?: { serverName: string } } = {
1218
+ const init: RequestInit = {
1220
1219
  method,
1221
1220
  headers,
1222
1221
  body: method === "GET" || method === "HEAD" ? undefined : upstreamBody(msg),
1223
1222
  redirect: "manual",
1224
1223
  };
1225
- // Bun's TLS option: SNI and certificate checks against the name, not the address.
1226
- if (url.protocol === "https:" && !isIP(hostname)) init.tls = { serverName: hostname };
1227
- const res = await doFetch(pinned, init);
1224
+ const res = egressUrl
1225
+ ? await viaEgressService(url, init)
1226
+ : await vettedFetch(url, init, { resolve: resolveHost, allow: egressAllow, fetchImpl: doFetch });
1228
1227
  const outHeaders: Array<[string, string]> = [];
1229
1228
  res.headers.forEach((v, k) => outHeaders.push([k, v]));
1230
1229
  return { reply: { ok: true, status: res.status, headers: outHeaders }, bytes: await readCapped(res, url.host) };
@@ -0,0 +1,131 @@
1
+ import { afterEach, expect, test } from "bun:test";
2
+ import { gzipSync } from "node:zlib";
3
+ import { createBroker } from "./broker";
4
+ import { createEgressHandler, EGRESS_ERROR_HEADER, EGRESS_TOKEN_HEADER, EGRESS_URL_HEADER, vettedFetch } from "./egress";
5
+
6
+ const TOKEN = "egress-test-token";
7
+ const stops: Array<() => void> = [];
8
+ afterEach(() => {
9
+ for (const stop of stops.splice(0)) stop();
10
+ });
11
+
12
+ /** A real upstream on loopback: echoes method, body and Host, or answers gzip / a redirect. */
13
+ function upstream() {
14
+ const server = Bun.serve({
15
+ port: 0,
16
+ hostname: "127.0.0.1",
17
+ fetch: async (request) => {
18
+ const path = new URL(request.url).pathname;
19
+ if (path === "/gzip")
20
+ return new Response(gzipSync("compressed hello"), { headers: { "content-encoding": "gzip" } });
21
+ if (path === "/redirect") return new Response(null, { status: 302, headers: { location: "http://127.0.0.2/" } });
22
+ return Response.json(
23
+ { method: request.method, body: await request.text(), host: request.headers.get("host") },
24
+ { status: 201, headers: { "x-upstream": "1" } },
25
+ );
26
+ },
27
+ });
28
+ stops.push(() => server.stop(true));
29
+ return server;
30
+ }
31
+
32
+ /** The egress service on loopback, allowed to reach 127.0.0.1 only (for the upstream). */
33
+ function egress() {
34
+ const server = Bun.serve({
35
+ port: 0,
36
+ hostname: "127.0.0.1",
37
+ fetch: createEgressHandler({ token: TOKEN, allow: ["127.0.0.1"] }),
38
+ });
39
+ stops.push(() => server.stop(true));
40
+ return server;
41
+ }
42
+
43
+ const call = (service: { url: URL }, target: string, init: RequestInit = {}, token = TOKEN) =>
44
+ fetch(service.url, {
45
+ ...init,
46
+ // SAFETY: every caller here passes headers as a plain record, or none.
47
+ headers: { ...(init.headers as Record<string, string>), [EGRESS_URL_HEADER]: target, [EGRESS_TOKEN_HEADER]: token },
48
+ });
49
+
50
+ test("#252: the service relays a request and its response, Host set to the target's name", async () => {
51
+ const up = upstream();
52
+ const res = await call(egress(), `http://localhost:${up.port}/x`, { method: "POST", body: "payload" });
53
+ expect(res.status).toBe(201);
54
+ expect(res.headers.get("x-upstream")).toBe("1");
55
+ expect(await res.json()).toEqual({ method: "POST", body: "payload", host: `localhost:${up.port}` });
56
+ });
57
+
58
+ test("#252: compressed bodies and redirects pass through as a direct fetch would see them", async () => {
59
+ const up = upstream();
60
+ const service = egress();
61
+ const gz = await call(service, `http://127.0.0.1:${up.port}/gzip`);
62
+ expect([gz.headers.get("content-encoding"), await gz.text()]).toEqual(["gzip", "compressed hello"]);
63
+ const hop = await call(service, `http://127.0.0.1:${up.port}/redirect`, { redirect: "manual" });
64
+ expect([hop.status, hop.headers.get("location")]).toEqual([302, "http://127.0.0.2/"]);
65
+ });
66
+
67
+ test("#252: without the token the service does nothing", async () => {
68
+ const up = upstream();
69
+ const res = await call(egress(), `http://127.0.0.1:${up.port}/`, {}, "wrong-token-of-same-size");
70
+ expect(res.status).toBe(401);
71
+ expect(createEgressHandler.bind(null, { token: "" })).toThrow("needs a token");
72
+ });
73
+
74
+ test("#252: the service refuses private addresses with the #174 message", async () => {
75
+ const res = await call(egress(), "http://169.254.169.254/latest/meta-data/");
76
+ expect(res.headers.get(EGRESS_ERROR_HEADER)).toBe("1");
77
+ // SAFETY: a refusal (EGRESS_ERROR_HEADER, checked above) is { error: string }.
78
+ expect(((await res.json()) as { error: string }).error).toBe(
79
+ "fetch() refused: 169.254.169.254 resolves to 169.254.169.254, a private or reserved address (169.254.0.0/16). An operator can allow it with SB_EGRESS_ALLOW=169.254.169.254",
80
+ );
81
+ });
82
+
83
+ test("#252: a broker given SB_EGRESS_URL fetches through the service, refusals included", async () => {
84
+ const up = upstream();
85
+ const service = egress();
86
+ // The broker itself may not connect anywhere: egressAllow [] and a resolver
87
+ // that would only ever answer loopback. Success can only come via the service.
88
+ const broker = createBroker({
89
+ egressUrl: service.url.href,
90
+ egressToken: TOKEN,
91
+ egressAllow: [],
92
+ resolveImpl: async () => [{ address: "127.0.0.1" }],
93
+ });
94
+ stops.push(() => broker.close());
95
+
96
+ const ok = await broker.dispatch({ op: "fetch", url: `http://127.0.0.1:${up.port}/gzip` });
97
+ expect(ok).toMatchObject({ ok: true, status: 200, body: "compressed hello" });
98
+
99
+ await expect(broker.dispatch({ op: "fetch", url: "http://10.0.0.1/" })).rejects.toThrow(
100
+ "fetch() refused: 10.0.0.1 resolves to 10.0.0.1",
101
+ );
102
+
103
+ const stranger = createBroker({ egressUrl: service.url.href, egressToken: "not-the-token" });
104
+ stops.push(() => stranger.close());
105
+ await expect(stranger.dispatch({ op: "fetch", url: `http://127.0.0.1:${up.port}/` })).rejects.toThrow(
106
+ "rejected this broker's token",
107
+ );
108
+ });
109
+
110
+ test("an address that cannot be connected to falls through to the next, and only then", async () => {
111
+ const tried: string[] = [];
112
+ const unreachable = Object.assign(new TypeError("no route"), { code: "FailedToOpenSocket" });
113
+ const fetchImpl = async (url: URL | string) => {
114
+ tried.push(new URL(url).hostname);
115
+ if (tried.length === 1) throw unreachable;
116
+ return new Response("ok");
117
+ };
118
+ const resolve = async () => [{ address: "2606:4700::1111" }, { address: "1.1.1.1" }];
119
+ const res = await vettedFetch(new URL("https://one.example/"), {}, { resolve, allow: [], fetchImpl });
120
+ expect([tried, await res.text()]).toEqual([["[2606:4700::1111]", "1.1.1.1"], "ok"]);
121
+
122
+ // Any other failure may have sent the request already: no second attempt.
123
+ tried.length = 0;
124
+ const reset = Object.assign(new TypeError("reset"), { code: "ConnectionClosed" });
125
+ const failing = async (url: URL | string) => {
126
+ tried.push(new URL(url).hostname);
127
+ throw reset;
128
+ };
129
+ await expect(vettedFetch(new URL("https://one.example/"), {}, { resolve, allow: [], fetchImpl: failing })).rejects.toBe(reset);
130
+ expect(tried).toEqual(["[2606:4700::1111]"]);
131
+ });
package/src/egress.ts ADDED
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Outbound `fetch()` for sprouts: the private-address block (#174) and the
3
+ * egress service (baronunread/sproutboat#252).
4
+ *
5
+ * The broker normally fetches itself. On the systemd install it runs inside the
6
+ * edge unit, whose `IPAddressDeny=any` keeps sprouts off the network and the
7
+ * broker with them, so it hands each request to this service instead: a
8
+ * separate unit outside that cgroup, listening on loopback. Sprouts share
9
+ * loopback, so every call carries a token that only brokers are given (the
10
+ * sprout sandbox clears its environment).
11
+ *
12
+ * SB_EGRESS_TOKEN=... bun egress.ts --port 8070
13
+ *
14
+ * Either way the request goes through `vettedFetch`, so the address checks and
15
+ * `SB_EGRESS_ALLOW` behave identically.
16
+ */
17
+ import { timingSafeEqual } from "node:crypto";
18
+ import { lookup } from "node:dns/promises";
19
+ import { isIP } from "node:net";
20
+ import { parseArgs } from "node:util";
21
+ import { egressAllowList, egressRefusal } from "@sproutboat/runtime";
22
+
23
+ type FetchLike = (input: URL | string, init?: RequestInit) => Promise<Response>;
24
+ export type Resolve = (hostname: string) => Promise<Array<{ address: string }>>;
25
+ export const resolveAll: Resolve = (hostname) => lookup(hostname, { all: true });
26
+
27
+ /** The target URL, the caller's token, and the marker on a refusal reply. */
28
+ export const EGRESS_URL_HEADER = "x-sb-egress-url";
29
+ export const EGRESS_TOKEN_HEADER = "x-sb-egress-token";
30
+ export const EGRESS_ERROR_HEADER = "x-sb-egress-error";
31
+
32
+ /**
33
+ * #174 — resolve, vet every address, then connect to the vetted one, never the
34
+ * name: handing the name to fetch() would resolve it again, and DNS rebinding
35
+ * lives in that gap. TLS still verifies against the name. Throws the refusal.
36
+ */
37
+ export async function vettedFetch(
38
+ url: URL,
39
+ init: RequestInit,
40
+ opts: { resolve: Resolve; allow: readonly string[]; fetchImpl: FetchLike },
41
+ ): Promise<Response> {
42
+ const hostname = url.hostname.replace(/^\[|\]$/g, "");
43
+ let addresses: Array<{ address: string }>;
44
+ try {
45
+ addresses = await opts.resolve(hostname);
46
+ } catch {
47
+ throw new Error(`could not resolve ${url.hostname}`);
48
+ }
49
+ let refusal: string | null = null;
50
+ const vetted = addresses.filter(({ address }) => {
51
+ const why = egressRefusal(url.hostname, address, opts.allow);
52
+ refusal ??= why;
53
+ return why === null;
54
+ });
55
+ if (vetted.length === 0) throw new Error(refusal ?? `could not resolve ${url.hostname}`);
56
+ const headers = new Headers(init.headers);
57
+ headers.set("host", url.host);
58
+ const pinnedInit: RequestInit & { tls?: { serverName: string } } = { ...init, headers, redirect: "manual" };
59
+ // Bun's TLS option: SNI and certificate checks against the name, not the address.
60
+ if (url.protocol === "https:" && !isIP(hostname)) pinnedInit.tls = { serverName: hostname };
61
+ // Like the standalone client, move on to the next address when one cannot be
62
+ // connected to (an IPv6 answer on a host with no IPv6 route). Only on those
63
+ // codes: nothing has been sent yet, so a retry cannot repeat a request.
64
+ let lastError: unknown;
65
+ for (const { address } of vetted) {
66
+ const pinned = new URL(url);
67
+ pinned.hostname = isIP(address) === 6 ? `[${address}]` : address;
68
+ try {
69
+ return await opts.fetchImpl(pinned, pinnedInit);
70
+ } catch (error) {
71
+ lastError = error;
72
+ // SAFETY: only reads an optional `code`; anything without one is rethrown.
73
+ const code = (error as { code?: unknown } | null)?.code;
74
+ if (!CONNECT_FAILURES.has(String(code))) throw error;
75
+ }
76
+ }
77
+ throw lastError;
78
+ }
79
+
80
+ /** Bun's fetch codes for a connection that never opened. */
81
+ const CONNECT_FAILURES = new Set(["FailedToOpenSocket", "ConnectionRefused", "ECONNREFUSED", "ENETUNREACH", "EHOSTUNREACH"]);
82
+
83
+ const refuse = (status: number, error: string) =>
84
+ Response.json({ error }, { status, headers: { [EGRESS_ERROR_HEADER]: "1" } });
85
+
86
+ export type EgressOptions = {
87
+ /** Required: brokers send it, sprouts never see it. */
88
+ token: string;
89
+ /** `SB_EGRESS_ALLOW`, split. */
90
+ allow?: string[];
91
+ /** Injected in tests. */
92
+ resolve?: Resolve;
93
+ fetchImpl?: FetchLike;
94
+ };
95
+
96
+ /** The service's request handler: one upstream request per call. */
97
+ export function createEgressHandler(opts: EgressOptions): (request: Request) => Promise<Response> {
98
+ if (!opts.token) throw new Error("the egress service needs a token");
99
+ const expected = Buffer.from(opts.token);
100
+ const fetchImpl = opts.fetchImpl ?? fetch;
101
+ const resolve = opts.resolve ?? resolveAll;
102
+ const allow = opts.allow ?? [];
103
+ return async (request) => {
104
+ const given = Buffer.from(request.headers.get(EGRESS_TOKEN_HEADER) ?? "");
105
+ if (given.length !== expected.length || !timingSafeEqual(given, expected)) {
106
+ return new Response("unauthorized", { status: 401 });
107
+ }
108
+ let url: URL;
109
+ try {
110
+ url = new URL(request.headers.get(EGRESS_URL_HEADER) ?? "");
111
+ } catch {
112
+ return refuse(400, `invalid url: ${request.headers.get(EGRESS_URL_HEADER) ?? ""}`);
113
+ }
114
+ if (url.protocol !== "https:" && url.protocol !== "http:") return refuse(400, `unsupported protocol: ${url.protocol}`);
115
+ const headers = new Headers(request.headers);
116
+ for (const name of [EGRESS_URL_HEADER, EGRESS_TOKEN_HEADER, "host", "content-length"]) headers.delete(name);
117
+ const method = request.method;
118
+ // `decompress: false` relays the upstream's bytes and Content-Encoding as
119
+ // they came, so the broker decodes them exactly as a direct fetch would.
120
+ const init: RequestInit & { decompress?: boolean } = {
121
+ method,
122
+ headers,
123
+ body: method === "GET" || method === "HEAD" ? undefined : await request.arrayBuffer(),
124
+ decompress: false,
125
+ };
126
+ try {
127
+ const upstream = await vettedFetch(url, init, { resolve, allow, fetchImpl });
128
+ return new Response(upstream.body, { status: upstream.status, headers: upstream.headers });
129
+ } catch (error) {
130
+ return refuse(502, error instanceof Error ? error.message : String(error));
131
+ }
132
+ };
133
+ }
134
+
135
+ if (import.meta.main) {
136
+ const { values } = parseArgs({ options: { port: { type: "string" }, host: { type: "string" } } });
137
+ const token = process.env.SB_EGRESS_TOKEN ?? "";
138
+ if (!token) {
139
+ console.error("sproutboat egress: SB_EGRESS_TOKEN is required");
140
+ process.exit(1);
141
+ }
142
+ const server = Bun.serve({
143
+ hostname: values.host ?? "127.0.0.1",
144
+ port: Number(values.port ?? process.env.PORT ?? 8070),
145
+ // A direct fetch has no idle cutoff either; the broker owns the deadline.
146
+ idleTimeout: 0,
147
+ fetch: createEgressHandler({ token, allow: egressAllowList(process.env.SB_EGRESS_ALLOW) }),
148
+ });
149
+ console.log(`sproutboat egress listening on ${server.url}`);
150
+ }