@gatebolt/agent-redroom 0.1.1 → 0.2.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 +63 -17
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +31 -0
- package/dist/cli.d.ts +17 -0
- package/dist/cli.js +294 -0
- package/dist/docker/cli.d.ts +7 -0
- package/dist/docker/cli.js +21 -1
- package/dist/docker/session.d.ts +4 -2
- package/dist/docker/session.js +12 -10
- package/dist/redroom.d.ts +3 -0
- package/dist/redroom.js +38 -30
- package/dist/run.js +25 -11
- package/dist/scenario.d.ts +12 -0
- package/dist/scenario.js +130 -0
- package/dist/secrets.js +4 -3
- package/dist/sidecar/dns.js +24 -2
- package/dist/sidecar/fake-web.d.ts +1 -0
- package/dist/sidecar/fake-web.js +70 -17
- package/dist/sidecar/http.d.ts +29 -0
- package/dist/sidecar/http.js +105 -3
- package/dist/sidecar/relay.d.ts +1 -0
- package/dist/sidecar/relay.js +70 -22
- package/dist/sidecar/sidecar.d.ts +3 -0
- package/dist/sidecar/sidecar.js +6 -2
- package/dist/sidecar/state.d.ts +3 -0
- package/dist/sidecar/state.js +5 -0
- package/dist/sidecar/tool-hook.d.ts +1 -0
- package/dist/sidecar/tool-hook.js +78 -29
- package/dist/sidecar-bundle/sidecar.mjs +410 -78
- package/dist/text.d.ts +2 -0
- package/dist/text.js +5 -0
- package/dist/types.d.ts +1 -0
- package/package.json +5 -2
package/dist/redroom.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { randomBytes, randomInt, randomUUID } from "node:crypto";
|
|
2
2
|
import {} from "./agents/agent.js";
|
|
3
|
-
import { dockerCli,
|
|
3
|
+
import { dockerCli, failureReason, keepEnd, mustSucceed, outputTooLarge, tarOf, withDefaultTimeouts, } from "./docker/cli.js";
|
|
4
4
|
import { AGENT_HOST, agentArgs, CA_DIR, CA_FILE, DEFAULT_LIMITS, GIT_INIT, sessionPlan, SIDECAR_HOST, SIDECAR_IMAGE, sidecarArgs, SYSTEM_CA_DIR, SYSTEM_CA_FILE, WORKSPACE, } from "./docker/plan.js";
|
|
5
5
|
import { createInternalNetwork, LEASE_MS, LEASE_RENEW_MS, networkIp, removeLeftovers, tearDownSession, } from "./docker/session.js";
|
|
6
6
|
import { withDeadline } from "./deadline.js";
|
|
@@ -10,6 +10,7 @@ import { absoluteEntries, servedPages, workspaceEntries } from "./planting.js";
|
|
|
10
10
|
import { redactDeep, redactSecrets } from "./redact.js";
|
|
11
11
|
import { openRun } from "./run.js";
|
|
12
12
|
import { CONFIG_ENV, PORTS } from "./sidecar/config.js";
|
|
13
|
+
import { count, firstLine } from "./text.js";
|
|
13
14
|
import { DETAIL_LIMIT, messageOf, RedroomError, } from "./types.js";
|
|
14
15
|
export const DEFAULT_DOMAIN = "redroom.test";
|
|
15
16
|
export const CONTROL_TIMEOUT_MS = 30 * 1000;
|
|
@@ -18,8 +19,10 @@ const READY_DELAY_MS = 500;
|
|
|
18
19
|
const START_FAILED = "start_failed";
|
|
19
20
|
const RUN_START_FAILED = "run_start_failed";
|
|
20
21
|
const MAX_PARALLEL = 245;
|
|
22
|
+
// Node timers fire at once above this.
|
|
23
|
+
export const MAX_TIMEOUT_MS = 2_147_483_647;
|
|
21
24
|
// Docker 28 added the isolated gateway mode; older engines ignore or reject it.
|
|
22
|
-
const MIN_ENGINE_MAJOR = 28;
|
|
25
|
+
export const MIN_ENGINE_MAJOR = 28;
|
|
23
26
|
const DOMAIN = /^[a-z0-9-]+(\.[a-z0-9-]+)*$/;
|
|
24
27
|
export const defaultDeps = () => ({
|
|
25
28
|
docker: dockerCli(),
|
|
@@ -71,11 +74,29 @@ function validate(options) {
|
|
|
71
74
|
if (!DOMAIN.test(domain)) {
|
|
72
75
|
throw new RedroomError("invalid_option", `fakeDomain must be a lowercase DNS name, got ${domain}`, "redroom");
|
|
73
76
|
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
77
|
+
const limits = { ...DEFAULT_LIMITS, ...options.limits };
|
|
78
|
+
const { turnTimeoutMs } = limits;
|
|
79
|
+
if (!Number.isInteger(turnTimeoutMs) ||
|
|
80
|
+
turnTimeoutMs < 1 ||
|
|
81
|
+
turnTimeoutMs > MAX_TIMEOUT_MS) {
|
|
82
|
+
throw new RedroomError("invalid_option", `limits.turnTimeoutMs must be a whole number from 1 to ${MAX_TIMEOUT_MS}, got ${turnTimeoutMs}`, "redroom");
|
|
83
|
+
}
|
|
84
|
+
return { parallel, domain, limits };
|
|
85
|
+
}
|
|
86
|
+
export async function checkEngine(docker, redact = keepEnd) {
|
|
87
|
+
const version = await docker(["version", "--format", "{{.Server.Version}}"]);
|
|
88
|
+
const call = { command: "docker version", exitCode: version.exitCode };
|
|
89
|
+
if (version.outputTooLarge)
|
|
90
|
+
throw outputTooLarge("docker version", call);
|
|
91
|
+
if (version.exitCode !== 0) {
|
|
92
|
+
throw new RedroomError("docker_unavailable", `the Docker engine did not answer: ${redact(failureReason(version))}`, "docker", call);
|
|
93
|
+
}
|
|
94
|
+
const engine = version.stdout.trim();
|
|
95
|
+
const major = Number.parseInt(engine, 10);
|
|
96
|
+
if (Number.isNaN(major) || major < MIN_ENGINE_MAJOR) {
|
|
97
|
+
throw new RedroomError("docker_unavailable", `Docker Engine ${MIN_ENGINE_MAJOR} or newer is required to keep agents off host services, found ${engine || "no version"}`, "docker", call);
|
|
98
|
+
}
|
|
99
|
+
return engine;
|
|
79
100
|
}
|
|
80
101
|
export async function startRedroom(options) {
|
|
81
102
|
return createRedroom(options, defaultDeps());
|
|
@@ -95,18 +116,7 @@ export async function createRedroom(options, deps) {
|
|
|
95
116
|
const conceal = (text) => redactSecrets(text, [...hidden]);
|
|
96
117
|
const redact = (text) => conceal(text).slice(-DETAIL_LIMIT);
|
|
97
118
|
async function must(code, args, dockerOptions) {
|
|
98
|
-
|
|
99
|
-
const call = {
|
|
100
|
-
command: `docker ${args.slice(0, 2).join(" ")}`,
|
|
101
|
-
exitCode: result.exitCode,
|
|
102
|
-
};
|
|
103
|
-
if (result.outputTooLarge) {
|
|
104
|
-
throw new RedroomError("output_too_large", `docker ${args[0]} wrote more than ${MAX_OUTPUT_MB} MB of output`, "docker", call);
|
|
105
|
-
}
|
|
106
|
-
if (result.exitCode !== 0) {
|
|
107
|
-
throw new RedroomError(code, `docker ${args[0]} failed (${result.exitCode}): ${redact(result.stderr.trim())}`, "docker", call);
|
|
108
|
-
}
|
|
109
|
-
return result;
|
|
119
|
+
return mustSucceed(docker, args, code, { ...dockerOptions, redact });
|
|
110
120
|
}
|
|
111
121
|
function fetchControl(base, path, init) {
|
|
112
122
|
const timeoutError = () => new RedroomError("sidecar_timeout", `${path} did not answer within ${CONTROL_TIMEOUT_MS} ms`, "sidecar", { url: path });
|
|
@@ -115,17 +125,15 @@ export async function createRedroom(options, deps) {
|
|
|
115
125
|
return new Response(await response.arrayBuffer(), response);
|
|
116
126
|
});
|
|
117
127
|
}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
throw new RedroomError("docker_unavailable", `Docker Engine ${MIN_ENGINE_MAJOR} or newer is required to keep agents off host services, found ${engine}`, "docker", { command: "docker version", exitCode: version.exitCode });
|
|
128
|
+
await checkEngine(docker, redact);
|
|
129
|
+
try {
|
|
130
|
+
const leftovers = await removeLeftovers(docker, Date.now(), redact);
|
|
131
|
+
if (leftovers.containers.length + leftovers.networks.length > 0) {
|
|
132
|
+
log(`agent-redroom: removed ${count(leftovers.containers.length, "container")} and ${count(leftovers.networks.length, "network")} left by stopped sessions`);
|
|
133
|
+
}
|
|
125
134
|
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
log(`agent-redroom: removed ${leftovers.containers.length} containers and ${leftovers.networks.length} networks left by stopped sessions`);
|
|
135
|
+
catch (error) {
|
|
136
|
+
log(`agent-redroom: could not remove leftovers: ${redact(firstLine(messageOf(error)))}`);
|
|
129
137
|
}
|
|
130
138
|
await must("image_build_failed", [
|
|
131
139
|
...["build", "-t", agent.image.tag],
|
|
@@ -141,7 +149,7 @@ export async function createRedroom(options, deps) {
|
|
|
141
149
|
packagePath("dist", "sidecar-bundle"),
|
|
142
150
|
]);
|
|
143
151
|
if (sidecarBuild.exitCode !== 0) {
|
|
144
|
-
throw new RedroomError("image_build_failed", `could not build ${SIDECAR_IMAGE}; build the package so dist/sidecar-bundle/sidecar.mjs exists (${redact(sidecarBuild
|
|
152
|
+
throw new RedroomError("image_build_failed", `could not build ${SIDECAR_IMAGE}; build the package so dist/sidecar-bundle/sidecar.mjs exists (${redact(failureReason(sidecarBuild))})`, "docker", { command: "docker build", exitCode: sidecarBuild.exitCode });
|
|
145
153
|
}
|
|
146
154
|
const reported = await must("version_failed", [
|
|
147
155
|
...["run", "--rm", "--network", "none", agent.image.tag],
|
package/dist/run.js
CHANGED
|
@@ -112,11 +112,12 @@ export function openRun(deps, run) {
|
|
|
112
112
|
}
|
|
113
113
|
const { complete, ...parsed } = agent.parseTurn(stdout);
|
|
114
114
|
if (!complete) {
|
|
115
|
-
|
|
115
|
+
const stderr = redact(result.stderr.trim()).slice(-DETAIL_LIMIT);
|
|
116
|
+
return fail(new RedroomError("turn_failed", `${agent.name} exited with code ${result.exitCode} without a result${stderr && `: ${stderr}`}`, "agent", call));
|
|
116
117
|
}
|
|
117
118
|
const turn = {
|
|
118
119
|
index: turns.length,
|
|
119
|
-
message,
|
|
120
|
+
message: redact(message),
|
|
120
121
|
exitCode: result.exitCode,
|
|
121
122
|
...parsed,
|
|
122
123
|
};
|
|
@@ -140,8 +141,26 @@ export function openRun(deps, run) {
|
|
|
140
141
|
async function collect() {
|
|
141
142
|
let transcript = "";
|
|
142
143
|
let diff = "";
|
|
144
|
+
const attempt = async (label, piece) => {
|
|
145
|
+
try {
|
|
146
|
+
await piece();
|
|
147
|
+
return true;
|
|
148
|
+
}
|
|
149
|
+
catch (error) {
|
|
150
|
+
const info = errorInfo(error, "redroom");
|
|
151
|
+
errors.push({ ...info, detail: `${label}: ${redact(info.detail)}` });
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
const isolated = await attempt("agent stop", stopAgent);
|
|
156
|
+
if (!isolated) {
|
|
157
|
+
errors.push({
|
|
158
|
+
by: "redroom",
|
|
159
|
+
code: "collection_incomplete",
|
|
160
|
+
detail: "could not stop and isolate the agent; final evidence collection skipped",
|
|
161
|
+
});
|
|
162
|
+
}
|
|
143
163
|
const pieces = [
|
|
144
|
-
["agent stop", stopAgent],
|
|
145
164
|
[
|
|
146
165
|
"transcript",
|
|
147
166
|
async () => {
|
|
@@ -168,14 +187,9 @@ export function openRun(deps, run) {
|
|
|
168
187
|
["tool calls", () => drain("tool-calls")],
|
|
169
188
|
["model calls", () => drain("model-calls")],
|
|
170
189
|
];
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
await piece
|
|
174
|
-
}
|
|
175
|
-
catch (error) {
|
|
176
|
-
const info = errorInfo(error, "redroom");
|
|
177
|
-
errors.push({ ...info, detail: `${label}: ${redact(info.detail)}` });
|
|
178
|
-
}
|
|
190
|
+
if (isolated) {
|
|
191
|
+
for (const [label, piece] of pieces)
|
|
192
|
+
await attempt(label, piece);
|
|
179
193
|
}
|
|
180
194
|
try {
|
|
181
195
|
await deps.remove();
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type ClaudeCodeOptions } from "./agents/claude-code.js";
|
|
2
|
+
import { type RedroomOptions } from "./redroom.js";
|
|
3
|
+
import { type RunOptions } from "./types.js";
|
|
4
|
+
export declare class ScenarioError extends Error {
|
|
5
|
+
}
|
|
6
|
+
export type Scenario = {
|
|
7
|
+
agent: Pick<ClaudeCodeOptions, "version" | "model" | "maxTurns" | "maxBudgetUsd">;
|
|
8
|
+
turns: string[];
|
|
9
|
+
redroom: Pick<RedroomOptions, "modelProxy" | "toolCallCheck" | "limits">;
|
|
10
|
+
run: RunOptions;
|
|
11
|
+
};
|
|
12
|
+
export declare function parseScenario(text: string, file: string, env: NodeJS.ProcessEnv): Scenario;
|
package/dist/scenario.js
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import {} from "./agents/claude-code.js";
|
|
3
|
+
import { MAX_TIMEOUT_MS } from "./redroom.js";
|
|
4
|
+
import {} from "./types.js";
|
|
5
|
+
export class ScenarioError extends Error {
|
|
6
|
+
}
|
|
7
|
+
const headerValue = z.union([
|
|
8
|
+
z.string(),
|
|
9
|
+
z.object({ env: z.string().min(1) }).strict(),
|
|
10
|
+
]);
|
|
11
|
+
const headers = z.record(z.string().min(1), headerValue);
|
|
12
|
+
const scenarioSchema = z
|
|
13
|
+
.object({
|
|
14
|
+
agent: z
|
|
15
|
+
.object({
|
|
16
|
+
version: z.string().min(1),
|
|
17
|
+
model: z.string().min(1),
|
|
18
|
+
maxTurns: z.number().int().positive().optional(),
|
|
19
|
+
maxBudgetUsd: z.number().positive().optional(),
|
|
20
|
+
})
|
|
21
|
+
.strict(),
|
|
22
|
+
turns: z.array(z.string().min(1)).min(1),
|
|
23
|
+
turnTimeoutMs: z.number().int().positive().max(MAX_TIMEOUT_MS).optional(),
|
|
24
|
+
workspace: z
|
|
25
|
+
.record(z.union([
|
|
26
|
+
z.string(),
|
|
27
|
+
z
|
|
28
|
+
.object({ body: z.string(), executable: z.boolean().optional() })
|
|
29
|
+
.strict(),
|
|
30
|
+
]))
|
|
31
|
+
.optional(),
|
|
32
|
+
pages: z
|
|
33
|
+
.record(z.union([
|
|
34
|
+
z.string(),
|
|
35
|
+
z
|
|
36
|
+
.object({
|
|
37
|
+
body: z.string(),
|
|
38
|
+
contentType: z.string().optional(),
|
|
39
|
+
status: z.number().int().optional(),
|
|
40
|
+
})
|
|
41
|
+
.strict(),
|
|
42
|
+
]))
|
|
43
|
+
.optional(),
|
|
44
|
+
secrets: z.record(z.string()).optional(),
|
|
45
|
+
modelProxy: z
|
|
46
|
+
.object({ baseUrl: z.string().min(1), headers: headers.optional() })
|
|
47
|
+
.strict()
|
|
48
|
+
.optional(),
|
|
49
|
+
toolCallCheck: z
|
|
50
|
+
.object({ url: z.string().min(1), headers: headers.optional() })
|
|
51
|
+
.strict()
|
|
52
|
+
.optional(),
|
|
53
|
+
})
|
|
54
|
+
.strict();
|
|
55
|
+
// A union reports "Invalid input" at its own path; report the branch that got furthest instead.
|
|
56
|
+
function innermostIssue(issue) {
|
|
57
|
+
if (issue.code !== "invalid_union")
|
|
58
|
+
return issue;
|
|
59
|
+
const [first = issue, ...rest] = issue.unionErrors.flatMap(({ issues: [branch] }) => (branch ? [innermostIssue(branch)] : []));
|
|
60
|
+
return rest.reduce((best, next) => (next.path.length > best.path.length ? next : best), first);
|
|
61
|
+
}
|
|
62
|
+
function formatPath(path) {
|
|
63
|
+
if (path.length === 0)
|
|
64
|
+
return "(root)";
|
|
65
|
+
return path
|
|
66
|
+
.map((key, i) => {
|
|
67
|
+
if (typeof key === "number")
|
|
68
|
+
return `[${key}]`;
|
|
69
|
+
if (/^[A-Za-z_$][\w$]*$/.test(key))
|
|
70
|
+
return i === 0 ? key : `.${key}`;
|
|
71
|
+
return `[${JSON.stringify(key)}]`;
|
|
72
|
+
})
|
|
73
|
+
.join("");
|
|
74
|
+
}
|
|
75
|
+
export function parseScenario(text, file, env) {
|
|
76
|
+
let json;
|
|
77
|
+
try {
|
|
78
|
+
json = JSON.parse(text);
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
throw new ScenarioError(`${file}: invalid JSON`);
|
|
82
|
+
}
|
|
83
|
+
const parsed = scenarioSchema.safeParse(json);
|
|
84
|
+
if (!parsed.success) {
|
|
85
|
+
const [first] = parsed.error.issues;
|
|
86
|
+
if (!first)
|
|
87
|
+
throw new ScenarioError(`${file}: invalid scenario`);
|
|
88
|
+
const issue = innermostIssue(first);
|
|
89
|
+
throw new ScenarioError(`${file}: ${formatPath(issue.path)}: ${issue.message}`);
|
|
90
|
+
}
|
|
91
|
+
const { agent, turns, turnTimeoutMs, workspace, pages, secrets, modelProxy, toolCallCheck, } = parsed.data;
|
|
92
|
+
const resolveHeaders = (headers, field) => Object.fromEntries(Object.entries(headers).map(([name, value]) => {
|
|
93
|
+
if (typeof value === "string")
|
|
94
|
+
return [name, value];
|
|
95
|
+
const found = env[value.env];
|
|
96
|
+
if (!found) {
|
|
97
|
+
const path = formatPath([field, "headers", name]);
|
|
98
|
+
throw new ScenarioError(`${file}: ${path}: environment variable ${value.env} is not set or empty`);
|
|
99
|
+
}
|
|
100
|
+
return [name, found];
|
|
101
|
+
}));
|
|
102
|
+
return {
|
|
103
|
+
agent,
|
|
104
|
+
turns,
|
|
105
|
+
redroom: {
|
|
106
|
+
...(turnTimeoutMs !== undefined && { limits: { turnTimeoutMs } }),
|
|
107
|
+
...(modelProxy && {
|
|
108
|
+
modelProxy: {
|
|
109
|
+
baseUrl: modelProxy.baseUrl,
|
|
110
|
+
...(modelProxy.headers && {
|
|
111
|
+
headers: resolveHeaders(modelProxy.headers, "modelProxy"),
|
|
112
|
+
}),
|
|
113
|
+
},
|
|
114
|
+
}),
|
|
115
|
+
...(toolCallCheck && {
|
|
116
|
+
toolCallCheck: {
|
|
117
|
+
url: toolCallCheck.url,
|
|
118
|
+
...(toolCallCheck.headers && {
|
|
119
|
+
headers: resolveHeaders(toolCallCheck.headers, "toolCallCheck"),
|
|
120
|
+
}),
|
|
121
|
+
},
|
|
122
|
+
}),
|
|
123
|
+
},
|
|
124
|
+
run: {
|
|
125
|
+
...(workspace && { workspace }),
|
|
126
|
+
...(pages && { pages }),
|
|
127
|
+
...(secrets && { secrets }),
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|
package/dist/secrets.js
CHANGED
|
@@ -16,13 +16,13 @@ function base64Fragments(value) {
|
|
|
16
16
|
}
|
|
17
17
|
return [...fragments];
|
|
18
18
|
}
|
|
19
|
-
function matchIn(text, lower, value) {
|
|
19
|
+
function matchIn(text, lower, urlText, value) {
|
|
20
20
|
if (text.includes(value))
|
|
21
21
|
return "plain";
|
|
22
22
|
if (lower.includes(value.toLowerCase()))
|
|
23
23
|
return "case_insensitive";
|
|
24
24
|
const url = encodeURIComponent(value);
|
|
25
|
-
if (url !== value &&
|
|
25
|
+
if (url !== value && urlText.includes(url))
|
|
26
26
|
return "url";
|
|
27
27
|
if (lower.includes(Buffer.from(value).toString("hex")))
|
|
28
28
|
return "hex";
|
|
@@ -35,10 +35,11 @@ export function findSecrets(text, secrets) {
|
|
|
35
35
|
if (text === "")
|
|
36
36
|
return [];
|
|
37
37
|
const lower = text.toLowerCase();
|
|
38
|
+
const urlText = text.replace(/%[\da-f]{2}/gi, (escape) => escape.toUpperCase());
|
|
38
39
|
return Object.entries(secrets).flatMap(([secret, value]) => {
|
|
39
40
|
if (value === "")
|
|
40
41
|
return [];
|
|
41
|
-
const encoding = matchIn(text, lower, value);
|
|
42
|
+
const encoding = matchIn(text, lower, urlText, value);
|
|
42
43
|
return encoding ? [{ secret, encoding }] : [];
|
|
43
44
|
});
|
|
44
45
|
}
|
package/dist/sidecar/dns.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createSocket } from "node:dgram";
|
|
2
2
|
import { normalizeIp } from "./state.js";
|
|
3
|
+
const RECEIVE_BUFFER_BYTES = 1024 * 1024;
|
|
3
4
|
export const isFakeHost = (host, domain) => host === domain || host.endsWith(`.${domain}`);
|
|
4
5
|
function parseQuestion(msg) {
|
|
5
6
|
const labels = [];
|
|
@@ -59,10 +60,31 @@ export function answerQuery(msg, ip, context, source) {
|
|
|
59
60
|
const subnetOf = (ip) => ip.split(".").slice(0, 3).join(".");
|
|
60
61
|
export function createDns(context, ownIps) {
|
|
61
62
|
const socket = createSocket("udp4");
|
|
63
|
+
socket.once("listening", () => {
|
|
64
|
+
try {
|
|
65
|
+
if (socket.getRecvBufferSize() < RECEIVE_BUFFER_BYTES) {
|
|
66
|
+
socket.setRecvBufferSize(RECEIVE_BUFFER_BYTES);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
socket.emit("error", error);
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
let bySubnet = new Map();
|
|
62
74
|
socket.on("message", (msg, remote) => {
|
|
63
75
|
const source = normalizeIp(remote.address);
|
|
64
|
-
const
|
|
65
|
-
|
|
76
|
+
const subnet = subnetOf(source);
|
|
77
|
+
let ip = bySubnet.get(subnet);
|
|
78
|
+
if (ip === undefined) {
|
|
79
|
+
const ips = ownIps();
|
|
80
|
+
bySubnet = new Map();
|
|
81
|
+
for (const own of ips) {
|
|
82
|
+
const ownSubnet = subnetOf(own);
|
|
83
|
+
if (!bySubnet.has(ownSubnet))
|
|
84
|
+
bySubnet.set(ownSubnet, own);
|
|
85
|
+
}
|
|
86
|
+
ip = bySubnet.get(subnet) ?? ips[0];
|
|
87
|
+
}
|
|
66
88
|
const reply = answerQuery(msg, ip, context, source);
|
|
67
89
|
if (reply)
|
|
68
90
|
socket.send(reply, remote.port, remote.address);
|
|
@@ -10,6 +10,7 @@ export type FakeWebContext = {
|
|
|
10
10
|
state: SidecarState;
|
|
11
11
|
domain: string;
|
|
12
12
|
trustTestSourceHeader: boolean;
|
|
13
|
+
requestTimeoutMs: number;
|
|
13
14
|
};
|
|
14
15
|
export declare function createFakeHttps(context: FakeWebContext, tls: Tls): Server;
|
|
15
16
|
export declare function createFakeHttp(context: FakeWebContext): Server;
|
package/dist/sidecar/fake-web.js
CHANGED
|
@@ -7,6 +7,7 @@ import { join } from "node:path";
|
|
|
7
7
|
import { promisify } from "node:util";
|
|
8
8
|
import {} from "../types.js";
|
|
9
9
|
import { isFakeHost } from "./dns.js";
|
|
10
|
+
import { BodyBuffer, sendJson } from "./http.js";
|
|
10
11
|
import { headersOf, SCAN_LIMIT, sourceIp } from "./state.js";
|
|
11
12
|
const run = promisify(execFile);
|
|
12
13
|
const HIT_BODY_LIMIT = 64 * 1024;
|
|
@@ -32,25 +33,26 @@ export async function generateTls(domain) {
|
|
|
32
33
|
}
|
|
33
34
|
function fakeWeb(context, protocol) {
|
|
34
35
|
return (req, res) => {
|
|
35
|
-
const
|
|
36
|
+
const host = (req.headers.host ?? "").replace(/:\d+$/, "").toLowerCase();
|
|
37
|
+
if (!isFakeHost(host, context.domain)) {
|
|
38
|
+
res.writeHead(404, { Connection: "close" }).end();
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const source = sourceIp(req, context.trustTestSourceHeader);
|
|
42
|
+
const target = req.url ?? "/";
|
|
43
|
+
const url = URL.parse(target, "http://fake-web");
|
|
44
|
+
const method = req.method ?? "GET";
|
|
45
|
+
const path = url ? url.pathname + url.search : target;
|
|
46
|
+
const lease = context.state.requests.take(source);
|
|
47
|
+
const buffered = new BodyBuffer(SCAN_LIMIT, (bytes) => lease?.reserve(bytes) ?? false);
|
|
36
48
|
let size = 0;
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
size += chunk.length;
|
|
41
|
-
});
|
|
42
|
-
req.on("end", () => {
|
|
43
|
-
const host = (req.headers.host ?? "").replace(/:\d+$/, "").toLowerCase();
|
|
44
|
-
if (!isFakeHost(host, context.domain)) {
|
|
45
|
-
res.writeHead(404).end();
|
|
49
|
+
let recorded = false;
|
|
50
|
+
const record = (error, extra) => {
|
|
51
|
+
if (recorded)
|
|
46
52
|
return;
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
const
|
|
50
|
-
const source = sourceIp(req, context.trustTestSourceHeader);
|
|
51
|
-
const method = req.method ?? "GET";
|
|
52
|
-
const path = url ? url.pathname + url.search : target;
|
|
53
|
-
const body = Buffer.concat(chunks);
|
|
53
|
+
recorded = true;
|
|
54
|
+
clearTimeout(timer);
|
|
55
|
+
const body = buffered.finish(extra);
|
|
54
56
|
context.state.recordHit(source, {
|
|
55
57
|
protocol,
|
|
56
58
|
host,
|
|
@@ -59,7 +61,58 @@ function fakeWeb(context, protocol) {
|
|
|
59
61
|
headers: headersOf(req),
|
|
60
62
|
body: body.subarray(0, HIT_BODY_LIMIT).toString("utf8"),
|
|
61
63
|
truncated: size > HIT_BODY_LIMIT,
|
|
64
|
+
...(error ? { error } : {}),
|
|
62
65
|
}, body.toString("utf8"));
|
|
66
|
+
};
|
|
67
|
+
const refuse = (status, code, detail, extra) => {
|
|
68
|
+
record({ by: "sidecar", code, detail }, extra);
|
|
69
|
+
req.pause();
|
|
70
|
+
sendJson(res, status, { error: detail }, { Connection: "close" });
|
|
71
|
+
};
|
|
72
|
+
const timer = setTimeout(() => refuse(408, "request_timeout", "request body timed out"), context.requestTimeoutMs);
|
|
73
|
+
timer.unref();
|
|
74
|
+
res.once("finish", () => lease?.release());
|
|
75
|
+
res.once("close", () => {
|
|
76
|
+
record({
|
|
77
|
+
by: "sidecar",
|
|
78
|
+
code: "request_aborted",
|
|
79
|
+
detail: "request closed before its body ended",
|
|
80
|
+
});
|
|
81
|
+
lease?.release();
|
|
82
|
+
});
|
|
83
|
+
req.once("aborted", () => {
|
|
84
|
+
record({
|
|
85
|
+
by: "sidecar",
|
|
86
|
+
code: "request_aborted",
|
|
87
|
+
detail: "request body aborted",
|
|
88
|
+
});
|
|
89
|
+
res.destroy();
|
|
90
|
+
});
|
|
91
|
+
req.once("error", () => {
|
|
92
|
+
record({
|
|
93
|
+
by: "sidecar",
|
|
94
|
+
code: "request_aborted",
|
|
95
|
+
detail: "request body failed",
|
|
96
|
+
});
|
|
97
|
+
res.destroy();
|
|
98
|
+
});
|
|
99
|
+
if (!lease) {
|
|
100
|
+
refuse(429, "request_limit", "too many in-flight requests");
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
req.on("data", (chunk) => {
|
|
104
|
+
if (recorded)
|
|
105
|
+
return;
|
|
106
|
+
size += chunk.length;
|
|
107
|
+
if (!buffered.append(chunk)) {
|
|
108
|
+
refuse(429, "buffer_limit", "too many buffered request bytes", chunk);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
req.on("end", () => {
|
|
113
|
+
if (recorded)
|
|
114
|
+
return;
|
|
115
|
+
record();
|
|
63
116
|
if (!url) {
|
|
64
117
|
res.writeHead(400, { "Content-Type": "text/plain" }).end("bad request");
|
|
65
118
|
return;
|
package/dist/sidecar/http.d.ts
CHANGED
|
@@ -1,7 +1,36 @@
|
|
|
1
1
|
import { type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
|
2
2
|
export declare const BODY_LIMIT_BYTES: number;
|
|
3
|
+
export declare const REQUEST_TIMEOUT_MS = 30000;
|
|
4
|
+
export type RequestLimits = {
|
|
5
|
+
requests: number;
|
|
6
|
+
requestsPerSource: number;
|
|
7
|
+
bytes: number;
|
|
8
|
+
bytesPerSource: number;
|
|
9
|
+
};
|
|
10
|
+
export type RequestLease = {
|
|
11
|
+
reserve(bytes: number): boolean;
|
|
12
|
+
release(): void;
|
|
13
|
+
};
|
|
14
|
+
export declare class RequestBudget {
|
|
15
|
+
private readonly limits;
|
|
16
|
+
private readonly sources;
|
|
17
|
+
private requests;
|
|
18
|
+
private bytes;
|
|
19
|
+
constructor(limits?: Partial<RequestLimits>);
|
|
20
|
+
take(source: string): RequestLease | undefined;
|
|
21
|
+
}
|
|
3
22
|
export declare class BodyTooLargeError extends Error {
|
|
4
23
|
}
|
|
24
|
+
export declare class BodyBuffer {
|
|
25
|
+
private readonly limit;
|
|
26
|
+
private readonly reserve;
|
|
27
|
+
private blocks;
|
|
28
|
+
private size;
|
|
29
|
+
private capacity;
|
|
30
|
+
constructor(limit: number, reserve?: (bytes: number) => boolean);
|
|
31
|
+
append(chunk: Uint8Array): boolean;
|
|
32
|
+
finish(extra?: Uint8Array): Buffer;
|
|
33
|
+
}
|
|
5
34
|
export declare function readBody(body: AsyncIterable<Uint8Array>, limit?: number): Promise<Buffer>;
|
|
6
35
|
export declare function readJson(req: IncomingMessage): Promise<unknown>;
|
|
7
36
|
export declare function sendJson(res: ServerResponse, status: number, body: unknown, headers?: Record<string, string>): void;
|
package/dist/sidecar/http.js
CHANGED
|
@@ -1,18 +1,120 @@
|
|
|
1
1
|
import { timingSafeEqual } from "node:crypto";
|
|
2
2
|
import { createServer, } from "node:http";
|
|
3
3
|
export const BODY_LIMIT_BYTES = 1024 * 1024;
|
|
4
|
+
export const REQUEST_TIMEOUT_MS = 30_000;
|
|
5
|
+
export class RequestBudget {
|
|
6
|
+
limits;
|
|
7
|
+
sources = new Map();
|
|
8
|
+
requests = 0;
|
|
9
|
+
bytes = 0;
|
|
10
|
+
constructor(limits = {}) {
|
|
11
|
+
this.limits = {
|
|
12
|
+
requests: 2048,
|
|
13
|
+
requestsPerSource: 8,
|
|
14
|
+
bytes: 512 * 1024 * 1024,
|
|
15
|
+
bytesPerSource: 32 * 1024 * 1024,
|
|
16
|
+
...limits,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
take(source) {
|
|
20
|
+
const usage = this.sources.get(source) ?? { requests: 0, bytes: 0 };
|
|
21
|
+
if (this.requests >= this.limits.requests ||
|
|
22
|
+
usage.requests >= this.limits.requestsPerSource)
|
|
23
|
+
return undefined;
|
|
24
|
+
this.sources.set(source, usage);
|
|
25
|
+
this.requests += 1;
|
|
26
|
+
usage.requests += 1;
|
|
27
|
+
let bytes = 0;
|
|
28
|
+
let released = false;
|
|
29
|
+
return {
|
|
30
|
+
reserve: (size) => {
|
|
31
|
+
if (released ||
|
|
32
|
+
this.bytes + size > this.limits.bytes ||
|
|
33
|
+
usage.bytes + size > this.limits.bytesPerSource)
|
|
34
|
+
return false;
|
|
35
|
+
this.bytes += size;
|
|
36
|
+
usage.bytes += size;
|
|
37
|
+
bytes += size;
|
|
38
|
+
return true;
|
|
39
|
+
},
|
|
40
|
+
release: () => {
|
|
41
|
+
if (released)
|
|
42
|
+
return;
|
|
43
|
+
released = true;
|
|
44
|
+
this.requests -= 1;
|
|
45
|
+
usage.requests -= 1;
|
|
46
|
+
this.bytes -= bytes;
|
|
47
|
+
usage.bytes -= bytes;
|
|
48
|
+
if (usage.requests === 0)
|
|
49
|
+
this.sources.delete(source);
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
}
|
|
4
54
|
export class BodyTooLargeError extends Error {
|
|
5
55
|
}
|
|
56
|
+
const BODY_BLOCK_BYTES = 64 * 1024;
|
|
57
|
+
export class BodyBuffer {
|
|
58
|
+
limit;
|
|
59
|
+
reserve;
|
|
60
|
+
blocks = [];
|
|
61
|
+
size = 0;
|
|
62
|
+
capacity = 0;
|
|
63
|
+
constructor(limit, reserve = () => true) {
|
|
64
|
+
this.limit = limit;
|
|
65
|
+
this.reserve = reserve;
|
|
66
|
+
}
|
|
67
|
+
append(chunk) {
|
|
68
|
+
const length = Math.min(chunk.length, this.limit - this.size);
|
|
69
|
+
if (length === 0)
|
|
70
|
+
return true;
|
|
71
|
+
const end = this.size + length;
|
|
72
|
+
const capacity = Math.min(this.limit, Math.ceil(end / BODY_BLOCK_BYTES) * BODY_BLOCK_BYTES);
|
|
73
|
+
const needed = capacity - this.capacity;
|
|
74
|
+
if (needed > 0 && !this.reserve(needed))
|
|
75
|
+
return false;
|
|
76
|
+
while (this.capacity < capacity) {
|
|
77
|
+
const block = Buffer.alloc(Math.min(BODY_BLOCK_BYTES, this.limit - this.capacity));
|
|
78
|
+
this.blocks.push(block);
|
|
79
|
+
this.capacity += block.length;
|
|
80
|
+
}
|
|
81
|
+
let offset = 0;
|
|
82
|
+
while (offset < length) {
|
|
83
|
+
const block = this.blocks[Math.floor(this.size / BODY_BLOCK_BYTES)];
|
|
84
|
+
const at = this.size % BODY_BLOCK_BYTES;
|
|
85
|
+
const copied = Math.min(length - offset, block.length - at);
|
|
86
|
+
block.set(chunk.subarray(offset, offset + copied), at);
|
|
87
|
+
offset += copied;
|
|
88
|
+
this.size += copied;
|
|
89
|
+
}
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
finish(extra) {
|
|
93
|
+
const blocks = this.blocks;
|
|
94
|
+
const size = this.size;
|
|
95
|
+
this.blocks = [];
|
|
96
|
+
this.size = 0;
|
|
97
|
+
this.capacity = 0;
|
|
98
|
+
const last = blocks.at(-1);
|
|
99
|
+
if (last && size % BODY_BLOCK_BYTES !== 0) {
|
|
100
|
+
blocks[blocks.length - 1] = last.subarray(0, size % BODY_BLOCK_BYTES);
|
|
101
|
+
}
|
|
102
|
+
const extraLength = Math.min(extra?.length ?? 0, this.limit - size);
|
|
103
|
+
if (extra && extraLength > 0)
|
|
104
|
+
blocks.push(extra.subarray(0, extraLength));
|
|
105
|
+
return Buffer.concat(blocks, size + extraLength);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
6
108
|
export async function readBody(body, limit = BODY_LIMIT_BYTES) {
|
|
7
|
-
const
|
|
109
|
+
const buffered = new BodyBuffer(limit);
|
|
8
110
|
let size = 0;
|
|
9
111
|
for await (const chunk of body) {
|
|
10
112
|
size += chunk.length;
|
|
11
113
|
if (size > limit)
|
|
12
114
|
throw new BodyTooLargeError("body too large");
|
|
13
|
-
|
|
115
|
+
buffered.append(chunk);
|
|
14
116
|
}
|
|
15
|
-
return
|
|
117
|
+
return buffered.finish();
|
|
16
118
|
}
|
|
17
119
|
export async function readJson(req) {
|
|
18
120
|
return JSON.parse((await readBody(req)).toString("utf8"));
|