@specific.dev/spectest 0.115.1 → 0.116.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/README.md +44 -0
- package/dist/components/replay-websocket.d.ts +2 -3
- package/dist/components/replay-websocket.js +19 -185
- package/dist/components/replayFake.d.ts +2 -53
- package/dist/components/replayFake.js +7 -348
- package/dist/daemon.js +4 -1
- package/dist/fake-upstream-websocket.d.ts +27 -0
- package/dist/fake-upstream-websocket.js +184 -0
- package/dist/fake-upstream.d.ts +114 -0
- package/dist/fake-upstream.js +348 -0
- package/dist/harness/fake-websocket.js +6 -3
- package/dist/harness/live-fake.d.ts +3 -0
- package/dist/harness/live-fake.js +66 -0
- package/dist/index.d.ts +15 -1
- package/dist/index.js +3 -0
- package/dist/record-secrets.d.ts +1 -1
- package/dist/record-secrets.js +3 -3
- package/dist/replay-mode.d.ts +2 -0
- package/dist/replay-mode.js +6 -2
- package/package.json +1 -1
- package/src/components/replay-websocket.ts +25 -145
- package/src/components/replayFake.ts +8 -459
- package/src/daemon.ts +3 -1
- package/src/fake-upstream-websocket.ts +157 -0
- package/src/fake-upstream.ts +465 -0
- package/src/harness/fake-websocket.ts +6 -3
- package/src/harness/live-fake.test.ts +227 -0
- package/src/harness/live-fake.ts +62 -0
- package/src/index.ts +19 -1
- package/src/record-secrets.ts +3 -3
- package/src/replay-mode.ts +7 -2
|
@@ -54,20 +54,15 @@
|
|
|
54
54
|
// with the real credentials (direct secret refs, supplied for the environment
|
|
55
55
|
// lifetime and redacted). Region/service are inferred from the request. See
|
|
56
56
|
// `../aws-sigv4.ts`.
|
|
57
|
-
import { createSocket } from "node:dgram";
|
|
58
57
|
import { createHash } from "node:crypto";
|
|
59
58
|
import { existsSync, readFileSync } from "node:fs";
|
|
60
|
-
import { request as httpsRequest } from "node:https";
|
|
61
59
|
import path from "node:path";
|
|
62
|
-
import { brotliDecompressSync, gunzipSync, inflateSync } from "node:zlib";
|
|
63
|
-
import * as dnsPacket from "dns-packet";
|
|
64
60
|
import { resolveReplayMode } from "../replay-mode.js";
|
|
65
61
|
import { getWebSocketLifetime } from "../harness/fake-websocket-lifetime.js";
|
|
66
62
|
import { reportBackgroundFailure } from "../harness/background-failures.js";
|
|
67
|
-
import { getRecordSecret } from "../record-secrets.js";
|
|
68
63
|
import { recordWebSocket, replayWebSocket, validateWebSocketKey, validateWebSocketRecording, websocketMatcher, } from "./replay-websocket.js";
|
|
69
64
|
import { replaySignature } from "./replay-key.js";
|
|
70
|
-
import {
|
|
65
|
+
import { STRIP_FORWARD_HEADERS, isAmzParam, collectSecretRefs, ruleMatches, brokerHeaders, resolveExternalA, forwardHttp, } from "../fake-upstream.js";
|
|
71
66
|
// ────────────────────────────────────────────────────────────────────────
|
|
72
67
|
// Helpers
|
|
73
68
|
// ────────────────────────────────────────────────────────────────────────
|
|
@@ -81,43 +76,6 @@ const SENSITIVE_HEADERS = new Set([
|
|
|
81
76
|
"x-goog-api-key",
|
|
82
77
|
"x-amz-security-token",
|
|
83
78
|
]);
|
|
84
|
-
/** Hop-by-hop + body-framing headers never forwarded / never replayed
|
|
85
|
-
* (we decompress on the way through, so the encoding/length no longer
|
|
86
|
-
* describe the bytes the app receives). */
|
|
87
|
-
const STRIP_FORWARD_HEADERS = new Set([
|
|
88
|
-
"host",
|
|
89
|
-
"connection",
|
|
90
|
-
"keep-alive",
|
|
91
|
-
"proxy-authenticate",
|
|
92
|
-
"proxy-authorization",
|
|
93
|
-
"te",
|
|
94
|
-
"trailers",
|
|
95
|
-
"transfer-encoding",
|
|
96
|
-
"upgrade",
|
|
97
|
-
"content-length",
|
|
98
|
-
"content-encoding",
|
|
99
|
-
// We force `accept-encoding: identity` on the forward and decompress any
|
|
100
|
-
// residual encoding, so the app never sees a stale accept-encoding.
|
|
101
|
-
"accept-encoding",
|
|
102
|
-
]);
|
|
103
|
-
/** External resolver used to find the REAL upstream IP, bypassing the in-VM
|
|
104
|
-
* resolver (which points the faked host back at the daemon). */
|
|
105
|
-
const EXTERNAL_DNS = process.env.SPECTEST_UPSTREAM_DNS ?? "1.1.1.1";
|
|
106
|
-
const EXTERNAL_DNS_PORT = Number(process.env.SPECTEST_UPSTREAM_PORT ?? "53");
|
|
107
|
-
/** SigV4 puts per-request signing params (`X-Amz-Signature`, `X-Amz-Date`, …)
|
|
108
|
-
* in the query for presigned URLs — they change every call and would poison
|
|
109
|
-
* query matching, so they're dropped from both the match key and the forward
|
|
110
|
-
* when signing is active. */
|
|
111
|
-
function isAmzParam(key) {
|
|
112
|
-
return key.toLowerCase().startsWith("x-amz-");
|
|
113
|
-
}
|
|
114
|
-
function stripAmzParams(search) {
|
|
115
|
-
const out = new URLSearchParams();
|
|
116
|
-
for (const [k, v] of search)
|
|
117
|
-
if (!isAmzParam(k))
|
|
118
|
-
out.append(k, v);
|
|
119
|
-
return out;
|
|
120
|
-
}
|
|
121
79
|
function normalizeQuery(search, stripAmz) {
|
|
122
80
|
const out = {};
|
|
123
81
|
for (const key of [...search.keys()].sort()) {
|
|
@@ -220,185 +178,6 @@ function makeRedactor(secrets, patterns) {
|
|
|
220
178
|
};
|
|
221
179
|
}
|
|
222
180
|
// ────────────────────────────────────────────────────────────────────────
|
|
223
|
-
// Credential brokering: matchers + secret-token resolution
|
|
224
|
-
// ────────────────────────────────────────────────────────────────────────
|
|
225
|
-
/** `{{secret:REF}}` template token embedded in an injected header value. */
|
|
226
|
-
const SECRET_TOKEN = /\{\{secret:([A-Za-z0-9_]+)\}\}/g;
|
|
227
|
-
/** Every secret ref referenced by any inject rule's header templates plus the
|
|
228
|
-
* SigV4 signing credentials (direct refs, not templates). */
|
|
229
|
-
function collectSecretRefs(rules, sign) {
|
|
230
|
-
const refs = new Set();
|
|
231
|
-
for (const rule of rules) {
|
|
232
|
-
for (const value of Object.values(rule.headers)) {
|
|
233
|
-
for (const m of value.matchAll(SECRET_TOKEN))
|
|
234
|
-
refs.add(m[1]);
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
if (sign?.type === "awsSigv4") {
|
|
238
|
-
refs.add(sign.accessKeyId);
|
|
239
|
-
refs.add(sign.secretAccessKey);
|
|
240
|
-
if (sign.sessionToken)
|
|
241
|
-
refs.add(sign.sessionToken);
|
|
242
|
-
}
|
|
243
|
-
return [...refs];
|
|
244
|
-
}
|
|
245
|
-
function matchString(m, value) {
|
|
246
|
-
if (typeof m === "string")
|
|
247
|
-
return value === m;
|
|
248
|
-
if ("exact" in m)
|
|
249
|
-
return value === m.exact;
|
|
250
|
-
if ("startsWith" in m)
|
|
251
|
-
return value.startsWith(m.startsWith);
|
|
252
|
-
if ("regex" in m)
|
|
253
|
-
return new RegExp(m.regex).test(value);
|
|
254
|
-
return false;
|
|
255
|
-
}
|
|
256
|
-
/** True if every dimension the matcher specifies is satisfied (AND across
|
|
257
|
-
* dimensions). No `match` → always true. */
|
|
258
|
-
function ruleMatches(match, req) {
|
|
259
|
-
if (!match)
|
|
260
|
-
return true;
|
|
261
|
-
if (match.path !== undefined && !matchString(match.path, req.path))
|
|
262
|
-
return false;
|
|
263
|
-
if (match.method !== undefined) {
|
|
264
|
-
const methods = (Array.isArray(match.method) ? match.method : [match.method]).map((s) => s.toUpperCase());
|
|
265
|
-
if (!methods.includes(req.method.toUpperCase()))
|
|
266
|
-
return false;
|
|
267
|
-
}
|
|
268
|
-
if (match.query) {
|
|
269
|
-
for (const [k, m] of Object.entries(match.query)) {
|
|
270
|
-
if (!req.query.getAll(k).some((v) => matchString(m, v)))
|
|
271
|
-
return false;
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
if (match.headers) {
|
|
275
|
-
for (const [k, m] of Object.entries(match.headers)) {
|
|
276
|
-
const v = req.headers.get(k.toLowerCase());
|
|
277
|
-
if (v === null || !matchString(m, v))
|
|
278
|
-
return false;
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
return true;
|
|
282
|
-
}
|
|
283
|
-
/** Resolve a rule's header templates, substituting every `{{secret:REF}}`
|
|
284
|
-
* with the environment credential. Collects resolved secrets (for redaction)
|
|
285
|
-
* and any refs the control plane didn't supply (fail loud). */
|
|
286
|
-
function brokerHeaders(rule) {
|
|
287
|
-
const headers = {};
|
|
288
|
-
const secrets = [];
|
|
289
|
-
const missing = [];
|
|
290
|
-
const seen = new Set();
|
|
291
|
-
for (const [name, template] of Object.entries(rule.headers)) {
|
|
292
|
-
headers[name.toLowerCase()] = template.replace(SECRET_TOKEN, (_full, ref) => {
|
|
293
|
-
const value = getRecordSecret(ref);
|
|
294
|
-
if (value === undefined) {
|
|
295
|
-
missing.push(ref);
|
|
296
|
-
return "";
|
|
297
|
-
}
|
|
298
|
-
if (!seen.has(ref)) {
|
|
299
|
-
seen.add(ref);
|
|
300
|
-
secrets.push({ ref, value });
|
|
301
|
-
}
|
|
302
|
-
return value;
|
|
303
|
-
});
|
|
304
|
-
}
|
|
305
|
-
return { headers, secrets, missing };
|
|
306
|
-
}
|
|
307
|
-
// ────────────────────────────────────────────────────────────────────────
|
|
308
|
-
// Record-mode egress: resolve the real host externally, then MITM-forward
|
|
309
|
-
// ────────────────────────────────────────────────────────────────────────
|
|
310
|
-
/** Resolve a host's A records against an EXTERNAL DNS server, bypassing the
|
|
311
|
-
* in-VM resolver (which would answer with the daemon's own gateway for a
|
|
312
|
-
* faked host). Mirrors spectest-resolver's own upstream-forward transport
|
|
313
|
-
* (dns-packet over UDP) so it works identically in-VM. */
|
|
314
|
-
function resolveExternalA(host) {
|
|
315
|
-
const query = dnsPacket.encode({
|
|
316
|
-
type: "query",
|
|
317
|
-
id: 1,
|
|
318
|
-
flags: dnsPacket.RECURSION_DESIRED,
|
|
319
|
-
questions: [{ type: "A", name: host }],
|
|
320
|
-
});
|
|
321
|
-
return new Promise((resolve) => {
|
|
322
|
-
const sock = createSocket("udp4");
|
|
323
|
-
let done = false;
|
|
324
|
-
const finish = (ips) => {
|
|
325
|
-
if (done)
|
|
326
|
-
return;
|
|
327
|
-
done = true;
|
|
328
|
-
try {
|
|
329
|
-
sock.close();
|
|
330
|
-
}
|
|
331
|
-
catch {
|
|
332
|
-
// ignore
|
|
333
|
-
}
|
|
334
|
-
resolve(ips);
|
|
335
|
-
};
|
|
336
|
-
sock.on("message", (msg) => {
|
|
337
|
-
try {
|
|
338
|
-
const res = dnsPacket.decode(msg);
|
|
339
|
-
const ips = (res.answers ?? []).flatMap((a) => a.type === "A" && typeof a.data === "string" && a.data.length > 0 ? [a.data] : []);
|
|
340
|
-
finish(ips);
|
|
341
|
-
}
|
|
342
|
-
catch {
|
|
343
|
-
finish([]);
|
|
344
|
-
}
|
|
345
|
-
});
|
|
346
|
-
sock.on("error", () => finish([]));
|
|
347
|
-
sock.send(query, EXTERNAL_DNS_PORT, EXTERNAL_DNS, (err) => {
|
|
348
|
-
if (err)
|
|
349
|
-
finish([]);
|
|
350
|
-
});
|
|
351
|
-
setTimeout(() => finish([]), 3_000);
|
|
352
|
-
});
|
|
353
|
-
}
|
|
354
|
-
/** Open a real HTTPS connection to `ip` but with SNI + `Host` = `host`, so
|
|
355
|
-
* the public certificate validates against the real hostname (the in-VM CA
|
|
356
|
-
* is not involved on this leg). Decompresses the response so the stored and
|
|
357
|
-
* returned bytes are the plain payload. */
|
|
358
|
-
function forwardToRealUpstream(args) {
|
|
359
|
-
return new Promise((resolve, reject) => {
|
|
360
|
-
const req = httpsRequest({
|
|
361
|
-
host: args.ip,
|
|
362
|
-
port: 443,
|
|
363
|
-
method: args.method,
|
|
364
|
-
path: args.pathWithQuery,
|
|
365
|
-
servername: args.host,
|
|
366
|
-
headers: { ...args.headers, host: args.host, "accept-encoding": "identity" },
|
|
367
|
-
}, (res) => {
|
|
368
|
-
const chunks = [];
|
|
369
|
-
res.on("data", (c) => chunks.push(c));
|
|
370
|
-
res.on("end", () => {
|
|
371
|
-
let bytes = Buffer.concat(chunks);
|
|
372
|
-
const enc = String(res.headers["content-encoding"] ?? "").toLowerCase();
|
|
373
|
-
try {
|
|
374
|
-
if (enc.includes("br"))
|
|
375
|
-
bytes = brotliDecompressSync(bytes);
|
|
376
|
-
else if (enc.includes("gzip"))
|
|
377
|
-
bytes = gunzipSync(bytes);
|
|
378
|
-
else if (enc.includes("deflate"))
|
|
379
|
-
bytes = inflateSync(bytes);
|
|
380
|
-
}
|
|
381
|
-
catch {
|
|
382
|
-
// Leave the bytes as-is if decompression fails.
|
|
383
|
-
}
|
|
384
|
-
const headers = {};
|
|
385
|
-
for (const [k, v] of Object.entries(res.headers)) {
|
|
386
|
-
if (typeof v === "string")
|
|
387
|
-
headers[k.toLowerCase()] = v;
|
|
388
|
-
else if (Array.isArray(v))
|
|
389
|
-
headers[k.toLowerCase()] = v.join(", ");
|
|
390
|
-
}
|
|
391
|
-
resolve({ status: res.statusCode ?? 0, headers, bytes });
|
|
392
|
-
});
|
|
393
|
-
res.on("error", reject);
|
|
394
|
-
});
|
|
395
|
-
req.on("error", reject);
|
|
396
|
-
if (args.body.length > 0)
|
|
397
|
-
req.write(Buffer.from(args.body));
|
|
398
|
-
req.end();
|
|
399
|
-
});
|
|
400
|
-
}
|
|
401
|
-
// ────────────────────────────────────────────────────────────────────────
|
|
402
181
|
// replayFake
|
|
403
182
|
// ────────────────────────────────────────────────────────────────────────
|
|
404
183
|
export function replayFake(opts) {
|
|
@@ -466,133 +245,13 @@ export function replayFake(opts) {
|
|
|
466
245
|
return new Response(payload, { status: hit.response.status, headers });
|
|
467
246
|
}
|
|
468
247
|
// ── RECORD (MITM) ───────────────────────────────────────────────────
|
|
469
|
-
async function record(req,
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
path: reqLike.path,
|
|
475
|
-
query: url.searchParams,
|
|
476
|
-
headers: req.headers,
|
|
477
|
-
}));
|
|
478
|
-
const brokered = rule ? brokerHeaders(rule) : { headers: {}, secrets: [], missing: [] };
|
|
479
|
-
// SigV4 signing: resolve the credential secret refs (direct refs, not
|
|
480
|
-
// `{{secret:}}` templates). Missing required refs fail loud alongside any
|
|
481
|
-
// missing inject refs.
|
|
482
|
-
let signCreds;
|
|
483
|
-
const signMissing = [];
|
|
484
|
-
if (opts.sign?.type === "awsSigv4") {
|
|
485
|
-
const s = opts.sign;
|
|
486
|
-
const accessKeyId = getRecordSecret(s.accessKeyId);
|
|
487
|
-
const secretAccessKey = getRecordSecret(s.secretAccessKey);
|
|
488
|
-
const sessionToken = s.sessionToken ? getRecordSecret(s.sessionToken) : undefined;
|
|
489
|
-
if (accessKeyId === undefined)
|
|
490
|
-
signMissing.push(s.accessKeyId);
|
|
491
|
-
if (secretAccessKey === undefined)
|
|
492
|
-
signMissing.push(s.secretAccessKey);
|
|
493
|
-
if (s.sessionToken && sessionToken === undefined)
|
|
494
|
-
signMissing.push(s.sessionToken);
|
|
495
|
-
if (accessKeyId !== undefined && secretAccessKey !== undefined) {
|
|
496
|
-
signCreds = { accessKeyId, secretAccessKey, sessionToken };
|
|
497
|
-
}
|
|
498
|
-
}
|
|
499
|
-
const missing = [...brokered.missing, ...signMissing];
|
|
500
|
-
if (missing.length > 0) {
|
|
501
|
-
const refs = [...new Set(missing)];
|
|
502
|
-
return new Response(`replayFake(${opts.name}): secret(s) ${JSON.stringify(refs)} were not supplied ` +
|
|
503
|
-
`(configure them on the project's Secrets page, then start a new environment or fork).\n`, { status: 599, headers: { "content-type": "text/plain" } });
|
|
504
|
-
}
|
|
505
|
-
// Resolve the REAL host's IP via an external resolver so we don't loop
|
|
506
|
-
// back into the daemon (the in-VM resolver answers our own gateway for
|
|
507
|
-
// this faked name). See the module header.
|
|
508
|
-
const ips = await resolveExternalA(host);
|
|
509
|
-
if (ips.length === 0) {
|
|
510
|
-
return new Response(`replayFake(${opts.name}): could not resolve real upstream ${JSON.stringify(host)} ` +
|
|
511
|
-
`via external DNS ${EXTERNAL_DNS} (is the VM online?).\n`, { status: 599, headers: { "content-type": "text/plain" } });
|
|
512
|
-
}
|
|
513
|
-
// Forward headers: drop hop-by-hop/encoding, then SET the brokered
|
|
514
|
-
// headers — overwriting whatever the app sent (so the credential can't
|
|
515
|
-
// be smuggled or spoofed by app code). The real values only ever appear
|
|
516
|
-
// on this outbound wire.
|
|
517
|
-
const fwdHeaders = {};
|
|
518
|
-
req.headers.forEach((v, k) => {
|
|
519
|
-
if (!STRIP_FORWARD_HEADERS.has(k.toLowerCase()))
|
|
520
|
-
fwdHeaders[k.toLowerCase()] = v;
|
|
521
|
-
});
|
|
522
|
-
for (const [k, v] of Object.entries(brokered.headers))
|
|
523
|
-
fwdHeaders[k] = v;
|
|
524
|
-
// SigV4 re-signing: strip the app's dummy signature and re-sign the exact
|
|
525
|
-
// outbound request (method/path/query/signed-headers/body-hash) with the
|
|
526
|
-
// brokered credentials, so `authorization`/`x-amz-date`/`x-amz-content-sha256`
|
|
527
|
-
// are internally consistent.
|
|
528
|
-
const signSecrets = [];
|
|
529
|
-
let wireQuery = url.searchParams.toString();
|
|
530
|
-
if (opts.sign?.type === "awsSigv4" && signCreds) {
|
|
531
|
-
const scope = inferSigV4Scope({
|
|
532
|
-
authorization: req.headers.get("authorization"),
|
|
533
|
-
credentialParam: url.searchParams.get("X-Amz-Credential"),
|
|
534
|
-
host,
|
|
535
|
-
});
|
|
536
|
-
if (!scope) {
|
|
537
|
-
return new Response(`replayFake(${opts.name}): could not infer the AWS region/service to sign ` +
|
|
538
|
-
`${reqLike.method} ${reqLike.path} (no SigV4 Credential on the request, and host ` +
|
|
539
|
-
`${JSON.stringify(host)} is not a <service>.<region>.amazonaws.com name).\n`, { status: 599, headers: { "content-type": "text/plain" } });
|
|
540
|
-
}
|
|
541
|
-
// Drop the app's own (dummy-signed) signing headers before re-signing.
|
|
542
|
-
for (const h of ["authorization", "x-amz-date", "x-amz-content-sha256", "x-amz-security-token"]) {
|
|
543
|
-
delete fwdHeaders[h];
|
|
544
|
-
}
|
|
545
|
-
// Fold the app's remaining `x-amz-*`/content-type headers (e.g.
|
|
546
|
-
// `x-amz-target`) into the signature; they stay on the outbound wire.
|
|
547
|
-
const additionalHeaders = {};
|
|
548
|
-
for (const [k, v] of Object.entries(fwdHeaders)) {
|
|
549
|
-
if (k === "content-type" ||
|
|
550
|
-
(k.startsWith("x-amz-") &&
|
|
551
|
-
k !== "x-amz-date" &&
|
|
552
|
-
k !== "x-amz-content-sha256" &&
|
|
553
|
-
k !== "x-amz-security-token")) {
|
|
554
|
-
additionalHeaders[k] = v;
|
|
555
|
-
}
|
|
556
|
-
}
|
|
557
|
-
const forwardParams = stripAmzParams(url.searchParams);
|
|
558
|
-
const signed = signAwsV4Request({
|
|
559
|
-
method: reqLike.method,
|
|
560
|
-
path: reqLike.path,
|
|
561
|
-
query: forwardParams,
|
|
562
|
-
host,
|
|
563
|
-
body: bodyBytes,
|
|
564
|
-
credentials: signCreds,
|
|
565
|
-
scope,
|
|
566
|
-
additionalHeaders,
|
|
567
|
-
});
|
|
568
|
-
for (const [k, v] of Object.entries(signed.headers))
|
|
569
|
-
fwdHeaders[k] = v;
|
|
570
|
-
// Send the canonical query we signed (URLSearchParams' `+`-for-space
|
|
571
|
-
// encoding would diverge from the signed `%20`).
|
|
572
|
-
wireQuery = signed.canonicalQuery;
|
|
573
|
-
signSecrets.push({ ref: opts.sign.accessKeyId, value: signCreds.accessKeyId }, { ref: opts.sign.secretAccessKey, value: signCreds.secretAccessKey });
|
|
574
|
-
if (signCreds.sessionToken && opts.sign.sessionToken) {
|
|
575
|
-
signSecrets.push({ ref: opts.sign.sessionToken, value: signCreds.sessionToken });
|
|
576
|
-
}
|
|
577
|
-
}
|
|
578
|
-
const pathWithQuery = reqLike.path + (wireQuery ? `?${wireQuery}` : "");
|
|
579
|
-
let upstream;
|
|
580
|
-
try {
|
|
581
|
-
upstream = await forwardToRealUpstream({
|
|
582
|
-
ip: ips[0],
|
|
583
|
-
host,
|
|
584
|
-
method: reqLike.method,
|
|
585
|
-
pathWithQuery,
|
|
586
|
-
headers: fwdHeaders,
|
|
587
|
-
body: bodyBytes,
|
|
588
|
-
});
|
|
589
|
-
}
|
|
590
|
-
catch (err) {
|
|
591
|
-
return new Response(`replayFake(${opts.name}): forward to real ${host} (${ips[0]}) failed: ${err.message}\n`, { status: 599, headers: { "content-type": "text/plain" } });
|
|
592
|
-
}
|
|
248
|
+
async function record(req, reqLike, bodyBytes, state) {
|
|
249
|
+
const forwarded = await forwardHttp(req, host, opts, bodyBytes, `replayFake(${opts.name})`);
|
|
250
|
+
if (forwarded instanceof Response)
|
|
251
|
+
return forwarded;
|
|
252
|
+
const { upstream, secrets: redactSecrets } = forwarded;
|
|
593
253
|
// Decode + redact for storage; return the real (un-redacted) bytes to
|
|
594
254
|
// the app so the manual session behaves like production.
|
|
595
|
-
const redactSecrets = [...brokered.secrets, ...signSecrets];
|
|
596
255
|
const redact = makeRedactor(redactSecrets, opts.redactPatterns);
|
|
597
256
|
const decoded = decodeBody(upstream.bytes);
|
|
598
257
|
const respHeaders = {};
|
|
@@ -673,7 +332,7 @@ export function replayFake(opts) {
|
|
|
673
332
|
};
|
|
674
333
|
if (resolveMode() === "replay")
|
|
675
334
|
return replay(reqLike, state);
|
|
676
|
-
return record(req,
|
|
335
|
+
return record(req, reqLike, bodyBytes, state);
|
|
677
336
|
};
|
|
678
337
|
const websocket = async (req, state) => {
|
|
679
338
|
const url = new URL(req.url);
|
package/dist/daemon.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { collectSecretRefs } from "./fake-upstream.js";
|
|
1
2
|
import { phoneScope, phoneRecordingChunk, releasePhoneRecording } from "./phone.js";
|
|
2
3
|
import { runWithBackgroundFailures } from "./harness/background-failures.js";
|
|
3
4
|
import { generateHostCert as mintHostCert } from "./harness/certificate.js";
|
|
@@ -5248,7 +5249,7 @@ export function harnessMethods(state) {
|
|
|
5248
5249
|
cases: casesMetadata(requireLoaded().project.tests),
|
|
5249
5250
|
groups: groupsMetadata(requireLoaded().project.tests),
|
|
5250
5251
|
}),
|
|
5251
|
-
// Union of platform secret refs the loaded fakes declare (replayFake's
|
|
5252
|
+
// Union of platform secret refs the loaded fakes declare (live options and replayFake's
|
|
5252
5253
|
// `secretRefs`). The control plane resolves these server-side and pushes
|
|
5253
5254
|
// the values for the environment lifetime. Empty if nothing's loaded.
|
|
5254
5255
|
recordSecretRefs: async () => {
|
|
@@ -5256,6 +5257,8 @@ export function harnessMethods(state) {
|
|
|
5256
5257
|
for (const fake of FAKES.values()) {
|
|
5257
5258
|
for (const ref of fake.def.secretRefs ?? [])
|
|
5258
5259
|
refs.add(ref);
|
|
5260
|
+
for (const ref of collectSecretRefs(fake.def.live?.inject ?? [], fake.def.live?.sign))
|
|
5261
|
+
refs.add(ref);
|
|
5259
5262
|
}
|
|
5260
5263
|
return { refs: [...refs], environmentScoped: true };
|
|
5261
5264
|
},
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type WS from "ws";
|
|
2
|
+
import type { FakeWebSocket, FakeWebSocketHandlers } from "./fake-websocket.js";
|
|
3
|
+
import { FakeWebSocketLifetime } from "./harness/fake-websocket-lifetime.js";
|
|
4
|
+
export declare const MAX_BUFFER: number;
|
|
5
|
+
export declare function closeSocket(socket: FakeWebSocket | WS, code: number, reason: string): void;
|
|
6
|
+
export interface WebSocketSession {
|
|
7
|
+
cancel(): void;
|
|
8
|
+
}
|
|
9
|
+
export declare function forwardWebSocket(args: {
|
|
10
|
+
url: string;
|
|
11
|
+
ip: string;
|
|
12
|
+
host: string;
|
|
13
|
+
headers: Record<string, string>;
|
|
14
|
+
protocols: string[];
|
|
15
|
+
label: string;
|
|
16
|
+
lifetime?: FakeWebSocketLifetime;
|
|
17
|
+
finished(session: WebSocketSession, error?: Error): void;
|
|
18
|
+
observe?: {
|
|
19
|
+
open(protocol: string): void;
|
|
20
|
+
input(message: string | Uint8Array): void;
|
|
21
|
+
output(message: string | Uint8Array): void;
|
|
22
|
+
close(code: number, reason: string, initiator: "client" | "server"): void;
|
|
23
|
+
};
|
|
24
|
+
}): Promise<{
|
|
25
|
+
handlers: FakeWebSocketHandlers;
|
|
26
|
+
session: WebSocketSession;
|
|
27
|
+
}>;
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// Bidirectional forwarding with bounded opening buffers. Recording is an optional observer.
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { FakeWebSocketLifetime, bindWebSocketLifetime } from "./harness/fake-websocket-lifetime.js";
|
|
5
|
+
// Bun's ws shim does not implement the external DNS lookup option.
|
|
6
|
+
const require = createRequire(import.meta.url);
|
|
7
|
+
const { WebSocket } = require(join(dirname(require.resolve("ws/package.json")), "index.js"));
|
|
8
|
+
export const MAX_BUFFER = 16 * 1024 * 1024;
|
|
9
|
+
const MAX_PENDING = 1024;
|
|
10
|
+
export function closeSocket(socket, code, reason) {
|
|
11
|
+
// Reserved status codes describe transport closure and cannot go on the wire.
|
|
12
|
+
if (code === 1006 && socket.terminate)
|
|
13
|
+
socket.terminate();
|
|
14
|
+
else if (code === 1005 || code === 1006)
|
|
15
|
+
socket.close();
|
|
16
|
+
else
|
|
17
|
+
socket.close(code, reason);
|
|
18
|
+
}
|
|
19
|
+
export async function forwardWebSocket(args) {
|
|
20
|
+
const lifetime = args.lifetime ?? new FakeWebSocketLifetime();
|
|
21
|
+
if (lifetime.ended)
|
|
22
|
+
throw new Error("WebSocket handshake aborted");
|
|
23
|
+
let detach = () => { };
|
|
24
|
+
const upstream = new WebSocket(args.url, args.protocols, {
|
|
25
|
+
headers: args.headers, maxPayload: MAX_BUFFER,
|
|
26
|
+
handshakeTimeout: 10_000,
|
|
27
|
+
lookup: ((_hostname, options, callback) => {
|
|
28
|
+
if (options.all)
|
|
29
|
+
callback(null, [{ address: args.ip, family: 4 }]);
|
|
30
|
+
else
|
|
31
|
+
callback(null, args.ip, 4);
|
|
32
|
+
}),
|
|
33
|
+
});
|
|
34
|
+
let socket;
|
|
35
|
+
let stopped = false;
|
|
36
|
+
let clientClosed = false;
|
|
37
|
+
let upstreamClosure;
|
|
38
|
+
let queue = [];
|
|
39
|
+
let queuedBytes = 0;
|
|
40
|
+
let openingTimer;
|
|
41
|
+
let opened = false;
|
|
42
|
+
let rejectOpen = () => { };
|
|
43
|
+
const finish = (error) => {
|
|
44
|
+
if (stopped)
|
|
45
|
+
return;
|
|
46
|
+
stopped = true;
|
|
47
|
+
clearTimeout(openingTimer);
|
|
48
|
+
detach();
|
|
49
|
+
queue = [];
|
|
50
|
+
upstream.terminate();
|
|
51
|
+
lifetime.end(error ? { kind: "failed", error } : { kind: "aborted" });
|
|
52
|
+
args.finished(session, error);
|
|
53
|
+
};
|
|
54
|
+
const fail = (error) => {
|
|
55
|
+
if (stopped)
|
|
56
|
+
return;
|
|
57
|
+
const failure = new Error(`${args.label}: ${error.message}`);
|
|
58
|
+
rejectOpen(failure);
|
|
59
|
+
finish(failure);
|
|
60
|
+
socket?.close(1011, "WebSocket forwarding failed");
|
|
61
|
+
};
|
|
62
|
+
const session = {
|
|
63
|
+
cancel() { rejectOpen(new Error("WebSocket handshake aborted")); finish(); socket?.close(1001, "forwarding cleanup"); },
|
|
64
|
+
};
|
|
65
|
+
upstream.on("error", fail);
|
|
66
|
+
upstream.on("message", (data, binary) => {
|
|
67
|
+
if (stopped || clientClosed)
|
|
68
|
+
return;
|
|
69
|
+
try {
|
|
70
|
+
const bytes = Array.isArray(data) ? Buffer.concat(data) : Buffer.from(data);
|
|
71
|
+
const message = binary ? new Uint8Array(bytes) : bytes.toString();
|
|
72
|
+
args.observe?.output(message);
|
|
73
|
+
if (socket)
|
|
74
|
+
socket.send(message);
|
|
75
|
+
else {
|
|
76
|
+
queuedBytes += bytes.length;
|
|
77
|
+
if (queue.length >= MAX_PENDING || queuedBytes > MAX_BUFFER)
|
|
78
|
+
throw new Error("WebSocket opening buffer exceeded");
|
|
79
|
+
queue.push(message);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
fail(error);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
upstream.on("close", (code, reason) => {
|
|
87
|
+
if (stopped)
|
|
88
|
+
return;
|
|
89
|
+
if (!opened) {
|
|
90
|
+
fail(new Error("upstream closed before handshake completed"));
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
try {
|
|
94
|
+
if (!clientClosed) {
|
|
95
|
+
upstreamClosure = { code, reason: reason.toString() };
|
|
96
|
+
args.observe?.close(code, reason.toString(), "server");
|
|
97
|
+
}
|
|
98
|
+
if (socket) {
|
|
99
|
+
closeSocket(socket, code, reason.toString());
|
|
100
|
+
finish();
|
|
101
|
+
}
|
|
102
|
+
// If the upstream closes before downstream open, open() flushes the queue.
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
fail(error);
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
const opening = new Promise((resolve, reject) => {
|
|
109
|
+
rejectOpen = reject;
|
|
110
|
+
upstream.once("open", () => {
|
|
111
|
+
try {
|
|
112
|
+
opened = true;
|
|
113
|
+
args.observe?.open(upstream.protocol);
|
|
114
|
+
openingTimer = setTimeout(() => fail(new Error("downstream did not open")), 10_000);
|
|
115
|
+
resolve();
|
|
116
|
+
}
|
|
117
|
+
catch (error) {
|
|
118
|
+
fail(error);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
const handlers = {
|
|
123
|
+
open(ws) {
|
|
124
|
+
socket = ws;
|
|
125
|
+
clearTimeout(openingTimer);
|
|
126
|
+
if (stopped) {
|
|
127
|
+
ws.close(1011, "WebSocket forwarding failed");
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
try {
|
|
131
|
+
for (const message of queue)
|
|
132
|
+
ws.send(message);
|
|
133
|
+
queue = [];
|
|
134
|
+
queuedBytes = 0;
|
|
135
|
+
if (upstreamClosure) {
|
|
136
|
+
closeSocket(ws, upstreamClosure.code, upstreamClosure.reason);
|
|
137
|
+
finish();
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
catch (error) {
|
|
141
|
+
fail(error);
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
message(_ws, message) {
|
|
145
|
+
if (stopped)
|
|
146
|
+
return;
|
|
147
|
+
try {
|
|
148
|
+
args.observe?.input(message);
|
|
149
|
+
const size = typeof message === "string" ? Buffer.byteLength(message) : message.byteLength;
|
|
150
|
+
if (size + upstream.bufferedAmount > MAX_BUFFER)
|
|
151
|
+
throw new Error("WebSocket upstream buffer exceeded");
|
|
152
|
+
if (upstream.readyState !== WebSocket.OPEN)
|
|
153
|
+
throw new Error("upstream is not open");
|
|
154
|
+
upstream.send(message);
|
|
155
|
+
}
|
|
156
|
+
catch (error) {
|
|
157
|
+
fail(error);
|
|
158
|
+
throw error;
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
close(_ws, code, reason) {
|
|
162
|
+
if (stopped || clientClosed)
|
|
163
|
+
return;
|
|
164
|
+
try {
|
|
165
|
+
clientClosed = true;
|
|
166
|
+
args.observe?.close(code, reason, "client");
|
|
167
|
+
closeSocket(upstream, code, reason);
|
|
168
|
+
openingTimer = setTimeout(() => finish(), 1_000);
|
|
169
|
+
}
|
|
170
|
+
catch (error) {
|
|
171
|
+
fail(error);
|
|
172
|
+
}
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
detach = lifetime.onEnd(event => {
|
|
176
|
+
if (event.kind === "closed" && socket)
|
|
177
|
+
handlers.close(socket, event.code, event.reason);
|
|
178
|
+
else
|
|
179
|
+
session.cancel();
|
|
180
|
+
});
|
|
181
|
+
await opening;
|
|
182
|
+
handlers.protocol = upstream.protocol || undefined;
|
|
183
|
+
return { session, handlers: bindWebSocketLifetime(handlers, lifetime) };
|
|
184
|
+
}
|