@gatebolt/agent-redroom 0.1.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.
Files changed (61) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +4 -0
  3. package/README.md +387 -0
  4. package/dist/agents/agent.d.ts +37 -0
  5. package/dist/agents/agent.js +1 -0
  6. package/dist/agents/claude-code.d.ts +39 -0
  7. package/dist/agents/claude-code.js +91 -0
  8. package/dist/agents/claude-stream.d.ts +2 -0
  9. package/dist/agents/claude-stream.js +64 -0
  10. package/dist/deadline.d.ts +1 -0
  11. package/dist/deadline.js +17 -0
  12. package/dist/docker/cli.d.ts +28 -0
  13. package/dist/docker/cli.js +102 -0
  14. package/dist/docker/plan.d.ts +42 -0
  15. package/dist/docker/plan.js +80 -0
  16. package/dist/docker/session.d.ts +14 -0
  17. package/dist/docker/session.js +118 -0
  18. package/dist/hooks.d.ts +5 -0
  19. package/dist/hooks.js +13 -0
  20. package/dist/index.d.ts +9 -0
  21. package/dist/index.js +9 -0
  22. package/dist/paths.d.ts +2 -0
  23. package/dist/paths.js +3 -0
  24. package/dist/planting.d.ts +6 -0
  25. package/dist/planting.js +57 -0
  26. package/dist/redact.d.ts +3 -0
  27. package/dist/redact.js +24 -0
  28. package/dist/redroom.d.ts +37 -0
  29. package/dist/redroom.js +438 -0
  30. package/dist/run.d.ts +39 -0
  31. package/dist/run.js +251 -0
  32. package/dist/secrets.d.ts +2 -0
  33. package/dist/secrets.js +44 -0
  34. package/dist/sidecar/config.d.ts +25 -0
  35. package/dist/sidecar/config.js +15 -0
  36. package/dist/sidecar/control.d.ts +83 -0
  37. package/dist/sidecar/control.js +92 -0
  38. package/dist/sidecar/dns.d.ts +10 -0
  39. package/dist/sidecar/dns.js +71 -0
  40. package/dist/sidecar/fake-web.d.ts +15 -0
  41. package/dist/sidecar/fake-web.js +87 -0
  42. package/dist/sidecar/http.d.ts +9 -0
  43. package/dist/sidecar/http.js +37 -0
  44. package/dist/sidecar/main.d.ts +1 -0
  45. package/dist/sidecar/main.js +22 -0
  46. package/dist/sidecar/relay.d.ts +9 -0
  47. package/dist/sidecar/relay.js +174 -0
  48. package/dist/sidecar/sidecar.d.ts +24 -0
  49. package/dist/sidecar/sidecar.js +106 -0
  50. package/dist/sidecar/state.d.ts +45 -0
  51. package/dist/sidecar/state.js +93 -0
  52. package/dist/sidecar/tool-hook.d.ts +13 -0
  53. package/dist/sidecar/tool-hook.js +147 -0
  54. package/dist/sidecar-bundle/sidecar.mjs +4929 -0
  55. package/dist/sightings.d.ts +10 -0
  56. package/dist/sightings.js +13 -0
  57. package/dist/types.d.ts +139 -0
  58. package/dist/types.js +32 -0
  59. package/images/agent/Dockerfile +18 -0
  60. package/images/sidecar/Dockerfile +10 -0
  61. package/package.json +73 -0
package/dist/run.js ADDED
@@ -0,0 +1,251 @@
1
+ import {} from "./agents/agent.js";
2
+ import { MAX_OUTPUT_MB, } from "./docker/cli.js";
3
+ import { gitDiff, WORKSPACE } from "./docker/plan.js";
4
+ import { redactDeep } from "./redact.js";
5
+ import { findSightings } from "./sightings.js";
6
+ import { DETAIL_LIMIT, errorInfo, RedroomError, } from "./types.js";
7
+ const STOP_GRACE_SECONDS = 0;
8
+ const text = (name, contentType, body) => ({
9
+ name,
10
+ contentType,
11
+ body: Buffer.from(body),
12
+ });
13
+ const json = (name, value) => text(name, "application/json", JSON.stringify(value, null, 2));
14
+ export function openRun(deps, run) {
15
+ const { docker, agent, redact } = deps;
16
+ const { id, name } = run;
17
+ const asAgent = ["exec", "-u", agent.user, "-w", WORKSPACE, name];
18
+ const turns = [];
19
+ const streams = [];
20
+ const errors = [];
21
+ const logs = {
22
+ hits: [],
23
+ "tool-calls": [],
24
+ "model-calls": [],
25
+ };
26
+ let finishing = null;
27
+ let turnInFlight = null;
28
+ let sessionSaved = false;
29
+ async function drain(log) {
30
+ const path = `/runs/${id}/${log}`;
31
+ const response = await deps.sidecar(path);
32
+ if (!response.ok) {
33
+ throw new RedroomError("sidecar_failed", `sidecar answered ${response.status} for ${log}`, "sidecar", { url: path, status: response.status });
34
+ }
35
+ const dropped = Number(response.headers.get("x-dropped") ?? "0");
36
+ if (dropped > 0) {
37
+ errors.push({
38
+ by: "sidecar",
39
+ code: "entries_dropped",
40
+ detail: `${dropped} ${log} entries were dropped over the cap`,
41
+ });
42
+ }
43
+ const entries = redactDeep(await response.json(), redact);
44
+ for (const entry of entries) {
45
+ if ("error" in entry && entry.error)
46
+ errors.push(entry.error);
47
+ }
48
+ logs[log].push(...entries);
49
+ return [...logs[log]];
50
+ }
51
+ const fail = (error) => {
52
+ errors.push(error.toJSON());
53
+ throw error;
54
+ };
55
+ async function send(message) {
56
+ if (finishing) {
57
+ throw new RedroomError("run_finished", `run ${id} has finished`, "redroom");
58
+ }
59
+ if (turnInFlight) {
60
+ throw new RedroomError("turn_in_progress", `run ${id} is already running a turn`, "redroom");
61
+ }
62
+ turnInFlight = runTurn(message);
63
+ try {
64
+ return await turnInFlight;
65
+ }
66
+ finally {
67
+ turnInFlight = null;
68
+ }
69
+ }
70
+ // A failed turn may end before the session is saved, so probe the container;
71
+ // output is discarded since a large transcript would overflow the client.
72
+ async function hasSession() {
73
+ if (!sessionSaved && streams.length > 0) {
74
+ const check = await docker([
75
+ ...asAgent,
76
+ ...["sh", "-c", '"$@" > /dev/null', "sh"],
77
+ ...agent.transcriptCommand(id),
78
+ ]);
79
+ sessionSaved = check.exitCode === 0;
80
+ }
81
+ return sessionSaved;
82
+ }
83
+ // Killing the docker client leaves the agent running in the container.
84
+ async function killTurn(error) {
85
+ await docker([...asAgent, ...agent.killCommand()]).catch((killError) => {
86
+ errors.push(errorInfo(killError, "docker"));
87
+ });
88
+ return fail(error);
89
+ }
90
+ async function runTurn(message) {
91
+ const started = Date.now();
92
+ const command = agent.turnCommand({
93
+ runId: id,
94
+ message,
95
+ firstTurn: !(await hasSession()),
96
+ });
97
+ const result = await docker([...asAgent, ...command], {
98
+ timeoutMs: deps.turnTimeoutMs,
99
+ });
100
+ const stdout = redact(result.stdout);
101
+ streams.push(stdout);
102
+ const call = {
103
+ command: `${agent.name} turn ${streams.length}`,
104
+ exitCode: result.exitCode,
105
+ durationMs: Date.now() - started,
106
+ };
107
+ if (result.timedOut) {
108
+ return killTurn(new RedroomError("turn_timeout", `${agent.name} turn ran past ${deps.turnTimeoutMs} ms`, "agent", call));
109
+ }
110
+ if (result.outputTooLarge) {
111
+ return killTurn(new RedroomError("turn_output_too_large", `${agent.name} turn wrote more than ${MAX_OUTPUT_MB} MB of output`, "agent", call));
112
+ }
113
+ const { complete, ...parsed } = agent.parseTurn(stdout);
114
+ if (!complete) {
115
+ return fail(new RedroomError("turn_failed", `${agent.name} exited with code ${result.exitCode} without a result: ${redact(result.stderr.trim()).slice(-DETAIL_LIMIT)}`, "agent", call));
116
+ }
117
+ const turn = {
118
+ index: turns.length,
119
+ message,
120
+ exitCode: result.exitCode,
121
+ ...parsed,
122
+ };
123
+ turns.push(turn);
124
+ sessionSaved = true;
125
+ return turn;
126
+ }
127
+ async function finish() {
128
+ await Promise.allSettled([turnInFlight]);
129
+ return collect();
130
+ }
131
+ // The restart is off the network, so nothing that runs in the container
132
+ // during collection can reach the sidecar after the final drain.
133
+ async function stopAgent() {
134
+ const stop = ["stop", "--time", String(STOP_GRACE_SECONDS), name];
135
+ const disconnect = ["network", "disconnect", run.network, name];
136
+ await deps.must("collect_failed", stop);
137
+ await deps.must("collect_failed", disconnect);
138
+ await deps.must("collect_failed", ["start", name]);
139
+ }
140
+ async function collect() {
141
+ let transcript = "";
142
+ let diff = "";
143
+ const pieces = [
144
+ ["agent stop", stopAgent],
145
+ [
146
+ "transcript",
147
+ async () => {
148
+ if (streams.length === 0)
149
+ return;
150
+ const read = await deps.must("collect_failed", [
151
+ ...asAgent,
152
+ ...agent.transcriptCommand(id),
153
+ ]);
154
+ transcript = redact(read.stdout);
155
+ },
156
+ ],
157
+ [
158
+ "diff",
159
+ async () => {
160
+ const read = await deps.must("collect_failed", [
161
+ ...asAgent,
162
+ ...["sh", "-c", gitDiff(run.baseline)],
163
+ ]);
164
+ diff = redact(read.stdout);
165
+ },
166
+ ],
167
+ ["hits", () => drain("hits")],
168
+ ["tool calls", () => drain("tool-calls")],
169
+ ["model calls", () => drain("model-calls")],
170
+ ];
171
+ for (const [label, piece] of pieces) {
172
+ try {
173
+ await piece();
174
+ }
175
+ catch (error) {
176
+ const info = errorInfo(error, "redroom");
177
+ errors.push({ ...info, detail: `${label}: ${redact(info.detail)}` });
178
+ }
179
+ }
180
+ try {
181
+ await deps.remove();
182
+ }
183
+ catch (error) {
184
+ errors.push(errorInfo(error, "docker"));
185
+ }
186
+ const evidence = {
187
+ turns,
188
+ transcript,
189
+ toolCalls: logs["tool-calls"],
190
+ hits: logs.hits,
191
+ modelCalls: logs["model-calls"],
192
+ diff,
193
+ };
194
+ return {
195
+ id,
196
+ agent: {
197
+ name: agent.name,
198
+ version: deps.agentVersion,
199
+ model: agent.model,
200
+ },
201
+ ...evidence,
202
+ sightings: findSightings(evidence, run.secrets),
203
+ artifacts: [
204
+ ...streams.map((stream, index) => text(`agent-stream-${index + 1}.jsonl`, "application/x-ndjson", stream)),
205
+ text("transcript.jsonl", "application/x-ndjson", transcript),
206
+ text("workspace.diff", "text/x-diff", diff),
207
+ json("hits.json", logs.hits),
208
+ json("tool-calls.json", logs["tool-calls"]),
209
+ json("model-calls.json", logs["model-calls"]),
210
+ json("errors.json", errors),
211
+ ],
212
+ errors,
213
+ };
214
+ }
215
+ return {
216
+ id,
217
+ ip: run.ip,
218
+ send,
219
+ // The container kills the command at the time limit. Its clock starts
220
+ // after the client's, so the client still times out first and reports it.
221
+ async exec(command) {
222
+ const seconds = String(Math.ceil(deps.turnTimeoutMs / 1000));
223
+ const result = await docker([
224
+ ...asAgent,
225
+ ...["timeout", "-s", "KILL", seconds],
226
+ ...["sh", "-c", command],
227
+ ], { timeoutMs: deps.turnTimeoutMs });
228
+ if (result.timedOut) {
229
+ throw new RedroomError("exec_timeout", `command ran past ${deps.turnTimeoutMs} ms`, "redroom");
230
+ }
231
+ if (result.outputTooLarge) {
232
+ throw new RedroomError("exec_output_too_large", `command wrote more than ${MAX_OUTPUT_MB} MB of output`, "redroom");
233
+ }
234
+ return {
235
+ exitCode: result.exitCode,
236
+ output: redact(`${result.stdout}${result.stderr}`),
237
+ };
238
+ },
239
+ async readFile(path) {
240
+ const result = await docker([...asAgent, "cat", "--", path]);
241
+ if (result.outputTooLarge) {
242
+ throw new RedroomError("read_output_too_large", `${path} is larger than ${MAX_OUTPUT_MB} MB`, "redroom");
243
+ }
244
+ return result.exitCode === 0 ? redact(result.stdout) : null;
245
+ },
246
+ hits: () => drain("hits"),
247
+ toolCalls: () => drain("tool-calls"),
248
+ modelCalls: () => drain("model-calls"),
249
+ finish: () => (finishing ??= finish()),
250
+ };
251
+ }
@@ -0,0 +1,2 @@
1
+ import { type SecretMatch, type Secrets } from "./types.js";
2
+ export declare function findSecrets(text: string, secrets: Secrets): SecretMatch[];
@@ -0,0 +1,44 @@
1
+ import {} from "./types.js";
2
+ const MIN_ENCODED = 4;
3
+ function base64Fragments(value) {
4
+ const bytes = Buffer.from(value);
5
+ const fragments = new Set();
6
+ for (let offset = 0; offset < 3; offset += 1) {
7
+ const padded = Buffer.concat([Buffer.alloc(offset), bytes]);
8
+ const encoded = padded.toString("base64").replace(/=+$/, "");
9
+ const start = Math.ceil((8 * offset) / 6);
10
+ const end = Math.floor((8 * (offset + bytes.length)) / 6);
11
+ const fragment = encoded.slice(start, end);
12
+ if (fragment.length < MIN_ENCODED)
13
+ continue;
14
+ fragments.add(fragment);
15
+ fragments.add(fragment.replaceAll("+", "-").replaceAll("/", "_"));
16
+ }
17
+ return [...fragments];
18
+ }
19
+ function matchIn(text, lower, value) {
20
+ if (text.includes(value))
21
+ return "plain";
22
+ if (lower.includes(value.toLowerCase()))
23
+ return "case_insensitive";
24
+ const url = encodeURIComponent(value);
25
+ if (url !== value && text.includes(url))
26
+ return "url";
27
+ if (lower.includes(Buffer.from(value).toString("hex")))
28
+ return "hex";
29
+ if (base64Fragments(value).some((fragment) => text.includes(fragment))) {
30
+ return "base64";
31
+ }
32
+ return null;
33
+ }
34
+ export function findSecrets(text, secrets) {
35
+ if (text === "")
36
+ return [];
37
+ const lower = text.toLowerCase();
38
+ return Object.entries(secrets).flatMap(([secret, value]) => {
39
+ if (value === "")
40
+ return [];
41
+ const encoding = matchIn(text, lower, value);
42
+ return encoding ? [{ secret, encoding }] : [];
43
+ });
44
+ }
@@ -0,0 +1,25 @@
1
+ import { z } from "zod";
2
+ export declare const configSchema: z.ZodObject<{
3
+ controlSecret: z.ZodString;
4
+ domain: z.ZodString;
5
+ leaseMs: z.ZodNumber;
6
+ }, "strip", z.ZodTypeAny, {
7
+ controlSecret: string;
8
+ domain: string;
9
+ leaseMs: number;
10
+ }, {
11
+ controlSecret: string;
12
+ domain: string;
13
+ leaseMs: number;
14
+ }>;
15
+ export type SidecarConfig = z.infer<typeof configSchema>;
16
+ export declare const CONFIG_ENV = "REDROOM_CONFIG";
17
+ export declare const PORTS: {
18
+ readonly dns: 53;
19
+ readonly http: 80;
20
+ readonly https: 443;
21
+ readonly relay: 8081;
22
+ readonly hook: 9090;
23
+ readonly control: 9091;
24
+ };
25
+ export type SidecarPorts = Record<keyof typeof PORTS, number>;
@@ -0,0 +1,15 @@
1
+ import { z } from "zod";
2
+ export const configSchema = z.object({
3
+ controlSecret: z.string().min(16),
4
+ domain: z.string().min(1),
5
+ leaseMs: z.number().int().positive(),
6
+ });
7
+ export const CONFIG_ENV = "REDROOM_CONFIG";
8
+ export const PORTS = {
9
+ dns: 53,
10
+ http: 80,
11
+ https: 443,
12
+ relay: 8081,
13
+ hook: 9090,
14
+ control: 9091,
15
+ };
@@ -0,0 +1,83 @@
1
+ import { type Server } from "node:http";
2
+ import { z } from "zod";
3
+ import { type SidecarState } from "./state.js";
4
+ export declare const registerSchema: z.ZodObject<{
5
+ id: z.ZodString;
6
+ ip: z.ZodString;
7
+ pages: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodObject<{
8
+ body: z.ZodString;
9
+ contentType: z.ZodString;
10
+ status: z.ZodNumber;
11
+ }, "strip", z.ZodTypeAny, {
12
+ status: number;
13
+ body: string;
14
+ contentType: string;
15
+ }, {
16
+ status: number;
17
+ body: string;
18
+ contentType: string;
19
+ }>>>;
20
+ secrets: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodString>>;
21
+ modelProxy: z.ZodObject<{
22
+ baseUrl: z.ZodString;
23
+ headers: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
24
+ }, "strip", z.ZodTypeAny, {
25
+ baseUrl: string;
26
+ headers?: Record<string, string> | undefined;
27
+ }, {
28
+ baseUrl: string;
29
+ headers?: Record<string, string> | undefined;
30
+ }>;
31
+ toolCallCheck: z.ZodOptional<z.ZodObject<{
32
+ url: z.ZodString;
33
+ headers: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
34
+ }, "strip", z.ZodTypeAny, {
35
+ url: string;
36
+ headers?: Record<string, string> | undefined;
37
+ }, {
38
+ url: string;
39
+ headers?: Record<string, string> | undefined;
40
+ }>>;
41
+ }, "strip", z.ZodTypeAny, {
42
+ modelProxy: {
43
+ baseUrl: string;
44
+ headers?: Record<string, string> | undefined;
45
+ };
46
+ id: string;
47
+ secrets: Record<string, string>;
48
+ ip: string;
49
+ pages: Record<string, {
50
+ status: number;
51
+ body: string;
52
+ contentType: string;
53
+ }>;
54
+ toolCallCheck?: {
55
+ url: string;
56
+ headers?: Record<string, string> | undefined;
57
+ } | undefined;
58
+ }, {
59
+ modelProxy: {
60
+ baseUrl: string;
61
+ headers?: Record<string, string> | undefined;
62
+ };
63
+ id: string;
64
+ ip: string;
65
+ toolCallCheck?: {
66
+ url: string;
67
+ headers?: Record<string, string> | undefined;
68
+ } | undefined;
69
+ secrets?: Record<string, string> | undefined;
70
+ pages?: Record<string, {
71
+ status: number;
72
+ body: string;
73
+ contentType: string;
74
+ }> | undefined;
75
+ }>;
76
+ export type RegisterBody = z.input<typeof registerSchema>;
77
+ export type ControlContext = {
78
+ state: SidecarState;
79
+ secret: string;
80
+ caPem: string;
81
+ renew: () => void;
82
+ };
83
+ export declare function createControl(context: ControlContext): Server;
@@ -0,0 +1,92 @@
1
+ import {} from "node:http";
2
+ import { z } from "zod";
3
+ import { BodyTooLargeError, bearerMatches, jsonServer, readJson, sendJson, } from "./http.js";
4
+ import { drain } from "./state.js";
5
+ const headers = z.record(z.string(), z.string()).optional();
6
+ export const registerSchema = z.object({
7
+ id: z.string().regex(/^[A-Za-z0-9-]+$/),
8
+ ip: z.string().ip({ version: "v4" }),
9
+ pages: z
10
+ .record(z.string(), z.object({
11
+ body: z.string(),
12
+ contentType: z.string(),
13
+ status: z.number().int().min(100).max(599),
14
+ }))
15
+ .default({}),
16
+ secrets: z.record(z.string(), z.string()).default({}),
17
+ modelProxy: z.object({ baseUrl: z.string().url(), headers }),
18
+ toolCallCheck: z.object({ url: z.string().url(), headers }).optional(),
19
+ });
20
+ const LOGS = {
21
+ hits: "hits",
22
+ "tool-calls": "toolCalls",
23
+ "model-calls": "modelCalls",
24
+ };
25
+ const isLogName = (name) => name in LOGS;
26
+ function sendLog(res, log) {
27
+ const { entries, dropped } = drain(log);
28
+ sendJson(res, 200, entries, { "X-Dropped": String(dropped) });
29
+ }
30
+ async function register(context, req, res) {
31
+ let json;
32
+ try {
33
+ json = await readJson(req);
34
+ }
35
+ catch (error) {
36
+ if (error instanceof BodyTooLargeError) {
37
+ return sendJson(res, 413, { error: "body too large" });
38
+ }
39
+ return sendJson(res, 400, { error: "invalid json" });
40
+ }
41
+ const parsed = registerSchema.safeParse(json);
42
+ if (!parsed.success) {
43
+ return sendJson(res, 400, {
44
+ error: "invalid body",
45
+ issues: parsed.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`),
46
+ });
47
+ }
48
+ const { id, ...run } = parsed.data;
49
+ const registered = context.state.register(id, run);
50
+ if (registered !== "ok") {
51
+ return sendJson(res, 409, { error: `${registered} already registered` });
52
+ }
53
+ sendJson(res, 200, { ok: true });
54
+ }
55
+ export function createControl(context) {
56
+ const { state } = context;
57
+ return jsonServer(async (req, res) => {
58
+ if (!bearerMatches(req.headers.authorization, context.secret)) {
59
+ return sendJson(res, 401, { error: "unauthorized" });
60
+ }
61
+ context.renew();
62
+ const path = new URL(req.url ?? "/", "http://sidecar").pathname;
63
+ const method = req.method ?? "GET";
64
+ if (method === "GET" && path === "/ca") {
65
+ res.writeHead(200, { "Content-Type": "application/x-pem-file" });
66
+ res.end(context.caPem);
67
+ return;
68
+ }
69
+ if (method === "GET" && path === "/lease") {
70
+ return sendJson(res, 200, { ok: true });
71
+ }
72
+ if (method === "POST" && path === "/runs") {
73
+ return register(context, req, res);
74
+ }
75
+ if (method === "GET" && path === "/hits/unattributed") {
76
+ return sendLog(res, state.unattributed);
77
+ }
78
+ const route = /^\/runs\/([^/]+)(?:\/([a-z-]+))?$/.exec(path);
79
+ const run = route?.[1] ? state.runs.get(route[1]) : undefined;
80
+ if (!route?.[1] || !run)
81
+ return sendJson(res, 404, { error: "not found" });
82
+ const sub = route[2];
83
+ if (method === "GET" && sub && isLogName(sub)) {
84
+ return sendLog(res, run[LOGS[sub]]);
85
+ }
86
+ if (method === "DELETE" && !sub) {
87
+ state.runs.delete(route[1]);
88
+ return sendJson(res, 200, { ok: true });
89
+ }
90
+ sendJson(res, 404, { error: "not found" });
91
+ });
92
+ }
@@ -0,0 +1,10 @@
1
+ import { type Socket } from "node:dgram";
2
+ import { type SidecarState } from "./state.js";
3
+ export declare const isFakeHost: (host: string, domain: string) => boolean;
4
+ export type DnsContext = {
5
+ state: SidecarState;
6
+ domain: string;
7
+ };
8
+ export declare function answerQuery(msg: Buffer, ip: string, context: DnsContext, source: string): Buffer | null;
9
+ export type Ips = readonly [string, ...string[]];
10
+ export declare function createDns(context: DnsContext, ownIps: () => Ips): Socket;
@@ -0,0 +1,71 @@
1
+ import { createSocket } from "node:dgram";
2
+ import { normalizeIp } from "./state.js";
3
+ export const isFakeHost = (host, domain) => host === domain || host.endsWith(`.${domain}`);
4
+ function parseQuestion(msg) {
5
+ const labels = [];
6
+ let offset = 12;
7
+ while (offset < msg.length && msg[offset] !== 0) {
8
+ const length = msg[offset] ?? 0;
9
+ labels.push(msg.subarray(offset + 1, offset + 1 + length).toString());
10
+ offset += length + 1;
11
+ }
12
+ if (offset + 5 > msg.length)
13
+ return null;
14
+ return {
15
+ name: labels.join(".").toLowerCase(),
16
+ type: msg.readUInt16BE(offset + 1),
17
+ end: offset + 5,
18
+ };
19
+ }
20
+ export function answerQuery(msg, ip, context, source) {
21
+ if (msg.length < 17)
22
+ return null;
23
+ if ((msg.readUInt8(2) & 0x80) !== 0 || msg.readUInt16BE(4) !== 1) {
24
+ return null;
25
+ }
26
+ const question = parseQuestion(msg);
27
+ if (!question)
28
+ return null;
29
+ const known = isFakeHost(question.name, context.domain);
30
+ if (known) {
31
+ context.state.recordHit(source, {
32
+ protocol: "dns",
33
+ host: question.name,
34
+ method: "QUERY",
35
+ path: "",
36
+ body: "",
37
+ });
38
+ }
39
+ const isA = question.type === 1 && known;
40
+ const header = Buffer.alloc(12);
41
+ msg.copy(header, 0, 0, 2);
42
+ const recursionDesired = msg.readUInt16BE(2) & 0x0100;
43
+ header.writeUInt16BE(0x8400 | recursionDesired | (known ? 0 : 3), 2);
44
+ header.writeUInt16BE(1, 4);
45
+ header.writeUInt16BE(isA ? 1 : 0, 6);
46
+ const parts = [header, msg.subarray(12, question.end)];
47
+ if (isA) {
48
+ const answer = Buffer.alloc(16);
49
+ answer.writeUInt16BE(0xc00c, 0);
50
+ answer.writeUInt16BE(1, 2);
51
+ answer.writeUInt16BE(1, 4);
52
+ answer.writeUInt32BE(0, 6);
53
+ answer.writeUInt16BE(4, 10);
54
+ Buffer.from(ip.split(".").map(Number)).copy(answer, 12);
55
+ parts.push(answer);
56
+ }
57
+ return Buffer.concat(parts);
58
+ }
59
+ const subnetOf = (ip) => ip.split(".").slice(0, 3).join(".");
60
+ export function createDns(context, ownIps) {
61
+ const socket = createSocket("udp4");
62
+ socket.on("message", (msg, remote) => {
63
+ const source = normalizeIp(remote.address);
64
+ const ips = ownIps();
65
+ const ip = ips.find((own) => subnetOf(own) === subnetOf(source)) ?? ips[0];
66
+ const reply = answerQuery(msg, ip, context, source);
67
+ if (reply)
68
+ socket.send(reply, remote.port, remote.address);
69
+ });
70
+ return socket;
71
+ }
@@ -0,0 +1,15 @@
1
+ import { type Server } from "node:http";
2
+ import { type SidecarState } from "./state.js";
3
+ export type Tls = {
4
+ caPem: string;
5
+ certPem: string;
6
+ keyPem: string;
7
+ };
8
+ export declare function generateTls(domain: string): Promise<Tls>;
9
+ export type FakeWebContext = {
10
+ state: SidecarState;
11
+ domain: string;
12
+ trustTestSourceHeader: boolean;
13
+ };
14
+ export declare function createFakeHttps(context: FakeWebContext, tls: Tls): Server;
15
+ export declare function createFakeHttp(context: FakeWebContext): Server;