@sentry/roach 0.2.0 → 0.3.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 +644 -130
- package/package.json +4 -35
- package/src/action.ts +5 -3
- package/src/client.ts +33 -10
- package/src/config.ts +52 -5
- package/src/report.ts +38 -7
- package/src/types.ts +53 -2
- package/src/values.ts +54 -16
- package/src/vitest-setup.ts +6 -10
- package/src/vitest.ts +9 -12
- package/src/addresses.ts +0 -103
- package/src/certificates.ts +0 -169
- package/src/cli.ts +0 -35
- package/src/gcs.ts +0 -145
- package/src/recorder.ts +0 -464
- package/src/recordings.ts +0 -42
- package/src/request-key.ts +0 -165
- package/src/secrets.ts +0 -66
- package/src/server.ts +0 -544
- package/src/service.ts +0 -572
- package/src/store.ts +0 -47
- package/src/streams.ts +0 -115
- package/src/usage.ts +0 -120
package/src/secrets.ts
DELETED
|
@@ -1,66 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Credentials that the proxy must never write to disk.
|
|
3
|
-
*
|
|
4
|
-
* The proxy learns the values of credential headers from each request that
|
|
5
|
-
* it sees, and the config can add more values. It replaces each known value
|
|
6
|
-
* with `<<redacted>>` before it writes a recording.
|
|
7
|
-
*/
|
|
8
|
-
import type { IncomingHttpHeaders } from "node:http";
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* Request headers that can carry credentials. The match is broad on
|
|
12
|
-
* purpose: redacting a value that is not a credential costs little.
|
|
13
|
-
*/
|
|
14
|
-
const CREDENTIAL_HEADER =
|
|
15
|
-
/auth|token|secret|key|cookie|session|password|credential|signature/i;
|
|
16
|
-
/**
|
|
17
|
-
* Headers that match `CREDENTIAL_HEADER` but carry no credential. A response
|
|
18
|
-
* can show their values, and the client must get them back without a change.
|
|
19
|
-
* AI Gateway clients put a conversation id in `x-session-affinity`, and a
|
|
20
|
-
* model can repeat that id in its response. Node gives header names in
|
|
21
|
-
* lower case.
|
|
22
|
-
*/
|
|
23
|
-
const NOT_CREDENTIAL_HEADERS = new Set(["x-session-affinity"]);
|
|
24
|
-
/** Shorter parts, such as `Bearer`, are not credentials. */
|
|
25
|
-
const MIN_LENGTH = 12;
|
|
26
|
-
const REDACTED = "<<redacted>>";
|
|
27
|
-
|
|
28
|
-
/** Create the secret list of one proxy run, with the values of `initial`. */
|
|
29
|
-
export function createSecrets(initial: string[] = []) {
|
|
30
|
-
const values = new Set<string>();
|
|
31
|
-
// Keep the whole value and its parts, such as the token of
|
|
32
|
-
// `Bearer <token>` or each value of a cookie header.
|
|
33
|
-
const add = (value: string) => {
|
|
34
|
-
for (const part of [value, ...value.split(/[\s;,=]+/)]) {
|
|
35
|
-
if (part.length < MIN_LENGTH) continue;
|
|
36
|
-
values.add(part);
|
|
37
|
-
// The recorder redacts JSON text, which escapes `"`, `\`, and
|
|
38
|
-
// control characters.
|
|
39
|
-
values.add(JSON.stringify(part).slice(1, -1));
|
|
40
|
-
}
|
|
41
|
-
};
|
|
42
|
-
initial.forEach(add);
|
|
43
|
-
// Redact longer values first, so no part of one stays visible.
|
|
44
|
-
const longestFirst = () =>
|
|
45
|
-
[...values].toSorted((a, b) => b.length - a.length);
|
|
46
|
-
|
|
47
|
-
return {
|
|
48
|
-
/** Learn the credentials in the headers of a request. */
|
|
49
|
-
learn(headers: IncomingHttpHeaders): void {
|
|
50
|
-
for (const [name, value] of Object.entries(headers)) {
|
|
51
|
-
if (!CREDENTIAL_HEADER.test(name) || value === undefined) continue;
|
|
52
|
-
if (NOT_CREDENTIAL_HEADERS.has(name)) continue;
|
|
53
|
-
for (const item of Array.isArray(value) ? value : [value]) add(item);
|
|
54
|
-
}
|
|
55
|
-
},
|
|
56
|
-
|
|
57
|
-
/** `text` with each known credential replaced. */
|
|
58
|
-
redact(text: string): string {
|
|
59
|
-
let result = text;
|
|
60
|
-
for (const value of longestFirst()) {
|
|
61
|
-
result = result.replaceAll(value, REDACTED);
|
|
62
|
-
}
|
|
63
|
-
return result;
|
|
64
|
-
},
|
|
65
|
-
};
|
|
66
|
-
}
|
package/src/server.ts
DELETED
|
@@ -1,544 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The proxy sockets of the service (`service.ts`).
|
|
3
|
-
*
|
|
4
|
-
* The proxy is a forward proxy. It intercepts HTTPS with its own
|
|
5
|
-
* certificate authority (`certificates.ts`). It gives each request that a
|
|
6
|
-
* rule matches to the recorder (`recorder.ts`). Other requests go live
|
|
7
|
-
* without a change, and their responses stream.
|
|
8
|
-
*
|
|
9
|
-
* It sends requests only to the `allow` origins of the run. It refuses
|
|
10
|
-
* other origins with HTTP 403 and sends nothing. The upstream host always
|
|
11
|
-
* comes from `allow`, never from the request. Only the path comes from the
|
|
12
|
-
* request. It also refuses an origin whose address is not public
|
|
13
|
-
* (`addresses.ts`), unless the service config has `privateNetworks`.
|
|
14
|
-
*
|
|
15
|
-
* Each response has an `x-roach` header: `replayed`, `live`,
|
|
16
|
-
* `missed` (`replay` mode had no recording), or `passthrough` (no rule
|
|
17
|
-
* matched).
|
|
18
|
-
*/
|
|
19
|
-
import { timingSafeEqual } from "node:crypto";
|
|
20
|
-
import http from "node:http";
|
|
21
|
-
import https from "node:https";
|
|
22
|
-
import { isIP, type Socket } from "node:net";
|
|
23
|
-
import tls from "node:tls";
|
|
24
|
-
import { isPublicAddress, notPublicError, publicLookup } from "./addresses.ts";
|
|
25
|
-
import type { CertificateAuthority } from "./certificates.ts";
|
|
26
|
-
import type { ProxyRequest, Recorder, Telemetry } from "./recorder.ts";
|
|
27
|
-
import type { OpenedSession } from "./types.ts";
|
|
28
|
-
|
|
29
|
-
/** Headers for one connection, which the proxy must not forward. */
|
|
30
|
-
const HOP_HEADERS = new Set([
|
|
31
|
-
"connection",
|
|
32
|
-
"keep-alive",
|
|
33
|
-
"proxy-authorization",
|
|
34
|
-
"proxy-connection",
|
|
35
|
-
"te",
|
|
36
|
-
"trailer",
|
|
37
|
-
"transfer-encoding",
|
|
38
|
-
"upgrade",
|
|
39
|
-
]);
|
|
40
|
-
const AUTHORITY = /^(\[[0-9a-f:.]+\]|[a-z0-9.-]+):(\d{1,5})$/i;
|
|
41
|
-
/** A request body over this size gets HTTP 413. */
|
|
42
|
-
const MAX_BODY_BYTES = 64 * 1024 * 1024;
|
|
43
|
-
|
|
44
|
-
/** An error that answers a request with its own status. */
|
|
45
|
-
const httpError = (status: number, message: string) =>
|
|
46
|
-
Object.assign(new Error(message), { status });
|
|
47
|
-
|
|
48
|
-
/** Parse the allowed origins, by origin. A value that is not one throws. */
|
|
49
|
-
export function parseOrigins(values: string[]): Map<string, URL> {
|
|
50
|
-
return new Map(
|
|
51
|
-
values.map((value) => {
|
|
52
|
-
const origin = new URL(value);
|
|
53
|
-
if (
|
|
54
|
-
(origin.protocol !== "http:" && origin.protocol !== "https:") ||
|
|
55
|
-
origin.origin !== value.replace(/\/$/, "")
|
|
56
|
-
) {
|
|
57
|
-
throw new Error(`Roach origin must be an origin: ${value}`);
|
|
58
|
-
}
|
|
59
|
-
return [origin.origin, origin];
|
|
60
|
-
}),
|
|
61
|
-
);
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/** The `host:port` of an origin, with the default port of its scheme. */
|
|
65
|
-
function authorityOf(origin: URL): string {
|
|
66
|
-
const port = origin.port || (origin.protocol === "https:" ? "443" : "80");
|
|
67
|
-
return `${origin.hostname}:${port}`;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* Read a whole body. A body over `maxBytes` fails with HTTP 413. Only
|
|
72
|
-
* request bodies have a limit: the client sends them, and a large upstream
|
|
73
|
-
* response is not the fault of the client.
|
|
74
|
-
*/
|
|
75
|
-
function readBody(
|
|
76
|
-
stream: http.IncomingMessage,
|
|
77
|
-
maxBytes = Infinity,
|
|
78
|
-
): Promise<Buffer> {
|
|
79
|
-
return new Promise((resolve, reject) => {
|
|
80
|
-
const chunks: Buffer[] = [];
|
|
81
|
-
let size = 0;
|
|
82
|
-
stream.on("data", (chunk: Buffer) => {
|
|
83
|
-
size += chunk.length;
|
|
84
|
-
if (size > maxBytes) {
|
|
85
|
-
// Read the rest without keeping it, so the client gets the 413.
|
|
86
|
-
chunks.length = 0;
|
|
87
|
-
reject(httpError(413, `body is over ${maxBytes} bytes`));
|
|
88
|
-
return;
|
|
89
|
-
}
|
|
90
|
-
chunks.push(chunk);
|
|
91
|
-
});
|
|
92
|
-
stream.on("end", () => resolve(Buffer.concat(chunks)));
|
|
93
|
-
stream.on("error", reject);
|
|
94
|
-
});
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
/** Read a JSON object request body. Any other body fails with HTTP 400. */
|
|
98
|
-
export async function readJson<T>(incoming: http.IncomingMessage): Promise<T> {
|
|
99
|
-
const text = (await readBody(incoming, MAX_BODY_BYTES)).toString("utf8");
|
|
100
|
-
let value: unknown;
|
|
101
|
-
try {
|
|
102
|
-
value = JSON.parse(text || "{}");
|
|
103
|
-
} catch {
|
|
104
|
-
throw httpError(400, "body is not JSON");
|
|
105
|
-
}
|
|
106
|
-
// Callers read fields, so `null`, a list, or a single value is a bad body.
|
|
107
|
-
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
108
|
-
throw httpError(400, "body is not a JSON object");
|
|
109
|
-
}
|
|
110
|
-
return value as T;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/** Compare tokens in constant time. */
|
|
114
|
-
export function sameToken(
|
|
115
|
-
actual: string | undefined,
|
|
116
|
-
expected: string,
|
|
117
|
-
): boolean {
|
|
118
|
-
if (actual === undefined) return false;
|
|
119
|
-
const a = Buffer.from(actual);
|
|
120
|
-
const b = Buffer.from(expected);
|
|
121
|
-
return a.length === b.length && timingSafeEqual(a, b);
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/**
|
|
125
|
-
* Send a request to an allowed origin. The host, port, and scheme come from
|
|
126
|
-
* the configuration. Only the path comes from the client. Without
|
|
127
|
-
* `privateNetworks`, only public addresses get the request.
|
|
128
|
-
*/
|
|
129
|
-
function sendUpstream(
|
|
130
|
-
origin: URL,
|
|
131
|
-
request: ProxyRequest,
|
|
132
|
-
privateNetworks: boolean,
|
|
133
|
-
extraHeaders: http.OutgoingHttpHeaders = {},
|
|
134
|
-
): Promise<http.IncomingMessage> {
|
|
135
|
-
const headers: http.OutgoingHttpHeaders = {};
|
|
136
|
-
for (const [name, value] of Object.entries(request.headers)) {
|
|
137
|
-
if (value !== undefined && !HOP_HEADERS.has(name)) headers[name] = value;
|
|
138
|
-
}
|
|
139
|
-
headers.host = origin.host;
|
|
140
|
-
if (request.body.length > 0) {
|
|
141
|
-
headers["content-length"] = String(request.body.length);
|
|
142
|
-
} else {
|
|
143
|
-
delete headers["content-length"];
|
|
144
|
-
}
|
|
145
|
-
Object.assign(headers, extraHeaders);
|
|
146
|
-
const client = origin.protocol === "https:" ? https : http;
|
|
147
|
-
// A URL keeps the brackets of an IPv6 host. A request option has none.
|
|
148
|
-
const hostname = origin.hostname.replace(/^\[|\]$/g, "");
|
|
149
|
-
// Node does not call `lookup` for an IP address, so check it here.
|
|
150
|
-
if (!privateNetworks && isIP(hostname) && !isPublicAddress(hostname)) {
|
|
151
|
-
return Promise.reject(notPublicError(hostname, hostname));
|
|
152
|
-
}
|
|
153
|
-
return new Promise((resolve, reject) => {
|
|
154
|
-
const upstream = client.request(
|
|
155
|
-
{
|
|
156
|
-
protocol: origin.protocol,
|
|
157
|
-
hostname,
|
|
158
|
-
port: origin.port || undefined,
|
|
159
|
-
// The check is on the address of the connection. A kept-alive
|
|
160
|
-
// connection had the check when it opened.
|
|
161
|
-
lookup: privateNetworks ? undefined : publicLookup,
|
|
162
|
-
servername: isIP(hostname) ? undefined : hostname,
|
|
163
|
-
path: `${request.url.pathname}${request.url.search}`,
|
|
164
|
-
method: request.method,
|
|
165
|
-
headers,
|
|
166
|
-
},
|
|
167
|
-
resolve,
|
|
168
|
-
);
|
|
169
|
-
upstream.on("error", reject);
|
|
170
|
-
upstream.end(request.body);
|
|
171
|
-
});
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
/** Proxy one request to an allowed origin, through the recorder. */
|
|
175
|
-
async function proxyRequest(
|
|
176
|
-
{ recorder, origins, replayOnly, privateNetworks = false }: ProxyTarget,
|
|
177
|
-
session: string | undefined,
|
|
178
|
-
incoming: http.IncomingMessage,
|
|
179
|
-
outgoing: http.ServerResponse,
|
|
180
|
-
tunnelOrigin: string | undefined,
|
|
181
|
-
): Promise<void> {
|
|
182
|
-
const url = new URL(
|
|
183
|
-
tunnelOrigin ? `${tunnelOrigin}${incoming.url}` : (incoming.url ?? ""),
|
|
184
|
-
);
|
|
185
|
-
const origin = origins.get(url.origin);
|
|
186
|
-
if (!origin) {
|
|
187
|
-
process.stderr.write(`[roach] Refused ${url.origin}\n`);
|
|
188
|
-
outgoing.writeHead(403, { "content-type": "text/plain" });
|
|
189
|
-
outgoing.end(`Roach: ${url.origin} is not an allowed origin\n`);
|
|
190
|
-
return;
|
|
191
|
-
}
|
|
192
|
-
const request: ProxyRequest = {
|
|
193
|
-
method: incoming.method ?? "GET",
|
|
194
|
-
url,
|
|
195
|
-
headers: incoming.headers,
|
|
196
|
-
body: await readBody(incoming, MAX_BODY_BYTES),
|
|
197
|
-
};
|
|
198
|
-
const rule = recorder.ruleFor(request);
|
|
199
|
-
|
|
200
|
-
if (!rule && replayOnly) {
|
|
201
|
-
// Without this, anyone could send live traffic through a public run.
|
|
202
|
-
outgoing.writeHead(403, { "content-type": "text/plain" });
|
|
203
|
-
outgoing.end(
|
|
204
|
-
"Roach: this run can only replay, and no rule records this request\n",
|
|
205
|
-
);
|
|
206
|
-
return;
|
|
207
|
-
}
|
|
208
|
-
if (!rule) {
|
|
209
|
-
recorder.countPassthrough(origin.origin, request.headers);
|
|
210
|
-
const upstream = await sendUpstream(origin, request, privateNetworks);
|
|
211
|
-
outgoing.writeHead(upstream.statusCode ?? 502, upstream.statusMessage, {
|
|
212
|
-
...upstream.headers,
|
|
213
|
-
"x-roach": "passthrough",
|
|
214
|
-
});
|
|
215
|
-
outgoing.on("close", () => upstream.destroy());
|
|
216
|
-
upstream.pipe(outgoing);
|
|
217
|
-
return;
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
const response = await recorder.respond(
|
|
221
|
-
rule,
|
|
222
|
-
request,
|
|
223
|
-
async () => {
|
|
224
|
-
// Ask for a plain body, so the recording is readable.
|
|
225
|
-
const upstream = await sendUpstream(origin, request, privateNetworks, {
|
|
226
|
-
"accept-encoding": "identity",
|
|
227
|
-
});
|
|
228
|
-
return {
|
|
229
|
-
status: upstream.statusCode ?? 502,
|
|
230
|
-
headers: upstream.headers,
|
|
231
|
-
body: await readBody(upstream),
|
|
232
|
-
};
|
|
233
|
-
},
|
|
234
|
-
session,
|
|
235
|
-
);
|
|
236
|
-
outgoing.writeHead(response.status, {
|
|
237
|
-
...response.headers,
|
|
238
|
-
"content-length": String(response.body.length),
|
|
239
|
-
"x-roach": response.source,
|
|
240
|
-
});
|
|
241
|
-
outgoing.end(response.body);
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
/** Write a JSON response. */
|
|
245
|
-
export function sendJson(
|
|
246
|
-
outgoing: http.ServerResponse,
|
|
247
|
-
status: number,
|
|
248
|
-
body: unknown,
|
|
249
|
-
): void {
|
|
250
|
-
outgoing.writeHead(status, { "content-type": "application/json" });
|
|
251
|
-
outgoing.end(JSON.stringify(body));
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
/**
|
|
255
|
-
* Answer a session or stats call of one recorder. `route` is the method
|
|
256
|
-
* and the path after the prefix of the run, such as `POST /session`.
|
|
257
|
-
* `sessionUrl` gives the proxy URL of a session id. Returns `false` when
|
|
258
|
-
* the route is not one of them.
|
|
259
|
-
*/
|
|
260
|
-
export async function controlRecorder(
|
|
261
|
-
recorder: Recorder,
|
|
262
|
-
route: string,
|
|
263
|
-
incoming: http.IncomingMessage,
|
|
264
|
-
outgoing: http.ServerResponse,
|
|
265
|
-
sessionUrl: (id: string) => string,
|
|
266
|
-
): Promise<boolean> {
|
|
267
|
-
switch (route) {
|
|
268
|
-
case "GET /stats":
|
|
269
|
-
sendJson(outgoing, 200, recorder.stats());
|
|
270
|
-
return true;
|
|
271
|
-
case "POST /session": {
|
|
272
|
-
const { name } = await readJson<{ name?: unknown }>(incoming);
|
|
273
|
-
if (typeof name !== "string" || name === "") {
|
|
274
|
-
sendJson(outgoing, 400, { error: "name must be a string" });
|
|
275
|
-
return true;
|
|
276
|
-
}
|
|
277
|
-
try {
|
|
278
|
-
const id = await recorder.startSession(name);
|
|
279
|
-
sendJson(outgoing, 200, {
|
|
280
|
-
url: sessionUrl(id),
|
|
281
|
-
} satisfies OpenedSession);
|
|
282
|
-
} catch (error) {
|
|
283
|
-
sendJson(outgoing, 429, { error: (error as Error).message });
|
|
284
|
-
}
|
|
285
|
-
return true;
|
|
286
|
-
}
|
|
287
|
-
case "POST /session/end": {
|
|
288
|
-
const { name, passed } = await readJson<{
|
|
289
|
-
name?: unknown;
|
|
290
|
-
passed?: unknown;
|
|
291
|
-
}>(incoming);
|
|
292
|
-
try {
|
|
293
|
-
const missed = await recorder.endSession(String(name), passed === true);
|
|
294
|
-
sendJson(outgoing, 200, { missed });
|
|
295
|
-
} catch (error) {
|
|
296
|
-
sendJson(outgoing, 409, { error: (error as Error).message });
|
|
297
|
-
}
|
|
298
|
-
return true;
|
|
299
|
-
}
|
|
300
|
-
default:
|
|
301
|
-
return false;
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
/** Where a proxied request goes: the allowed origins and the recorder. */
|
|
306
|
-
export interface ProxyTarget {
|
|
307
|
-
origins: Map<string, URL>;
|
|
308
|
-
recorder: Recorder;
|
|
309
|
-
/**
|
|
310
|
-
* Refuse requests that no rule matches, so nothing goes live. The
|
|
311
|
-
* service sets this for a run without the write token.
|
|
312
|
-
*/
|
|
313
|
-
replayOnly?: boolean;
|
|
314
|
-
/**
|
|
315
|
-
* Send requests to addresses that are not public too, such as
|
|
316
|
-
* `127.0.0.1`. Only for tests and local development.
|
|
317
|
-
*/
|
|
318
|
-
privateNetworks?: boolean;
|
|
319
|
-
/** Set when the target stops. Its tunnels then refuse new requests. */
|
|
320
|
-
closed?: boolean;
|
|
321
|
-
/** Gets a span for each proxied request. */
|
|
322
|
-
telemetry: Telemetry;
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
/**
|
|
326
|
-
* The target of a request and the session id of its proxy URL, or the HTTP
|
|
327
|
-
* status that refuses the request.
|
|
328
|
-
*/
|
|
329
|
-
export type TargetResult =
|
|
330
|
-
| { target: ProxyTarget; session: string | undefined }
|
|
331
|
-
| { status: 403 | 407 };
|
|
332
|
-
|
|
333
|
-
/** The options of `listenProxy()`. */
|
|
334
|
-
export interface ListenOptions {
|
|
335
|
-
host: string;
|
|
336
|
-
port: number;
|
|
337
|
-
authority: CertificateAuthority;
|
|
338
|
-
/**
|
|
339
|
-
* The target of a proxied request. It reads `Proxy-Authorization` of a
|
|
340
|
-
* `CONNECT` or an absolute-form request.
|
|
341
|
-
*/
|
|
342
|
-
targetFor(incoming: http.IncomingMessage): TargetResult;
|
|
343
|
-
/** Answer a request to the proxy itself, such as the control API. */
|
|
344
|
-
control(
|
|
345
|
-
incoming: http.IncomingMessage,
|
|
346
|
-
outgoing: http.ServerResponse,
|
|
347
|
-
): Promise<void>;
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
/** A listening proxy socket. */
|
|
351
|
-
export interface ListeningProxy {
|
|
352
|
-
port: number;
|
|
353
|
-
close(): Promise<void>;
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
/** Refuse a proxied request that has no target. */
|
|
357
|
-
function refuse(status: 403 | 407, outgoing: http.ServerResponse): void {
|
|
358
|
-
outgoing.writeHead(
|
|
359
|
-
status,
|
|
360
|
-
status === 407
|
|
361
|
-
? {
|
|
362
|
-
"proxy-authenticate": 'Basic realm="roach"',
|
|
363
|
-
"content-type": "text/plain",
|
|
364
|
-
}
|
|
365
|
-
: { "content-type": "text/plain" },
|
|
366
|
-
);
|
|
367
|
-
outgoing.end(
|
|
368
|
-
`Roach: ${status === 407 ? "proxy credentials required" : "forbidden"}\n`,
|
|
369
|
-
);
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
/** Proxy one request through its target, and answer errors with 502. */
|
|
373
|
-
function serveProxied(
|
|
374
|
-
{ target, session }: { target: ProxyTarget; session: string | undefined },
|
|
375
|
-
incoming: http.IncomingMessage,
|
|
376
|
-
outgoing: http.ServerResponse,
|
|
377
|
-
tunnelOrigin: string | undefined,
|
|
378
|
-
): void {
|
|
379
|
-
if (target.closed) {
|
|
380
|
-
outgoing.writeHead(410, { "content-type": "text/plain" });
|
|
381
|
-
outgoing.end("Roach: this run has ended\n");
|
|
382
|
-
return;
|
|
383
|
-
}
|
|
384
|
-
// Only the origin: the path and query can hold a credential.
|
|
385
|
-
const origin = tunnelOrigin ?? URL.parse(incoming.url ?? "")?.origin;
|
|
386
|
-
const method = incoming.method ?? "GET";
|
|
387
|
-
target.telemetry
|
|
388
|
-
.span(
|
|
389
|
-
{
|
|
390
|
-
op: "roach.request",
|
|
391
|
-
name: `${method} ${origin}`,
|
|
392
|
-
attributes: { origin: String(origin), "http.request.method": method },
|
|
393
|
-
},
|
|
394
|
-
async () => {
|
|
395
|
-
await proxyRequest(target, session, incoming, outgoing, tunnelOrigin);
|
|
396
|
-
target.telemetry.annotate({
|
|
397
|
-
"http.response.status_code": outgoing.statusCode,
|
|
398
|
-
});
|
|
399
|
-
},
|
|
400
|
-
)
|
|
401
|
-
.catch((error: unknown) => {
|
|
402
|
-
if (!outgoing.headersSent) {
|
|
403
|
-
const status = (error as { status?: number }).status ?? 502;
|
|
404
|
-
outgoing.writeHead(status, { "content-type": "text/plain" });
|
|
405
|
-
}
|
|
406
|
-
outgoing.end(
|
|
407
|
-
`Roach error: ${error instanceof Error ? error.message : String(error)}\n`,
|
|
408
|
-
);
|
|
409
|
-
});
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
/**
|
|
413
|
-
* Listen for proxied requests and control requests on one port. The
|
|
414
|
-
* caller decides the target of each proxied request, so one port serves
|
|
415
|
-
* many runs.
|
|
416
|
-
*/
|
|
417
|
-
export async function listenProxy(
|
|
418
|
-
options: ListenOptions,
|
|
419
|
-
): Promise<ListeningProxy> {
|
|
420
|
-
const { authority } = options;
|
|
421
|
-
// The origin, target, and session of each tunnel. Absolute-form requests
|
|
422
|
-
// have none.
|
|
423
|
-
const tunnels = new WeakMap<
|
|
424
|
-
Socket,
|
|
425
|
-
{ origin: string; target: ProxyTarget; session: string | undefined }
|
|
426
|
-
>();
|
|
427
|
-
const sockets = new Set<Socket>();
|
|
428
|
-
|
|
429
|
-
// Requests inside a CONNECT tunnel. The tunnel gives the origin.
|
|
430
|
-
const tunnelServer = http.createServer((incoming, outgoing) => {
|
|
431
|
-
const tunnel = tunnels.get(incoming.socket);
|
|
432
|
-
if (!tunnel) {
|
|
433
|
-
outgoing.writeHead(400).end();
|
|
434
|
-
return;
|
|
435
|
-
}
|
|
436
|
-
serveProxied(tunnel, incoming, outgoing, tunnel.origin);
|
|
437
|
-
});
|
|
438
|
-
const server = http.createServer((incoming, outgoing) => {
|
|
439
|
-
// A request to the proxy itself has a path. A proxied plain HTTP
|
|
440
|
-
// request has an absolute URL.
|
|
441
|
-
if (!incoming.url?.startsWith("/")) {
|
|
442
|
-
const target = options.targetFor(incoming);
|
|
443
|
-
if ("status" in target) refuse(target.status, outgoing);
|
|
444
|
-
else serveProxied(target, incoming, outgoing, undefined);
|
|
445
|
-
return;
|
|
446
|
-
}
|
|
447
|
-
options.control(incoming, outgoing).catch((error: unknown) => {
|
|
448
|
-
process.stderr.write(`[roach] Control failed: ${String(error)}\n`);
|
|
449
|
-
// An error with a status, such as 413 from readBody, is the fault of
|
|
450
|
-
// the client. Any other error is a bug of the proxy.
|
|
451
|
-
const status = (error as { status?: number }).status ?? 500;
|
|
452
|
-
if (outgoing.headersSent) {
|
|
453
|
-
outgoing.end();
|
|
454
|
-
return;
|
|
455
|
-
}
|
|
456
|
-
sendJson(outgoing, status, {
|
|
457
|
-
error: status === 500 ? "internal error" : (error as Error).message,
|
|
458
|
-
});
|
|
459
|
-
});
|
|
460
|
-
});
|
|
461
|
-
server.on("connection", (socket: Socket) => {
|
|
462
|
-
sockets.add(socket);
|
|
463
|
-
socket.on("close", () => sockets.delete(socket));
|
|
464
|
-
});
|
|
465
|
-
server.on(
|
|
466
|
-
"connect",
|
|
467
|
-
(request: http.IncomingMessage, socket: Socket, head: Buffer) => {
|
|
468
|
-
socket.on("error", () => socket.destroy());
|
|
469
|
-
const target = options.targetFor(request);
|
|
470
|
-
if ("status" in target) {
|
|
471
|
-
socket.end(
|
|
472
|
-
target.status === 407
|
|
473
|
-
? 'HTTP/1.1 407 Proxy Authentication Required\r\nProxy-Authenticate: Basic realm="roach"\r\n\r\n'
|
|
474
|
-
: "HTTP/1.1 403 Forbidden\r\n\r\n",
|
|
475
|
-
);
|
|
476
|
-
return;
|
|
477
|
-
}
|
|
478
|
-
const match = AUTHORITY.exec(request.url ?? "");
|
|
479
|
-
if (!match) {
|
|
480
|
-
socket.end("HTTP/1.1 400 Bad Request\r\n\r\n");
|
|
481
|
-
return;
|
|
482
|
-
}
|
|
483
|
-
const [, host, port] = match as unknown as [string, string, string];
|
|
484
|
-
const allowed = [...target.target.origins.values()].some(
|
|
485
|
-
(origin) => authorityOf(origin) === `${host.toLowerCase()}:${port}`,
|
|
486
|
-
);
|
|
487
|
-
if (!allowed) {
|
|
488
|
-
process.stderr.write(`[roach] Refused ${host}:${port}\n`);
|
|
489
|
-
socket.end("HTTP/1.1 403 Forbidden\r\n\r\n");
|
|
490
|
-
return;
|
|
491
|
-
}
|
|
492
|
-
socket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
|
|
493
|
-
const open = async (first: Buffer) => {
|
|
494
|
-
socket.unshift(first);
|
|
495
|
-
// A TLS handshake starts with byte 0x16. Anything else is plain HTTP.
|
|
496
|
-
if (first[0] !== 0x16) {
|
|
497
|
-
tunnels.set(socket, {
|
|
498
|
-
...target,
|
|
499
|
-
origin: `http://${host}${port === "80" ? "" : `:${port}`}`,
|
|
500
|
-
});
|
|
501
|
-
tunnelServer.emit("connection", socket);
|
|
502
|
-
socket.resume();
|
|
503
|
-
return;
|
|
504
|
-
}
|
|
505
|
-
const secure = new tls.TLSSocket(socket, {
|
|
506
|
-
isServer: true,
|
|
507
|
-
secureContext: await authority.contextFor(host),
|
|
508
|
-
ALPNProtocols: ["http/1.1"],
|
|
509
|
-
});
|
|
510
|
-
secure.on("error", () => secure.destroy());
|
|
511
|
-
tunnels.set(secure, {
|
|
512
|
-
...target,
|
|
513
|
-
origin: `https://${host}${port === "443" ? "" : `:${port}`}`,
|
|
514
|
-
});
|
|
515
|
-
tunnelServer.emit("connection", secure);
|
|
516
|
-
};
|
|
517
|
-
const onFirst = (first: Buffer) => {
|
|
518
|
-
socket.pause();
|
|
519
|
-
open(first).catch(() => socket.destroy());
|
|
520
|
-
};
|
|
521
|
-
if (head.length > 0) onFirst(head);
|
|
522
|
-
else socket.once("data", onFirst);
|
|
523
|
-
},
|
|
524
|
-
);
|
|
525
|
-
|
|
526
|
-
await new Promise<void>((resolve, reject) => {
|
|
527
|
-
server.once("error", reject);
|
|
528
|
-
server.listen(options.port, options.host, () => resolve());
|
|
529
|
-
});
|
|
530
|
-
const address = server.address();
|
|
531
|
-
if (!address || typeof address === "string") {
|
|
532
|
-
throw new Error("Roach did not bind to a TCP port");
|
|
533
|
-
}
|
|
534
|
-
return {
|
|
535
|
-
port: address.port,
|
|
536
|
-
async close() {
|
|
537
|
-
await new Promise<void>((resolve) => {
|
|
538
|
-
server.close(() => resolve());
|
|
539
|
-
for (const socket of sockets) socket.destroy();
|
|
540
|
-
});
|
|
541
|
-
tunnelServer.close();
|
|
542
|
-
},
|
|
543
|
-
};
|
|
544
|
-
}
|