Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 33 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,11 +71,12 @@ A repository adds one step and a `roach.json` file:
run: pnpm test
```

`roach.json` is at the root of the repository. It has the `rules` of the
run, and can have `allow` (`RunConfig` in `src/service.ts`):
`roach.json` is at the root of the repository. It has the `allow` origins
and the `rules` of the run (`RunConfig` in `src/service.ts`):

```json
{
"allow": ["https://ai-gateway.vercel.sh"],
"rules": [
{
"name": "model",
Expand Down Expand Up @@ -108,13 +109,16 @@ A run takes `RunConfig` (`src/service.ts`): `tenant`, `mode`, `rules`,
recordings and fails a miss with HTTP 412, so nothing goes live. `record`
sends every request live and records it again. `off` records and replays
nothing.
- `allow`: the only origins that the proxy sends requests to. It refuses all
other origins with HTTP 403. The upstream host always comes from this
list, not from the client.
- `allow`: the only origins that the proxy sends requests to, 1 to 32 of
them. It refuses all other origins with HTTP 403. The upstream host
always comes from this list, not from the request. The service sends
requests only to public addresses, so an origin that resolves to a
private, loopback, or link-local address also gets HTTP 403.
- `rules`: the traffic to record. `name` uses letters, digits, `_`, `.`,
and `-`. `match` has `method`, `url` (a prefix),
and `headers`. `keyHeaders` adds request headers to the key. `values`
names the values that change from run to run.
names the values that change from run to run. A run can have 64 rules,
and a rule can have 32 values.
- Credentials: the proxy never writes the `secrets` of the service config
or of the run. Use `secrets` of the run for the credentials of a CI job
that a response can show but no request header has, such as a token that
Expand Down Expand Up @@ -183,6 +187,14 @@ it, and the provider refuses a changed thinking block.
Keep the patterns narrow. A broad pattern makes requests that differ in a
real way use the same recording.

The service takes any pattern that compiles in 1 s, up to 2048
characters. A run without the token must use the same patterns as the run
that recorded, or its keys do not match. All runs share one process, so
the key of each request has a time limit: 1 s, and 0.5 s for each MiB of
the body. The built-in patterns take less than 0.1 s for each MiB. A
request whose key takes longer fails with HTTP 400, and other runs go on.
Avoid nested quantifiers, such as `(a+)+`.

## Misses

The proxy adds each request without a recording to `stats().misses`, with
Expand Down Expand Up @@ -214,10 +226,16 @@ first miss of each test, which is the one to fix.
stats. A proxied request names its run in `Proxy-Authorization`, as
`Basic base64(<id>:<token>)`. The proxy URL of the run has them, so
`HTTPS_PROXY` sends them. A request without them gets HTTP 407.
- **Value patterns.** A pattern runs for every run in the process, so a
run can only use the patterns in `VALUE_PATTERNS` and the ones in
`valuePatterns` of the config. Check each one for slow backtracking
before you add it.
- **Config of a run.** Each run sends its own `allow` and `rules`, with
their value patterns. A tenant that calls a new origin or uses a new
pattern changes only its own config. The service needs no change and no
restart. Two checks keep one run from harming the others:
- The proxy sends requests only to public addresses (`addresses.ts`).
It checks the address of each connection after DNS, so a host name
cannot point a run at the metadata server, at the VPC, or at the VM.
Only tests and local development set `privateNetworks`.
- The key of a request has a time limit (`request-key.ts`), so a slow
pattern fails only its own request.
- **Sentry.** With `sentryDsn`, each read and write of a recording sends
the metric `roach.recording`. Its attributes are `tenant`, `rule`, `key`,
`run` (the `name` of the run), and `result`: `replayed`, `missed`, or
Expand Down Expand Up @@ -261,9 +279,8 @@ The `Image` workflow builds the image on each pull request and pushes it on
1. Make the package `ghcr.io/getsentry/roach` public once, after the first
push to `main`, so the VM can pull it without credentials.
2. `deploy/gcp/roach.tfvars` has the values of the Sentry deployment: the
GCP project `roach-511216`, the domain, `allow`, and `value_patterns`.
`allow` and `value_patterns` must cover the rules of every tenant. Another
deployment changes these values.
GCP project `roach-511216` and the domain. Another deployment changes
these values. It has no origins or patterns: runs send them.
3. The Terraform state is in the private bucket that `backend "gcs"` in
`versions.tf` names. The state holds the CA key and the write token, so
the bucket must stay private. Make the bucket before the first
Expand Down Expand Up @@ -296,10 +313,8 @@ To deploy a new image, restart the VM:

To add a project, add its repository to the repositories of the
`ROACH_TOKEN` organization secret, in the GitHub settings of the
organization. Its CI jobs set `tenant` to `GITHUB_REPOSITORY`. When the
project calls origins or uses value patterns that are not in
`roach.tfvars` yet, add them and apply again. Terraform writes a new config version, and the
VM reads it at its next start.
organization. Its CI jobs set `tenant` to `GITHUB_REPOSITORY`, and send
their own origins and patterns. The service needs no change.

To change the write token, run
`terraform apply -var-file=roach.tfvars -replace=random_password.write_token`,
Expand Down Expand Up @@ -376,6 +391,7 @@ The proxy is in `src/`. Tests are in `tests/`. The production setup is in
- `types.ts`: rules, modes, and the results of a run.
- `server.ts`: sockets, HTTPS interception, the allow list, and the control
API.
- `addresses.ts`: the public addresses that the proxy can send requests to.
- `service.ts`: the shared service: tenants, runs, access, and metrics.
- `recorder.ts`: modes, sessions, replay, and misses.
- `request-key.ts`: the key of a request.
Expand Down
2 changes: 0 additions & 2 deletions deploy/gcp/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -84,9 +84,7 @@ resource "google_secret_manager_secret_version" "config" {
port = local.port
publicUrl = "https://${var.domain}"
bucket = google_storage_bucket.recordings.name
allow = var.allow
writeTokenHash = sha256(random_password.write_token.result)
valuePatterns = var.value_patterns
ca = {
cert = tls_self_signed_cert.ca.cert_pem
key = tls_private_key.ca.private_key_pem
Expand Down
18 changes: 2 additions & 16 deletions deploy/gcp/roach.tfvars
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,7 @@ project = "roach-511216"
# The record is in getsentry/ops (getsentry/ops#24232).
domain = "roach-proxy.getsentry.net"

# The origins of Junior's evals (packages/junior-evals/src/recording-rules.ts).
allow = [
"https://ai-gateway.vercel.sh",
"https://vercel.com",
"https://api.vercel.com",
"https://oidc.vercel.com",
"https://docs.slack.dev",
]

# Junior's own value patterns, in addition to the built-in ones. Each one must
# be equal to its source in packages/junior-evals/src/recording-rules.ts, or
# the service refuses the run.
value_patterns = [
"(?<!(?:^|[^\\\\])[0-9A-Za-z])(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) {1,2}\\d{1,2}(?:, \\d{4}, \\d{1,2}:\\d{2}\\s?[AP]M| \\d{2}:\\d{2})\\b",
"(?<=event_id=)[0-9a-f]{32}(?![0-9A-Za-z])",
]
# Runs send their own origins and value patterns (RunConfig in src/service.ts),
# so a tenant needs no change here.

# To send metrics to Sentry, set TF_VAR_sentry_dsn when you apply.
11 changes: 0 additions & 11 deletions deploy/gcp/variables.tf
Original file line number Diff line number Diff line change
Expand Up @@ -32,17 +32,6 @@ variable "machine_type" {
default = "e2-small"
}

variable "allow" {
description = "The only origins that runs can reach, such as https://ai-gateway.vercel.sh."
type = list(string)
}

variable "value_patterns" {
description = "Regular expression sources that rules can use in values, in addition to the built-in ones. Check each one for slow backtracking."
type = list(string)
default = []
}

variable "recording_days" {
description = "GCS deletes each recording this many days after it was written."
type = number
Expand Down
103 changes: 103 additions & 0 deletions src/addresses.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
/**
* The addresses that the proxy can send requests to.
*
* A run names its own origins (`service.ts`). So without this check, a run
* could make the service send requests into its own network: to the GCP
* metadata server, which gives tokens of the VM service account, or to
* other hosts in the VPC. The proxy sends requests only to public
* addresses.
*
* The check is on the address that the connection uses, after DNS. A host
* name that resolves to a public address for the check and to a private
* one for the connection (DNS rebinding) thus does not get through.
*/
import { lookup as dnsLookup, type LookupAddress } from "node:dns";
import { BlockList, isIP, type LookupFunction } from "node:net";

/**
* Addresses that are not on the public internet: this host, private
* networks, link-local (with the metadata server), shared, documentation,
* multicast, and reserved ranges. IPv6 forms that hold an IPv4 address
* (NAT64, 6to4, Teredo) are refused too, because they can lead to a
* private IPv4 address. `BlockList` checks IPv4-mapped IPv6 addresses,
* such as `::ffff:127.0.0.1`, as IPv4.
*/
const NOT_PUBLIC = new BlockList();
for (const [network, prefix] of [
["0.0.0.0", 8],
["10.0.0.0", 8],
["100.64.0.0", 10],
["127.0.0.0", 8],
["169.254.0.0", 16],
["172.16.0.0", 12],
["192.0.0.0", 24],
["192.0.2.0", 24],
["192.88.99.0", 24],
["192.168.0.0", 16],
["198.18.0.0", 15],
["198.51.100.0", 24],
["203.0.113.0", 24],
["224.0.0.0", 4],
["240.0.0.0", 4],
] as const) {
NOT_PUBLIC.addSubnet(network, prefix, "ipv4");
}
for (const [network, prefix] of [
["::", 96],
["64:ff9b::", 96],
["64:ff9b:1::", 48],
["100::", 64],
["2001::", 23],
["2001:db8::", 32],
["2002::", 16],
["fc00::", 7],
["fe80::", 10],
["fec0::", 10],
["ff00::", 8],
] as const) {
NOT_PUBLIC.addSubnet(network, prefix, "ipv6");
}

/** Check that an IP address is on the public internet. */
export function isPublicAddress(address: string): boolean {
const family = isIP(address);
if (family === 0) return false;
return !NOT_PUBLIC.check(address, family === 6 ? "ipv6" : "ipv4");
}

/** The error of a request to an address that is not public. */
export const notPublicError = (host: string, address: string) =>
Object.assign(
new Error(
`Roach does not send requests to ${host}, because ${address} is not a public address`,
),
{ status: 403, code: "ERR_ROACH_NOT_PUBLIC" },
);

/**
* A DNS lookup for `http.request()` that fails when the host has an
* address that is not public. It checks all addresses of the host, so the
* choice of the connection does not matter.
*/
export const publicLookup: LookupFunction = (hostname, options, callback) => {
dnsLookup(hostname, { ...options, all: true }, (error, addresses) => {
if (error) {
callback(error, "", 0);
return;
}
const blocked = addresses.find(({ address }) => !isPublicAddress(address));
if (blocked) {
callback(notPublicError(hostname, blocked.address), "", 0);
return;
}
if (options.all) {
// The type has only the single-address form of the callback.
(callback as unknown as (e: null, list: LookupAddress[]) => void)(
null,
addresses,
);
return;
}
callback(null, addresses[0]!.address, addresses[0]!.family);
});
};
20 changes: 15 additions & 5 deletions src/certificates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
*
* The proxy intercepts HTTPS. It signs one certificate for each host when a
* client first connects to that host, and signs it again before it
* expires. Clients must trust the authority certificate. The service loads
* expires. Runs name their own hosts, so the authority keeps the
* certificates of at most `MAX_HOSTS` hosts, and keeps no files for them.
* Clients must trust the authority certificate. The service loads
* a fixed one, so that clients can trust it across restarts.
*/
import { execFile } from "node:child_process";
Expand Down Expand Up @@ -44,6 +46,8 @@ export interface CertificateAuthorityPem {
const HOST_DAYS = 7;
/** Sign a host again when its certificate is older than this. */
const RESIGN_MS = (HOST_DAYS - 1) * 24 * 60 * 60 * 1000;
/** The hosts with a certificate in memory. More hosts drop the oldest one. */
const MAX_HOSTS = 1000;

/**
* Create a certificate authority in a new temporary directory. Without
Expand Down Expand Up @@ -109,10 +113,11 @@ export async function createCertificateAuthority(
"-out",
cert,
]);
return tls.createSecureContext({
cert: await readFile(cert, "utf8"),
key: hostKeyPem,
});
const certPem = await readFile(cert, "utf8");
await Promise.all(
[csr, extensions, cert].map((file) => rm(file, { force: true })),
);
return tls.createSecureContext({ cert: certPem, key: hostKeyPem });
};

return {
Expand All @@ -126,7 +131,12 @@ export async function createCertificateAuthority(
fresh.context.catch(() => {
if (contexts.get(host) === fresh) contexts.delete(host);
});
contexts.delete(host);
contexts.set(host, fresh);
// A map keeps the order of insertion, so the first key is the oldest.
if (contexts.size > MAX_HOSTS) {
contexts.delete(contexts.keys().next().value!);
}
entry = fresh;
}
return entry.context;
Expand Down
42 changes: 35 additions & 7 deletions src/request-key.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@
* and the body. A JSON body has sorted object keys, so key order does not
* matter. Each changing value of the rule is `<<name>>` in the key
* (`values.ts`).
*
* The patterns of `values` come from the client, and all runs share one
* process. A slow pattern would block every run, so a key has a time limit
* (`KEY_TIME_LIMIT`).
*/
import { createHash } from "node:crypto";
import type { IncomingHttpHeaders } from "node:http";
Expand All @@ -15,11 +19,27 @@ import {
type KnownValues,
type RequestValues,
type ValuePatterns,
withTimeLimit,
} from "./values.ts";

/** Change this to make every recording a miss. */
const KEY_VERSION = "http-v1";

/**
* The time that the key of one request can take: 1 s, and 0.5 s for each
* MiB of the body. The built-in patterns take less than 0.1 s for each
* MiB. A key that takes longer fails its request, and other runs go on.
*/
const KEY_TIME_LIMIT = { baseMs: 1000, msPerMiB: 500 };

/** The parts of a request that make its key. */
interface RequestToKey {
method: string;
url: string;
headers: IncomingHttpHeaders;
body: string;
}

/** A request as the key sees it. */
export interface KeyedRequest {
/** The name of the recording file, without `.json`. */
Expand Down Expand Up @@ -100,16 +120,24 @@ const sha256 = (text: string) =>
createHash("sha256").update(text).digest("hex");
/**
* The key and the changing values of a request. `known` holds
* the values of earlier requests in the same session (`values.ts`).
* the values of earlier requests in the same session (`values.ts`). It
* throws an error with HTTP status 400 when it takes too long.
*/
export function keyRequest(
rule: RecordingRule,
request: {
method: string;
url: string;
headers: IncomingHttpHeaders;
body: string;
},
request: RequestToKey,
known?: KnownValues,
): KeyedRequest {
const mib = Buffer.byteLength(request.body) / (1024 * 1024);
return withTimeLimit(
Math.ceil(KEY_TIME_LIMIT.baseMs + KEY_TIME_LIMIT.msPerMiB * mib),
() => keyRequestNow(rule, request, known),
);
}

function keyRequestNow(
rule: RecordingRule,
request: RequestToKey,
known?: KnownValues,
): KeyedRequest {
const headers = Object.fromEntries(
Expand Down
Loading
Loading