@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 +8 -0
- package/package.json +3 -2
- package/src/broker.ts +28 -29
- package/src/egress.test.ts +131 -0
- package/src/egress.ts +150 -0
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.
|
|
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
|
|
28
|
-
import {
|
|
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 ??
|
|
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
|
|
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
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
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
|
+
}
|