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
56 changes: 40 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import { VALUE_PATTERNS } from "@sentry/roach/values";
const run = await startRemoteRun(
{ url: "https://roach.example.com", token: process.env.ROACH_TOKEN },
{
tenant: "junior",
tenant: process.env.GITHUB_REPOSITORY!, // such as getsentry/junior
mode: "auto",
name: `${process.env.GITHUB_RUN_ID}-${process.env.GITHUB_RUN_ATTEMPT}`,
rules: [
Expand Down Expand Up @@ -198,14 +198,20 @@ give this hint, because it would have to read every recording.
`cli.ts service <config.json>` starts the service. The config is
`RoachServiceConfig` in `src/service.ts`. `deploy/gcp/` writes it for you.

- **Tenants.** A tenant is a project, such as `junior`. Each tenant has a
write token. The config keeps only the SHA-256 of each token. The
recordings of a tenant are under `<tenant>/` in the bucket. Tenants never
share recordings.
- **Tenants.** A tenant is a GitHub repository, as `owner/repo`, such as
`getsentry/junior`. In GitHub Actions, use `GITHUB_REPOSITORY`. The
service has no list of tenants, so a new project needs no change to the
service. The recordings of a tenant are under `<owner>/<repo>/` in the
bucket. Tenants never share recordings.
- **One write token.** All tenants share one write token. The config keeps
only its SHA-256, in `writeTokenHash`. The tenant name is only a label,
not proof of identity: a job with the token can write the recordings of
any tenant. So give the token only to repositories and workflows that
you trust, and never to a workflow that runs code from a fork.
- **Reads are public.** Anyone can start a `replay` run of a tenant, so CI
jobs of forks replay without a secret. Such a run never writes, and it
refuses a request that no rule records, so nothing goes live. Every
other mode needs the tenant token. So treat each recording as readable by
other mode needs the write token. So treat each recording as readable by
anyone who can make its request. Do not record responses that must stay
private.
- **Runs.** Each run has its own id, token, mode, rules, sessions, and
Expand All @@ -229,8 +235,9 @@ give this hint, because it would have to read every recording.

- Runs live in memory in one process. A restart ends the open runs, and
their CI jobs fail. A run that is open for 6 hours ends as failed.
- A tenant can have 50 open runs without a token. Another one gets HTTP
429 until one ends. Runs with the token have no cap.
- The service can have 100 open runs without a token, for all tenants
together. Another one gets HTTP 429 until one ends. Runs with the token
have no cap.
- A request body over 64 MiB gets HTTP 413.
- GCS deletes each recording 30 days after it was written, also when runs
still replay it. Then `auto` mode records it again, and `replay` mode
Expand All @@ -242,7 +249,7 @@ give this hint, because it would have to read every recording.
`deploy/gcp/` is the production setup, in Terraform. It makes:

- a GCS bucket that deletes each recording after `recording_days` (30),
- the certificate authority and one write token for each tenant,
- the certificate authority and the write token,
- the service config, in Secret Manager,
- one Container-Optimized OS VM that runs the image
`ghcr.io/getsentry/roach:main`, with a service account that can only use
Expand All @@ -260,7 +267,7 @@ The `Image` workflow builds the image on each pull request and pushes it on
2. Copy `deploy/gcp/roach.tfvars.example` to `roach.tfvars` and fill it in.
`allow` and `value_patterns` must cover the rules of every tenant.
3. Keep the Terraform state in a private bucket. It holds the CA key and the
tenant tokens. See the `backend "gcs"` comment in `versions.tf`.
write token. See the `backend "gcs"` comment in `versions.tf`.
4. Apply:

```sh
Expand All @@ -271,15 +278,32 @@ The `Image` workflow builds the image on each pull request and pushes it on

5. Point an A record of `domain` at the `ip_address` output. The
certificate works some minutes after DNS does.
6. Give each tenant its token as a CI secret, such as `ROACH_TOKEN`:
`terraform output -json tenant_tokens`.
6. Put the write token in one GitHub organization secret, `ROACH_TOKEN`.
Give it access only to the repositories that you trust. This command
does not show the token:

```sh
terraform output -raw write_token \
| gh secret set ROACH_TOKEN --org getsentry --visibility selected \
--repos getsentry/junior
```

7. Check it: `curl https://<domain>/__roach/ca.pem` returns the CA
certificate.

To deploy a new image, restart the VM:
`gcloud compute instances reset roach --zone <zone>`. To add a tenant,
add it to `tenants` and apply again. Terraform writes a new config version,
and the VM reads it at its next start.
`gcloud compute instances reset roach --zone <zone>`.

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.

To change the write token, run
`terraform apply -var-file=roach.tfvars -replace=random_password.write_token`,
set the secret again, and restart the VM.

## Command line

Expand Down Expand Up @@ -310,7 +334,7 @@ the run token or the token of the local proxy. `client.ts` calls them.
The service also has:

- `POST /__roach/runs` with a `RunConfig`: start a run. Send
`Authorization: Bearer <tenant token>` for any mode but `replay`. Returns
`Authorization: Bearer <write token>` for any mode but `replay`. Returns
the `id`, `token`, proxy `url`, `controlUrl`, and `caCert` of the run.
- `DELETE /__roach/runs/<id>`: end the run. Returns its stats.
- `GET /__roach/ca.pem`: the CA certificate. No token.
Expand Down
25 changes: 13 additions & 12 deletions deploy/gcp/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -58,11 +58,12 @@ resource "tls_self_signed_cert" "ca" {
}
}

# One write token for each tenant. A run without a token can only replay.
resource "random_password" "tenant" {
for_each = var.tenants
length = 48
special = false
# The write token. All tenants share it, so a new project needs no change
# here. A run without it can only replay. To change it:
# terraform apply -replace=random_password.write_token
resource "random_password" "write_token" {
length = 48
special = false
}

# The service config (`RoachServiceConfig` in src/service.ts). The VM reads
Expand All @@ -79,13 +80,13 @@ resource "google_secret_manager_secret_version" "config" {
secret = google_secret_manager_secret.config.id
secret_data = jsonencode(merge(
{
host = "0.0.0.0"
port = local.port
publicUrl = "https://${var.domain}"
bucket = google_storage_bucket.recordings.name
allow = var.allow
tenants = { for name, token in random_password.tenant : name => sha256(token.result) }
valuePatterns = var.value_patterns
host = "0.0.0.0"
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
6 changes: 3 additions & 3 deletions deploy/gcp/outputs.tf
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ output "ca_cert" {
value = tls_self_signed_cert.ca.cert_pem
}

output "tenant_tokens" {
description = "The write token of each tenant. Keep each one in a CI secret of its project, such as ROACH_TOKEN."
value = { for name, token in random_password.tenant : name => token.result }
output "write_token" {
description = "The write token of all tenants. Keep it in one organization secret, ROACH_TOKEN, for the repositories that you trust."
value = random_password.write_token.result
sensitive = true
}
2 changes: 0 additions & 2 deletions deploy/gcp/roach.tfvars.example
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,6 @@
project = "my-gcp-project"
domain = "roach.example.com"

tenants = ["junior"]

# The origins of Junior's evals (packages/junior-evals/src/recording-rules.ts).
allow = [
"https://ai-gateway.vercel.sh",
Expand Down
5 changes: 0 additions & 5 deletions deploy/gcp/variables.tf
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,6 @@ variable "machine_type" {
default = "e2-small"
}

variable "tenants" {
description = "The names of the projects that use Roach, such as junior. Terraform makes a write token for each one."
type = set(string)
}

variable "allow" {
description = "The only origins that runs can reach, such as https://ai-gateway.vercel.sh."
type = list(string)
Expand Down
2 changes: 1 addition & 1 deletion deploy/gcp/versions.tf
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ terraform {
version = "~> 4.4"
}
}
# The state holds the CA key and the tenant tokens. Keep it in a private
# The state holds the CA key and the write token. Keep it in a private
# bucket, for example:
# backend "gcs" {
# bucket = "my-terraform-state"
Expand Down
2 changes: 1 addition & 1 deletion src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,7 @@ export interface RemoteRoach extends RemoteRun, RoachControl {
/**
* Start a run on a Roach service, such as `https://roach.example.com`.
*
* `token` is the token of the tenant. Only this call uses it. Without it,
* `token` is the write token of the service. Only this call uses it. Without it,
* the service only allows `replay` mode, so CI jobs of forks can replay
* without a secret. The run and its workers use the run token.
*/
Expand Down
2 changes: 1 addition & 1 deletion src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -323,7 +323,7 @@ export interface ProxyTarget {
recorder: Recorder;
/**
* Refuse requests that no rule matches, so nothing goes live. The
* service sets this for a run without the tenant token.
* service sets this for a run without the write token.
*/
replayOnly?: boolean;
/** Set when the target stops. Its tunnels then refuse new requests. */
Expand Down
81 changes: 39 additions & 42 deletions src/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,17 @@
* Roach as a shared service. See "Service" in `README.md`.
*
* One process serves the runs of many tenants on one port. A tenant is a
* project, such as Junior. A run is one test run of a tenant, such as one
* CI job. It has its own mode, rules, sessions, and stats, as one local
* proxy (`server.ts`) has.
* GitHub repository, named `owner/repo`, such as `getsentry/junior`. The
* service has no list of tenants: a run names its tenant. A run is one test
* run of a tenant, such as one CI job. It has its own mode, rules,
* sessions, and stats, as one local proxy (`server.ts`) has.
*
* - Anyone can create a `replay` run of a tenant, so CI jobs of forks
* replay recordings without a secret. Such a run never writes.
* - A run in another mode needs the token of the tenant, because it
* writes recordings. The service keeps only the SHA-256 of each token.
* - A run in another mode needs the write token, because it writes
* recordings. All tenants share one write token, and the service keeps
* only its SHA-256. The tenant name is a label, not proof of identity:
* a holder of the token can write the recordings of any tenant.
* - The service gives each run its own id and token. 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.
Expand Down Expand Up @@ -63,10 +66,10 @@ export interface RoachServiceConfig {
/** The only origins that runs can reach. A run can allow fewer. */
allow: string[];
/**
* The tenants, by name. Each value is the SHA-256 of the token of the
* tenant, in hex.
* The SHA-256 of the write token, in hex. All tenants share the token.
* Give it only to CI jobs that you trust.
*/
tenants: Record<string, string>;
writeTokenHash: string;
/**
* More regular expression sources that rules can use in `values`, in
* addition to `VALUE_PATTERNS`. A slow pattern blocks all runs, so check
Expand All @@ -87,7 +90,10 @@ export interface RoachServiceConfig {

/** The configuration of one run. The client sends it. */
export interface RunConfig {
/** The tenant of the run. */
/**
* The tenant of the run, as `owner/repo`, such as
* `process.env.GITHUB_REPOSITORY`.
*/
tenant: string;
mode: RecordingMode;
rules: RecordingRule[];
Expand Down Expand Up @@ -124,7 +130,6 @@ export interface RoachService {

interface Run extends ProxyTarget {
id: string;
tenant: string;
token: string;
started: number;
}
Expand All @@ -133,27 +138,28 @@ const MODES = new Set<RecordingMode>(["auto", "off", "record", "replay"]);
/** A run that is open longer than this ends as failed, such as a dead job. */
const RUN_TIMEOUT_MS = 6 * 60 * 60 * 1000;
/**
* The open runs without a token that one tenant can have. Anyone can start
* them, so the cap keeps memory bounded. Runs with the token have no cap.
* The open runs without a token that the service can have, for all tenants.
* Anyone can start them with any tenant name, so the cap is not per tenant.
* It keeps memory bounded. Runs with the token have no cap.
*/
const MAX_PUBLIC_RUNS = 50;
const MAX_PUBLIC_RUNS = 100;
const MAX_RUN_NAME = 128;
/**
* A tenant name: `owner/repo`, with the characters of GitHub names. It is a
* path in the store, so each part starts with a letter, digit, or `_`, and
* there are always two parts. Then no tenant is in the directory of another.
*/
const TENANT_NAME = /^[A-Za-z0-9_][\w.-]{0,99}\/[A-Za-z0-9_][\w.-]{0,99}$/;

const sha256 = (value: string) => createHash("sha256").update(value).digest();

/** Check that `token` is the token of `tenant`, in constant time. */
function isTenantToken(
tenants: Record<string, string>,
tenant: string,
/** Check that `authorization` has the write token, in constant time. */
function isWriteToken(
expected: Buffer,
authorization: string | undefined,
): boolean {
const token = /^Bearer (\S+)$/.exec(authorization ?? "")?.[1];
const expected = Object.hasOwn(tenants, tenant)
? Buffer.from(tenants[tenant]!, "hex")
: undefined;
if (!token || !expected) return false;
const actual = sha256(token);
return expected.length === actual.length && timingSafeEqual(expected, actual);
return token !== undefined && timingSafeEqual(expected, sha256(token));
}

/** The run id and token in a `Proxy-Authorization` header. */
Expand Down Expand Up @@ -263,14 +269,10 @@ export async function startRoachService(
if ((config.directory === undefined) === (config.bucket === undefined)) {
throw new Error("Roach service config must set directory or bucket");
}
for (const [tenant, hash] of Object.entries(config.tenants)) {
if (!RULE_NAME.test(tenant)) {
throw new Error(`Roach tenant name must match ${RULE_NAME}: ${tenant}`);
}
if (!/^[0-9a-f]{64}$/i.test(hash)) {
throw new Error(`Roach tenant ${tenant} needs a SHA-256 token hash`);
}
if (!/^[0-9a-f]{64}$/i.test(config.writeTokenHash ?? "")) {
throw new Error("Roach service config needs writeTokenHash, a SHA-256");
}
const writeTokenHash = Buffer.from(config.writeTokenHash, "hex");
if (config.sentryDsn) {
Sentry.init({ dsn: config.sentryDsn, tracesSampleRate: 0 });
}
Expand Down Expand Up @@ -305,7 +307,6 @@ export async function startRoachService(
);
const run: Run = {
id,
tenant: runConfig.tenant,
token,
started: Date.now(),
origins: parseOrigins(allow.map((origin) => origin.replace(/\/$/, ""))),
Expand Down Expand Up @@ -361,38 +362,34 @@ export async function startRoachService(
if (incoming.method === "POST" && pathname === `${CONTROL_PATH}/runs`) {
const runConfig = await readJson<Partial<RunConfig>>(incoming);
const tenant = runConfig.tenant;
if (
typeof tenant !== "string" ||
!Object.hasOwn(config.tenants, tenant)
) {
sendJson(outgoing, 400, { error: "tenant is not known" });
if (typeof tenant !== "string" || !TENANT_NAME.test(tenant)) {
sendJson(outgoing, 400, { error: "tenant must be owner/repo" });
return;
}
const error = invalidRunConfig(runConfig, serviceOrigins, valuePatterns);
if (error) {
sendJson(outgoing, 400, { error });
return;
}
const canWrite = isTenantToken(
config.tenants,
tenant,
const canWrite = isWriteToken(
writeTokenHash,
incoming.headers.authorization,
);
// Reads are public. Every other mode can write, so it needs the token.
if (!canWrite && runConfig.mode !== "replay") {
sendJson(outgoing, 401, {
error: `${runConfig.mode} mode needs the token of ${tenant}`,
error: `${runConfig.mode} mode needs the write token`,
});
return;
}
if (!canWrite) {
let open = 0;
for (const other of runs.values()) {
if (other.replayOnly && other.tenant === tenant) open += 1;
if (other.replayOnly) open += 1;
}
if (open >= MAX_PUBLIC_RUNS) {
sendJson(outgoing, 429, {
error: `${tenant} has ${MAX_PUBLIC_RUNS} open runs without a token`,
error: `The service has ${MAX_PUBLIC_RUNS} open runs without a token`,
});
return;
}
Expand Down
Loading
Loading