@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/service.ts
DELETED
|
@@ -1,572 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Roach as a shared service. See "Service" in `README.md`.
|
|
3
|
-
*
|
|
4
|
-
* One process serves the runs of many tenants on one port. A tenant is a
|
|
5
|
-
* GitHub repository, named `owner/repo`, such as `getsentry/junior`. The
|
|
6
|
-
* service has no list of tenants: a run names its tenant. A run is one test
|
|
7
|
-
* run of a tenant, such as one CI job. It has its own mode, rules,
|
|
8
|
-
* sessions, and stats.
|
|
9
|
-
*
|
|
10
|
-
* - Anyone can create a `replay` run of a tenant, so CI jobs of forks
|
|
11
|
-
* replay recordings without a secret. Such a run never writes.
|
|
12
|
-
* - A run in another mode needs the write token, because it writes
|
|
13
|
-
* recordings. All tenants share one write token, and the service keeps
|
|
14
|
-
* only its SHA-256. The tenant name is a label, not proof of identity:
|
|
15
|
-
* a holder of the token can write the recordings of any tenant.
|
|
16
|
-
* - The service gives each run its own id and token. A proxied request
|
|
17
|
-
* names its run in `Proxy-Authorization`, as `Basic base64(<id>:<token>)`.
|
|
18
|
-
* The proxy URL of the run has them, so `HTTPS_PROXY` sends them.
|
|
19
|
-
* - A run sends its own config: its origins (`allow`), rules, and value
|
|
20
|
-
* patterns. So a tenant changes what it records without a change to the
|
|
21
|
-
* service. Two checks keep that safe for the other tenants. The proxy
|
|
22
|
-
* sends requests only to public addresses (`addresses.ts`), so a run
|
|
23
|
-
* cannot reach the metadata server or the VPC. The key of a request has
|
|
24
|
-
* a time limit (`request-key.ts`), so a slow pattern fails only its own
|
|
25
|
-
* request.
|
|
26
|
-
* - The recordings of a tenant are under `<tenant>/`, in a directory or a
|
|
27
|
-
* GCS bucket (`gcs.ts`). Tenants never share recordings, because a key
|
|
28
|
-
* does not include credentials.
|
|
29
|
-
* - With `sentryDsn`, the service sends spans and metrics to Sentry. See
|
|
30
|
-
* "Sentry" in `README.md`. Metrics have only attributes with few values,
|
|
31
|
-
* so the key and the run are only on spans.
|
|
32
|
-
*/
|
|
33
|
-
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
34
|
-
import type http from "node:http";
|
|
35
|
-
import path from "node:path";
|
|
36
|
-
import * as Sentry from "@sentry/node";
|
|
37
|
-
import {
|
|
38
|
-
createCertificateAuthority,
|
|
39
|
-
type CertificateAuthorityPem,
|
|
40
|
-
} from "./certificates.ts";
|
|
41
|
-
import { createGcsStore } from "./gcs.ts";
|
|
42
|
-
import { createRecorder, type Telemetry } from "./recorder.ts";
|
|
43
|
-
import { createFileStore } from "./recordings.ts";
|
|
44
|
-
import {
|
|
45
|
-
controlRecorder,
|
|
46
|
-
listenProxy,
|
|
47
|
-
parseOrigins,
|
|
48
|
-
readJson,
|
|
49
|
-
sameToken,
|
|
50
|
-
sendJson,
|
|
51
|
-
type ProxyTarget,
|
|
52
|
-
type TargetResult,
|
|
53
|
-
} from "./server.ts";
|
|
54
|
-
import { RULE_NAME, type RecordingStore } from "./store.ts";
|
|
55
|
-
import {
|
|
56
|
-
CONTROL_PATH,
|
|
57
|
-
type RecordingMode,
|
|
58
|
-
type RecordingStats,
|
|
59
|
-
type RemoteRun,
|
|
60
|
-
type RunConfig,
|
|
61
|
-
} from "./types.ts";
|
|
62
|
-
import { invalidValuePatterns } from "./values.ts";
|
|
63
|
-
|
|
64
|
-
/** The configuration of the service. The operator owns it. */
|
|
65
|
-
export interface RoachServiceConfig {
|
|
66
|
-
/** Default: `127.0.0.1`. */
|
|
67
|
-
host?: string;
|
|
68
|
-
/** Default: a free port. */
|
|
69
|
-
port?: number;
|
|
70
|
-
/**
|
|
71
|
-
* The URL that clients use to reach the service, such as
|
|
72
|
-
* `https://roach.example.com`. Default: `http://<host>:<port>`.
|
|
73
|
-
*/
|
|
74
|
-
publicUrl?: string;
|
|
75
|
-
/** A directory for the recordings. Set this or `bucket`. */
|
|
76
|
-
directory?: string;
|
|
77
|
-
/** A GCS bucket for the recordings. Set this or `directory`. */
|
|
78
|
-
bucket?: string;
|
|
79
|
-
/**
|
|
80
|
-
* The SHA-256 of the write token, in hex. All tenants share the token.
|
|
81
|
-
* Give it only to CI jobs that you trust.
|
|
82
|
-
*/
|
|
83
|
-
writeTokenHash: string;
|
|
84
|
-
/** Credentials to redact in all recordings. */
|
|
85
|
-
secrets?: string[];
|
|
86
|
-
/**
|
|
87
|
-
* Let runs reach addresses that are not public, such as `127.0.0.1` and
|
|
88
|
-
* the metadata server. Only for tests and local development. Default:
|
|
89
|
-
* `false`.
|
|
90
|
-
*/
|
|
91
|
-
privateNetworks?: boolean;
|
|
92
|
-
/**
|
|
93
|
-
* The certificate authority that signs intercepted hosts. Clients trust
|
|
94
|
-
* it, so a deployed service must keep it across restarts. Default: a new
|
|
95
|
-
* one at each start.
|
|
96
|
-
*/
|
|
97
|
-
ca?: CertificateAuthorityPem;
|
|
98
|
-
/** Without a DSN, the service sends nothing to Sentry. */
|
|
99
|
-
sentryDsn?: string;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/** A service that runs in this process. */
|
|
103
|
-
export interface RoachService {
|
|
104
|
-
/** The URL of the service. */
|
|
105
|
-
url: string;
|
|
106
|
-
/** Stop the service. Open runs fail. */
|
|
107
|
-
close(): Promise<void>;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
interface Run extends ProxyTarget {
|
|
111
|
-
id: string;
|
|
112
|
-
token: string;
|
|
113
|
-
started: number;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
const MODES = new Set<RecordingMode>(["auto", "off", "record", "replay"]);
|
|
117
|
-
/** The service gets little traffic, so it keeps all traces. */
|
|
118
|
-
const TRACES_SAMPLE_RATE = 1;
|
|
119
|
-
/** A run that is open longer than this ends as failed, such as a dead job. */
|
|
120
|
-
const RUN_TIMEOUT_MS = 6 * 60 * 60 * 1000;
|
|
121
|
-
/**
|
|
122
|
-
* The open runs without a token that the service can have, for all tenants.
|
|
123
|
-
* Anyone can start them with any tenant name, so the cap is not per tenant.
|
|
124
|
-
* It keeps memory bounded. Runs with the token have no cap.
|
|
125
|
-
*/
|
|
126
|
-
const MAX_PUBLIC_RUNS = 100;
|
|
127
|
-
const MAX_RUN_NAME = 128;
|
|
128
|
-
/**
|
|
129
|
-
* Limits of the config of a run. Anyone can start a run, so they keep the
|
|
130
|
-
* work of a run small: the service makes a certificate for each host that
|
|
131
|
-
* a run tunnels to, and runs each value pattern on each request.
|
|
132
|
-
*/
|
|
133
|
-
const MAX_RUN_ORIGINS = 32;
|
|
134
|
-
const MAX_RULES = 64;
|
|
135
|
-
const MAX_RULE_VALUES = 32;
|
|
136
|
-
const MAX_PATTERN_LENGTH = 2048;
|
|
137
|
-
/** Enough for the API keys and OIDC tokens of a CI job. */
|
|
138
|
-
const MAX_RUN_SECRETS = 32;
|
|
139
|
-
const MAX_SECRET_LENGTH = 16 * 1024;
|
|
140
|
-
/**
|
|
141
|
-
* A tenant name: `owner/repo`, with the characters of GitHub names. It is a
|
|
142
|
-
* path in the store, so each part starts with a letter, digit, or `_`, and
|
|
143
|
-
* there are always two parts. Then no tenant is in the directory of another.
|
|
144
|
-
*/
|
|
145
|
-
const TENANT_NAME = /^[A-Za-z0-9_][\w.-]{0,99}\/[A-Za-z0-9_][\w.-]{0,99}$/;
|
|
146
|
-
|
|
147
|
-
const sha256 = (value: string) => createHash("sha256").update(value).digest();
|
|
148
|
-
|
|
149
|
-
/** Check that `authorization` has the write token, in constant time. */
|
|
150
|
-
function isWriteToken(
|
|
151
|
-
expected: Buffer,
|
|
152
|
-
authorization: string | undefined,
|
|
153
|
-
): boolean {
|
|
154
|
-
const token = /^Bearer (\S+)$/.exec(authorization ?? "")?.[1];
|
|
155
|
-
return token !== undefined && timingSafeEqual(expected, sha256(token));
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
/**
|
|
159
|
-
* The run id, session id, and token in a `Proxy-Authorization` header. The
|
|
160
|
-
* user is `<run id>` or, for a session, `<run id>-<session id>`.
|
|
161
|
-
*/
|
|
162
|
-
function proxyCredentials(
|
|
163
|
-
header: string | undefined,
|
|
164
|
-
): { id: string; session: string | undefined; token: string } | undefined {
|
|
165
|
-
const match = /^Basic (.+)$/i.exec(header ?? "");
|
|
166
|
-
if (!match) return undefined;
|
|
167
|
-
const decoded = Buffer.from(match[1]!, "base64").toString("utf8");
|
|
168
|
-
const colon = decoded.indexOf(":");
|
|
169
|
-
if (colon <= 0) return undefined;
|
|
170
|
-
const [id, session] = decoded.slice(0, colon).split("-", 2) as [
|
|
171
|
-
string,
|
|
172
|
-
string | undefined,
|
|
173
|
-
];
|
|
174
|
-
return { id, session, token: decoded.slice(colon + 1) };
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
/** The proxy URL of a run, or of one session of the run. */
|
|
178
|
-
function proxyUrl(
|
|
179
|
-
publicUrl: string,
|
|
180
|
-
run: { id: string; token: string },
|
|
181
|
-
session?: string,
|
|
182
|
-
): string {
|
|
183
|
-
const url = new URL(publicUrl);
|
|
184
|
-
url.username = session ? `${run.id}-${session}` : run.id;
|
|
185
|
-
url.password = run.token;
|
|
186
|
-
return url.href.replace(/\/$/, "");
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
/** Check the run config of a client. Returns the error, or `undefined`. */
|
|
190
|
-
function invalidRunConfig(config: Partial<RunConfig>): string | undefined {
|
|
191
|
-
if (!config.mode || !MODES.has(config.mode)) {
|
|
192
|
-
return "mode must be auto, off, record, or replay";
|
|
193
|
-
}
|
|
194
|
-
if (config.name !== undefined) {
|
|
195
|
-
if (typeof config.name !== "string" || config.name.length > MAX_RUN_NAME) {
|
|
196
|
-
return `name must be a string of at most ${MAX_RUN_NAME} characters`;
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
if (config.secrets !== undefined) {
|
|
200
|
-
if (
|
|
201
|
-
!Array.isArray(config.secrets) ||
|
|
202
|
-
config.secrets.length > MAX_RUN_SECRETS ||
|
|
203
|
-
!config.secrets.every(
|
|
204
|
-
(secret) =>
|
|
205
|
-
typeof secret === "string" && secret.length <= MAX_SECRET_LENGTH,
|
|
206
|
-
)
|
|
207
|
-
) {
|
|
208
|
-
return `secrets must be a list of at most ${MAX_RUN_SECRETS} strings of at most ${MAX_SECRET_LENGTH} characters`;
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
if (!Array.isArray(config.rules) || config.rules.length > MAX_RULES) {
|
|
212
|
-
return `rules must be a list of at most ${MAX_RULES} rules`;
|
|
213
|
-
}
|
|
214
|
-
const names = new Set<string>();
|
|
215
|
-
for (const rule of config.rules) {
|
|
216
|
-
if (typeof rule?.name !== "string" || !RULE_NAME.test(rule.name)) {
|
|
217
|
-
return `rule name must match ${RULE_NAME}`;
|
|
218
|
-
}
|
|
219
|
-
if (names.has(rule.name)) return `rule ${rule.name} is not unique`;
|
|
220
|
-
names.add(rule.name);
|
|
221
|
-
if (typeof rule.match !== "object" || rule.match === null) {
|
|
222
|
-
return `rule ${rule.name} must have match`;
|
|
223
|
-
}
|
|
224
|
-
// A slow pattern fails only its own requests (`request-key.ts`), so
|
|
225
|
-
// the service takes any pattern that compiles.
|
|
226
|
-
if (rule.values !== undefined) {
|
|
227
|
-
if (typeof rule.values !== "object" || rule.values === null) {
|
|
228
|
-
return `values of rule ${rule.name} must be an object`;
|
|
229
|
-
}
|
|
230
|
-
const values = Object.entries(rule.values);
|
|
231
|
-
if (values.length > MAX_RULE_VALUES) {
|
|
232
|
-
return `rule ${rule.name} can have at most ${MAX_RULE_VALUES} values`;
|
|
233
|
-
}
|
|
234
|
-
for (const [name, source] of values) {
|
|
235
|
-
if (typeof source !== "string" || source.length > MAX_PATTERN_LENGTH) {
|
|
236
|
-
return `value ${name} of rule ${rule.name} must be a pattern of at most ${MAX_PATTERN_LENGTH} characters`;
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
|
-
const patternError = invalidValuePatterns(
|
|
242
|
-
config.rules.flatMap((rule) => (rule.values ? [rule.values] : [])),
|
|
243
|
-
);
|
|
244
|
-
if (patternError) return patternError;
|
|
245
|
-
if (
|
|
246
|
-
!Array.isArray(config.allow) ||
|
|
247
|
-
config.allow.length === 0 ||
|
|
248
|
-
config.allow.length > MAX_RUN_ORIGINS
|
|
249
|
-
) {
|
|
250
|
-
return `allow must be a list of 1 to ${MAX_RUN_ORIGINS} origins`;
|
|
251
|
-
}
|
|
252
|
-
for (const origin of config.allow) {
|
|
253
|
-
// The client sends JSON, so check the type at run time.
|
|
254
|
-
if (typeof origin !== "string") return "allow must be a list of origins";
|
|
255
|
-
try {
|
|
256
|
-
parseOrigins([origin.replace(/\/$/, "")]);
|
|
257
|
-
} catch {
|
|
258
|
-
return `${origin} is not an http or https origin`;
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
return undefined;
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
/**
|
|
265
|
-
* The telemetry of one run, in Sentry. Metrics get `labels`. Spans also get
|
|
266
|
-
* the name of the run.
|
|
267
|
-
*/
|
|
268
|
-
function runTelemetry(
|
|
269
|
-
labels: { tenant: string; mode: RecordingMode },
|
|
270
|
-
run: string,
|
|
271
|
-
): Telemetry {
|
|
272
|
-
return {
|
|
273
|
-
span: ({ op, name = op, attributes }, fn) =>
|
|
274
|
-
Sentry.startSpan(
|
|
275
|
-
{ op, name, attributes: { ...labels, run, ...attributes } },
|
|
276
|
-
fn,
|
|
277
|
-
),
|
|
278
|
-
annotate: (attributes) => {
|
|
279
|
-
Sentry.getActiveSpan()?.setAttributes(attributes);
|
|
280
|
-
},
|
|
281
|
-
count: (name, value, attributes) => {
|
|
282
|
-
Sentry.metrics.count(name, value, {
|
|
283
|
-
attributes: { ...labels, ...attributes },
|
|
284
|
-
});
|
|
285
|
-
},
|
|
286
|
-
time: (name, ms, attributes) => {
|
|
287
|
-
Sentry.metrics.distribution(name, ms, {
|
|
288
|
-
unit: "millisecond",
|
|
289
|
-
attributes: { ...labels, ...attributes },
|
|
290
|
-
});
|
|
291
|
-
},
|
|
292
|
-
};
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
/**
|
|
296
|
-
* The store of one run. Each read and write has a span, and a run without
|
|
297
|
-
* write access cannot write.
|
|
298
|
-
*/
|
|
299
|
-
function runStore(
|
|
300
|
-
base: RecordingStore,
|
|
301
|
-
labels: { tenant: string; mode: RecordingMode; run: string },
|
|
302
|
-
canWrite: boolean,
|
|
303
|
-
): RecordingStore {
|
|
304
|
-
const span = <T>(
|
|
305
|
-
op: string,
|
|
306
|
-
attributes: Record<string, string | number>,
|
|
307
|
-
fn: (span: Sentry.Span) => Promise<T>,
|
|
308
|
-
) =>
|
|
309
|
-
Sentry.startSpan(
|
|
310
|
-
{ op, name: op, attributes: { ...labels, ...attributes } },
|
|
311
|
-
fn,
|
|
312
|
-
);
|
|
313
|
-
return {
|
|
314
|
-
read: (key) =>
|
|
315
|
-
span("roach.store.read", { key }, async (current) => {
|
|
316
|
-
const recording = await base.read(key);
|
|
317
|
-
current.setAttribute("result", recording ? "hit" : "miss");
|
|
318
|
-
return recording;
|
|
319
|
-
}),
|
|
320
|
-
async write(recordings) {
|
|
321
|
-
if (recordings.length === 0) return 0;
|
|
322
|
-
if (!canWrite) throw new Error("This run cannot write recordings");
|
|
323
|
-
return span(
|
|
324
|
-
"roach.store.write",
|
|
325
|
-
{ recordings: recordings.length },
|
|
326
|
-
async (current) => {
|
|
327
|
-
const written = await base.write(recordings);
|
|
328
|
-
current.setAttribute("written", written);
|
|
329
|
-
return written;
|
|
330
|
-
},
|
|
331
|
-
);
|
|
332
|
-
},
|
|
333
|
-
};
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
/** Start the service in this process. */
|
|
337
|
-
export async function startRoachService(
|
|
338
|
-
config: RoachServiceConfig,
|
|
339
|
-
): Promise<RoachService> {
|
|
340
|
-
if ((config.directory === undefined) === (config.bucket === undefined)) {
|
|
341
|
-
throw new Error("Roach service config must set directory or bucket");
|
|
342
|
-
}
|
|
343
|
-
if (!/^[0-9a-f]{64}$/i.test(config.writeTokenHash ?? "")) {
|
|
344
|
-
throw new Error("Roach service config needs writeTokenHash, a SHA-256");
|
|
345
|
-
}
|
|
346
|
-
const writeTokenHash = Buffer.from(config.writeTokenHash, "hex");
|
|
347
|
-
if (config.sentryDsn) {
|
|
348
|
-
Sentry.init({
|
|
349
|
-
dsn: config.sentryDsn,
|
|
350
|
-
tracesSampleRate: TRACES_SAMPLE_RATE,
|
|
351
|
-
// Send only what Roach puts on its spans. The HTTP integrations and
|
|
352
|
-
// `RequestData` add whole URLs, headers, and bodies, which can hold
|
|
353
|
-
// credentials, such as the `secrets` of a run. And the proxy must not
|
|
354
|
-
// add trace headers to the requests that it sends.
|
|
355
|
-
tracePropagationTargets: [],
|
|
356
|
-
integrations: (defaults) => [
|
|
357
|
-
...defaults.filter(
|
|
358
|
-
({ name }) => !["Http", "NodeFetch", "RequestData"].includes(name),
|
|
359
|
-
),
|
|
360
|
-
Sentry.httpIntegration({
|
|
361
|
-
spans: false,
|
|
362
|
-
breadcrumbs: false,
|
|
363
|
-
maxRequestBodySize: "none",
|
|
364
|
-
}),
|
|
365
|
-
Sentry.nativeNodeFetchIntegration({ spans: false, breadcrumbs: false }),
|
|
366
|
-
],
|
|
367
|
-
});
|
|
368
|
-
}
|
|
369
|
-
const runs = new Map<string, Run>();
|
|
370
|
-
const authority = await createCertificateAuthority(config.ca);
|
|
371
|
-
const storeOf = (tenant: string): RecordingStore =>
|
|
372
|
-
config.bucket
|
|
373
|
-
? createGcsStore(config.bucket, `${tenant}/`)
|
|
374
|
-
: createFileStore(path.join(config.directory!, tenant));
|
|
375
|
-
let publicUrl = config.publicUrl?.replace(/\/$/, "");
|
|
376
|
-
|
|
377
|
-
const endRun = async (run: Run): Promise<RecordingStats> => {
|
|
378
|
-
runs.delete(run.id);
|
|
379
|
-
run.closed = true;
|
|
380
|
-
await run.recorder.close();
|
|
381
|
-
return run.recorder.stats();
|
|
382
|
-
};
|
|
383
|
-
|
|
384
|
-
const createRun = (runConfig: RunConfig, canWrite: boolean): RemoteRun => {
|
|
385
|
-
const id = randomBytes(8).toString("hex");
|
|
386
|
-
const telemetry = runTelemetry(
|
|
387
|
-
{ tenant: runConfig.tenant, mode: runConfig.mode },
|
|
388
|
-
runConfig.name ?? id,
|
|
389
|
-
);
|
|
390
|
-
const token = randomBytes(24).toString("hex");
|
|
391
|
-
const store = runStore(
|
|
392
|
-
storeOf(runConfig.tenant),
|
|
393
|
-
{
|
|
394
|
-
tenant: runConfig.tenant,
|
|
395
|
-
mode: runConfig.mode,
|
|
396
|
-
run: runConfig.name ?? id,
|
|
397
|
-
},
|
|
398
|
-
canWrite,
|
|
399
|
-
);
|
|
400
|
-
const run: Run = {
|
|
401
|
-
id,
|
|
402
|
-
token,
|
|
403
|
-
started: Date.now(),
|
|
404
|
-
origins: parseOrigins(
|
|
405
|
-
runConfig.allow.map((origin) => origin.replace(/\/$/, "")),
|
|
406
|
-
),
|
|
407
|
-
// A public run must not be an open proxy to the allowed origins.
|
|
408
|
-
replayOnly: !canWrite,
|
|
409
|
-
privateNetworks: config.privateNetworks === true,
|
|
410
|
-
// Each run learns only the credentials of its own requests.
|
|
411
|
-
recorder: createRecorder(
|
|
412
|
-
{
|
|
413
|
-
mode: runConfig.mode,
|
|
414
|
-
rules: runConfig.rules,
|
|
415
|
-
secrets: [...(config.secrets ?? []), ...(runConfig.secrets ?? [])],
|
|
416
|
-
telemetry,
|
|
417
|
-
},
|
|
418
|
-
store,
|
|
419
|
-
),
|
|
420
|
-
telemetry,
|
|
421
|
-
};
|
|
422
|
-
runs.set(id, run);
|
|
423
|
-
process.stderr.write(
|
|
424
|
-
`[roach] Run ${id} of ${runConfig.tenant} started in ${runConfig.mode} mode\n`,
|
|
425
|
-
);
|
|
426
|
-
return {
|
|
427
|
-
id,
|
|
428
|
-
url: proxyUrl(publicUrl!, run),
|
|
429
|
-
token,
|
|
430
|
-
controlUrl: `${publicUrl}${CONTROL_PATH}/runs/${id}`,
|
|
431
|
-
caCert: authority.caCert,
|
|
432
|
-
};
|
|
433
|
-
};
|
|
434
|
-
|
|
435
|
-
const targetFor = (incoming: http.IncomingMessage): TargetResult => {
|
|
436
|
-
const credentials = proxyCredentials(
|
|
437
|
-
incoming.headers["proxy-authorization"],
|
|
438
|
-
);
|
|
439
|
-
const run = credentials && runs.get(credentials.id);
|
|
440
|
-
if (!run || !sameToken(credentials.token, run.token)) {
|
|
441
|
-
return { status: 407 };
|
|
442
|
-
}
|
|
443
|
-
return { target: run, session: credentials.session };
|
|
444
|
-
};
|
|
445
|
-
|
|
446
|
-
const control = async (
|
|
447
|
-
incoming: http.IncomingMessage,
|
|
448
|
-
outgoing: http.ServerResponse,
|
|
449
|
-
): Promise<void> => {
|
|
450
|
-
const pathname = new URL(incoming.url ?? "/", "http://proxy").pathname;
|
|
451
|
-
if (incoming.method === "GET" && pathname === `${CONTROL_PATH}/ca.pem`) {
|
|
452
|
-
outgoing.writeHead(200, { "content-type": "application/x-pem-file" });
|
|
453
|
-
outgoing.end(authority.caCert);
|
|
454
|
-
return;
|
|
455
|
-
}
|
|
456
|
-
if (incoming.method === "POST" && pathname === `${CONTROL_PATH}/runs`) {
|
|
457
|
-
const runConfig = await readJson<Partial<RunConfig>>(incoming);
|
|
458
|
-
const tenant = runConfig.tenant;
|
|
459
|
-
if (typeof tenant !== "string" || !TENANT_NAME.test(tenant)) {
|
|
460
|
-
sendJson(outgoing, 400, { error: "tenant must be owner/repo" });
|
|
461
|
-
return;
|
|
462
|
-
}
|
|
463
|
-
const error = invalidRunConfig(runConfig);
|
|
464
|
-
if (error) {
|
|
465
|
-
sendJson(outgoing, 400, { error });
|
|
466
|
-
return;
|
|
467
|
-
}
|
|
468
|
-
const canWrite = isWriteToken(
|
|
469
|
-
writeTokenHash,
|
|
470
|
-
incoming.headers.authorization,
|
|
471
|
-
);
|
|
472
|
-
// Reads are public. Every other mode can write, so it needs the token.
|
|
473
|
-
if (!canWrite && runConfig.mode !== "replay") {
|
|
474
|
-
sendJson(outgoing, 401, {
|
|
475
|
-
error: `${runConfig.mode} mode needs the write token`,
|
|
476
|
-
});
|
|
477
|
-
return;
|
|
478
|
-
}
|
|
479
|
-
if (!canWrite) {
|
|
480
|
-
let open = 0;
|
|
481
|
-
for (const other of runs.values()) {
|
|
482
|
-
if (other.replayOnly) open += 1;
|
|
483
|
-
}
|
|
484
|
-
if (open >= MAX_PUBLIC_RUNS) {
|
|
485
|
-
sendJson(outgoing, 429, {
|
|
486
|
-
error: `The service has ${MAX_PUBLIC_RUNS} open runs without a token`,
|
|
487
|
-
});
|
|
488
|
-
return;
|
|
489
|
-
}
|
|
490
|
-
}
|
|
491
|
-
const valid = runConfig as RunConfig;
|
|
492
|
-
const remote = Sentry.startSpan(
|
|
493
|
-
{
|
|
494
|
-
op: "roach.run.start",
|
|
495
|
-
name: "roach.run.start",
|
|
496
|
-
attributes: { tenant, mode: valid.mode, write: canWrite },
|
|
497
|
-
},
|
|
498
|
-
(span) => {
|
|
499
|
-
const created = createRun(valid, canWrite);
|
|
500
|
-
span.setAttribute("run", valid.name ?? created.id);
|
|
501
|
-
return created;
|
|
502
|
-
},
|
|
503
|
-
);
|
|
504
|
-
sendJson(outgoing, 201, remote);
|
|
505
|
-
return;
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
const runRoute = new RegExp(
|
|
509
|
-
`^${CONTROL_PATH}/runs/([0-9a-f]+)(/.*)?$`,
|
|
510
|
-
).exec(pathname);
|
|
511
|
-
const run = runRoute && runs.get(runRoute[1]!);
|
|
512
|
-
if (
|
|
513
|
-
!run ||
|
|
514
|
-
!sameToken(incoming.headers.authorization, `Bearer ${run.token}`)
|
|
515
|
-
) {
|
|
516
|
-
// The same answer for a run that does not exist, so ids stay secret.
|
|
517
|
-
outgoing.writeHead(404).end();
|
|
518
|
-
return;
|
|
519
|
-
}
|
|
520
|
-
const rest = runRoute[2] ?? "";
|
|
521
|
-
if (incoming.method === "DELETE" && rest === "") {
|
|
522
|
-
sendJson(outgoing, 200, await endRun(run));
|
|
523
|
-
return;
|
|
524
|
-
}
|
|
525
|
-
const handled = await controlRecorder(
|
|
526
|
-
run.recorder,
|
|
527
|
-
`${incoming.method} ${rest}`,
|
|
528
|
-
incoming,
|
|
529
|
-
outgoing,
|
|
530
|
-
(session) => proxyUrl(publicUrl!, run, session),
|
|
531
|
-
);
|
|
532
|
-
if (!handled) outgoing.writeHead(404).end();
|
|
533
|
-
};
|
|
534
|
-
|
|
535
|
-
const host = config.host ?? "127.0.0.1";
|
|
536
|
-
const listening = await listenProxy({
|
|
537
|
-
host,
|
|
538
|
-
port: config.port ?? 0,
|
|
539
|
-
authority,
|
|
540
|
-
targetFor,
|
|
541
|
-
control,
|
|
542
|
-
});
|
|
543
|
-
publicUrl ??= `http://${host.includes(":") ? `[${host}]` : host}:${listening.port}`;
|
|
544
|
-
|
|
545
|
-
// End runs that their client never ended, such as a CI job that died.
|
|
546
|
-
const sweep = setInterval(() => {
|
|
547
|
-
const now = Date.now();
|
|
548
|
-
for (const run of runs.values()) {
|
|
549
|
-
if (now - run.started > RUN_TIMEOUT_MS) {
|
|
550
|
-
process.stderr.write(`[roach] Run ${run.id} timed out\n`);
|
|
551
|
-
// A failed close must not stop the service for other runs.
|
|
552
|
-
endRun(run).catch((error: unknown) => {
|
|
553
|
-
process.stderr.write(
|
|
554
|
-
`[roach] Run ${run.id} did not end: ${String(error)}\n`,
|
|
555
|
-
);
|
|
556
|
-
});
|
|
557
|
-
}
|
|
558
|
-
}
|
|
559
|
-
}, 60_000);
|
|
560
|
-
sweep.unref();
|
|
561
|
-
|
|
562
|
-
return {
|
|
563
|
-
url: publicUrl,
|
|
564
|
-
async close() {
|
|
565
|
-
clearInterval(sweep);
|
|
566
|
-
await listening.close();
|
|
567
|
-
await Promise.all([...runs.values()].map(endRun));
|
|
568
|
-
await authority.close();
|
|
569
|
-
await Sentry.flush(2000);
|
|
570
|
-
},
|
|
571
|
-
};
|
|
572
|
-
}
|
package/src/store.ts
DELETED
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Recordings, and the contract of a store that keeps them.
|
|
3
|
-
*
|
|
4
|
-
* A recording keeps the response of one request, and the session (test)
|
|
5
|
-
* that recorded it. It does not keep the request body, so prompts and other
|
|
6
|
-
* inputs are not stored.
|
|
7
|
-
*
|
|
8
|
-
* A store keeps recordings by key, `<rule>/<hash>.json`. The GCS store
|
|
9
|
-
* (`gcs.ts`) keeps them in a bucket. The file store (`recordings.ts`) keeps
|
|
10
|
-
* them in a directory, for tests and development.
|
|
11
|
-
*/
|
|
12
|
-
/** One recorded response. */
|
|
13
|
-
export interface Recording {
|
|
14
|
-
/** The session (test) that recorded the response. */
|
|
15
|
-
session?: string | undefined;
|
|
16
|
-
request: { method: string; url: string };
|
|
17
|
-
response: {
|
|
18
|
-
status: number;
|
|
19
|
-
headers: Record<string, string>;
|
|
20
|
-
/**
|
|
21
|
-
* A text body keeps each changing value of its request as a
|
|
22
|
-
* placeholder (`values.ts`).
|
|
23
|
-
*/
|
|
24
|
-
body: string;
|
|
25
|
-
/** `base64` for a body that is not text, such as an image. */
|
|
26
|
-
bodyEncoding: "base64" | "utf8";
|
|
27
|
-
};
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/** Where the recordings of a tenant are. */
|
|
31
|
-
export interface RecordingStore {
|
|
32
|
-
/** The recording of `key`, or `undefined` when there is none. */
|
|
33
|
-
read(key: string): Promise<Recording | undefined>;
|
|
34
|
-
/** Write recordings by key. Returns how many were new or changed. */
|
|
35
|
-
write(recordings: Array<[string, Recording]>): Promise<number>;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/** A rule name that a store accepts. It is a directory name. */
|
|
39
|
-
export const RULE_NAME = /^[A-Za-z0-9][\w.-]{0,63}$/;
|
|
40
|
-
|
|
41
|
-
/** The key of a request of `rule` with the hash `hash`. */
|
|
42
|
-
export const recordingKey = (rule: string, hash: string) =>
|
|
43
|
-
`${rule}/${hash}.json`;
|
|
44
|
-
|
|
45
|
-
/** The text of a recording, as the stores keep it. */
|
|
46
|
-
export const formatRecording = (recording: Recording) =>
|
|
47
|
-
`${JSON.stringify(recording, null, 2)}\n`;
|