@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/src/addresses.ts DELETED
@@ -1,103 +0,0 @@
1
- /**
2
- * The addresses that the proxy can send requests to.
3
- *
4
- * A run names its own origins (`service.ts`). So without this check, a run
5
- * could make the service send requests into its own network: to the GCP
6
- * metadata server, which gives tokens of the VM service account, or to
7
- * other hosts in the VPC. The proxy sends requests only to public
8
- * addresses.
9
- *
10
- * The check is on the address that the connection uses, after DNS. A host
11
- * name that resolves to a public address for the check and to a private
12
- * one for the connection (DNS rebinding) thus does not get through.
13
- */
14
- import { lookup as dnsLookup, type LookupAddress } from "node:dns";
15
- import { BlockList, isIP, type LookupFunction } from "node:net";
16
-
17
- /**
18
- * Addresses that are not on the public internet: this host, private
19
- * networks, link-local (with the metadata server), shared, documentation,
20
- * multicast, and reserved ranges. IPv6 forms that hold an IPv4 address
21
- * (NAT64, 6to4, Teredo) are refused too, because they can lead to a
22
- * private IPv4 address. `BlockList` checks IPv4-mapped IPv6 addresses,
23
- * such as `::ffff:127.0.0.1`, as IPv4.
24
- */
25
- const NOT_PUBLIC = new BlockList();
26
- for (const [network, prefix] of [
27
- ["0.0.0.0", 8],
28
- ["10.0.0.0", 8],
29
- ["100.64.0.0", 10],
30
- ["127.0.0.0", 8],
31
- ["169.254.0.0", 16],
32
- ["172.16.0.0", 12],
33
- ["192.0.0.0", 24],
34
- ["192.0.2.0", 24],
35
- ["192.88.99.0", 24],
36
- ["192.168.0.0", 16],
37
- ["198.18.0.0", 15],
38
- ["198.51.100.0", 24],
39
- ["203.0.113.0", 24],
40
- ["224.0.0.0", 4],
41
- ["240.0.0.0", 4],
42
- ] as const) {
43
- NOT_PUBLIC.addSubnet(network, prefix, "ipv4");
44
- }
45
- for (const [network, prefix] of [
46
- ["::", 96],
47
- ["64:ff9b::", 96],
48
- ["64:ff9b:1::", 48],
49
- ["100::", 64],
50
- ["2001::", 23],
51
- ["2001:db8::", 32],
52
- ["2002::", 16],
53
- ["fc00::", 7],
54
- ["fe80::", 10],
55
- ["fec0::", 10],
56
- ["ff00::", 8],
57
- ] as const) {
58
- NOT_PUBLIC.addSubnet(network, prefix, "ipv6");
59
- }
60
-
61
- /** Check that an IP address is on the public internet. */
62
- export function isPublicAddress(address: string): boolean {
63
- const family = isIP(address);
64
- if (family === 0) return false;
65
- return !NOT_PUBLIC.check(address, family === 6 ? "ipv6" : "ipv4");
66
- }
67
-
68
- /** The error of a request to an address that is not public. */
69
- export const notPublicError = (host: string, address: string) =>
70
- Object.assign(
71
- new Error(
72
- `Roach does not send requests to ${host}, because ${address} is not a public address`,
73
- ),
74
- { status: 403, code: "ERR_ROACH_NOT_PUBLIC" },
75
- );
76
-
77
- /**
78
- * A DNS lookup for `http.request()` that fails when the host has an
79
- * address that is not public. It checks all addresses of the host, so the
80
- * choice of the connection does not matter.
81
- */
82
- export const publicLookup: LookupFunction = (hostname, options, callback) => {
83
- dnsLookup(hostname, { ...options, all: true }, (error, addresses) => {
84
- if (error) {
85
- callback(error, "", 0);
86
- return;
87
- }
88
- const blocked = addresses.find(({ address }) => !isPublicAddress(address));
89
- if (blocked) {
90
- callback(notPublicError(hostname, blocked.address), "", 0);
91
- return;
92
- }
93
- if (options.all) {
94
- // The type has only the single-address form of the callback.
95
- (callback as unknown as (e: null, list: LookupAddress[]) => void)(
96
- null,
97
- addresses,
98
- );
99
- return;
100
- }
101
- callback(null, addresses[0]!.address, addresses[0]!.family);
102
- });
103
- };
@@ -1,169 +0,0 @@
1
- /**
2
- * The certificate authority of Roach.
3
- *
4
- * The proxy intercepts HTTPS. It signs one certificate for each host when a
5
- * client first connects to that host, and signs it again before it
6
- * expires. Runs name their own hosts, so the authority keeps the
7
- * certificates of at most `MAX_HOSTS` hosts, and keeps no files for them.
8
- * Clients must trust the authority certificate. The service loads
9
- * a fixed one, so that clients can trust it across restarts.
10
- */
11
- import { execFile } from "node:child_process";
12
- import { createHash, randomBytes } from "node:crypto";
13
- import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
14
- import { isIP } from "node:net";
15
- import { tmpdir } from "node:os";
16
- import path from "node:path";
17
- import tls from "node:tls";
18
-
19
- function openssl(args: string[]): Promise<void> {
20
- return new Promise((resolve, reject) => {
21
- execFile("openssl", args, (error, _stdout, stderr) => {
22
- if (error)
23
- reject(new Error(`openssl failed: ${stderr || error.message}`));
24
- else resolve();
25
- });
26
- });
27
- }
28
-
29
- /** A certificate authority that signs a certificate for each host. */
30
- export interface CertificateAuthority {
31
- /** The PEM certificate that clients must trust. */
32
- caCert: string;
33
- /** The TLS context for a host. Signs its certificate on first use. */
34
- contextFor(host: string): Promise<tls.SecureContext>;
35
- /** Delete the keys and certificates. */
36
- close(): Promise<void>;
37
- }
38
-
39
- /** A PEM certificate authority, such as the one of a deployed service. */
40
- export interface CertificateAuthorityPem {
41
- cert: string;
42
- key: string;
43
- }
44
-
45
- /** Days that a host certificate is valid. */
46
- const HOST_DAYS = 7;
47
- /** Sign a host again when its certificate is older than this. */
48
- const RESIGN_MS = (HOST_DAYS - 1) * 24 * 60 * 60 * 1000;
49
- /** The hosts with a certificate in memory. More hosts drop the oldest one. */
50
- const MAX_HOSTS = 1000;
51
-
52
- /**
53
- * Create a certificate authority in a new temporary directory. Without
54
- * `pem`, it creates a new authority that is valid for 7 days.
55
- */
56
- export async function createCertificateAuthority(
57
- pem?: CertificateAuthorityPem,
58
- ): Promise<CertificateAuthority> {
59
- const directory = await mkdtemp(path.join(tmpdir(), "roach-ca-"));
60
- const caKey = path.join(directory, "ca.key");
61
- const caCertFile = path.join(directory, "ca.crt");
62
- const hostKey = path.join(directory, "host.key");
63
- if (pem) {
64
- await writeFile(caKey, pem.key, { mode: 0o600 });
65
- await writeFile(caCertFile, pem.cert);
66
- } else {
67
- await createAuthority(caKey, caCertFile);
68
- }
69
- // All hosts share one key. Only their certificates differ.
70
- await openssl(["genrsa", "-out", hostKey, "2048"]);
71
- const hostKeyPem = await readFile(hostKey, "utf8");
72
- const contexts = new Map<
73
- string,
74
- { signed: number; context: Promise<tls.SecureContext> }
75
- >();
76
-
77
- const sign = async (host: string): Promise<tls.SecureContext> => {
78
- const name = createHash("sha256").update(host).digest("hex").slice(0, 16);
79
- const csr = path.join(directory, `${name}.csr`);
80
- const extensions = path.join(directory, `${name}.ext`);
81
- const cert = path.join(directory, `${name}.crt`);
82
- const bare = host.replace(/^\[|\]$/g, "");
83
- const altName = isIP(bare) ? `IP:${bare}` : `DNS:${host}`;
84
- await writeFile(
85
- extensions,
86
- `subjectAltName=${altName}\nextendedKeyUsage=serverAuth\n`,
87
- );
88
- await openssl([
89
- "req",
90
- "-new",
91
- "-key",
92
- hostKey,
93
- "-subj",
94
- "/CN=Roach host",
95
- "-out",
96
- csr,
97
- ]);
98
- await openssl([
99
- "x509",
100
- "-req",
101
- "-in",
102
- csr,
103
- "-CA",
104
- caCertFile,
105
- "-CAkey",
106
- caKey,
107
- "-set_serial",
108
- `0x${randomBytes(8).toString("hex")}`,
109
- "-days",
110
- String(HOST_DAYS),
111
- "-extfile",
112
- extensions,
113
- "-out",
114
- cert,
115
- ]);
116
- const certPem = await readFile(cert, "utf8");
117
- await Promise.all(
118
- [csr, extensions, cert].map((file) => rm(file, { force: true })),
119
- );
120
- return tls.createSecureContext({ cert: certPem, key: hostKeyPem });
121
- };
122
-
123
- return {
124
- caCert: await readFile(caCertFile, "utf8"),
125
- contextFor(host) {
126
- const now = Date.now();
127
- let entry = contexts.get(host);
128
- if (!entry || now - entry.signed > RESIGN_MS) {
129
- const fresh = { signed: now, context: sign(host) };
130
- // A failed signature must not stay in the cache.
131
- fresh.context.catch(() => {
132
- if (contexts.get(host) === fresh) contexts.delete(host);
133
- });
134
- contexts.delete(host);
135
- contexts.set(host, fresh);
136
- // A map keeps the order of insertion, so the first key is the oldest.
137
- if (contexts.size > MAX_HOSTS) {
138
- contexts.delete(contexts.keys().next().value!);
139
- }
140
- entry = fresh;
141
- }
142
- return entry.context;
143
- },
144
- close: () => rm(directory, { force: true, recursive: true }),
145
- };
146
- }
147
-
148
- /** Create a new authority that is valid for 7 days. */
149
- async function createAuthority(caKey: string, caCertFile: string) {
150
- await openssl([
151
- "req",
152
- "-x509",
153
- "-newkey",
154
- "rsa:2048",
155
- "-nodes",
156
- "-keyout",
157
- caKey,
158
- "-out",
159
- caCertFile,
160
- "-days",
161
- "7",
162
- "-subj",
163
- "/CN=Roach CA",
164
- "-addext",
165
- "basicConstraints=critical,CA:TRUE",
166
- "-addext",
167
- "keyUsage=critical,keyCertSign,cRLSign",
168
- ]);
169
- }
package/src/cli.ts DELETED
@@ -1,35 +0,0 @@
1
- /**
2
- * The command line of Roach.
3
- *
4
- * `cli.ts service <config.json>` starts the shared service (`service.ts`)
5
- * with the configuration in the file. It prints the URL of the service as
6
- * one JSON line, and stops on SIGINT or SIGTERM. The deployed container
7
- * runs this command.
8
- */
9
- import { readFile } from "node:fs/promises";
10
- import { startRoachService, type RoachServiceConfig } from "./service.ts";
11
-
12
- const [command, ...args] = process.argv.slice(2);
13
- if (command !== "service" || args.length !== 1) {
14
- process.stderr.write("Usage: cli.ts service <config.json>\n");
15
- process.exit(2);
16
- }
17
-
18
- const config = JSON.parse(
19
- await readFile(args[0]!, "utf8"),
20
- ) as RoachServiceConfig;
21
- const service = await startRoachService(config);
22
- process.stdout.write(`${JSON.stringify({ url: service.url })}\n`);
23
-
24
- /** Stop on SIGINT or SIGTERM. A failed close logs its error and exits 1. */
25
- const stop = () => {
26
- service.close().then(
27
- () => process.exit(0),
28
- (error: unknown) => {
29
- process.stderr.write(`[roach] Close failed: ${String(error)}\n`);
30
- process.exit(1);
31
- },
32
- );
33
- };
34
- process.once("SIGINT", stop);
35
- process.once("SIGTERM", stop);
package/src/gcs.ts DELETED
@@ -1,145 +0,0 @@
1
- /**
2
- * The GCS store: the recordings of the shared service (`service.ts`) in a
3
- * Google Cloud Storage bucket.
4
- *
5
- * Each recording is the object `<prefix><key>`. A lifecycle rule of the
6
- * bucket deletes it 30 days after it was written (`deploy/gcp/`), so
7
- * nothing here deletes recordings.
8
- *
9
- * The store calls the JSON API with `fetch`, and takes its access token
10
- * from the metadata server of the VM. So it needs no key file and no
11
- * Google SDK. Two standard variables change the hosts, for local tests:
12
- * `STORAGE_EMULATOR_HOST` (a URL) and `GCE_METADATA_HOST` (a host).
13
- */
14
- import {
15
- formatRecording,
16
- type Recording,
17
- type RecordingStore,
18
- } from "./store.ts";
19
-
20
- /** A call that takes longer than this fails. */
21
- const TIMEOUT_MS = 30_000;
22
- /** Tries of one call. GCS asks clients to retry 429 and 5xx. */
23
- const TRIES = 3;
24
- /** Get a new token when the current one expires within this time. */
25
- const TOKEN_MARGIN_MS = 60_000;
26
-
27
- const isTemporary = (status: number) => status === 429 || status >= 500;
28
- const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
29
-
30
- /** `fetch`, with a timeout and retries of temporary failures. */
31
- async function call(url: string, init: RequestInit): Promise<Response> {
32
- for (let attempt = 1; ; attempt += 1) {
33
- try {
34
- const response = await fetch(url, {
35
- ...init,
36
- signal: AbortSignal.timeout(TIMEOUT_MS),
37
- });
38
- if (!isTemporary(response.status) || attempt === TRIES) return response;
39
- await response.body?.cancel();
40
- } catch (error) {
41
- if (attempt === TRIES) throw error;
42
- }
43
- await wait(250 * 2 ** attempt);
44
- }
45
- }
46
-
47
- /** The access token of the VM service account, from the metadata server. */
48
- function createTokenSource(metadataHost: string): () => Promise<string> {
49
- let cached: { token: string; expires: number } | undefined;
50
- return async () => {
51
- if (cached && cached.expires - TOKEN_MARGIN_MS > Date.now()) {
52
- return cached.token;
53
- }
54
- const response = await call(
55
- `http://${metadataHost}/computeMetadata/v1/instance/service-accounts/default/token`,
56
- { headers: { "metadata-flavor": "Google" } },
57
- );
58
- if (!response.ok) {
59
- throw new Error(
60
- `GCE metadata token failed with HTTP ${response.status}: ${await response.text()}`,
61
- );
62
- }
63
- const body = (await response.json()) as {
64
- access_token: string;
65
- expires_in: number;
66
- };
67
- cached = {
68
- token: body.access_token,
69
- expires: Date.now() + body.expires_in * 1000,
70
- };
71
- return cached.token;
72
- };
73
- }
74
-
75
- /** Where the store sends its calls. */
76
- interface GcsHosts {
77
- /** The base URL of the storage API. */
78
- storage: string;
79
- /** The host of the metadata server. */
80
- metadata: string;
81
- }
82
-
83
- /** The hosts of GCS, or the ones of the standard variables. */
84
- function gcsHosts(env = process.env): GcsHosts {
85
- return {
86
- storage: (
87
- env.STORAGE_EMULATOR_HOST ?? "https://storage.googleapis.com"
88
- ).replace(/\/$/, ""),
89
- metadata: env.GCE_METADATA_HOST ?? "metadata.google.internal",
90
- };
91
- }
92
-
93
- /** The store of the recordings in `bucket`, under `prefix`. */
94
- export function createGcsStore(bucket: string, prefix: string): RecordingStore {
95
- const hosts = gcsHosts();
96
- const token = createTokenSource(hosts.metadata);
97
- const bucketPath = `b/${encodeURIComponent(bucket)}/o`;
98
-
99
- const writeOne = async ([key, recording]: [string, Recording]) => {
100
- const name = encodeURIComponent(`${prefix}${key}`);
101
- const response = await call(
102
- `${hosts.storage}/upload/storage/v1/${bucketPath}?uploadType=media&name=${name}`,
103
- {
104
- method: "POST",
105
- headers: {
106
- authorization: `Bearer ${await token()}`,
107
- "content-type": "application/json",
108
- },
109
- body: formatRecording(recording),
110
- },
111
- );
112
- if (!response.ok) {
113
- throw new Error(
114
- `GCS write of ${key} failed with HTTP ${response.status}: ${await response.text()}`,
115
- );
116
- }
117
- await response.body?.cancel();
118
- };
119
-
120
- return {
121
- async read(key) {
122
- const name = encodeURIComponent(`${prefix}${key}`);
123
- const response = await call(
124
- `${hosts.storage}/storage/v1/${bucketPath}/${name}?alt=media`,
125
- { headers: { authorization: `Bearer ${await token()}` } },
126
- );
127
- if (response.status === 404) {
128
- await response.body?.cancel();
129
- return undefined;
130
- }
131
- if (!response.ok) {
132
- throw new Error(
133
- `GCS read of ${key} failed with HTTP ${response.status}: ${await response.text()}`,
134
- );
135
- }
136
- return (await response.json()) as Recording;
137
- },
138
-
139
- // GCS does not say if an object changed, so each write counts.
140
- async write(recordings) {
141
- await Promise.all(recordings.map(writeOne));
142
- return recordings.length;
143
- },
144
- };
145
- }