@sproutboat/wire 0.9.3 → 0.11.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 +24 -0
- package/package.json +3 -2
- package/src/broker.test.ts +102 -17
- package/src/broker.ts +70 -25
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
# @sproutboat/wire
|
|
2
2
|
|
|
3
|
+
## 0.11.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 4dd16b7: `fetch()` reaches any public address and never a private or reserved one (baronunread/sproutboat#174). The `outbound` allowlist is gone.
|
|
8
|
+
|
|
9
|
+
- Both backends, the broker and the standalone client, check the address they resolved, not the hostname, so DNS rebinding can't get past the check. The broker connects to the vetted address and verifies TLS against the name.
|
|
10
|
+
- Blocked: loopback, RFC 1918, link-local and cloud metadata, CGNAT, benchmarking, documentation, multicast and reserved ranges, IPv6 ULA and link-local, and the IPv4 address inside IPv4-mapped, NAT64 and 6to4 addresses.
|
|
11
|
+
- An operator can let addresses through with `SB_EGRESS_ALLOW` (`*` or exact addresses) in the broker's or the binary's environment. Handler code can't.
|
|
12
|
+
- `@sproutboat/runtime`: `URL.hostname` and `URL.port` handle IPv6 literals (`[::1]:9` used to give hostname `[`). Every sprout gets `fetch()`, with no binding needed. `validateHttpSyncSource` no longer takes an `outboundAllowed` argument, and `Bindings` loses `outbound`.
|
|
13
|
+
- `@sproutboat/wire`: `Bindings` loses `outbound`.
|
|
14
|
+
- `@sproutboat/config`: `outbound` is accepted but ignored, with a warning in the new `warnings` field of a successful parse (`OUTBOUND_IGNORED`). It will be refused in a later minor release.
|
|
15
|
+
|
|
16
|
+
### Patch Changes
|
|
17
|
+
|
|
18
|
+
- Updated dependencies [4dd16b7]
|
|
19
|
+
- @sproutboat/runtime@0.14.0
|
|
20
|
+
|
|
21
|
+
## 0.10.0
|
|
22
|
+
|
|
23
|
+
### Minor Changes
|
|
24
|
+
|
|
25
|
+
- 276fd90: New `doDb` option and `--do-db` flag: Durable Object storage and alarms can live in their own SQLite file instead of the per-deployment database (baronunread/sproutboat#207). The platform points it at a per-project file so an object's state survives redeploys, as on Workers. Without it, behaviour is unchanged.
|
|
26
|
+
|
|
3
27
|
## 0.9.3
|
|
4
28
|
|
|
5
29
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sproutboat/wire",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
"main": "./src/index.ts",
|
|
12
12
|
"types": "./src/index.ts",
|
|
13
13
|
"dependencies": {
|
|
14
|
-
"@sproutboat/assets": "^0.3.0"
|
|
14
|
+
"@sproutboat/assets": "^0.3.0",
|
|
15
|
+
"@sproutboat/runtime": "^0.14.0"
|
|
15
16
|
},
|
|
16
17
|
"exports": {
|
|
17
18
|
".": "./src/index.ts",
|
package/src/broker.test.ts
CHANGED
|
@@ -29,7 +29,8 @@ afterEach(() => {
|
|
|
29
29
|
});
|
|
30
30
|
|
|
31
31
|
function make(opts: Parameters<typeof createBroker>[0] = {}): Broker {
|
|
32
|
-
|
|
32
|
+
// A public address, so fetch tests are about fetch and not about DNS (#174).
|
|
33
|
+
const b = createBroker({ resolveImpl: async () => [{ address: "93.184.216.34" }], ...opts });
|
|
33
34
|
brokers.push(b);
|
|
34
35
|
return b;
|
|
35
36
|
}
|
|
@@ -151,20 +152,70 @@ test("secrets resolve only when both bound and present", async () => {
|
|
|
151
152
|
await expect(b.dispatch({ op: "secret.get", name: "UNBOUND" })).rejects.toThrow("not bound");
|
|
152
153
|
});
|
|
153
154
|
|
|
154
|
-
test("fetch
|
|
155
|
+
test("fetch reaches any public host, with no allowlist (#174)", async () => {
|
|
155
156
|
const calls: string[] = [];
|
|
156
157
|
const fetchImpl: FetchLike = async (url) => {
|
|
157
158
|
calls.push(String(url));
|
|
158
159
|
return new Response("body", { status: 200, headers: { "x-mark": "1" } });
|
|
159
160
|
};
|
|
160
|
-
const b = make({
|
|
161
|
-
|
|
162
|
-
await expect(b.dispatch({ op: "fetch", url: "https://evil.example.com/x" })).rejects.toThrow("allowlist");
|
|
163
|
-
expect(calls).toEqual([]);
|
|
161
|
+
const b = make({ fetchImpl });
|
|
164
162
|
|
|
163
|
+
await expect(b.dispatch({ op: "fetch", url: "ftp://api.example.com/x" })).rejects.toThrow("unsupported protocol");
|
|
165
164
|
const res = await b.dispatch({ op: "fetch", url: "https://api.example.com/x" });
|
|
166
165
|
expect(res).toMatchObject({ ok: true, status: 200, body: "body" });
|
|
167
|
-
expect(calls).toEqual(["https://
|
|
166
|
+
expect(calls).toEqual(["https://93.184.216.34/x"]);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("#174: fetch never connects to a private address, whatever the name says", async () => {
|
|
170
|
+
const calls: string[] = [];
|
|
171
|
+
const fetchImpl: FetchLike = async (url) => {
|
|
172
|
+
calls.push(String(url));
|
|
173
|
+
return new Response("ok");
|
|
174
|
+
};
|
|
175
|
+
const resolving = (addresses: string[]) => async () => addresses.map((address) => ({ address }));
|
|
176
|
+
|
|
177
|
+
// A public-looking name that resolves somewhere private is refused before connect.
|
|
178
|
+
for (const address of ["127.0.0.1", "169.254.169.254", "10.0.0.7", "::1", "64:ff9b::7f00:1"]) {
|
|
179
|
+
const b = make({ fetchImpl, resolveImpl: resolving([address]), egressAllow: [] });
|
|
180
|
+
await expect(b.dispatch({ op: "fetch", url: "https://api.example.com/x" })).rejects.toThrow(
|
|
181
|
+
`api.example.com resolves to ${address}, a private or reserved address`,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
expect(calls).toEqual([]);
|
|
185
|
+
|
|
186
|
+
// A mixed answer connects to the public address, not the first one.
|
|
187
|
+
const mixed = make({ fetchImpl, resolveImpl: resolving(["10.0.0.7", "2606:4700::1111"]), egressAllow: [] });
|
|
188
|
+
await mixed.dispatch({ op: "fetch", url: "https://api.example.com/x" });
|
|
189
|
+
expect(calls).toEqual(["https://[2606:4700::1111]/x"]);
|
|
190
|
+
|
|
191
|
+
// The operator, not the handler, can let one address through.
|
|
192
|
+
const allowed = make({ fetchImpl, resolveImpl: resolving(["10.0.0.7"]), egressAllow: ["10.0.0.7"] });
|
|
193
|
+
expect(await allowed.dispatch({ op: "fetch", url: "https://api.example.com/x" })).toMatchObject({ status: 200 });
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("#174: the pinned connection still sends the name as Host and verifies TLS against it", async () => {
|
|
197
|
+
const inits: RequestInit[] = [];
|
|
198
|
+
const fetchImpl: FetchLike = async (_url, init) => {
|
|
199
|
+
inits.push(init ?? {});
|
|
200
|
+
return new Response("ok");
|
|
201
|
+
};
|
|
202
|
+
const b = make({ fetchImpl });
|
|
203
|
+
await b.dispatch({ op: "fetch", url: "https://api.example.com/x" });
|
|
204
|
+
expect(new Headers(inits[0].headers).get("host")).toBe("api.example.com");
|
|
205
|
+
expect(inits[0]).toMatchObject({ tls: { serverName: "api.example.com" } });
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("#174: a real request reaches an allowed local upstream through the pinned address", async () => {
|
|
209
|
+
const upstream = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: (req) => new Response(req.headers.get("host")) });
|
|
210
|
+
try {
|
|
211
|
+
const host = `localhost:${upstream.port}`;
|
|
212
|
+
const b = make({ resolveImpl: undefined, egressAllow: ["127.0.0.1"] });
|
|
213
|
+
expect(await b.dispatch({ op: "fetch", url: `http://${host}/` })).toMatchObject({ status: 200, body: host });
|
|
214
|
+
const blocked = make({ resolveImpl: undefined, egressAllow: [] });
|
|
215
|
+
await expect(blocked.dispatch({ op: "fetch", url: `http://${host}/` })).rejects.toThrow("SB_EGRESS_ALLOW=");
|
|
216
|
+
} finally {
|
|
217
|
+
upstream.stop(true);
|
|
218
|
+
}
|
|
168
219
|
});
|
|
169
220
|
|
|
170
221
|
test("#232: a binary fetch request body is sent as its bytes, a text body as UTF-8", async () => {
|
|
@@ -173,7 +224,7 @@ test("#232: a binary fetch request body is sent as its bytes, a text body as UTF
|
|
|
173
224
|
seen.push(new Uint8Array(await new Response(init?.body).arrayBuffer()));
|
|
174
225
|
return new Response("ok");
|
|
175
226
|
};
|
|
176
|
-
const b = make({
|
|
227
|
+
const b = make({ fetchImpl });
|
|
177
228
|
// The runtime sends a binary body one char per byte, flagged bodyBinary.
|
|
178
229
|
const raw = Array.from({ length: 256 }, (_, i) => String.fromCharCode(i)).join("");
|
|
179
230
|
await b.dispatch({ op: "fetch", url: "https://api.example.com/up", method: "POST", body: raw, bodyBinary: true });
|
|
@@ -186,7 +237,7 @@ test("#232: a v1 fetch returns the upstream body as raw bytes; v0 keeps decoded
|
|
|
186
237
|
const binary = Uint8Array.from({ length: 256 }, (_, i) => i);
|
|
187
238
|
const fetchImpl: FetchLike = async (url) =>
|
|
188
239
|
new Response(String(url).endsWith("/bin") ? binary : "café", { status: 200 });
|
|
189
|
-
const b = make({
|
|
240
|
+
const b = make({ fetchImpl });
|
|
190
241
|
|
|
191
242
|
const raw = decodeV1Frame(
|
|
192
243
|
await b.handleFrame(encodeV1({ v: 1, token: "tok", op: "fetch", url: "https://api.example.com/bin" })),
|
|
@@ -706,6 +757,40 @@ test("cronMatches: fields, steps, ranges, lists (UTC)", () => {
|
|
|
706
757
|
// #74 — a binding wired to an account-level resource stores in its own file
|
|
707
758
|
// under resourceDir, keyed by id, so the data outlives a "redeploy" (a new
|
|
708
759
|
// broker with a fresh per-deployment db but the same resourceDir).
|
|
760
|
+
test("#207: Durable Object storage and alarms in doDb outlive a redeploy", async () => {
|
|
761
|
+
const root = mkdtempSync(join(tmpdir(), "sb-broker-do-"));
|
|
762
|
+
try {
|
|
763
|
+
mkdirSync(join(root, "dep-a"), { recursive: true });
|
|
764
|
+
mkdirSync(join(root, "dep-b"), { recursive: true });
|
|
765
|
+
const doDb = join(root, "do_0123456789abcdef01234567.sqlite");
|
|
766
|
+
const bindings = { do: [{ binding: "COUNTER", className: "Counter" }] };
|
|
767
|
+
|
|
768
|
+
const first = createBroker({ db: join(root, "dep-a", "state.sqlite"), doDb, bindings });
|
|
769
|
+
await first.dispatch({ op: "do.storage.put", cls: "Counter", id: "global", key: "n", value: "41" });
|
|
770
|
+
await first.dispatch({ op: "do.alarm.set", cls: "Counter", id: "global", at: 1234 });
|
|
771
|
+
first.close();
|
|
772
|
+
|
|
773
|
+
// A redeploy: new per-deployment state.sqlite, same per-project doDb.
|
|
774
|
+
const second = createBroker({ db: join(root, "dep-b", "state.sqlite"), doDb, bindings });
|
|
775
|
+
expect(await second.dispatch({ op: "do.storage.get", cls: "Counter", id: "global", key: "n" })).toEqual({
|
|
776
|
+
ok: true,
|
|
777
|
+
found: true,
|
|
778
|
+
value: "41",
|
|
779
|
+
});
|
|
780
|
+
expect(await second.dispatch({ op: "do.alarm.get", cls: "Counter", id: "global" })).toMatchObject({ at: 1234 });
|
|
781
|
+
second.close();
|
|
782
|
+
|
|
783
|
+
// Without doDb the state stays per deployment, as before.
|
|
784
|
+
const third = createBroker({ db: join(root, "dep-a", "state.sqlite"), bindings });
|
|
785
|
+
expect(await third.dispatch({ op: "do.storage.get", cls: "Counter", id: "global", key: "n" })).toMatchObject({
|
|
786
|
+
found: false,
|
|
787
|
+
});
|
|
788
|
+
third.close();
|
|
789
|
+
} finally {
|
|
790
|
+
rmSync(root, { recursive: true, force: true });
|
|
791
|
+
}
|
|
792
|
+
});
|
|
793
|
+
|
|
709
794
|
test("resource-backed KV persists across brokers and is shared by id", async () => {
|
|
710
795
|
const root = mkdtempSync(join(tmpdir(), "sb-broker-res-"));
|
|
711
796
|
try {
|
|
@@ -1077,21 +1162,21 @@ test("service: declared but not deployed says so, instead of a bare 502", async
|
|
|
1077
1162
|
);
|
|
1078
1163
|
});
|
|
1079
1164
|
|
|
1080
|
-
test("service: a call
|
|
1081
|
-
// The target is reached internally through the edge, so
|
|
1082
|
-
//
|
|
1165
|
+
test("service: a call reaches the edge on loopback, which plain fetch() may not", async () => {
|
|
1166
|
+
// The target is reached internally through the edge, so the private-address
|
|
1167
|
+
// block (#174) does not apply to it.
|
|
1083
1168
|
const fetchImpl: FetchLike = async () => new Response("ok", { status: 200 });
|
|
1084
1169
|
const b = make({
|
|
1085
|
-
bindings:
|
|
1170
|
+
bindings: svcBindings,
|
|
1086
1171
|
services: { AUTH: "auth-api.andrea.example.com" },
|
|
1087
1172
|
edgeUrl: "http://127.0.0.1:8080/",
|
|
1088
1173
|
fetchImpl,
|
|
1174
|
+
resolveImpl: async () => [{ address: "127.0.0.1" }],
|
|
1175
|
+
egressAllow: [],
|
|
1089
1176
|
});
|
|
1090
1177
|
expect((await b.dispatch({ op: "service.fetch", binding: "AUTH", url: "https://service/" })).status).toBe(200);
|
|
1091
|
-
// ...while real egress to the same
|
|
1092
|
-
await expect(b.dispatch({ op: "fetch", url: "
|
|
1093
|
-
/outbound allowlist/,
|
|
1094
|
-
);
|
|
1178
|
+
// ...while real egress to the same place is refused.
|
|
1179
|
+
await expect(b.dispatch({ op: "fetch", url: "http://127.0.0.1:8080/" })).rejects.toThrow("private or reserved");
|
|
1095
1180
|
});
|
|
1096
1181
|
|
|
1097
1182
|
// --- v1 frames (#63) --------------------------------------------------------
|
package/src/broker.ts
CHANGED
|
@@ -13,9 +13,9 @@
|
|
|
13
13
|
* reply payload : "<json>"
|
|
14
14
|
*
|
|
15
15
|
* ponytail: one SQLite file for KV, secrets from a plain JSON file, fetch
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
16
|
+
* to any public address, never a private one (#174).
|
|
17
|
+
* Encryption at rest and per-key TTLs are v2; redirects are never followed,
|
|
18
|
+
* so there is nothing to re-validate.
|
|
19
19
|
*/
|
|
20
20
|
import { Database, type Statement } from "bun:sqlite";
|
|
21
21
|
import { createHash, randomBytes } from "node:crypto";
|
|
@@ -24,13 +24,15 @@ 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
30
|
import { isSafeInteger } from "./json";
|
|
28
31
|
import { isBoolean, isString, jsonObject, parseJsonValue, type JsonObject, type JsonValue } from "./json";
|
|
29
32
|
|
|
30
33
|
export type Bindings = {
|
|
31
34
|
kv: string[];
|
|
32
35
|
secrets: string[];
|
|
33
|
-
outbound: string[];
|
|
34
36
|
d1: string[];
|
|
35
37
|
r2: string[];
|
|
36
38
|
queues: string[];
|
|
@@ -85,6 +87,12 @@ export type BrokerOptions = {
|
|
|
85
87
|
* data outlives a redeploy.
|
|
86
88
|
*/
|
|
87
89
|
resourceDir?: string;
|
|
90
|
+
/**
|
|
91
|
+
* #207 — SQLite file for Durable Object storage and alarms. The supervisor
|
|
92
|
+
* points this at a per-project path, so an object's state outlives a
|
|
93
|
+
* redeploy as it does on Workers. Defaults to the per-broker `db`.
|
|
94
|
+
*/
|
|
95
|
+
doDb?: string;
|
|
88
96
|
/** Account whose R2 resources this broker may mutate. */
|
|
89
97
|
ownerId?: string;
|
|
90
98
|
/** Account-wide R2 byte ceiling. Zero disables the ceiling for local development. */
|
|
@@ -111,6 +119,11 @@ export type BrokerOptions = {
|
|
|
111
119
|
assetsDir?: string;
|
|
112
120
|
/** Injected in tests; defaults to the global `fetch`. */
|
|
113
121
|
fetchImpl?: FetchLike;
|
|
122
|
+
/** Injected in tests; defaults to `dns.lookup` with every address. */
|
|
123
|
+
resolveImpl?: (hostname: string) => Promise<Array<{ address: string }>>;
|
|
124
|
+
/** #174 — private addresses `fetch()` may reach anyway. Defaults to
|
|
125
|
+
* `SB_EGRESS_ALLOW` (`*` or comma-separated exact addresses). */
|
|
126
|
+
egressAllow?: string[];
|
|
114
127
|
};
|
|
115
128
|
|
|
116
129
|
type SqlParam = string | number | null;
|
|
@@ -208,7 +221,6 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
208
221
|
const bindings: Bindings = {
|
|
209
222
|
kv: [],
|
|
210
223
|
secrets: [],
|
|
211
|
-
outbound: [],
|
|
212
224
|
d1: [],
|
|
213
225
|
r2: [],
|
|
214
226
|
queues: [],
|
|
@@ -249,6 +261,8 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
249
261
|
const replayed = new Map<string, Frame>();
|
|
250
262
|
const serviceHosts = opts.services ?? {};
|
|
251
263
|
const doFetch = opts.fetchImpl ?? fetch;
|
|
264
|
+
const resolveHost = opts.resolveImpl ?? ((hostname: string) => lookup(hostname, { all: true }));
|
|
265
|
+
const egressAllow = opts.egressAllow ?? egressAllowList(process.env.SB_EGRESS_ALLOW);
|
|
252
266
|
|
|
253
267
|
const dbPath = opts.db ?? ":memory:";
|
|
254
268
|
const inMemory = dbPath === ":memory:" || dbPath === "";
|
|
@@ -312,9 +326,14 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
312
326
|
};
|
|
313
327
|
|
|
314
328
|
const db = openStore(dbPath);
|
|
315
|
-
// Durable Object
|
|
316
|
-
//
|
|
317
|
-
|
|
329
|
+
// #207 — Durable Object state lives in its own file when the supervisor gives
|
|
330
|
+
// one (per project, stable across deploys); otherwise in the per-broker db.
|
|
331
|
+
const doDb = opts.doDb ? new Database(opts.doDb, { create: true }) : db;
|
|
332
|
+
if (doDb !== db) {
|
|
333
|
+
doDb.exec("PRAGMA journal_mode = WAL");
|
|
334
|
+
doDb.exec("PRAGMA synchronous = NORMAL");
|
|
335
|
+
}
|
|
336
|
+
doDb.exec(
|
|
318
337
|
"CREATE TABLE IF NOT EXISTS do_storage (cls TEXT NOT NULL, id TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, " +
|
|
319
338
|
"PRIMARY KEY (cls, id, key))",
|
|
320
339
|
);
|
|
@@ -324,12 +343,12 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
324
343
|
);
|
|
325
344
|
// #125 — at most one pending alarm per object, which is the Workers rule: a
|
|
326
345
|
// later setAlarm replaces the earlier one rather than queueing beside it.
|
|
327
|
-
|
|
346
|
+
doDb.exec(
|
|
328
347
|
"CREATE TABLE IF NOT EXISTS do_alarm (cls TEXT NOT NULL, id TEXT NOT NULL, at INTEGER NOT NULL, " +
|
|
329
348
|
"attempts INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (cls, id))",
|
|
330
349
|
);
|
|
331
350
|
// Alarm polling reads the earliest due objects across classes.
|
|
332
|
-
|
|
351
|
+
doDb.exec("CREATE INDEX IF NOT EXISTS do_alarm_due ON do_alarm (at)");
|
|
333
352
|
// #69 — fixed-window rate-limiter counters, per binding name + key.
|
|
334
353
|
db.exec(
|
|
335
354
|
"CREATE TABLE IF NOT EXISTS ratelimit (name TEXT NOT NULL, key TEXT NOT NULL, window_start INTEGER NOT NULL, " +
|
|
@@ -1118,7 +1137,7 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
1118
1137
|
*
|
|
1119
1138
|
* The size is the remote host's choice, and the body is held whole: measured
|
|
1120
1139
|
* on a standalone binary, a 100 MB response took resident memory from 43 MB
|
|
1121
|
-
* to 321 MB. One
|
|
1140
|
+
* to 321 MB. One upstream having a bad day should not be able to
|
|
1122
1141
|
* end the process. 32 MiB by default, and the same variable the embedded
|
|
1123
1142
|
* transport reads so both backends agree.
|
|
1124
1143
|
*/
|
|
@@ -1168,7 +1187,6 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
1168
1187
|
throw new Error(`invalid url: ${str(msg.url)}`);
|
|
1169
1188
|
}
|
|
1170
1189
|
if (url.protocol !== "https:" && url.protocol !== "http:") throw new Error(`unsupported protocol: ${url.protocol}`);
|
|
1171
|
-
if (!bindings.outbound.includes(url.host)) throw new Error(`host not in outbound allowlist: ${url.host}`);
|
|
1172
1190
|
|
|
1173
1191
|
const headers = new Headers();
|
|
1174
1192
|
if (Array.isArray(msg.headers)) {
|
|
@@ -1176,13 +1194,37 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
1176
1194
|
if (Array.isArray(pair) && pair.length === 2) headers.set(str(pair[0]), str(pair[1]));
|
|
1177
1195
|
}
|
|
1178
1196
|
}
|
|
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
|
+
|
|
1179
1218
|
const method = str(msg.method || "GET").toUpperCase();
|
|
1180
|
-
const
|
|
1219
|
+
const init: RequestInit & { tls?: { serverName: string } } = {
|
|
1181
1220
|
method,
|
|
1182
1221
|
headers,
|
|
1183
1222
|
body: method === "GET" || method === "HEAD" ? undefined : upstreamBody(msg),
|
|
1184
1223
|
redirect: "manual",
|
|
1185
|
-
}
|
|
1224
|
+
};
|
|
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);
|
|
1186
1228
|
const outHeaders: Array<[string, string]> = [];
|
|
1187
1229
|
res.headers.forEach((v, k) => outHeaders.push([k, v]));
|
|
1188
1230
|
return { reply: { ok: true, status: res.status, headers: outHeaders }, bytes: await readCapped(res, url.host) };
|
|
@@ -1564,7 +1606,7 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
1564
1606
|
|
|
1565
1607
|
case "do.storage.get": {
|
|
1566
1608
|
const cls = requireDoClass(msg.cls);
|
|
1567
|
-
const row =
|
|
1609
|
+
const row = doDb
|
|
1568
1610
|
.query<{ value: string }, [string, string, string]>(
|
|
1569
1611
|
"SELECT value FROM do_storage WHERE cls = ? AND id = ? AND key = ?",
|
|
1570
1612
|
)
|
|
@@ -1572,24 +1614,24 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
1572
1614
|
return row ? { ok: true, found: true, value: row.value } : { ok: true, found: false };
|
|
1573
1615
|
}
|
|
1574
1616
|
case "do.storage.put":
|
|
1575
|
-
|
|
1617
|
+
doDb.query(
|
|
1576
1618
|
"INSERT INTO do_storage (cls, id, key, value) VALUES (?1,?2,?3,?4) " +
|
|
1577
1619
|
"ON CONFLICT (cls, id, key) DO UPDATE SET value = ?4",
|
|
1578
1620
|
).run(requireDoClass(msg.cls), str(msg.id), str(msg.key), str(msg.value));
|
|
1579
1621
|
return { ok: true };
|
|
1580
1622
|
case "do.storage.delete": {
|
|
1581
|
-
const r =
|
|
1623
|
+
const r = doDb
|
|
1582
1624
|
.query("DELETE FROM do_storage WHERE cls = ? AND id = ? AND key = ?")
|
|
1583
1625
|
.run(requireDoClass(msg.cls), str(msg.id), str(msg.key));
|
|
1584
1626
|
return { ok: true, deleted: r.changes > 0 };
|
|
1585
1627
|
}
|
|
1586
1628
|
case "do.storage.delete_all":
|
|
1587
|
-
|
|
1629
|
+
doDb.query("DELETE FROM do_storage WHERE cls = ? AND id = ?").run(requireDoClass(msg.cls), str(msg.id));
|
|
1588
1630
|
return { ok: true };
|
|
1589
1631
|
case "do.storage.list": {
|
|
1590
1632
|
const cls = requireDoClass(msg.cls);
|
|
1591
1633
|
const limit = Math.min(Math.max(Number(msg.limit) || 1000, 1), 10000);
|
|
1592
|
-
const rows =
|
|
1634
|
+
const rows = doDb
|
|
1593
1635
|
.query<{ key: string; value: string }, [string, string, string, number]>(
|
|
1594
1636
|
"SELECT key, value FROM do_storage WHERE cls = ? AND id = ? AND key LIKE ? || '%' ORDER BY key LIMIT ?",
|
|
1595
1637
|
)
|
|
@@ -1600,19 +1642,19 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
1600
1642
|
// #125 — alarms. `at` is epoch ms; setting one replaces any pending alarm
|
|
1601
1643
|
// for that object, and attempts resets because this is a fresh schedule.
|
|
1602
1644
|
case "do.alarm.set":
|
|
1603
|
-
|
|
1645
|
+
doDb.query(
|
|
1604
1646
|
"INSERT INTO do_alarm (cls, id, at, attempts) VALUES (?1,?2,?3,0) " +
|
|
1605
1647
|
"ON CONFLICT (cls, id) DO UPDATE SET at = ?3, attempts = 0",
|
|
1606
1648
|
).run(requireDoClass(msg.cls), str(msg.id), Math.trunc(Number(msg.at) || 0));
|
|
1607
1649
|
return { ok: true };
|
|
1608
1650
|
case "do.alarm.get": {
|
|
1609
|
-
const row =
|
|
1651
|
+
const row = doDb
|
|
1610
1652
|
.query<{ at: number }, [string, string]>("SELECT at FROM do_alarm WHERE cls = ? AND id = ?")
|
|
1611
1653
|
.get(requireDoClass(msg.cls), str(msg.id));
|
|
1612
1654
|
return { ok: true, at: row ? row.at : null };
|
|
1613
1655
|
}
|
|
1614
1656
|
case "do.alarm.delete": {
|
|
1615
|
-
const r =
|
|
1657
|
+
const r = doDb.query("DELETE FROM do_alarm WHERE cls = ? AND id = ?").run(requireDoClass(msg.cls), str(msg.id));
|
|
1616
1658
|
return { ok: true, deleted: r.changes > 0 };
|
|
1617
1659
|
}
|
|
1618
1660
|
|
|
@@ -1930,14 +1972,14 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
1930
1972
|
function fireAlarmsOnce(): void {
|
|
1931
1973
|
if (!opts.sproutUrl || !dispatchEnabled() || bindings.do.length === 0) return;
|
|
1932
1974
|
const now = Date.now();
|
|
1933
|
-
const due =
|
|
1975
|
+
const due = doDb
|
|
1934
1976
|
.query<{ cls: string; id: string; at: number; attempts: number }, [number]>(
|
|
1935
1977
|
"SELECT cls, id, at, attempts FROM do_alarm WHERE at <= ? ORDER BY at LIMIT 10",
|
|
1936
1978
|
)
|
|
1937
1979
|
.all(now);
|
|
1938
1980
|
if (due.length === 0) return;
|
|
1939
|
-
const claim =
|
|
1940
|
-
const rearm =
|
|
1981
|
+
const claim = doDb.query("DELETE FROM do_alarm WHERE cls = ? AND id = ?");
|
|
1982
|
+
const rearm = doDb.query(
|
|
1941
1983
|
"INSERT INTO do_alarm (cls, id, at, attempts) VALUES (?1,?2,?3,?4) ON CONFLICT (cls, id) DO NOTHING",
|
|
1942
1984
|
);
|
|
1943
1985
|
for (const row of due) {
|
|
@@ -1980,6 +2022,7 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
1980
2022
|
for (const t of timers) clearInterval(t);
|
|
1981
2023
|
for (const conn of d1Conns.values()) conn.close();
|
|
1982
2024
|
for (const conn of resourceDbs.values()) conn.close();
|
|
2025
|
+
if (doDb !== db) doDb.close();
|
|
1983
2026
|
db.close();
|
|
1984
2027
|
},
|
|
1985
2028
|
};
|
|
@@ -2086,6 +2129,7 @@ if (import.meta.main) {
|
|
|
2086
2129
|
db: { type: "string" },
|
|
2087
2130
|
"data-dir": { type: "string" },
|
|
2088
2131
|
"resource-dir": { type: "string" },
|
|
2132
|
+
"do-db": { type: "string" },
|
|
2089
2133
|
"owner-id": { type: "string" },
|
|
2090
2134
|
"r2-quota-bytes": { type: "string" },
|
|
2091
2135
|
"r2-resource-ids": { type: "string" },
|
|
@@ -2138,6 +2182,7 @@ if (import.meta.main) {
|
|
|
2138
2182
|
db: values.db,
|
|
2139
2183
|
dataDir: values["data-dir"],
|
|
2140
2184
|
resourceDir: values["resource-dir"],
|
|
2185
|
+
doDb: values["do-db"],
|
|
2141
2186
|
ownerId: values["owner-id"],
|
|
2142
2187
|
r2QuotaBytes: Number(values["r2-quota-bytes"] ?? process.env.SPROUTBOAT_R2_QUOTA_BYTES) || undefined,
|
|
2143
2188
|
r2ResourceIds: values["r2-resource-ids"] ? String(values["r2-resource-ids"]).split(",").filter(Boolean) : undefined,
|