diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..07c788c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +* +!package.json +!pnpm-lock.yaml +!pnpm-workspace.yaml +!src diff --git a/.github/workflows/image.yml b/.github/workflows/image.yml new file mode 100644 index 0000000..5fd5439 --- /dev/null +++ b/.github/workflows/image.yml @@ -0,0 +1,58 @@ +name: Image + +# Build the service image on each pull request. On main, also push it to +# ghcr.io/getsentry/roach, where deploy/gcp pulls it from. +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +jobs: + image: + name: image + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 + - if: github.ref == 'refs/heads/main' + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 + with: + context: . + push: ${{ github.ref == 'refs/heads/main' }} + tags: | + ghcr.io/getsentry/roach:main + ghcr.io/getsentry/roach:${{ github.sha }} + + terraform: + name: terraform + runs-on: ubuntu-latest + timeout-minutes: 10 + defaults: + run: + working-directory: deploy/gcp + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1 + - run: terraform fmt -check + - run: terraform init -backend=false + - run: terraform validate diff --git a/AGENTS.md b/AGENTS.md index 89bee72..d7d7f46 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,20 +14,23 @@ | Lint file | `pnpm exec oxlint --deny-warnings src/server.ts` | | Format | `pnpm format` | | Typecheck | `pnpm typecheck` | +| Deployed shape | `pnpm exec vitest run tests/deployed.test.ts` | | Unused code/deps | `pnpm knip` | | Everything (CI) | `pnpm check` | ## External References -| Need | File | -| -------------------------------------- | ---------------------- | -| Usage, config, recordings, control API | `README.md` | -| Public types | `src/types.ts` | -| Map of source files | "Files" in `README.md` | +| Need | File | +| -------------------------------------- | ------------------------------------- | +| Usage, config, recordings, control API | `README.md` | +| Service access, limits, and deploy | "Service" and "Deploy" in `README.md` | +| Public types | `src/types.ts` | +| Map of source files | "Files" in `README.md` | ## Key Conventions -- Add no runtime dependencies. Use Node built-ins and the `openssl` command only. +- Use Node built-ins and the `openssl` command. The only runtime dependency is `@sentry/node`, and only `src/service.ts` imports it. The local proxy and the client must not load it. +- `deploy/gcp/` is the production setup (Terraform). Change it in the same change when the service config (`RoachServiceConfig`) changes. CI runs `terraform validate` on it. - Node runs `src/` as TypeScript with type stripping. Use only erasable syntax, and import local files with the `.ts` extension. - Use functions and plain objects, not classes. - Start each source file with a comment that says what the file owns. Give each export a short JSDoc. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e7019e1 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +# The image of the Roach service. deploy/gcp runs it with the config file +# at /config/roach.json. See "Deploy" in README.md. +FROM node:24-alpine +# Roach signs a certificate for each intercepted host with openssl. +RUN apk add --no-cache openssl && corepack enable +WORKDIR /app +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +RUN pnpm install --prod --frozen-lockfile --ignore-scripts +COPY src ./src +USER node +EXPOSE 8080 +ENTRYPOINT ["node", "src/cli.ts"] +CMD ["service", "/config/roach.json"] diff --git a/README.md b/README.md index 3f31d18..6ec6841 100644 --- a/README.md +++ b/README.md @@ -1,58 +1,95 @@ # Roach -Roach is a recording HTTP proxy for tests. It records chosen requests to files in git -and replays them, so a second run of a test suite makes no live calls. The +Roach is a recording HTTPS proxy for tests. It records chosen requests and +replays them, so a second run of a test suite makes no live calls. The proxy owns every read and write of the recordings. A test runner only gives it rules and tells it when each test starts and ends. +Roach runs in one of two ways: + +- **A shared service** (`service.ts`): one deployed proxy for many + projects and CI runs. A client points `HTTPS_PROXY` at it and trusts its + certificate authority. It installs nothing else. The recordings are in a + GCS bucket, which deletes each one 30 days after it was written. See + [Service](#service) and [Deploy](#deploy). +- **A local proxy** (`server.ts`): a process in the process tree of the + test run, with the recordings in JSON files that you commit to git. + Roach started in [`getsentry/junior`](https://github.com/getsentry/junior/tree/main/packages/junior-evals/src/roach). -It uses only Node built-ins and the `openssl` command. +It uses Node built-ins and the `openssl` command. Only the service uses the +Sentry SDK (`@sentry/node`). -## Use +## Use the service ```ts -import { spawnRoach, connectRoach } from "@sentry/roach/client"; +import { startRemoteRun, connectRoach } from "@sentry/roach/client"; import { describeRecordingStats } from "@sentry/roach/report"; import { VALUE_PATTERNS } from "@sentry/roach/values"; -// Once per run. +// Once per CI job. Without `token`, only `replay` mode is allowed. +const run = await startRemoteRun( + { url: "https://roach.example.com", token: process.env.ROACH_TOKEN }, + { + tenant: "junior", + mode: "auto", + name: `${process.env.GITHUB_RUN_ID}-${process.env.GITHUB_RUN_ATTEMPT}`, + rules: [ + { + name: "model", + match: { method: "POST", url: "https://ai-gateway.vercel.sh/" }, + keyHeaders: ["ai-model-id"], + values: { uuid: VALUE_PATTERNS.uuid, time: VALUE_PATTERNS.isoTime }, + }, + ], + }, +); +spawn("vitest", { env: { ...process.env, ...run.env } }); + +// Once per test, in a worker that has the `url`, `controlUrl`, and `token` +// of the run. +const session = await connectRoach({ url, controlUrl, token }).startSession( + testName, +); +const { missed } = await session.end(passed); +// In `replay` mode, fail the test when `missed` is not 0. + +// At the end of the job. +console.log(describeRecordingStats(await run.close())); +``` + +A client that does not use `client.ts` can do the same with plain HTTP. See +[Control API](#control-api). + +## Use a local proxy + +```ts +import { spawnRoach } from "@sentry/roach/client"; + const proxy = await spawnRoach({ directory: "recordings", mode: "auto", allow: ["https://ai-gateway.vercel.sh"], - rules: [ - { - name: "model", - match: { method: "POST", url: "https://ai-gateway.vercel.sh/" }, - keyHeaders: ["ai-model-id"], - values: { uuid: VALUE_PATTERNS.uuid, time: VALUE_PATTERNS.isoTime }, - }, - ], + rules: [/* as above */], }); spawn("vitest", { env: { ...process.env, ...proxy.env } }); - -// Once per test, in a worker that has the `url` and `token` of the proxy. -const session = await connectRoach({ url, token }).startSession(testName); -const { missed } = await session.end(passed); -// In `replay` mode, fail the test when `missed` is not 0. - -// At the end of the run. +// Sessions as above, with connectRoach({ url: proxy.url, token: proxy.token }). console.log(describeRecordingStats(await proxy.stats())); await proxy.close(); ``` -- `proxy.env` has `HTTP_PROXY`, `HTTPS_PROXY`, `NO_PROXY`, - `NODE_USE_ENV_PROXY`, and `NODE_EXTRA_CA_CERTS`. Node 24 reads the last two - only at startup. A process that is already running must set its own - agents. +- `env` has `HTTP_PROXY`, `HTTPS_PROXY`, `NO_PROXY`, `NODE_USE_ENV_PROXY`, + and `NODE_EXTRA_CA_CERTS`. Node 24 reads the last two only at startup. A + process that is already running must set its own agents. - `spawnRoach(config, { launcher })` runs the proxy with a command prefix, such as `sudo`. Use it when the caller cannot reach the network, but the proxy must. ## Config -`src/types.ts` has the full types. +A local proxy takes `RoachConfig` (`src/types.ts`). A run on the service +takes `RunConfig` (`src/service.ts`): `tenant`, `mode`, `rules`, `allow`, +and `name`. The fields below mean the same in both. - `directory`: where the recordings are. Each rule has a subdirectory. - `mode`: `auto` replays recordings and records misses. `replay` replays @@ -62,12 +99,14 @@ await proxy.close(); - `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. -- `rules`: the traffic to record. `match` has `method`, `url` (a prefix), +- `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. - `usedFile`: when the proxy stops, it lists here the recordings that sessions used. A failed session uses all recordings that it recorded - before, because it stops early. `cli.ts prune` takes these files. + before, because it stops early. `cli.ts prune` takes these files. Only + for `directory`. - `missDirectory`: for each miss, the proxy writes the request as the key sees it, and its diagnosis, to `//.json`. These files contain request bodies, but no request headers except `keyHeaders`. @@ -85,7 +124,8 @@ stream. Each response has an `x-roach` header: `replayed`, `live`, ## Recordings -A recording is `//.json`. The key is the hash of the +The key of a recording is `/.json`. The file store keeps it at +`/`. The hash is the hash of the rule, the method, the URL, the key headers, and the body. A JSON body has sorted keys. A recording keeps the response, the session that recorded it, and a short hash of each part of the request. It does not keep the request @@ -142,33 +182,138 @@ real way use the same recording. The parts of a request are the method, the URL, each key header, each top-level field of a JSON body, and each item of a top-level array, such as -`messages[3]`. For a request without a recording, the proxy finds the -recording with the most equal parts, from the same session if it can. It -logs the parts that differ and adds the miss to `stats().misses`. -`describeRecordingMisses()` in `report.ts` gives the first miss of each -test, which is the one to fix. +`messages[3]`. The proxy adds each request without a recording to +`stats().misses`. `describeRecordingMisses()` in `report.ts` gives the +first miss of each test, which is the one to fix. + +With the file store, the proxy also finds the recording with the most +equal parts, from the same session if it can, and logs the parts that +differ. This is only a hint to debug a miss. It never makes a replay. To +find it, the file store reads all recordings of a rule on the first miss of +that rule in a run, and then keeps them in memory. The GCS store does not +give this hint, because it would have to read every recording. + +## Service + +`cli.ts service ` 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 `/` in the bucket. Tenants never + share recordings. +- **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 + 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 + stats. A proxied request names its run in `Proxy-Authorization`, as + `Basic base64(:)`. 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. +- **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 + `written`. Group by `key` and count `run`, and a recording that only one + run uses shows that a value changes on each run. +- **Certificate authority.** `ca` holds the PEM certificate and key that + clients trust. Keep it across restarts. Host certificates live for 7 + days, and the service makes new ones before they expire. + +### Limits + +- 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. +- 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 + fails the request with HTTP 412. +- A write replaces the recording with the same key. + +## Deploy + +`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 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 + the bucket and the secret, +- an SSL proxy load balancer with a Google-managed certificate. It ends TLS + and passes the raw TCP stream, with `CONNECT`, to the VM. + +The `Image` workflow builds the image on each pull request and pushes it on +`main`. The VM pulls the image at each start. + +### Set up + +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. 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`. +4. Apply: + + ```sh + cd deploy/gcp + terraform init + terraform apply -var-file=roach.tfvars + ``` + +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`. +7. Check it: `curl https:///__roach/ca.pem` returns the CA + certificate. + +To deploy a new image, restart the VM: +`gcloud compute instances reset roach --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. ## Command line ```sh -node --experimental-strip-types src/cli.ts serve < config.json -node --experimental-strip-types src/cli.ts prune ... +node src/cli.ts service +node src/cli.ts serve < config.json +node src/cli.ts prune ... ``` -`serve` prints the address of the proxy as one JSON line. `client.ts` runs -it. `prune` deletes the recordings that no used file lists. Give it the used -files of every run that shares the directory, or it deletes recordings that -another run needs. +`service` starts the shared service. `serve` starts a local proxy and prints +its address as one JSON line. `client.ts` runs it. `prune` deletes the +recordings that no used file lists. Give it the used files of every run +that shares the directory, or it deletes recordings that another run +needs. ## Control API -All calls need `Authorization: Bearer `. `client.ts` calls them. +The calls of a run are under `/__roach/runs/` on the service, and under +`/__roach` on a local proxy. They need `Authorization: Bearer `, with +the run token or the token of the local proxy. `client.ts` calls them. + +- `POST /session` with `{"name": "..."}`: open a session. +- `POST /session/end` with `{"name": "...", "passed": true}`: end it. + Returns `{"missed": 0}`, the misses that `replay` mode failed. Returns + HTTP 409 when the session is not open. +- `GET /stats`: the totals of the run. -- `POST /__roach/session` with `{"name": "..."}`: open a session. -- `POST /__roach/session/end` with `{"name": "...", "passed": true}`: - end it. Returns `{"missed": 0}`, the misses that `replay` mode failed. - Returns HTTP 409 when the session is not open. -- `GET /__roach/stats`: the totals of the run. +The service also has: + +- `POST /__roach/runs` with a `RunConfig`: start a run. Send + `Authorization: Bearer ` for any mode but `replay`. Returns + the `id`, `token`, proxy `url`, `controlUrl`, and `caCert` of the run. +- `DELETE /__roach/runs/`: end the run. Returns its stats. +- `GET /__roach/ca.pem`: the CA certificate. No token. ## Development @@ -179,20 +324,32 @@ pnpm check # format, lint, types, unused code, and tests, as CI runs them `AGENTS.md` has the conventions and the commands for one file. +- `tests/roach.test.ts` tests a local proxy. +- `tests/service.test.ts` tests the service in the test process. +- `tests/deployed.test.ts` runs the service as it runs in production: from + the command line, behind a TLS server that does what the load balancer + does, with a local GCS server, metadata server, and Sentry server. Each + CI job (`tests/ci-job.ts`) sets only the proxy variables of its run. + ## Files -All source files are in `src/`. Tests are in `tests/`. +The proxy is in `src/`. Tests are in `tests/`. The production setup is in +`deploy/gcp/` and `Dockerfile`. -- `types.ts`: the configuration and the results. +- `types.ts`: the configuration and the results of a local proxy. - `server.ts`: sockets, HTTPS interception, the allow list, and the control API. +- `service.ts`: the shared service: tenants, runs, access, and metrics. - `recorder.ts`: modes, sessions, replay, and misses. -- `request-key.ts`: the key and the parts of a request. -- `recordings.ts`: the recording files, the miss index, and prune. +- `request-key.ts`: the key and the part hashes of a request. +- `store.ts`: the recording format and the store contract. +- `recordings.ts`: the file store and prune. +- `gcs.ts`: the GCS store. - `values.ts`: changing values and their placeholders. - `streams.ts`: merges the deltas of a recorded model stream. +- `secrets.ts`: redaction of credentials. - `certificates.ts`: the certificate authority for HTTPS. -- `client.ts`: starts the proxy in its own process and calls its control +- `client.ts`: starts a remote run or a local proxy, and calls the control API. - `report.ts`: text reports of a run. - `cli.ts`: the command line. diff --git a/deploy/gcp/.gitignore b/deploy/gcp/.gitignore new file mode 100644 index 0000000..f8d2ff9 --- /dev/null +++ b/deploy/gcp/.gitignore @@ -0,0 +1,4 @@ +.terraform/ +*.tfstate +*.tfstate.* +roach.tfvars diff --git a/deploy/gcp/.terraform.lock.hcl b/deploy/gcp/.terraform.lock.hcl new file mode 100644 index 0000000..4df6e15 --- /dev/null +++ b/deploy/gcp/.terraform.lock.hcl @@ -0,0 +1,64 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/google" { + version = "8.6.0" + constraints = "~> 8.6" + hashes = [ + "h1:0R4NzRqWuYpKsLsrojiG9+AubWYZd2rZig/a5ZH5EBg=", + "zh:02daa55a1eb95f6d15a525eddc08627955aebd3deb150d3ee0e1e4ade9463507", + "zh:24364014f79c57573c193df1578816b3a49c43600c3579dbecaa6e91ec3f308a", + "zh:35865bc61b746f53bc0945b07cc37bba6b9121a82d95bee26faa6dcf8a67d10a", + "zh:47a39b4580bfb3a99958153fbffedf704d35389ed74eeabb5d886474d127ef97", + "zh:4a737ee52f32f0178f48af97f314ff7332b734b86ff813b05e8c4abe4b5f12cd", + "zh:518195549682db69daf4d683be5e91987669cb0662d04b53fed5c86c43001374", + "zh:6d5b2f905fac8d0dfdf590014f0d981e5505ceae01ba9d34f31cc0333ab1daf5", + "zh:78be62e1d00c76d2dff5a104cb191c26b297dc98249f81e2ff50f7c0e8182ec6", + "zh:9131cc8d6cc5bd2efc6025da2ccd6f23830d70331443bbe06ee2c8683d434494", + "zh:9c9e0c5562af727dfc79c3eed4449784cb10240966752a178821d82194c06f45", + "zh:e18ee10080428e66bd60c22b78277e8d470dfd9a9d4605426aef4a23d06bd912", + "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c", + ] +} + +provider "registry.terraform.io/hashicorp/random" { + version = "3.9.1" + constraints = "~> 3.9" + hashes = [ + "h1:g40qr7yDmIpaur4SsK5BcOda3HSo1RJ6zHVMqN4EJ+0=", + "zh:05f4734c1f0be840b711b3eff259ebc5fca436784c728955b1678078466f48d7", + "zh:0b91bf19371d012434eba1deeb6aab77158def9b39601dcbd94450b3974a2a26", + "zh:0ee6eacd47ec00183d55d726a4b6c4ce951a199f944bf22f1aa58392ebdfa7a2", + "zh:19388a4074b76a89a43a6c8328d7ae8ee2e7de3d346af51e80d3e6d3d12925f1", + "zh:23e74d48c5e2ac2e823fd527f49fee9db37d32a1990c9e3bf126ead697b843eb", + "zh:3cabf7fbd096c520064aae3aba61aba670af83ab91291a71fa1b1332929c2b7f", + "zh:5c0a3b8af0be60be4eca12ddee385cfa8babc1ec8e98cdf9de2f2274c73eabfa", + "zh:60b4f8a8ef18f52bf8e19215229dae408bee732825964092db7c989fd2de4097", + "zh:7359015acfedcbd6366f2329c854cf8d3c8ca5cd0faa89d2d37db358d6eba6c5", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:7b38758402f0e13a1071162da28994023cd2ac676e54af350c9ffd8dfa73fa7b", + "zh:7c7fbb8895eb75bb4de1f933e98553bd99c8d048c89a925ddba490aa5a67f7dc", + "zh:8c2b8c6a7ccdec16b73e2fb9f3700ea097f58c592571e4c5de60c93d2301732c", + ] +} + +provider "registry.terraform.io/hashicorp/tls" { + version = "4.4.1" + constraints = "~> 4.4" + hashes = [ + "h1:1Zk4TqZwWmUZHfGTtM/vw8EKoWecYboMRqwhNaoTopc=", + "zh:0855f9a980426a0af2c4415fb23cccbbb88c2d1dd40d83da6f45622c075b398b", + "zh:189227fdae28e547b1d988f5d83c4280fce02e3152d8b62aa810047e2f9f7e59", + "zh:2557db4f493aa90b3683905d9eeb103e55a3facc4ccda763d6de6025116ae121", + "zh:2cfd8d6a543ac61c6e7c0461839ab89bb8da1ba6c8090ab946b1cf47f100d7fa", + "zh:5c04cf2a683ea60a0072c59bbcf415f232c382c891b3aaeb9bbbb28213e0d878", + "zh:6681e35d50328bc11a49bb61c002121353f6a844cc6d63a0c3db57745ec38a56", + "zh:693b810e438bb1dfbc13a42629597284ac1eb08eb30f46f18738d7a9bb768d34", + "zh:77c4425f75346b23817837f3947b6ab7e765308abbf573fec5d2cafd4b82f142", + "zh:b3fd64ec1cecea98c88d2332dbeac40498ce2506e3235bdbd1afdfbaa47bb758", + "zh:c06224b5797b129a84e2514be43f2b140622928fe9c92b55d7941be3296ea289", + "zh:debc830ec123f27944c6170d76cb4ea3542845896960bb9fc530702774df328e", + "zh:f300fcc909014550fd74b2b751665dcc049f874e7a475903f65671baf2c2d86d", + "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c", + ] +} diff --git a/deploy/gcp/cloud-init.yaml b/deploy/gcp/cloud-init.yaml new file mode 100644 index 0000000..2440f37 --- /dev/null +++ b/deploy/gcp/cloud-init.yaml @@ -0,0 +1,31 @@ +#cloud-config +# Container-Optimized OS runs this at each boot. The roach unit reads the +# config secret into memory (/run is tmpfs), pulls the image, and runs it. +write_files: + - path: /etc/systemd/system/roach.service + permissions: "0644" + owner: root + content: | + [Unit] + Description=Roach + Wants=network-online.target + After=network-online.target docker.service + + [Service] + Environment=HOME=/run/roach-home + ExecStartPre=/bin/mkdir -p /run/roach /run/roach-home + ExecStartPre=/bin/sh -c 'iptables -w -C INPUT -p tcp --dport ${port} -j ACCEPT || iptables -w -A INPUT -p tcp --dport ${port} -j ACCEPT' + ExecStartPre=/bin/sh -c 'umask 077 && docker run --rm --network host gcr.io/google.com/cloudsdktool/google-cloud-cli:alpine gcloud secrets versions access latest --secret=${secret} --project=${project} > /run/roach/roach.json && chown 1000 /run/roach/roach.json' + ExecStartPre=-/usr/bin/docker rm -f roach + ExecStartPre=/usr/bin/docker pull ${image} + ExecStart=/usr/bin/docker run --rm --name roach --network host -v /run/roach:/config:ro ${image} + ExecStop=/usr/bin/docker stop roach + Restart=always + RestartSec=5 + + [Install] + WantedBy=multi-user.target + +runcmd: + - systemctl daemon-reload + - systemctl start roach.service diff --git a/deploy/gcp/main.tf b/deploy/gcp/main.tf new file mode 100644 index 0000000..7cd97b3 --- /dev/null +++ b/deploy/gcp/main.tf @@ -0,0 +1,247 @@ +# Roach on GCP: one VM behind a TLS load balancer, with the recordings in a +# GCS bucket. See "Deploy" in README.md. +# +# client --TLS--> load balancer (Google-managed certificate) +# --TCP--> VM: Roach container on port 8080 --> upstream APIs +# --> GCS bucket + +locals { + port = 8080 + # Load balancer proxies and health checks come from these ranges. + google_ranges = ["130.211.0.0/22", "35.191.0.0/16"] + # Identity-Aware Proxy, for `gcloud compute ssh --tunnel-through-iap`. + iap_range = "35.235.240.0/20" +} + +resource "google_project_service" "apis" { + for_each = toset(["compute.googleapis.com", "secretmanager.googleapis.com", "storage.googleapis.com"]) + service = each.value + disable_on_destroy = false +} + +# Recordings. Each one is deleted `recording_days` after it was written. +resource "google_storage_bucket" "recordings" { + name = "${var.project}-roach-recordings" + location = var.region + uniform_bucket_level_access = true + public_access_prevention = "enforced" + # Recordings are a cache. Delete them for good. + soft_delete_policy { + retention_duration_seconds = 0 + } + lifecycle_rule { + condition { + age = var.recording_days + } + action { + type = "Delete" + } + } + depends_on = [google_project_service.apis] +} + +# The certificate authority that signs intercepted hosts. Clients trust its +# certificate (the ca_cert output), so it must not change on a restart. +resource "tls_private_key" "ca" { + algorithm = "RSA" + rsa_bits = 2048 +} + +resource "tls_self_signed_cert" "ca" { + private_key_pem = tls_private_key.ca.private_key_pem + is_ca_certificate = true + validity_period_hours = 24 * 365 * 10 + allowed_uses = ["cert_signing", "crl_signing"] + subject { + common_name = "Roach CA" + organization = "Roach" + } +} + +# 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 service config (`RoachServiceConfig` in src/service.ts). The VM reads +# it at each start. +resource "google_secret_manager_secret" "config" { + secret_id = "roach-config" + replication { + auto {} + } + depends_on = [google_project_service.apis] +} + +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 + ca = { + cert = tls_self_signed_cert.ca.cert_pem + key = tls_private_key.ca.private_key_pem + } + }, + var.sentry_dsn == "" ? {} : { sentryDsn = var.sentry_dsn }, + )) +} + +resource "google_service_account" "roach" { + account_id = "roach-proxy" + display_name = "Roach" +} + +resource "google_storage_bucket_iam_member" "roach" { + bucket = google_storage_bucket.recordings.name + role = "roles/storage.objectUser" + member = google_service_account.roach.member +} + +resource "google_secret_manager_secret_iam_member" "roach" { + secret_id = google_secret_manager_secret.config.id + role = "roles/secretmanager.secretAccessor" + member = google_service_account.roach.member +} + +resource "google_compute_network" "roach" { + name = "roach" + auto_create_subnetworks = false + depends_on = [google_project_service.apis] +} + +resource "google_compute_subnetwork" "roach" { + name = "roach" + network = google_compute_network.roach.id + region = var.region + ip_cidr_range = "10.10.0.0/24" +} + +resource "google_compute_firewall" "proxy" { + name = "roach-proxy" + network = google_compute_network.roach.id + source_ranges = local.google_ranges + target_tags = ["roach"] + allow { + protocol = "tcp" + ports = [tostring(local.port)] + } +} + +resource "google_compute_firewall" "ssh" { + name = "roach-ssh" + network = google_compute_network.roach.id + source_ranges = [local.iap_range] + target_tags = ["roach"] + allow { + protocol = "tcp" + ports = ["22"] + } +} + +resource "google_compute_instance" "roach" { + name = "roach" + machine_type = var.machine_type + zone = var.zone + tags = ["roach"] + boot_disk { + initialize_params { + image = "cos-cloud/cos-stable" + size = 20 + } + } + network_interface { + subnetwork = google_compute_subnetwork.roach.id + # An external IP for calls to upstream APIs. The firewall only lets the + # load balancer and IAP in. + access_config {} + } + service_account { + email = google_service_account.roach.email + scopes = ["cloud-platform"] + } + shielded_instance_config { + enable_secure_boot = true + } + metadata = { + user-data = templatefile("${path.module}/cloud-init.yaml", { + image = var.image + project = var.project + port = local.port + secret = google_secret_manager_secret.config.secret_id + }) + google-logging-enabled = "true" + block-project-ssh-keys = "true" + enable-oslogin = "TRUE" + } + allow_stopping_for_update = true + depends_on = [ + google_secret_manager_secret_iam_member.roach, + google_secret_manager_secret_version.config, + google_storage_bucket_iam_member.roach, + ] +} + +resource "google_compute_instance_group" "roach" { + name = "roach" + zone = var.zone + instances = [google_compute_instance.roach.self_link] + named_port { + name = "roach" + port = local.port + } +} + +resource "google_compute_health_check" "roach" { + name = "roach" + tcp_health_check { + port = local.port + } +} + +resource "google_compute_backend_service" "roach" { + name = "roach" + protocol = "TCP" + port_name = "roach" + load_balancing_scheme = "EXTERNAL" + # A tunnel can be idle while a model thinks. + timeout_sec = 3600 + health_checks = [google_compute_health_check.roach.id] + backend { + group = google_compute_instance_group.roach.self_link + } +} + +resource "google_compute_managed_ssl_certificate" "roach" { + name = "roach" + managed { + domains = [var.domain] + } +} + +resource "google_compute_target_ssl_proxy" "roach" { + name = "roach" + backend_service = google_compute_backend_service.roach.id + ssl_certificates = [google_compute_managed_ssl_certificate.roach.id] +} + +resource "google_compute_global_address" "roach" { + name = "roach" +} + +resource "google_compute_global_forwarding_rule" "roach" { + name = "roach" + target = google_compute_target_ssl_proxy.roach.id + ip_address = google_compute_global_address.roach.address + port_range = "443" + ip_protocol = "TCP" + load_balancing_scheme = "EXTERNAL" +} diff --git a/deploy/gcp/outputs.tf b/deploy/gcp/outputs.tf new file mode 100644 index 0000000..8038c04 --- /dev/null +++ b/deploy/gcp/outputs.tf @@ -0,0 +1,20 @@ +output "ip_address" { + description = "Point an A record of the domain at this address. The TLS certificate starts to work some minutes after DNS does." + value = google_compute_global_address.roach.address +} + +output "url" { + description = "The URL of the service. Clients give it to startRemoteRun()." + value = "https://${var.domain}" +} + +output "ca_cert" { + description = "The certificate that clients trust. The service also serves it at /__roach/ca.pem, and each run returns it." + 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 } + sensitive = true +} diff --git a/deploy/gcp/roach.tfvars.example b/deploy/gcp/roach.tfvars.example new file mode 100644 index 0000000..9935360 --- /dev/null +++ b/deploy/gcp/roach.tfvars.example @@ -0,0 +1,23 @@ +# Copy to roach.tfvars, fill in, then: +# terraform init && terraform apply -var-file=roach.tfvars +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", + "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. +value_patterns = [ + "(?= 1.9" + required_providers { + google = { + source = "hashicorp/google" + version = "~> 8.6" + } + random = { + source = "hashicorp/random" + version = "~> 3.9" + } + tls = { + source = "hashicorp/tls" + version = "~> 4.4" + } + } + # The state holds the CA key and the tenant tokens. Keep it in a private + # bucket, for example: + # backend "gcs" { + # bucket = "my-terraform-state" + # prefix = "roach" + # } +} + +provider "google" { + project = var.project + region = var.region + zone = var.zone +} diff --git a/knip.json b/knip.json index b39cc6a..5742288 100644 --- a/knip.json +++ b/knip.json @@ -1,5 +1,6 @@ { "$schema": "https://unpkg.com/knip@6/schema.json", "project": ["src/**/*.ts", "tests/**/*.ts"], - "ignoreBinaries": ["openssl"] + "ignoreBinaries": ["openssl"], + "entry": ["tests/ci-job.ts"] } diff --git a/package.json b/package.json index d015c4a..7db7109 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "@sentry/roach", "version": "0.0.0", "private": true, - "description": "A recording HTTPS proxy for tests.", + "description": "A recording HTTPS proxy for tests, as a shared service or a local process.", "license": "Apache-2.0", "repository": { "type": "git", @@ -16,6 +16,7 @@ "./client": "./src/client.ts", "./report": "./src/report.ts", "./server": "./src/server.ts", + "./service": "./src/service.ts", "./types": "./src/types.ts", "./values": "./src/values.ts" }, @@ -32,6 +33,9 @@ "test:coverage": "vitest run --coverage", "typecheck": "tsc -p ." }, + "dependencies": { + "@sentry/node": "11.6.0" + }, "devDependencies": { "@sentry/dotagents": "^3.3.0", "@types/node": "^24.19.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ec1942c..94b61ee 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,6 +7,10 @@ settings: importers: .: + dependencies: + '@sentry/node': + specifier: 11.6.0 + version: 11.6.0 devDependencies: '@sentry/dotagents': specifier: ^3.3.0 @@ -43,7 +47,7 @@ importers: version: 8.11.2 vitest: specifier: ^5.0.3 - version: 5.0.3(@types/node@24.19.1)(@vitest/coverage-v8@5.0.3)(vite@8.3.4(@types/node@24.19.1)(jiti@2.7.0)(yaml@2.9.1)) + version: 5.0.3(@opentelemetry/api@1.9.1)(@types/node@24.19.1)(@vitest/coverage-v8@5.0.3)(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)) packages: @@ -85,6 +89,162 @@ packages: '@emnapi/wasi-threads@1.2.2': resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@jridgewell/resolve-uri@3.1.2': resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} @@ -102,48 +262,94 @@ packages: '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4 '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4 + '@opentelemetry/api@1.9.1': + resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} + engines: {node: '>=8.0.0'} + '@oxc-parser/binding-android-arm-eabi@0.150.0': resolution: {integrity: sha512-oQef2Zu4Prz1KLKznz3HqZzU9uVoA5PMoDZuuLmqms7hKmKSAPzlaMnLllJq3t+rgKmfJJ2siPrpZfFrW06btw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] + '@oxc-parser/binding-android-arm-eabi@0.152.0': + resolution: {integrity: sha512-393VtirINkae4XHBTkq/yQxhOwEr4V+r0et37P2fp+sYZSdYgZTRDPo5Um8vBh0QjMZ+RmaMy4PP0qwr4cTMwA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + '@oxc-parser/binding-android-arm64@0.150.0': resolution: {integrity: sha512-B6ofpoFiAUwIZ0MJ2IgHPvZK8FAtL4qzSZRwOkAKIfxEobE1mQC8nDdiFZj7pUaJiVUVCsfkMsBJKedzO8rZvA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] + '@oxc-parser/binding-android-arm64@0.152.0': + resolution: {integrity: sha512-iYFcExATJNZ0rZj0o02THbD1Nc9jAW0nVgIQPee6ejbSASpbZXlIMw8jxtpzaG14JQTWwGSZ6U5dEtRJXE6V3Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + '@oxc-parser/binding-darwin-arm64@0.150.0': resolution: {integrity: sha512-J+9IHKzx/bSz1JetOfD4zKXSK9sOm4/a7+0qomJODcTL6JsRXGeV/ZdkAPSIDydfFmWzYCraMlQEosSZEyBYkQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] + '@oxc-parser/binding-darwin-arm64@0.152.0': + resolution: {integrity: sha512-fnU+DfUzi5/rFy0LzmipYRz06PAUUkOS8kCJQ3Eg6oqlunC1XZXoffFqxmdL51RmcbYtUG3P8DVG1U5fckoa2A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + '@oxc-parser/binding-darwin-x64@0.150.0': resolution: {integrity: sha512-v6IPfcAcSYrBWXV5Tce1DmxDMXLhEYpIIWiRFPJopgCVscZdLaV4MRQOUxvL3usQlw+yrwYOjBFB9IwBx+jUiQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] + '@oxc-parser/binding-darwin-x64@0.152.0': + resolution: {integrity: sha512-Y3U2okryWTs7hDld6UhjJwLNf8/do+x4g4C+1U5SiGwrTLwbO83WJYJA8+l9iFSilWBgnfpd/+U76tfeNxUHzA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + '@oxc-parser/binding-freebsd-x64@0.150.0': resolution: {integrity: sha512-AoR/4jD02HET0KO0yNT4nbTE+XJUxiO9jB1X/ycEjUE3WrIJ3IjV/Vf+gskhWLcqqeXfvNnkDtBR7epllAm88A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] + '@oxc-parser/binding-freebsd-x64@0.152.0': + resolution: {integrity: sha512-XkhkRrrPffEw+ZLv/d/5pEni0buxinm/E/N6fLbJCunD1DBbeCTtVP23ESV+4CWCWdSz397asks1ViGslpmWiQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + '@oxc-parser/binding-linux-arm-gnueabihf@0.150.0': resolution: {integrity: sha512-A/hycCFjLUrCmLoL5O/vBp40ohlaXO1Ta3v5hqYZxicYFs129wZmgZJggcW4mYGYbZebQLhup+N8JjeQl8qwyw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] + '@oxc-parser/binding-linux-arm-gnueabihf@0.152.0': + resolution: {integrity: sha512-MLzsRRbdfmdzo6JEhbC1yKU9ItfvM8tEf18HM32jCiNYZEz6J3bRBRl3cTkKR0vBdrk9ZVlcARXtwHccs+epDA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + '@oxc-parser/binding-linux-arm-musleabihf@0.150.0': resolution: {integrity: sha512-pbqahg1Pkz7J4RKmXm30s/iQu99hv64ayXCu8P4p75HcEH5azOdR4eGqiB6lFGkFR3mMpzaYsSKLkS8oJbjmeQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] + '@oxc-parser/binding-linux-arm-musleabihf@0.152.0': + resolution: {integrity: sha512-HHerTEJz7Iy10Ji6BGsk4gqG6PU0UzFuBu91jIEOX89FnrI5ECWSEr7BLpkYQhp7U0QFtMnBhNfQKI0hYZK78w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + '@oxc-parser/binding-linux-arm64-gnu@0.150.0': resolution: {integrity: sha512-HV11aRbBQGwqv8bEo+K/6qr88uB4fEe1mhXncMhlVCrj+WpBSraxrUzHaPVmeQRU6x6x8v/3DuCbbo93rpp+fw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -151,6 +357,13 @@ packages: os: [linux] libc: [glibc] + '@oxc-parser/binding-linux-arm64-gnu@0.152.0': + resolution: {integrity: sha512-9Bb8GyzZ2uSLzRDZ27usaKokr07mxiBe2dDF5b6bd2janmaJTT1gL51UfP7AU30yFiXrJvcRyFKREAgKwZYs0w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + '@oxc-parser/binding-linux-arm64-musl@0.150.0': resolution: {integrity: sha512-k6pVkJqALwtEuP4zukVhGRhdIy4+ofChUIUUsAHHyqDZlNsknmel3JjbggrJiWGaes6h/dIcWmEXsKW4SmK9oQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -158,6 +371,13 @@ packages: os: [linux] libc: [musl] + '@oxc-parser/binding-linux-arm64-musl@0.152.0': + resolution: {integrity: sha512-om7iDMUroI74A/lfRRWY9/Ev3VWrVsrUZ4tyvyh8raBey/sdcfyAD7tYWc+iykAiotnmftyKTpUKFr1QnaBrAQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + '@oxc-parser/binding-linux-ppc64-gnu@0.150.0': resolution: {integrity: sha512-tEFg39mw/rHO5n8GK2DR4ZMFfsPQZtnQIPbsIpjoQRomJc/2tRfsCSmCT6gNit+NZGoeJG5aH9ATDH5bf0WnoQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -165,6 +385,13 @@ packages: os: [linux] libc: [glibc] + '@oxc-parser/binding-linux-ppc64-gnu@0.152.0': + resolution: {integrity: sha512-Lf1OL2lMTqwDMFC6jnRb30B+WYHKV0XT+bPHcFM6xHtNVwxpobiTzUIvKH6a0LWpvfwyru8Yu6zCGHqqFHH+gg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + '@oxc-parser/binding-linux-riscv64-gnu@0.150.0': resolution: {integrity: sha512-0JO7IFkoek6HqV589Menx3hJySNXi6quRhO4tq2P7kpEHKEXoDATnoPVUpn5B7gDH2KLkdo751N0uIrGJFCTCw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -172,6 +399,13 @@ packages: os: [linux] libc: [glibc] + '@oxc-parser/binding-linux-riscv64-gnu@0.152.0': + resolution: {integrity: sha512-BYWSMQK+YJ9ZILStPcmmiSVq8KEG9Qdq/c4BHPC3rUEq/fEDCVjUIwdj5ZzjgZ9P9is9PKu0tcAXzq1ZXg4urQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + '@oxc-parser/binding-linux-riscv64-musl@0.150.0': resolution: {integrity: sha512-7XPzREnyAS5wHU9aB+49Uaqgoo1gVCklHc3DRlc3fJy2tXD/eAJFN1QFz/crj+Ulup5P1+axNREF+/RGaB/IRA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -179,6 +413,13 @@ packages: os: [linux] libc: [musl] + '@oxc-parser/binding-linux-riscv64-musl@0.152.0': + resolution: {integrity: sha512-d2LqHNNNqdH185sRK5dPKWZu201JrNu9/CRFuVEuT+ht7n/QaVblEMWT9MALQPrDWYvhG10P+NSKJFquDWZ09w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + '@oxc-parser/binding-linux-s390x-gnu@0.150.0': resolution: {integrity: sha512-7n7ZxcbRWDFaTdyj8M8p0O9OfzoMKm8O6syBAIgcSutbOALq0Bb9G52Me+XH0anMLZV+NgXc726gqgG2q39vcQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -186,6 +427,13 @@ packages: os: [linux] libc: [glibc] + '@oxc-parser/binding-linux-s390x-gnu@0.152.0': + resolution: {integrity: sha512-8gBeHt5rA/8+pKhHl+zLggfa3L/V/L2at96p7ty6pBE7id6lFO19nFUc15u2PcjC4OYeYK465MKy1fhhbpFIGw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + '@oxc-parser/binding-linux-x64-gnu@0.150.0': resolution: {integrity: sha512-Vx0GSA9ZCRTUiczoEQnIIyXkMvtEY8uc6IiwLQtMe5ci2sXPqjrry0Ek5fsPP1k2kCzkML3ow9UOOEgnEDi7Bw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -193,6 +441,13 @@ packages: os: [linux] libc: [glibc] + '@oxc-parser/binding-linux-x64-gnu@0.152.0': + resolution: {integrity: sha512-ltgrSF6L+yPTVsIdaAyqKq3zTqzj/1VlNbxWZuxAe8flR3sWdyNsKvdXukoO8/ucJbLickhmieRBge+o/e8Ugw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + '@oxc-parser/binding-linux-x64-musl@0.150.0': resolution: {integrity: sha512-ii4/9m3viDLssMnfXU7/Pni/3nYplApuGayceX4qsVGaqqQJCx3tHIH9M/HWIeSty5i8LjS21zPYT6lVIkwLxw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -200,33 +455,67 @@ packages: os: [linux] libc: [musl] + '@oxc-parser/binding-linux-x64-musl@0.152.0': + resolution: {integrity: sha512-LcMp6BvrBnewSfdkLHoSqidhoAq4syKOTBV5TYHWd+2VnGkfD/nPRRVIzTBt/Lkbt7pfOFDGtkiTntFS0p6sQQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + '@oxc-parser/binding-openharmony-arm64@0.150.0': resolution: {integrity: sha512-ZtoxX5rez36ZWkwtiEhjkCPnlTPoQ2I7lIL0IYZ1yjxMYohbvoOjBmUIZzrf9W/HouONq0omIfTeCWEY+R380w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] + '@oxc-parser/binding-openharmony-arm64@0.152.0': + resolution: {integrity: sha512-jbhoMCslVxzRCRNQ8TSHxjYdSKRvk8O6ciLVDkahJvmyHDzBqba/bMQHM6ULKj/rgHNNbSzlIiakdx8SEfkJzA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + '@oxc-parser/binding-win32-arm64-msvc@0.150.0': resolution: {integrity: sha512-VqeRb5JX/bKYBrff7TUDvJyKY0914UzuCkuXIGxJS4FUmvMNfqkCbc7Sq90iMz9DlmAtlggwaBYQ9eg3h3ltiw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] + '@oxc-parser/binding-win32-arm64-msvc@0.152.0': + resolution: {integrity: sha512-JZYVlhv36mr/nertnRB7vchJECOBb1Lz6lltalILsB+o9M/is+H6BEDRLQKdqA4JKs6NjJszo46+iM5sfwj6jQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + '@oxc-parser/binding-win32-ia32-msvc@0.150.0': resolution: {integrity: sha512-EPqJfeZ4Pgg2BJSsCV8GozxV0FDltRzpVtGVa1r2noJu1iu+opOw3gtBWXwIo9odCT/MdFfyHxdy0/CRqs3OqA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] + '@oxc-parser/binding-win32-ia32-msvc@0.152.0': + resolution: {integrity: sha512-1X9oganvVsunRH0lH10UU05N+H9atpX6WsOybByUjwnfcd3h/0tYwvuv16XuHglrIUOnxV2TodbLVEwwafd64g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + '@oxc-parser/binding-win32-x64-msvc@0.150.0': resolution: {integrity: sha512-n5YMzbqwPQqozbkrexi0lNZrUz5cnPHalYpFWe6Dau7AmKIWNo+y24IwzDuZEQtEdUHuKhXmMMih/MPjOI+CMw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] + '@oxc-parser/binding-win32-x64-msvc@0.152.0': + resolution: {integrity: sha512-sw0M80/dn9rcpu+Rqqph/C+MjvDDce6MlXmFjsNQtPEymha0eJy3ml6+foSUTqFfpDIDyHZZ4eoJ43IoMe8bJw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + '@oxc-project/types@0.150.0': resolution: {integrity: sha512-rDS5/31E9HfPl/CIzGrn0DOlvBbXFseQ5URJ9sYMfstbKLD/c6Gm9vmRzRGDdAXyOIL4zmO37lc9RIwYqVruZw==} + '@oxc-project/types@0.152.0': + resolution: {integrity: sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw==} + '@oxc-project/types@0.153.0': resolution: {integrity: sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==} @@ -706,6 +995,29 @@ packages: '@rolldown/pluginutils@1.0.1': resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + '@sentry/bundler-plugins@11.6.0': + resolution: {integrity: sha512-qHKQgOQaayRuwDCWWoAkEMkJe13RZwrpAH5Y5edL3d5XD2Gd7uenA6lPdXFw3qP1slezeSYErV0GAdcVYMo8fg==} + engines: {node: '>=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0'} + peerDependencies: + '@babel/core': ^7.18.5 + rollup: '>=3.2.0' + webpack: '>=5.1.0' + peerDependenciesMeta: + '@babel/core': + optional: true + rollup: + optional: true + webpack: + optional: true + + '@sentry/conventions@0.26.0': + resolution: {integrity: sha512-ttSRcoLf2kIENDIFzH12aJaVMdxnidMYLvVNwEX/JjXfXaQot8u5M47QmgAFOOhVJ4nQ2uU8wTCh+fwSPrrCfg==} + engines: {node: '>=14'} + + '@sentry/core@11.6.0': + resolution: {integrity: sha512-PbITT1kfJYKJiS9fH4ZtOcwIONKyt+tUsTZSPtcAjPdkUDOzMKT61MkUa1Ze07VzNtLALT24ZdUOc6d9K4YoKA==} + engines: {node: '>=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0'} + '@sentry/dotagents-lib@3.3.0': resolution: {integrity: sha512-A/venGOGqDKqGTEb9Iw9785Q+JlCUhmEfa6YfJ1lu0lI7fz1LiMVgjD750ajm7Z1ymhg5clktwnMqUEvj/iMQA==} engines: {node: '>=20'} @@ -715,6 +1027,24 @@ packages: engines: {node: '>=20'} hasBin: true + '@sentry/node@11.6.0': + resolution: {integrity: sha512-5uHdUSACyodmtar+LxhFl4/MdICJ1/38mrp5lrMpDKfWSV/7tu/KRmr5gD2J9Nm47I/xxbghSz+oinJ4EtJatQ==} + engines: {node: '>=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0'} + + '@sentry/opentelemetry@11.6.0': + resolution: {integrity: sha512-W6b+/8TVejHSCSOWMGsU2wVQY+d7FH/kI2zIFYXmIcxJCd7A/ULhOXe29UYeh+VX9wObR23LvEcEUmgsFVqf6A==} + engines: {node: '>=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0'} + peerDependencies: + '@opentelemetry/api': ^1.9.0 + + '@sentry/server-runtime-injection@11.6.0': + resolution: {integrity: sha512-3plQzwQfvz4HJkXLDfr5VuQQL5vyjar7RKn7UwFne3jc+9OSXRkxkZ6S0Z3RdBgbaFO1GkFwf/mFNX4H8nNcuw==} + engines: {node: '>=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0'} + + '@sentry/server-utils@11.6.0': + resolution: {integrity: sha512-pVpGqwxBOXDMSngxTVARRlYyEXEfKA3WU1ENCdgLCeJ4xJ4vNRf4ODGXYPvXBk0zkt2NZZFvray2JHXIeYG+tg==} + engines: {node: '>=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0'} + '@tybys/wasm-util@0.10.4': resolution: {integrity: sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==} @@ -888,6 +1218,14 @@ packages: ast-v8-to-istanbul@1.0.7: resolution: {integrity: sha512-kFL68AG6ajd8fg248zwM9GQrUWEp79gsmjum34OEXjs4yHuUMZfYKwOLW9GMmB4oNvVrj+EAGxsP7ye2UR9UlA==} + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + brace-expansion@5.0.12: + resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==} + engines: {node: 20 || >=22} + chai@6.3.0: resolution: {integrity: sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==} engines: {node: '>=18'} @@ -900,9 +1238,18 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + dotenv@17.4.2: + resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} + engines: {node: '>=12'} + es-module-lexer@2.3.2: resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} @@ -944,6 +1291,14 @@ packages: get-tsconfig@4.14.3: resolution: {integrity: sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA==} + glob@13.0.6: + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + engines: {node: 18 || 20 || >=22} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + jiti@2.7.0: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true @@ -1038,12 +1393,24 @@ packages: engines: {node: '>=22.22.1'} hasBin: true + lru-cache@11.5.3: + resolution: {integrity: sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==} + engines: {node: 20 || >=22} + magic-string@1.4.3: resolution: {integrity: sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==} magicast@0.5.5: resolution: {integrity: sha512-UicdXN8zQ3JHlxVq+28afMXPr1z7WNY6+7EJnzTdQWkTAlMLF5fNCCKxJHBQwGaNGR11581EiQmQzx73+MvszA==} + minimatch@10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} + engines: {node: 18 || 20 || >=22} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + nanoid@3.3.20: resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -1057,6 +1424,10 @@ packages: resolution: {integrity: sha512-zwajKw1GUa57IOdafIS7/yOObGF2NihwIfZfs5yJTRprMSkiwhWOc/k+n8bwnUPbHr2/XsjfBO8KyTPaW3VpWg==} engines: {node: ^20.19.0 || >=22.12.0} + oxc-parser@0.152.0: + resolution: {integrity: sha512-ZztsZgaCWmPeKYMdoTT5e0oV/oxD1NeLWNR3kAtr/G707cUPYaaozRlVDHLCGnszHJRszN9mJwmPNpqgwloC0A==} + engines: {node: ^20.19.0 || >=22.12.0} + oxc-resolver@11.24.2: resolution: {integrity: sha512-FY91FiDBj7ls5MsFS9jN3tjz2o0/zsdSsymlakySaBwVJZorHhkWyICLZMKxlu1R9vYo+sd3z1jwb4J8x7bNDw==} @@ -1093,6 +1464,10 @@ packages: package-manager-detector@1.9.0: resolution: {integrity: sha512-zAMfbta9nPA8AKkA4WdmNAT8R8s6Dk0/xwPsqNnRidMFcLo1YkLuPDTgs2f64XdzS8vbLNnoJ+uUIkgbz7oRHA==} + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -1112,6 +1487,11 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + sentry@0.45.0: + resolution: {integrity: sha512-kgmEbLlC22lL7uneeUm9Sjz65rp5cvwYOMTxXbZCmBum4sbVre/lLT3zTV3kwXHJ9/BM8ZS0/GhZe9Cl1t6WyA==} + engines: {node: '>=20.0'} + hasBin: true + simple-git-hooks@2.14.0: resolution: {integrity: sha512-kkTORPAuxQz2g1QUuN0J8yGWT28tjGBfPOdJ4xT7Vbeu30veKUZQIoID2qGgCDAakaQn59UeZJJ4cFGB8PVP2A==} hasBin: true @@ -1138,6 +1518,10 @@ packages: resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} engines: {node: '>=14.16'} + supports-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + tinybench@6.2.1: resolution: {integrity: sha512-IAbQaPJIIdhVxi0mqy9lez1wEwje0UQf9F1vwtbelEG5Nbgn0nPOxCvU+mNeNS9gyJTAhrHp74CbUaSynQpZcQ==} engines: {node: '>=20.0.0'} @@ -1323,6 +1707,84 @@ snapshots: tslib: 2.8.1 optional: true + '@esbuild/aix-ppc64@0.28.2': + optional: true + + '@esbuild/android-arm64@0.28.2': + optional: true + + '@esbuild/android-arm@0.28.2': + optional: true + + '@esbuild/android-x64@0.28.2': + optional: true + + '@esbuild/darwin-arm64@0.28.2': + optional: true + + '@esbuild/darwin-x64@0.28.2': + optional: true + + '@esbuild/freebsd-arm64@0.28.2': + optional: true + + '@esbuild/freebsd-x64@0.28.2': + optional: true + + '@esbuild/linux-arm64@0.28.2': + optional: true + + '@esbuild/linux-arm@0.28.2': + optional: true + + '@esbuild/linux-ia32@0.28.2': + optional: true + + '@esbuild/linux-loong64@0.28.2': + optional: true + + '@esbuild/linux-mips64el@0.28.2': + optional: true + + '@esbuild/linux-ppc64@0.28.2': + optional: true + + '@esbuild/linux-riscv64@0.28.2': + optional: true + + '@esbuild/linux-s390x@0.28.2': + optional: true + + '@esbuild/linux-x64@0.28.2': + optional: true + + '@esbuild/netbsd-arm64@0.28.2': + optional: true + + '@esbuild/netbsd-x64@0.28.2': + optional: true + + '@esbuild/openbsd-arm64@0.28.2': + optional: true + + '@esbuild/openbsd-x64@0.28.2': + optional: true + + '@esbuild/openharmony-arm64@0.28.2': + optional: true + + '@esbuild/sunos-x64@0.28.2': + optional: true + + '@esbuild/win32-arm64@0.28.2': + optional: true + + '@esbuild/win32-ia32@0.28.2': + optional: true + + '@esbuild/win32-x64@0.28.2': + optional: true + '@jridgewell/resolve-uri@3.1.2': {} '@jridgewell/sourcemap-codec@1.6.0': {} @@ -1339,65 +1801,126 @@ snapshots: '@tybys/wasm-util': 0.10.4 optional: true + '@opentelemetry/api@1.9.1': {} + '@oxc-parser/binding-android-arm-eabi@0.150.0': optional: true + '@oxc-parser/binding-android-arm-eabi@0.152.0': + optional: true + '@oxc-parser/binding-android-arm64@0.150.0': optional: true + '@oxc-parser/binding-android-arm64@0.152.0': + optional: true + '@oxc-parser/binding-darwin-arm64@0.150.0': optional: true + '@oxc-parser/binding-darwin-arm64@0.152.0': + optional: true + '@oxc-parser/binding-darwin-x64@0.150.0': optional: true + '@oxc-parser/binding-darwin-x64@0.152.0': + optional: true + '@oxc-parser/binding-freebsd-x64@0.150.0': optional: true + '@oxc-parser/binding-freebsd-x64@0.152.0': + optional: true + '@oxc-parser/binding-linux-arm-gnueabihf@0.150.0': optional: true + '@oxc-parser/binding-linux-arm-gnueabihf@0.152.0': + optional: true + '@oxc-parser/binding-linux-arm-musleabihf@0.150.0': optional: true + '@oxc-parser/binding-linux-arm-musleabihf@0.152.0': + optional: true + '@oxc-parser/binding-linux-arm64-gnu@0.150.0': optional: true + '@oxc-parser/binding-linux-arm64-gnu@0.152.0': + optional: true + '@oxc-parser/binding-linux-arm64-musl@0.150.0': optional: true + '@oxc-parser/binding-linux-arm64-musl@0.152.0': + optional: true + '@oxc-parser/binding-linux-ppc64-gnu@0.150.0': optional: true + '@oxc-parser/binding-linux-ppc64-gnu@0.152.0': + optional: true + '@oxc-parser/binding-linux-riscv64-gnu@0.150.0': optional: true + '@oxc-parser/binding-linux-riscv64-gnu@0.152.0': + optional: true + '@oxc-parser/binding-linux-riscv64-musl@0.150.0': optional: true + '@oxc-parser/binding-linux-riscv64-musl@0.152.0': + optional: true + '@oxc-parser/binding-linux-s390x-gnu@0.150.0': optional: true + '@oxc-parser/binding-linux-s390x-gnu@0.152.0': + optional: true + '@oxc-parser/binding-linux-x64-gnu@0.150.0': optional: true + '@oxc-parser/binding-linux-x64-gnu@0.152.0': + optional: true + '@oxc-parser/binding-linux-x64-musl@0.150.0': optional: true + '@oxc-parser/binding-linux-x64-musl@0.152.0': + optional: true + '@oxc-parser/binding-openharmony-arm64@0.150.0': optional: true + '@oxc-parser/binding-openharmony-arm64@0.152.0': + optional: true + '@oxc-parser/binding-win32-arm64-msvc@0.150.0': optional: true + '@oxc-parser/binding-win32-arm64-msvc@0.152.0': + optional: true + '@oxc-parser/binding-win32-ia32-msvc@0.150.0': optional: true + '@oxc-parser/binding-win32-ia32-msvc@0.152.0': + optional: true + '@oxc-parser/binding-win32-x64-msvc@0.150.0': optional: true + '@oxc-parser/binding-win32-x64-msvc@0.152.0': + optional: true + '@oxc-project/types@0.150.0': {} + '@oxc-project/types@0.152.0': {} + '@oxc-project/types@0.153.0': {} '@oxc-resolver/binding-android-arm-eabi@11.24.2': @@ -1640,6 +2163,22 @@ snapshots: '@rolldown/pluginutils@1.0.1': {} + '@sentry/bundler-plugins@11.6.0': + dependencies: + '@sentry/core': 11.6.0 + dotenv: 17.4.2 + glob: 13.0.6 + magic-string: 1.4.3 + oxc-parser: 0.152.0 + sentry: 0.45.0 + supports-color: 8.1.1 + + '@sentry/conventions@0.26.0': {} + + '@sentry/core@11.6.0': + dependencies: + '@sentry/conventions': 0.26.0 + '@sentry/dotagents-lib@3.3.0': dependencies: yaml: 2.9.1 @@ -1653,6 +2192,37 @@ snapshots: smol-toml: 1.9.0 zod: 4.6.5 + '@sentry/node@11.6.0': + dependencies: + '@opentelemetry/api': 1.9.1 + '@sentry/bundler-plugins': 11.6.0 + '@sentry/conventions': 0.26.0 + '@sentry/core': 11.6.0 + '@sentry/opentelemetry': 11.6.0(@opentelemetry/api@1.9.1) + '@sentry/server-runtime-injection': 11.6.0 + '@sentry/server-utils': 11.6.0 + transitivePeerDependencies: + - '@babel/core' + - rollup + - webpack + + '@sentry/opentelemetry@11.6.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@sentry/conventions': 0.26.0 + '@sentry/core': 11.6.0 + + '@sentry/server-runtime-injection@11.6.0': + dependencies: + '@sentry/core': 11.6.0 + '@sentry/server-utils': 11.6.0 + + '@sentry/server-utils@11.6.0': + dependencies: + '@opentelemetry/api': 1.9.1 + '@sentry/conventions': 0.26.0 + '@sentry/core': 11.6.0 + '@tybys/wasm-util@0.10.4': dependencies: tslib: 2.8.1 @@ -1741,7 +2311,7 @@ snapshots: obug: 2.2.1 std-env: 4.3.0 tinyrainbow: 3.2.0 - vitest: 5.0.3(@types/node@24.19.1)(@vitest/coverage-v8@5.0.3)(vite@8.3.4(@types/node@24.19.1)(jiti@2.7.0)(yaml@2.9.1)) + vitest: 5.0.3(@opentelemetry/api@1.9.1)(@types/node@24.19.1)(@vitest/coverage-v8@5.0.3)(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)) '@vitest/istanbul-lib-coverage@1.0.2': {} @@ -1749,14 +2319,14 @@ snapshots: dependencies: '@vitest/istanbul-lib-coverage': 1.0.2 - '@vitest/mocker@5.0.3(vite@8.3.4(@types/node@24.19.1)(jiti@2.7.0)(yaml@2.9.1))': + '@vitest/mocker@5.0.3(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))': dependencies: '@jridgewell/trace-mapping': 0.3.31 '@vitest/spy': 5.0.3 estree-walker: 3.0.3 magic-string: 1.4.3 optionalDependencies: - vite: 8.3.4(@types/node@24.19.1)(jiti@2.7.0)(yaml@2.9.1) + vite: 8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1) '@vitest/spy@5.0.3': {} @@ -1768,14 +2338,52 @@ snapshots: estree-walker: 3.0.3 js-tokens: 10.0.0 + balanced-match@4.0.4: {} + + brace-expansion@5.0.12: + dependencies: + balanced-match: 4.0.4 + chai@6.3.0: {} chalk@5.6.2: {} detect-libc@2.1.2: {} + dotenv@17.4.2: {} + es-module-lexer@2.3.2: {} + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + optional: true + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.9 @@ -1812,6 +2420,14 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 + glob@13.0.6: + dependencies: + minimatch: 10.2.6 + minipass: 7.1.3 + path-scurry: 2.0.2 + + has-flag@4.0.0: {} + jiti@2.7.0: {} js-tokens@10.0.0: {} @@ -1891,6 +2507,8 @@ snapshots: optionalDependencies: yaml: 2.9.1 + lru-cache@11.5.3: {} + magic-string@1.4.3: dependencies: '@jridgewell/sourcemap-codec': 1.6.0 @@ -1901,6 +2519,12 @@ snapshots: '@babel/types': 7.29.8 source-map-js: 1.2.2 + minimatch@10.2.6: + dependencies: + brace-expansion: 5.0.12 + + minipass@7.1.3: {} + nanoid@3.3.20: {} obug@2.2.1: {} @@ -1929,6 +2553,30 @@ snapshots: '@oxc-parser/binding-win32-ia32-msvc': 0.150.0 '@oxc-parser/binding-win32-x64-msvc': 0.150.0 + oxc-parser@0.152.0: + dependencies: + '@oxc-project/types': 0.152.0 + optionalDependencies: + '@oxc-parser/binding-android-arm-eabi': 0.152.0 + '@oxc-parser/binding-android-arm64': 0.152.0 + '@oxc-parser/binding-darwin-arm64': 0.152.0 + '@oxc-parser/binding-darwin-x64': 0.152.0 + '@oxc-parser/binding-freebsd-x64': 0.152.0 + '@oxc-parser/binding-linux-arm-gnueabihf': 0.152.0 + '@oxc-parser/binding-linux-arm-musleabihf': 0.152.0 + '@oxc-parser/binding-linux-arm64-gnu': 0.152.0 + '@oxc-parser/binding-linux-arm64-musl': 0.152.0 + '@oxc-parser/binding-linux-ppc64-gnu': 0.152.0 + '@oxc-parser/binding-linux-riscv64-gnu': 0.152.0 + '@oxc-parser/binding-linux-riscv64-musl': 0.152.0 + '@oxc-parser/binding-linux-s390x-gnu': 0.152.0 + '@oxc-parser/binding-linux-x64-gnu': 0.152.0 + '@oxc-parser/binding-linux-x64-musl': 0.152.0 + '@oxc-parser/binding-openharmony-arm64': 0.152.0 + '@oxc-parser/binding-win32-arm64-msvc': 0.152.0 + '@oxc-parser/binding-win32-ia32-msvc': 0.152.0 + '@oxc-parser/binding-win32-x64-msvc': 0.152.0 + oxc-resolver@11.24.2: optionalDependencies: '@oxc-resolver/binding-android-arm-eabi': 11.24.2 @@ -2009,6 +2657,11 @@ snapshots: package-manager-detector@1.9.0: {} + path-scurry@2.0.2: + dependencies: + lru-cache: 11.5.3 + minipass: 7.1.3 + picocolors@1.1.1: {} picomatch@4.0.7: {} @@ -2042,6 +2695,8 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.2.13 '@rolldown/binding-win32-x64-msvc': 1.2.13 + sentry@0.45.0: {} + simple-git-hooks@2.14.0: {} sisteransi@1.0.5: {} @@ -2056,6 +2711,10 @@ snapshots: strip-json-comments@5.0.3: {} + supports-color@8.1.1: + dependencies: + has-flag: 4.0.0 + tinybench@6.2.1: {} tinyexec@1.3.1: {} @@ -2101,7 +2760,7 @@ snapshots: undici@8.11.2: {} - vite@8.3.4(@types/node@24.19.1)(jiti@2.7.0)(yaml@2.9.1): + vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1): dependencies: lightningcss: 1.33.0 picomatch: 4.0.7 @@ -2110,14 +2769,15 @@ snapshots: tinyglobby: 0.2.17 optionalDependencies: '@types/node': 24.19.1 + esbuild: 0.28.2 fsevents: 2.3.3 jiti: 2.7.0 yaml: 2.9.1 - vitest@5.0.3(@types/node@24.19.1)(@vitest/coverage-v8@5.0.3)(vite@8.3.4(@types/node@24.19.1)(jiti@2.7.0)(yaml@2.9.1)): + vitest@5.0.3(@opentelemetry/api@1.9.1)(@types/node@24.19.1)(@vitest/coverage-v8@5.0.3)(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)): dependencies: '@types/chai': 5.2.3 - '@vitest/mocker': 5.0.3(vite@8.3.4(@types/node@24.19.1)(jiti@2.7.0)(yaml@2.9.1)) + '@vitest/mocker': 5.0.3(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)) chai: 6.3.0 es-module-lexer: 2.3.2 expect-type: 1.4.0 @@ -2128,9 +2788,10 @@ snapshots: tinybench: 6.2.1 tinyexec: 1.3.1 tinyglobby: 0.2.17 - vite: 8.3.4(@types/node@24.19.1)(jiti@2.7.0)(yaml@2.9.1) + vite: 8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1) why-is-node-running: 3.2.1 optionalDependencies: + '@opentelemetry/api': 1.9.1 '@types/node': 24.19.1 '@vitest/coverage-v8': 5.0.3(vitest@5.0.3) transitivePeerDependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 8dcb504..3a85d6d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,4 +2,5 @@ # has time to be pulled before it reaches us. minimumReleaseAge: 1440 allowBuilds: + esbuild: true simple-git-hooks: true diff --git a/src/certificates.ts b/src/certificates.ts index 48e9bb7..ef90a87 100644 --- a/src/certificates.ts +++ b/src/certificates.ts @@ -1,9 +1,11 @@ /** * The certificate authority of Roach. * - * The proxy intercepts HTTPS. It creates its own certificate authority when - * it starts, and signs one certificate for each host when a client first - * connects to that host. Clients must trust the authority certificate. + * 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. A local proxy + * creates a new authority when it starts. The shared service loads a fixed + * one, so that clients can trust it across restarts. */ import { execFile } from "node:child_process"; import { createHash, randomBytes } from "node:crypto"; @@ -33,35 +35,41 @@ export interface CertificateAuthority { close(): Promise; } -/** Create a certificate authority in a new temporary directory. */ -export async function createCertificateAuthority(): Promise { +/** A PEM certificate authority, such as the one of a deployed service. */ +export interface CertificateAuthorityPem { + cert: string; + key: string; +} + +/** Days that a host certificate is valid. */ +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; + +/** + * Create a certificate authority in a new temporary directory. Without + * `pem`, it creates a new authority that is valid for 7 days. + */ +export async function createCertificateAuthority( + pem?: CertificateAuthorityPem, +): Promise { const directory = await mkdtemp(path.join(tmpdir(), "roach-ca-")); const caKey = path.join(directory, "ca.key"); const caCertFile = path.join(directory, "ca.crt"); const hostKey = path.join(directory, "host.key"); - await openssl([ - "req", - "-x509", - "-newkey", - "rsa:2048", - "-nodes", - "-keyout", - caKey, - "-out", - caCertFile, - "-days", - "7", - "-subj", - "/CN=Roach CA", - "-addext", - "basicConstraints=critical,CA:TRUE", - "-addext", - "keyUsage=critical,keyCertSign,cRLSign", - ]); + if (pem) { + await writeFile(caKey, pem.key, { mode: 0o600 }); + await writeFile(caCertFile, pem.cert); + } else { + await createAuthority(caKey, caCertFile); + } // All hosts share one key. Only their certificates differ. await openssl(["genrsa", "-out", hostKey, "2048"]); const hostKeyPem = await readFile(hostKey, "utf8"); - const contexts = new Map>(); + const contexts = new Map< + string, + { signed: number; context: Promise } + >(); const sign = async (host: string): Promise => { const name = createHash("sha256").update(host).digest("hex").slice(0, 16); @@ -96,7 +104,7 @@ export async function createCertificateAuthority(): Promise RESIGN_MS) { + const fresh = { signed: now, context: sign(host) }; + // A failed signature must not stay in the cache. + fresh.context.catch(() => { + if (contexts.get(host) === fresh) contexts.delete(host); + }); + contexts.set(host, fresh); + entry = fresh; } - return context; + return entry.context; }, close: () => rm(directory, { force: true, recursive: true }), }; } + +/** Create a new authority that is valid for 7 days. */ +async function createAuthority(caKey: string, caCertFile: string) { + await openssl([ + "req", + "-x509", + "-newkey", + "rsa:2048", + "-nodes", + "-keyout", + caKey, + "-out", + caCertFile, + "-days", + "7", + "-subj", + "/CN=Roach CA", + "-addext", + "basicConstraints=critical,CA:TRUE", + "-addext", + "keyUsage=critical,keyCertSign,cRLSign", + ]); +} diff --git a/src/cli.ts b/src/cli.ts index 9131867..bd17c32 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,24 +1,54 @@ /** * The command line of Roach. * - * - `cli.ts serve`: read the configuration as JSON from stdin, and start the - * proxy. Print its address as one JSON line. Stop on SIGINT or SIGTERM. - * `client.ts` runs this command. + * - `cli.ts service `: start the shared service (`service.ts`) + * with the configuration in the file. Print its URL as one JSON line. + * Stop on SIGINT or SIGTERM. The deployed container runs this command. + * - `cli.ts serve`: read the configuration of a local proxy as JSON from + * stdin, and start it. Print its address as one JSON line. Stop on SIGINT + * or SIGTERM. `client.ts` runs this command. * - `cli.ts prune ...`: delete the recordings that no * used file lists. */ +import { readFile } from "node:fs/promises"; import { text } from "node:stream/consumers"; import { pruneRecordings } from "./recordings.ts"; import { startRoach } from "./server.ts"; +import type { RoachServiceConfig } from "./service.ts"; import type { RoachAddress, RoachConfig } from "./types.ts"; const USAGE = `Usage: + cli.ts service cli.ts serve < config.json cli.ts prune ...`; const [command, ...args] = process.argv.slice(2); -if (command === "serve") { +/** Stop on SIGINT or SIGTERM. A failed close logs its error and exits 1. */ +function stopOnSignal(close: () => Promise): void { + const stop = () => { + close().then( + () => process.exit(0), + (error: unknown) => { + process.stderr.write(`[roach] Close failed: ${String(error)}\n`); + process.exit(1); + }, + ); + }; + process.once("SIGINT", stop); + process.once("SIGTERM", stop); +} + +if (command === "service" && args.length === 1) { + const config = JSON.parse( + await readFile(args[0]!, "utf8"), + ) as RoachServiceConfig; + // Only the service loads the Sentry SDK. + const { startRoachService } = await import("./service.ts"); + const service = await startRoachService(config); + process.stdout.write(`${JSON.stringify({ url: service.url })}\n`); + stopOnSignal(() => service.close()); +} else if (command === "serve") { const config = JSON.parse(await text(process.stdin)) as RoachConfig; const proxy = await startRoach(config); const address: RoachAddress = { @@ -27,11 +57,7 @@ if (command === "serve") { caCert: proxy.caCert, }; process.stdout.write(`${JSON.stringify(address)}\n`); - const stop = () => { - void proxy.close().finally(() => process.exit(0)); - }; - process.once("SIGINT", stop); - process.once("SIGTERM", stop); + stopOnSignal(() => proxy.close()); } else if (command === "prune" && args.length >= 2) { const [directory, ...usedFiles] = args as [string, ...string[]]; const count = await pruneRecordings(directory, usedFiles); diff --git a/src/client.ts b/src/client.ts index f1d5ef0..e5b1193 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,18 +1,21 @@ /** * The client of Roach. See `README.md`. * - * `spawnRoach()` starts the proxy in its own process and returns - * `env`, the variables that send the traffic of a process through it. - * `connectRoach()` controls a running proxy from another process, - * such as a test worker. + * `startRemoteRun()` starts a run on a Roach service (`service.ts`). + * `spawnRoach()` starts a local proxy in its own process. Both return + * `env`, the variables that send the traffic of a process through the + * proxy. `connectRoach()` controls a run or a local proxy from another + * process, such as a test worker. */ import { spawn } from "node:child_process"; import { mkdtemp, rm, writeFile } from "node:fs/promises"; import http from "node:http"; +import https from "node:https"; import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { CONTROL_PATH } from "./server.ts"; +import type { RemoteRun, RunConfig } from "./service.ts"; import type { RoachAddress, RoachConfig, RecordingStats } from "./types.ts"; /** The open session of one test. */ @@ -57,23 +60,33 @@ const PROXY_VARIABLES = new Set([ "node_use_env_proxy", ]); +/** Where the control API is, and its token. */ +type ControlAddress = Pick & { + token?: string | undefined; +}; + /** Call the control API, and return the JSON of the response. */ function callControl( - address: Pick, - method: "GET" | "POST", + address: ControlAddress, + method: "DELETE" | "GET" | "POST", route: string, body?: unknown, ): Promise { const payload = body === undefined ? undefined : JSON.stringify(body); + const base = address.controlUrl ?? `${address.url}${CONTROL_PATH}`; + const url = route ? `${base}/${route}` : base; + const client = url.startsWith("https:") ? https : http; return new Promise((resolve, reject) => { - const request = http.request( - `${address.url}${CONTROL_PATH}/${route}`, + const request = client.request( + url, { // Its own agent, so that a proxy agent of the process is not used. agent: false, method, headers: { - authorization: `Bearer ${address.token}`, + ...(address.token + ? { authorization: `Bearer ${address.token}` } + : {}), ...(payload ? { "content-type": "application/json" } : {}), }, }, @@ -101,9 +114,9 @@ function callControl( }); } -/** Control a running proxy, for example from a test worker. */ +/** Control a run or a local proxy, for example from a test worker. */ export function connectRoach( - address: Pick, + address: Pick, ): RoachControl { return { async startSession(name) { @@ -168,14 +181,73 @@ export async function spawnRoach( ); child.stdin.end(JSON.stringify(config)); const address = await readAddress(child); + const proxyEnv = await createProxyEnv(address, noProxy); + + return { + ...address, + ...connectRoach(address), + env: proxyEnv.env, + async close() { + if (child.exitCode === null && child.signalCode === null) { + child.kill("SIGTERM"); + await exited; + } + await proxyEnv.remove(); + }, + }; +} + +/** A run on a Roach service (`service.ts`). */ +export interface RemoteRoach extends RemoteRun, RoachControl { + /** The same variables as `Roach.env`, for the proxy URL of the run. */ + env: Record; + /** End the run. Returns its stats. */ + close(): Promise; +} + +/** + * 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, + * 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. + */ +export async function startRemoteRun( + service: { url: string; token?: string | undefined }, + config: RunConfig, + { noProxy = "localhost,127.0.0.1,::1" }: { noProxy?: string } = {}, +): Promise { + const run = await callControl( + { url: service.url.replace(/\/$/, ""), token: service.token }, + "POST", + "runs", + config, + ); + const proxyEnv = await createProxyEnv(run, noProxy); + return { + ...run, + ...connectRoach(run), + env: proxyEnv.env, + async close() { + try { + return await callControl(run, "DELETE", ""); + } finally { + await proxyEnv.remove(); + } + }, + }; +} + +/** The proxy variables of a process, with the CA certificate in a file. */ +async function createProxyEnv( + address: Pick, + noProxy: string, +): Promise<{ env: Record; remove(): Promise }> { // `NODE_EXTRA_CA_CERTS` takes a file. const caDirectory = await mkdtemp(path.join(tmpdir(), "roach-")); const caFile = path.join(caDirectory, "ca.pem"); await writeFile(caFile, address.caCert); - return { - ...address, - ...connectRoach(address), env: { HTTP_PROXY: address.url, HTTPS_PROXY: address.url, @@ -183,12 +255,6 @@ export async function spawnRoach( NODE_USE_ENV_PROXY: "1", NODE_EXTRA_CA_CERTS: caFile, }, - async close() { - if (child.exitCode === null && child.signalCode === null) { - child.kill("SIGTERM"); - await exited; - } - await rm(caDirectory, { force: true, recursive: true }); - }, + remove: () => rm(caDirectory, { force: true, recursive: true }), }; } diff --git a/src/gcs.ts b/src/gcs.ts new file mode 100644 index 0000000..82617b2 --- /dev/null +++ b/src/gcs.ts @@ -0,0 +1,145 @@ +/** + * The GCS store: the recordings of the shared service (`service.ts`) in a + * Google Cloud Storage bucket. + * + * Each recording is the object ``. A lifecycle rule of the + * bucket deletes it 30 days after it was written (`deploy/gcp/`), so + * nothing here deletes recordings. + * + * The store calls the JSON API with `fetch`, and takes its access token + * from the metadata server of the VM. So it needs no key file and no + * Google SDK. Two standard variables change the hosts, for local tests: + * `STORAGE_EMULATOR_HOST` (a URL) and `GCE_METADATA_HOST` (a host). + */ +import { + formatRecording, + type Recording, + type RecordingStore, +} from "./store.ts"; + +/** A call that takes longer than this fails. */ +const TIMEOUT_MS = 30_000; +/** Tries of one call. GCS asks clients to retry 429 and 5xx. */ +const TRIES = 3; +/** Get a new token when the current one expires within this time. */ +const TOKEN_MARGIN_MS = 60_000; + +const isTemporary = (status: number) => status === 429 || status >= 500; +const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +/** `fetch`, with a timeout and retries of temporary failures. */ +async function call(url: string, init: RequestInit): Promise { + for (let attempt = 1; ; attempt += 1) { + try { + const response = await fetch(url, { + ...init, + signal: AbortSignal.timeout(TIMEOUT_MS), + }); + if (!isTemporary(response.status) || attempt === TRIES) return response; + await response.body?.cancel(); + } catch (error) { + if (attempt === TRIES) throw error; + } + await wait(250 * 2 ** attempt); + } +} + +/** The access token of the VM service account, from the metadata server. */ +function createTokenSource(metadataHost: string): () => Promise { + let cached: { token: string; expires: number } | undefined; + return async () => { + if (cached && cached.expires - TOKEN_MARGIN_MS > Date.now()) { + return cached.token; + } + const response = await call( + `http://${metadataHost}/computeMetadata/v1/instance/service-accounts/default/token`, + { headers: { "metadata-flavor": "Google" } }, + ); + if (!response.ok) { + throw new Error( + `GCE metadata token failed with HTTP ${response.status}: ${await response.text()}`, + ); + } + const body = (await response.json()) as { + access_token: string; + expires_in: number; + }; + cached = { + token: body.access_token, + expires: Date.now() + body.expires_in * 1000, + }; + return cached.token; + }; +} + +/** Where the store sends its calls. */ +interface GcsHosts { + /** The base URL of the storage API. */ + storage: string; + /** The host of the metadata server. */ + metadata: string; +} + +/** The hosts of GCS, or the ones of the standard variables. */ +function gcsHosts(env = process.env): GcsHosts { + return { + storage: ( + env.STORAGE_EMULATOR_HOST ?? "https://storage.googleapis.com" + ).replace(/\/$/, ""), + metadata: env.GCE_METADATA_HOST ?? "metadata.google.internal", + }; +} + +/** The store of the recordings in `bucket`, under `prefix`. */ +export function createGcsStore(bucket: string, prefix: string): RecordingStore { + const hosts = gcsHosts(); + const token = createTokenSource(hosts.metadata); + const bucketPath = `b/${encodeURIComponent(bucket)}/o`; + + const writeOne = async ([key, recording]: [string, Recording]) => { + const name = encodeURIComponent(`${prefix}${key}`); + const response = await call( + `${hosts.storage}/upload/storage/v1/${bucketPath}?uploadType=media&name=${name}`, + { + method: "POST", + headers: { + authorization: `Bearer ${await token()}`, + "content-type": "application/json", + }, + body: formatRecording(recording), + }, + ); + if (!response.ok) { + throw new Error( + `GCS write of ${key} failed with HTTP ${response.status}: ${await response.text()}`, + ); + } + await response.body?.cancel(); + }; + + return { + async read(key) { + const name = encodeURIComponent(`${prefix}${key}`); + const response = await call( + `${hosts.storage}/storage/v1/${bucketPath}/${name}?alt=media`, + { headers: { authorization: `Bearer ${await token()}` } }, + ); + if (response.status === 404) { + await response.body?.cancel(); + return undefined; + } + if (!response.ok) { + throw new Error( + `GCS read of ${key} failed with HTTP ${response.status}: ${await response.text()}`, + ); + } + return (await response.json()) as Recording; + }, + + // GCS does not say if an object changed, so each write counts. + async write(recordings) { + await Promise.all(recordings.map(writeOne)); + return recordings.length; + }, + }; +} diff --git a/src/recorder.ts b/src/recorder.ts index fbcfc1e..4781479 100644 --- a/src/recorder.ts +++ b/src/recorder.ts @@ -4,7 +4,8 @@ * `server.ts` owns the sockets. It gives the recorder each request that a * rule matches, with a function that sends the request live. The recorder * decides whether to replay, send live, or fail the request, and it owns - * every read and write of the recordings. + * every read and write of the recordings. A store keeps them: the file + * store (`recordings.ts`) or a GCS bucket (`gcs.ts`). * * A session groups the requests of one test. One session is open at a * time. The recorder keeps the new recordings of a session in memory until @@ -19,14 +20,14 @@ import { mkdir, writeFile } from "node:fs/promises"; import type { IncomingHttpHeaders } from "node:http"; import path from "node:path"; -import { - createRecordingIndex, - readRecording, - writeRecordings, - type Recording, -} from "./recordings.ts"; import { describeParts, keyRequest, type KeyedRequest } from "./request-key.ts"; import { createSecrets } from "./secrets.ts"; +import { + recordingKey, + RULE_NAME, + type Recording, + type RecordingStore, +} from "./store.ts"; import { isEventStream, mapStreamEvents, @@ -60,9 +61,9 @@ export type ResponseSource = "live" | "missed" | "replayed"; interface Session { name: string; - /** New recordings, by file. */ + /** New recordings, by key. */ recorded: Map; - /** Files that the session replayed. They stay used if it fails. */ + /** Keys that the session replayed. They stay used if it fails. */ replayed: Set; /** Requests that `replay` mode failed. */ missed: number; @@ -144,18 +145,22 @@ function fromRecording( return { status: response.status, headers: response.headers, body }; } -/** Create the recorder of one proxy run. */ -export function createRecorder(config: RoachConfig) { +/** The parts of a config that the recorder uses. */ +export type RecorderConfig = Pick< + RoachConfig, + "missDirectory" | "mode" | "rules" | "secrets" | "usedFile" +>; + +/** Create the recorder of one proxy run, with the store of its recordings. */ +export function createRecorder(config: RecorderConfig, store: RecordingStore) { + for (const rule of config.rules) { + if (!RULE_NAME.test(rule.name)) { + throw new Error(`Roach rule name is not valid: ${rule.name}`); + } + } const missDirectory = config.missDirectory ? path.resolve(config.missDirectory) : undefined; - if ( - missDirectory && - !path.relative(config.directory, missDirectory).startsWith("..") - ) { - // Miss files hold request bodies. Keep them out of the committed files. - throw new Error("missDirectory must not be inside directory"); - } let session: Session | undefined; const secrets = createSecrets(config.secrets); /** Recordings that passed sessions, or requests outside one, used. */ @@ -172,55 +177,42 @@ export function createRecorder(config: RoachConfig) { discarded: 0, passthrough: {}, }; - const indexes = new Map( - config.rules.map((rule) => [ - rule.name, - createRecordingIndex(path.join(config.directory, rule.name)), - ]), - ); - const relative = (file: string) => path.relative(config.directory, file); - const write = async (recordings: Array<[string, Recording]>) => { - stats.written += await writeRecordings(recordings); - for (const [file, recording] of recordings) { - indexes.get(path.basename(path.dirname(file)))!.add(file, recording); - } + stats.written += await store.write(recordings); }; - const markReplayed = (owner: Session | undefined, file: string) => { - if (owner && owner.passed === undefined) owner.replayed.add(file); - else used.add(file); + const markReplayed = (owner: Session | undefined, key: string) => { + if (owner && owner.passed === undefined) owner.replayed.add(key); + else used.add(key); }; const record = async ( owner: Session | undefined, - file: string, + key: string, recording: Recording, ) => { if (owner && owner.passed === undefined) { - owner.recorded.set(file, recording); + owner.recorded.set(key, recording); } else if (owner?.passed === false) { stats.discarded += 1; } else { - used.add(file); - await write([[file, recording]]); + used.add(key); + await write([[key, recording]]); } }; const reportMiss = async ( rule: RecordingRule, - file: string, + key: string, keyed: KeyedRequest, owner: Session | undefined, ) => { - const closest = await indexes - .get(rule.name)! - .closest(keyed.parts, owner?.name); + const closest = await store.closest?.(rule.name, keyed.parts, owner?.name); const miss: RecordingMiss = { rule: rule.name, session: owner?.name, - file: relative(file), - closest: closest && relative(closest.file), + file: key, + closest: closest?.key, differs: closest?.differs ?? [], }; if (stats.misses.length < MAX_MISSES) stats.misses.push(miss); @@ -248,17 +240,17 @@ export function createRecorder(config: RoachConfig) { session = undefined; ended.passed = passed; // A failed test can still show that a recording is in use. - for (const file of ended.replayed) used.add(file); + for (const key of ended.replayed) used.add(key); if (passed) { - for (const file of ended.recorded.keys()) used.add(file); + for (const key of ended.recorded.keys()) used.add(key); await write([...ended.recorded]); } else { stats.discarded += ended.recorded.size; // A failed test stops early, so it does not replay all of its // recordings. Keep every recording that it made before, so that a // prune does not delete them. - for (const index of indexes.values()) { - for (const file of await index.filesOf(ended.name)) used.add(file); + for (const key of (await store.keysOf?.(ended.name)) ?? []) { + used.add(key); } } return ended.missed; @@ -308,19 +300,19 @@ export function createRecorder(config: RoachConfig) { } } } - const file = path.join(config.directory, rule.name, `${keyed.key}.json`); + const key = recordingKey(rule.name, keyed.key); if (config.mode !== "record") { - const recording = await readRecording(file); + const recording = await store.read(key); if (recording) { counts.replayed += 1; - markReplayed(owner, file); + markReplayed(owner, key); return { ...fromRecording(recording, keyed.values), source: "replayed", }; } - await reportMiss(rule, file, keyed, owner); + await reportMiss(rule, key, keyed, owner); if (config.mode === "replay") { counts.missed += 1; if (owner) owner.missed += 1; @@ -346,7 +338,7 @@ export function createRecorder(config: RoachConfig) { ), ) as Recording; if (!isTemporaryStatus(recording.response.status)) { - await record(owner, file, recording); + await record(owner, key, recording); } return { ...fromRecording(recording, keyed.values), source: "live" }; }, @@ -387,8 +379,11 @@ export function createRecorder(config: RoachConfig) { async close(): Promise { await finish(false); if (config.usedFile) { - const files = [...used].map(relative).toSorted(); - await writeFile(config.usedFile, files.map((f) => `${f}\n`).join("")); + const keys = [...used].toSorted(); + await writeFile( + config.usedFile, + keys.map((key) => `${key}\n`).join(""), + ); } }, }; diff --git a/src/recordings.ts b/src/recordings.ts index f6f7816..1d5341f 100644 --- a/src/recordings.ts +++ b/src/recordings.ts @@ -1,138 +1,134 @@ /** - * The recording files of Roach. + * The file store, and prune. * - * A recording is one JSON file, `//.json`. It keeps - * the response of one request, the session (test) that recorded it, and - * the parts of the request (`request-key.ts`). It does not keep the request - * body, so prompts and other inputs are not committed. + * The file store keeps each recording (`store.ts`) in `/`, + * so recordings can be committed. */ import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises"; import path from "node:path"; -import { closestRequest, type RequestParts } from "./request-key.ts"; - -/** One recorded response. */ -export interface Recording { - /** The session (test) that recorded the response. */ - session?: string | undefined; - request: { method: string; url: string; parts: RequestParts }; - response: { - status: number; - headers: Record; - /** - * A text body keeps each changing value of its request as a - * placeholder (`values.ts`). - */ - body: string; - /** `base64` for a body that is not text, such as an image. */ - bodyEncoding: "base64" | "utf8"; - }; -} +import { closestRequest } from "./request-key.ts"; +import { + formatRecording, + type Recording, + type RecordingStore, + type RequestParts, +} from "./store.ts"; const isMissing = (error: unknown) => (error as NodeJS.ErrnoException).code === "ENOENT"; -/** Read a recording. Returns `undefined` when there is none. */ -export async function readRecording( - file: string, -): Promise { - try { - return JSON.parse(await readFile(file, "utf8")) as Recording; - } catch (error) { +const readText = (file: string) => + readFile(file, "utf8").catch((error: unknown) => { if (isMissing(error)) return undefined; throw error; - } -} + }); -/** Write recordings. Returns how many were new or changed. */ -export async function writeRecordings( - recordings: Iterable<[string, Recording]>, -): Promise { - const changed = await Promise.all( - [...recordings].map(async ([file, recording]) => { - const content = `${JSON.stringify(recording, null, 2)}\n`; - const previous = await readFile(file, "utf8").catch((error) => { - if (isMissing(error)) return undefined; - throw error; - }); - if (previous === content) return false; - await mkdir(path.dirname(file), { recursive: true }); - await writeFile(file, content); - return true; - }), - ); - return changed.filter(Boolean).length; -} +/** The rule of a key, `/.json`. */ +const ruleOf = (key: string) => key.slice(0, key.indexOf("/")); interface IndexEntry { - file: string; + key: string; session?: string | undefined; parts: RequestParts; } -/** The recordings of one rule, for miss diagnosis. */ -export interface RecordingIndex { - /** Add or replace a recording that the proxy wrote. */ - add(file: string, recording: Recording): void; - /** - * The recording with the most equal parts, and the parts that differ. - * Recordings of the same session come first, because a test usually - * sends the same requests as the last time it ran. - */ - closest( - parts: RequestParts, - session: string | undefined, - ): Promise<{ file: string; differs: string[] } | undefined>; - /** The recordings that the session `session` recorded. */ - filesOf(session: string): Promise; -} - -const entryOf = (file: string, recording: Recording): IndexEntry => ({ - file, - session: recording.session, - parts: recording.request.parts, -}); - -/** Index the recordings of one rule directory. It reads them on first use. */ -export function createRecordingIndex(directory: string): RecordingIndex { - const entries = new Map(); - let loaded: Promise | undefined; - const load = async () => { - const names = await readdir(directory).catch((error: unknown) => { - if (isMissing(error)) return []; - throw error; - }); - for (const name of names.filter((entry) => entry.endsWith(".json"))) { - const file = path.join(directory, name); - const recording = await readRecording(file); - if (recording && !entries.has(file)) { - entries.set(file, entryOf(file, recording)); - } +/** + * The store of the recordings in `directory`. To find the closest + * recording of a miss, it reads all recordings of the rule on the first + * miss of that rule, and then keeps them in memory. + */ +export function createFileStore(directory: string): RecordingStore { + const indexes = new Map>>(); + const indexOf = (rule: string) => { + let index = indexes.get(rule); + if (!index) { + index = loadIndex(directory, rule); + indexes.set(rule, index); } + return index; }; return { - add(file, recording) { - entries.set(file, entryOf(file, recording)); + async read(key) { + const text = await readText(path.join(directory, key)); + return text === undefined ? undefined : (JSON.parse(text) as Recording); }, - async closest(parts, session) { - await (loaded ??= load()); - const all = [...entries.values()]; + + async write(recordings) { + const changed = await Promise.all( + recordings.map(async ([key, recording]) => { + const file = path.join(directory, key); + const content = formatRecording(recording); + // Keep a loaded index current, so later misses compare with it. + if (indexes.has(ruleOf(key))) { + (await indexOf(ruleOf(key))).set(key, entryOf(key, recording)); + } + if ((await readText(file)) === content) return false; + await mkdir(path.dirname(file), { recursive: true }); + await writeFile(file, content); + return true; + }), + ); + return changed.filter(Boolean).length; + }, + + async closest(rule, parts, session) { + const all = [...(await indexOf(rule)).values()]; const same = all.filter((entry) => entry.session === session); const found = closestRequest( parts, session !== undefined && same.length > 0 ? same : all, ); - return found && { file: found.candidate.file, differs: found.differs }; + return found && { key: found.candidate.key, differs: found.differs }; }, - async filesOf(session) { - await (loaded ??= load()); - return [...entries.values()] - .filter((entry) => entry.session === session) - .map((entry) => entry.file); + + async keysOf(session) { + const entries = await readdir(directory, { withFileTypes: true }).catch( + (error: unknown) => { + if (isMissing(error)) return []; + throw error; + }, + ); + const keys: string[] = []; + // Each rule is a directory. Skip other files, such as `.DS_Store`. + for (const rule of entries.filter((entry) => entry.isDirectory())) { + for (const entry of (await indexOf(rule.name)).values()) { + if (entry.session === session) keys.push(entry.key); + } + } + return keys; }, }; } +const entryOf = (key: string, recording: Recording): IndexEntry => ({ + key, + session: recording.session, + parts: recording.request.parts, +}); + +/** Read every recording of one rule, by key. */ +async function loadIndex( + directory: string, + rule: string, +): Promise> { + const names = await readdir(path.join(directory, rule)).catch( + (error: unknown) => { + if (isMissing(error)) return []; + throw error; + }, + ); + const index = new Map(); + for (const name of names.filter((entry) => entry.endsWith(".json"))) { + const key = `${rule}/${name}`; + const text = await readText(path.join(directory, key)); + if (text !== undefined) { + index.set(key, entryOf(key, JSON.parse(text) as Recording)); + } + } + return index; +} + /** * Delete the recordings in `directory` that no used file lists. Each used * file comes from `usedFile` of one proxy run. Returns how many it deleted. @@ -152,7 +148,10 @@ export async function pruneRecordings( const recordings = (await readdir(directory, { recursive: true })).filter( (file) => file.endsWith(".json"), ); - const unused = recordings.filter((file) => !used.has(file)); + // Used files list keys, which use `/`. On Windows, `readdir` uses `\`. + const unused = recordings.filter( + (file) => !used.has(file.split(path.sep).join("/")), + ); await Promise.all(unused.map((file) => rm(path.join(directory, file)))); return unused.length; } diff --git a/src/request-key.ts b/src/request-key.ts index d113d32..27e0587 100644 --- a/src/request-key.ts +++ b/src/request-key.ts @@ -10,12 +10,14 @@ * A recording also keeps a short hash of each part of its request: the * method, the URL, each key header, each top-level field of a JSON body, * and each item of a top-level array, such as `messages[3]`. When a request - * has no recording, the proxy finds the recording with the most equal parts - * and reports the parts that differ. + * has no recording, the file store finds the recording with the most equal + * parts and reports the parts that differ. This only helps a person debug a + * miss. It never makes a replay. */ import { createHash } from "node:crypto"; import type { IncomingHttpHeaders } from "node:http"; import { THINKING_BLOCK_TYPES } from "./streams.ts"; +import type { RequestParts } from "./store.ts"; import type { RecordingRule } from "./types.ts"; import { extractValues, @@ -27,9 +29,6 @@ import { /** Change this to make every recording a miss. */ const KEY_VERSION = "http-v1"; -/** A short hash of each part of a request, by part name. */ -export type RequestParts = Record; - /** A request as the key sees it. */ export interface KeyedRequest { /** The name of the recording file, without `.json`. */ diff --git a/src/server.ts b/src/server.ts index 645d623..be8b5cc 100644 --- a/src/server.ts +++ b/src/server.ts @@ -19,12 +19,17 @@ import http from "node:http"; import https from "node:https"; import { isIP, type Socket } from "node:net"; import tls from "node:tls"; -import { createCertificateAuthority } from "./certificates.ts"; +import { + createCertificateAuthority, + type CertificateAuthority, +} from "./certificates.ts"; import { createRecorder, type ProxyRequest, type Recorder, } from "./recorder.ts"; +import path from "node:path"; +import { createFileStore } from "./recordings.ts"; import type { RoachAddress, RoachConfig } from "./types.ts"; /** The path prefix of the control API. */ @@ -48,9 +53,15 @@ const HOP_HEADERS = new Set([ "upgrade", ]); const AUTHORITY = /^(\[[0-9a-f:.]+\]|[a-z0-9.-]+):(\d{1,5})$/i; +/** A request body over this size gets HTTP 413. */ +const MAX_BODY_BYTES = 64 * 1024 * 1024; + +/** An error that answers a request with its own status. */ +const httpError = (status: number, message: string) => + Object.assign(new Error(message), { status }); /** Parse the allowed origins, by origin. A value that is not one throws. */ -function parseOrigins(values: string[]): Map { +export function parseOrigins(values: string[]): Map { return new Map( values.map((value) => { const origin = new URL(value); @@ -71,21 +82,54 @@ function authorityOf(origin: URL): string { return `${origin.hostname}:${port}`; } -function readBody(stream: http.IncomingMessage): Promise { +/** + * Read a whole body. A body over `maxBytes` fails with HTTP 413. Only + * request bodies have a limit: the client sends them, and a large upstream + * response is not the fault of the client. + */ +function readBody( + stream: http.IncomingMessage, + maxBytes = Infinity, +): Promise { return new Promise((resolve, reject) => { const chunks: Buffer[] = []; - stream.on("data", (chunk: Buffer) => chunks.push(chunk)); + let size = 0; + stream.on("data", (chunk: Buffer) => { + size += chunk.length; + if (size > maxBytes) { + // Read the rest without keeping it, so the client gets the 413. + chunks.length = 0; + reject(httpError(413, `body is over ${maxBytes} bytes`)); + return; + } + chunks.push(chunk); + }); stream.on("end", () => resolve(Buffer.concat(chunks))); stream.on("error", reject); }); } -async function readJson(incoming: http.IncomingMessage): Promise { - return JSON.parse((await readBody(incoming)).toString("utf8") || "{}") as T; +/** Read a JSON object request body. Any other body fails with HTTP 400. */ +export async function readJson(incoming: http.IncomingMessage): Promise { + const text = (await readBody(incoming, MAX_BODY_BYTES)).toString("utf8"); + let value: unknown; + try { + value = JSON.parse(text || "{}"); + } catch { + throw httpError(400, "body is not JSON"); + } + // Callers read fields, so `null`, a list, or a single value is a bad body. + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw httpError(400, "body is not a JSON object"); + } + return value as T; } /** Compare tokens in constant time. */ -function sameToken(actual: string | undefined, expected: string): boolean { +export function sameToken( + actual: string | undefined, + expected: string, +): boolean { if (actual === undefined) return false; const a = Buffer.from(actual); const b = Buffer.from(expected); @@ -135,8 +179,7 @@ function sendUpstream( /** Proxy one request to an allowed origin, through the recorder. */ async function proxyRequest( - recorder: Recorder, - origins: Map, + { recorder, origins, replayOnly }: ProxyTarget, incoming: http.IncomingMessage, outgoing: http.ServerResponse, tunnelOrigin: string | undefined, @@ -155,10 +198,18 @@ async function proxyRequest( method: incoming.method ?? "GET", url, headers: incoming.headers, - body: await readBody(incoming), + body: await readBody(incoming, MAX_BODY_BYTES), }; const rule = recorder.ruleFor(request); + if (!rule && replayOnly) { + // Without this, anyone could send live traffic through a public run. + outgoing.writeHead(403, { "content-type": "text/plain" }); + outgoing.end( + "Roach: this run can only replay, and no rule records this request\n", + ); + return; + } if (!rule) { recorder.countPassthrough(origin.origin, request.headers); const upstream = await sendUpstream(origin, request); @@ -190,172 +241,331 @@ async function proxyRequest( outgoing.end(response.body); } -/** Answer one request to the control API. See `README.md`. */ -async function control( +/** Write a JSON response. */ +export function sendJson( + outgoing: http.ServerResponse, + status: number, + body: unknown, +): void { + outgoing.writeHead(status, { "content-type": "application/json" }); + outgoing.end(JSON.stringify(body)); +} + +/** + * Answer a session or stats call of one recorder. `route` is the method + * and the path after the prefix of the run, such as `POST /session`. + * Returns `false` when the route is not one of them. + */ +export async function controlRecorder( recorder: Recorder, - token: string, + route: string, incoming: http.IncomingMessage, outgoing: http.ServerResponse, -): Promise { - if (!sameToken(incoming.headers.authorization, `Bearer ${token}`)) { - outgoing.writeHead(401).end(); - return; - } - const route = `${incoming.method} ${new URL(incoming.url ?? "/", "http://proxy").pathname}`; - const json = (status: number, body: unknown) => { - outgoing.writeHead(status, { "content-type": "application/json" }); - outgoing.end(JSON.stringify(body)); - }; +): Promise { switch (route) { - case `GET ${CONTROL_PATH}/stats`: - json(200, recorder.stats()); - return; - case `POST ${CONTROL_PATH}/session`: { + case "GET /stats": + sendJson(outgoing, 200, recorder.stats()); + return true; + case "POST /session": { const { name } = await readJson<{ name?: unknown }>(incoming); if (typeof name !== "string" || name === "") { - json(400, { error: "name must be a string" }); - return; + sendJson(outgoing, 400, { error: "name must be a string" }); + return true; } await recorder.startSession(name); outgoing.writeHead(204).end(); - return; + return true; } - case `POST ${CONTROL_PATH}/session/end`: { + case "POST /session/end": { const { name, passed } = await readJson<{ name?: unknown; passed?: unknown; }>(incoming); try { const missed = await recorder.endSession(String(name), passed === true); - json(200, { missed }); + sendJson(outgoing, 200, { missed }); } catch (error) { - json(409, { error: (error as Error).message }); + sendJson(outgoing, 409, { error: (error as Error).message }); } - return; + return true; } default: - outgoing.writeHead(404).end(); + return false; } } -/** Start the proxy in this process, on a free port of `127.0.0.1`. */ -export async function startRoach(config: RoachConfig): Promise { - const origins = parseOrigins(config.allow); - const allowedAuthorities = new Set([...origins.values()].map(authorityOf)); - const recorder = createRecorder(config); - const authority = await createCertificateAuthority(); - const token = randomBytes(24).toString("hex"); - // The origin of each tunnel. Absolute-form requests have no tunnel. - const tunnelOrigins = new WeakMap(); - const sockets = new Set(); +/** Answer one request to the control API of a local proxy. */ +async function control( + recorder: Recorder, + token: string, + incoming: http.IncomingMessage, + outgoing: http.ServerResponse, +): Promise { + if (!sameToken(incoming.headers.authorization, `Bearer ${token}`)) { + outgoing.writeHead(401).end(); + return; + } + const pathname = new URL(incoming.url ?? "/", "http://proxy").pathname; + const handled = + pathname.startsWith(`${CONTROL_PATH}/`) && + (await controlRecorder( + recorder, + `${incoming.method} ${pathname.slice(CONTROL_PATH.length)}`, + incoming, + outgoing, + )); + if (!handled) outgoing.writeHead(404).end(); +} + +/** Where a proxied request goes: the allowed origins and the recorder. */ +export interface ProxyTarget { + origins: Map; + recorder: Recorder; + /** + * Refuse requests that no rule matches, so nothing goes live. The + * service sets this for a run without the tenant token. + */ + replayOnly?: boolean; + /** Set when the target stops. Its tunnels then refuse new requests. */ + closed?: boolean; +} + +/** A target, or the HTTP status that refuses the request. */ +export type TargetResult = ProxyTarget | { status: 403 | 407 }; + +/** The options of `listenProxy()`. */ +export interface ListenOptions { + host: string; + port: number; + authority: CertificateAuthority; + /** + * The target of a proxied request. It reads `Proxy-Authorization` of a + * `CONNECT` or an absolute-form request. + */ + targetFor(incoming: http.IncomingMessage): TargetResult; + /** Answer a request to the proxy itself, such as the control API. */ + control( + incoming: http.IncomingMessage, + outgoing: http.ServerResponse, + ): Promise; +} - const serve = - (originOf: (incoming: http.IncomingMessage) => string | undefined) => - (incoming: http.IncomingMessage, outgoing: http.ServerResponse) => { - proxyRequest( - recorder, - origins, - incoming, - outgoing, - originOf(incoming), - ).catch((error: unknown) => { - if (!outgoing.headersSent) { - outgoing.writeHead(502, { "content-type": "text/plain" }); +/** A listening proxy socket. */ +export interface ListeningProxy { + port: number; + close(): Promise; +} + +/** Refuse a proxied request that has no target. */ +function refuse(status: 403 | 407, outgoing: http.ServerResponse): void { + outgoing.writeHead( + status, + status === 407 + ? { + "proxy-authenticate": 'Basic realm="roach"', + "content-type": "text/plain", } - outgoing.end( - `Roach error: ${error instanceof Error ? error.message : String(error)}\n`, - ); - }); - }; + : { "content-type": "text/plain" }, + ); + outgoing.end( + `Roach: ${status === 407 ? "proxy credentials required" : "forbidden"}\n`, + ); +} - // Requests inside a CONNECT tunnel. The tunnel gives the origin. - const tunnelServer = http.createServer( - serve((incoming) => tunnelOrigins.get(incoming.socket)), +/** Proxy one request through its target, and answer errors with 502. */ +function serveProxied( + target: ProxyTarget, + incoming: http.IncomingMessage, + outgoing: http.ServerResponse, + tunnelOrigin: string | undefined, +): void { + if (target.closed) { + outgoing.writeHead(410, { "content-type": "text/plain" }); + outgoing.end("Roach: this run has ended\n"); + return; + } + proxyRequest(target, incoming, outgoing, tunnelOrigin).catch( + (error: unknown) => { + if (!outgoing.headersSent) { + const status = (error as { status?: number }).status ?? 502; + outgoing.writeHead(status, { "content-type": "text/plain" }); + } + outgoing.end( + `Roach error: ${error instanceof Error ? error.message : String(error)}\n`, + ); + }, ); - const proxyAbsolute = serve(() => undefined); +} + +/** + * Listen for proxied requests and control requests on one port. The + * caller decides the target of each proxied request, so one port can serve + * one run (`startRoach()`) or many runs (`service.ts`). + */ +export async function listenProxy( + options: ListenOptions, +): Promise { + const { authority } = options; + // The origin and target of each tunnel. Absolute-form requests have none. + const tunnels = new WeakMap< + Socket, + { origin: string; target: ProxyTarget } + >(); + const sockets = new Set(); + + // Requests inside a CONNECT tunnel. The tunnel gives the origin. + const tunnelServer = http.createServer((incoming, outgoing) => { + const tunnel = tunnels.get(incoming.socket); + if (!tunnel) { + outgoing.writeHead(400).end(); + return; + } + serveProxied(tunnel.target, incoming, outgoing, tunnel.origin); + }); const server = http.createServer((incoming, outgoing) => { // A request to the proxy itself has a path. A proxied plain HTTP // request has an absolute URL. if (!incoming.url?.startsWith("/")) { - proxyAbsolute(incoming, outgoing); + const target = options.targetFor(incoming); + if ("status" in target) refuse(target.status, outgoing); + else serveProxied(target, incoming, outgoing, undefined); return; } - control(recorder, token, incoming, outgoing).catch((error: unknown) => { + options.control(incoming, outgoing).catch((error: unknown) => { process.stderr.write(`[roach] Control failed: ${String(error)}\n`); - if (!outgoing.headersSent) outgoing.writeHead(500); - outgoing.end(); + // An error with a status, such as 413 from readBody, is the fault of + // the client. Any other error is a bug of the proxy. + const status = (error as { status?: number }).status ?? 500; + if (outgoing.headersSent) { + outgoing.end(); + return; + } + sendJson(outgoing, status, { + error: status === 500 ? "internal error" : (error as Error).message, + }); }); }); server.on("connection", (socket: Socket) => { sockets.add(socket); socket.on("close", () => sockets.delete(socket)); }); - server.on("connect", (request, socket: Socket, head: Buffer) => { - socket.on("error", () => socket.destroy()); - const match = AUTHORITY.exec(request.url ?? ""); - if (!match) { - socket.end("HTTP/1.1 400 Bad Request\r\n\r\n"); - return; - } - const [, host, port] = match as unknown as [string, string, string]; - if (!allowedAuthorities.has(`${host.toLowerCase()}:${port}`)) { - process.stderr.write(`[roach] Refused ${host}:${port}\n`); - socket.end("HTTP/1.1 403 Forbidden\r\n\r\n"); - return; - } - socket.write("HTTP/1.1 200 Connection Established\r\n\r\n"); - const open = async (first: Buffer) => { - socket.unshift(first); - // A TLS handshake starts with byte 0x16. Anything else is plain HTTP. - if (first[0] !== 0x16) { - tunnelOrigins.set( - socket, - `http://${host}${port === "80" ? "" : `:${port}`}`, + server.on( + "connect", + (request: http.IncomingMessage, socket: Socket, head: Buffer) => { + socket.on("error", () => socket.destroy()); + const target = options.targetFor(request); + if ("status" in target) { + socket.end( + target.status === 407 + ? 'HTTP/1.1 407 Proxy Authentication Required\r\nProxy-Authenticate: Basic realm="roach"\r\n\r\n' + : "HTTP/1.1 403 Forbidden\r\n\r\n", ); - tunnelServer.emit("connection", socket); - socket.resume(); return; } - const secure = new tls.TLSSocket(socket, { - isServer: true, - secureContext: await authority.contextFor(host), - ALPNProtocols: ["http/1.1"], - }); - secure.on("error", () => secure.destroy()); - tunnelOrigins.set( - secure, - `https://${host}${port === "443" ? "" : `:${port}`}`, + const match = AUTHORITY.exec(request.url ?? ""); + if (!match) { + socket.end("HTTP/1.1 400 Bad Request\r\n\r\n"); + return; + } + const [, host, port] = match as unknown as [string, string, string]; + const allowed = [...target.origins.values()].some( + (origin) => authorityOf(origin) === `${host.toLowerCase()}:${port}`, ); - tunnelServer.emit("connection", secure); - }; - const onFirst = (first: Buffer) => { - socket.pause(); - open(first).catch(() => socket.destroy()); - }; - if (head.length > 0) onFirst(head); - else socket.once("data", onFirst); - }); + if (!allowed) { + process.stderr.write(`[roach] Refused ${host}:${port}\n`); + socket.end("HTTP/1.1 403 Forbidden\r\n\r\n"); + return; + } + socket.write("HTTP/1.1 200 Connection Established\r\n\r\n"); + const open = async (first: Buffer) => { + socket.unshift(first); + // A TLS handshake starts with byte 0x16. Anything else is plain HTTP. + if (first[0] !== 0x16) { + tunnels.set(socket, { + origin: `http://${host}${port === "80" ? "" : `:${port}`}`, + target, + }); + tunnelServer.emit("connection", socket); + socket.resume(); + return; + } + const secure = new tls.TLSSocket(socket, { + isServer: true, + secureContext: await authority.contextFor(host), + ALPNProtocols: ["http/1.1"], + }); + secure.on("error", () => secure.destroy()); + tunnels.set(secure, { + origin: `https://${host}${port === "443" ? "" : `:${port}`}`, + target, + }); + tunnelServer.emit("connection", secure); + }; + const onFirst = (first: Buffer) => { + socket.pause(); + open(first).catch(() => socket.destroy()); + }; + if (head.length > 0) onFirst(head); + else socket.once("data", onFirst); + }, + ); await new Promise((resolve, reject) => { server.once("error", reject); - server.listen(0, "127.0.0.1", () => resolve()); + server.listen(options.port, options.host, () => resolve()); }); const address = server.address(); if (!address || typeof address === "string") { throw new Error("Roach did not bind to a TCP port"); } - return { - url: `http://127.0.0.1:${address.port}`, - token, - caCert: authority.caCert, + port: address.port, async close() { await new Promise((resolve) => { server.close(() => resolve()); for (const socket of sockets) socket.destroy(); }); tunnelServer.close(); - await recorder.close(); + }, + }; +} + +/** Start the proxy in this process, on a free port of `127.0.0.1`. */ +export async function startRoach(config: RoachConfig): Promise { + if ( + config.missDirectory && + !path + .relative(config.directory, path.resolve(config.missDirectory)) + .startsWith("..") + ) { + // Miss files hold request bodies. Keep them out of the committed files. + throw new Error("missDirectory must not be inside directory"); + } + const target: ProxyTarget = { + origins: parseOrigins(config.allow), + recorder: createRecorder(config, createFileStore(config.directory)), + }; + const authority = await createCertificateAuthority(); + const token = randomBytes(24).toString("hex"); + // Only local processes reach this port, so proxied requests need no + // credentials. + const listening = await listenProxy({ + host: "127.0.0.1", + port: 0, + authority, + targetFor: () => target, + control: (incoming, outgoing) => + control(target.recorder, token, incoming, outgoing), + }); + + return { + url: `http://127.0.0.1:${listening.port}`, + token, + caCert: authority.caCert, + async close() { + target.closed = true; + await listening.close(); + await target.recorder.close(); await authority.close(); }, }; diff --git a/src/service.ts b/src/service.ts new file mode 100644 index 0000000..eb44f9b --- /dev/null +++ b/src/service.ts @@ -0,0 +1,467 @@ +/** + * 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. + * + * - 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. + * - The service gives each run its own id and token. A proxied request + * names its run in `Proxy-Authorization`, as `Basic base64(:)`. + * The proxy URL of the run has them, so `HTTPS_PROXY` sends them. + * - The recordings of a tenant are under `/`, in a directory or a + * GCS bucket (`gcs.ts`). Tenants never share recordings, because a key + * does not include credentials. + * - Each read and write of a recording sends the Sentry metric + * `roach.recording`, with the tenant, the key, and the run. + */ +import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; +import type http from "node:http"; +import path from "node:path"; +import * as Sentry from "@sentry/node"; +import { + createCertificateAuthority, + type CertificateAuthorityPem, +} from "./certificates.ts"; +import { createGcsStore } from "./gcs.ts"; +import { createRecorder } from "./recorder.ts"; +import { createFileStore } from "./recordings.ts"; +import { + CONTROL_PATH, + controlRecorder, + listenProxy, + parseOrigins, + readJson, + sameToken, + sendJson, + type ProxyTarget, + type TargetResult, +} from "./server.ts"; +import { RULE_NAME, type RecordingStore } from "./store.ts"; +import type { RecordingMode, RecordingRule, RecordingStats } from "./types.ts"; +import { VALUE_PATTERNS } from "./values.ts"; + +/** The configuration of the service. The operator owns it. */ +export interface RoachServiceConfig { + /** Default: `127.0.0.1`. */ + host?: string; + /** Default: a free port. */ + port?: number; + /** + * The URL that clients use to reach the service, such as + * `https://roach.example.com`. Default: `http://:`. + */ + publicUrl?: string; + /** A directory for the recordings. Set this or `bucket`. */ + directory?: string; + /** A GCS bucket for the recordings. Set this or `directory`. */ + bucket?: string; + /** 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. + */ + tenants: Record; + /** + * More regular expression sources that rules can use in `values`, in + * addition to `VALUE_PATTERNS`. A slow pattern blocks all runs, so check + * each one for catastrophic backtracking before you add it. + */ + valuePatterns?: string[]; + /** Credentials to redact in all recordings. */ + secrets?: string[]; + /** + * The certificate authority that signs intercepted hosts. Clients trust + * it, so a deployed service must keep it across restarts. Default: a new + * one at each start. + */ + ca?: CertificateAuthorityPem; + /** Without a DSN, the service sends nothing to Sentry. */ + sentryDsn?: string; +} + +/** The configuration of one run. The client sends it. */ +export interface RunConfig { + /** The tenant of the run. */ + tenant: string; + mode: RecordingMode; + rules: RecordingRule[]; + /** Default: all origins of the service. */ + allow?: string[]; + /** + * The name of the run in Sentry, such as + * `${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}`. Default: the run id. + */ + name?: string; +} + +/** A run that the service created. */ +export interface RemoteRun { + /** The id of the run. */ + id: string; + /** The proxy URL, with the run id and token as its user and password. */ + url: string; + /** The run token. Session and stats calls use it. */ + token: string; + /** The base URL of the control API of the run. */ + controlUrl: string; + /** The PEM certificate of the authority that signs intercepted hosts. */ + caCert: string; +} + +/** A service that runs in this process. */ +export interface RoachService { + /** The URL of the service. */ + url: string; + /** Stop the service. Open runs fail. */ + close(): Promise; +} + +interface Run extends ProxyTarget { + id: string; + tenant: string; + token: string; + started: number; +} + +const MODES = new Set(["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. + */ +const MAX_PUBLIC_RUNS = 50; +const MAX_RUN_NAME = 128; + +const sha256 = (value: string) => createHash("sha256").update(value).digest(); + +/** Check that `token` is the token of `tenant`, in constant time. */ +function isTenantToken( + tenants: Record, + tenant: string, + 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); +} + +/** The run id and token in a `Proxy-Authorization` header. */ +function proxyCredentials( + header: string | undefined, +): { id: string; token: string } | undefined { + const match = /^Basic (.+)$/i.exec(header ?? ""); + if (!match) return undefined; + const decoded = Buffer.from(match[1]!, "base64").toString("utf8"); + const colon = decoded.indexOf(":"); + if (colon <= 0) return undefined; + return { id: decoded.slice(0, colon), token: decoded.slice(colon + 1) }; +} + +/** Check the run config of a client. Returns the error, or `undefined`. */ +function invalidRunConfig( + config: Partial, + serviceOrigins: Map, + valuePatterns: Set, +): string | undefined { + if (!config.mode || !MODES.has(config.mode)) { + return "mode must be auto, off, record, or replay"; + } + if (config.name !== undefined) { + if (typeof config.name !== "string" || config.name.length > MAX_RUN_NAME) { + return `name must be a string of at most ${MAX_RUN_NAME} characters`; + } + } + if (!Array.isArray(config.rules)) return "rules must be a list"; + const names = new Set(); + for (const rule of config.rules) { + if (typeof rule?.name !== "string" || !RULE_NAME.test(rule.name)) { + return `rule name must match ${RULE_NAME}`; + } + if (names.has(rule.name)) return `rule ${rule.name} is not unique`; + names.add(rule.name); + if (typeof rule.match !== "object" || rule.match === null) { + return `rule ${rule.name} must have match`; + } + // A pattern runs on the requests of all runs in one process, so a + // client can only use patterns that the operator knows to be fast. + if (rule.values !== undefined) { + if (typeof rule.values !== "object" || rule.values === null) { + return `values of rule ${rule.name} must be an object`; + } + for (const [name, source] of Object.entries(rule.values)) { + if (typeof source !== "string" || !valuePatterns.has(source)) { + return `value ${name} of rule ${rule.name} is not a pattern that the service allows`; + } + } + } + } + if (config.allow !== undefined) { + if (!Array.isArray(config.allow)) return "allow must be a list"; + for (const origin of config.allow) { + // The client sends JSON, so check the type at run time. + if ( + typeof origin !== "string" || + !serviceOrigins.has(origin.replace(/\/$/, "")) + ) { + return `the service does not allow ${origin}`; + } + } + } + return undefined; +} + +/** + * The store of one run. It counts each read and write in Sentry, and a + * run without write access cannot write. + */ +function runStore( + base: RecordingStore, + labels: { tenant: string; run: string }, + canWrite: boolean, +): RecordingStore { + const count = (key: string, result: string) => { + Sentry.metrics.count("roach.recording", 1, { + attributes: { + ...labels, + rule: key.slice(0, key.indexOf("/")), + key, + result, + }, + }); + }; + return { + async read(key) { + const recording = await base.read(key); + count(key, recording ? "replayed" : "missed"); + return recording; + }, + async write(recordings) { + if (recordings.length === 0) return 0; + if (!canWrite) throw new Error("This run cannot write recordings"); + const written = await base.write(recordings); + for (const [key] of recordings) count(key, "written"); + return written; + }, + }; +} + +/** Start the service in this process. */ +export async function startRoachService( + config: RoachServiceConfig, +): Promise { + 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 (config.sentryDsn) { + Sentry.init({ dsn: config.sentryDsn, tracesSampleRate: 0 }); + } + const serviceOrigins = parseOrigins(config.allow); + const runs = new Map(); + const authority = await createCertificateAuthority(config.ca); + const valuePatterns = new Set([ + ...Object.values(VALUE_PATTERNS), + ...(config.valuePatterns ?? []), + ]); + const storeOf = (tenant: string): RecordingStore => + config.bucket + ? createGcsStore(config.bucket, `${tenant}/`) + : createFileStore(path.join(config.directory!, tenant)); + let publicUrl = config.publicUrl?.replace(/\/$/, ""); + + const endRun = async (run: Run): Promise => { + runs.delete(run.id); + run.closed = true; + await run.recorder.close(); + return run.recorder.stats(); + }; + + const createRun = (runConfig: RunConfig, canWrite: boolean): RemoteRun => { + const id = randomBytes(8).toString("hex"); + const token = randomBytes(24).toString("hex"); + const allow = runConfig.allow ?? config.allow; + const store = runStore( + storeOf(runConfig.tenant), + { tenant: runConfig.tenant, run: runConfig.name ?? id }, + canWrite, + ); + const run: Run = { + id, + tenant: runConfig.tenant, + token, + started: Date.now(), + origins: parseOrigins(allow.map((origin) => origin.replace(/\/$/, ""))), + // A public run must not be an open proxy to the allowed origins. + replayOnly: !canWrite, + // Each run learns only the credentials of its own requests. + recorder: createRecorder( + { + mode: runConfig.mode, + rules: runConfig.rules, + ...(config.secrets ? { secrets: config.secrets } : {}), + }, + store, + ), + }; + runs.set(id, run); + process.stderr.write( + `[roach] Run ${id} of ${runConfig.tenant} started in ${runConfig.mode} mode\n`, + ); + const base = new URL(publicUrl!); + base.username = id; + base.password = token; + return { + id, + url: base.href.replace(/\/$/, ""), + token, + controlUrl: `${publicUrl}${CONTROL_PATH}/runs/${id}`, + caCert: authority.caCert, + }; + }; + + const targetFor = (incoming: http.IncomingMessage): TargetResult => { + const credentials = proxyCredentials( + incoming.headers["proxy-authorization"], + ); + const run = credentials && runs.get(credentials.id); + if (!run || !sameToken(credentials.token, run.token)) { + return { status: 407 }; + } + return run; + }; + + const control = async ( + incoming: http.IncomingMessage, + outgoing: http.ServerResponse, + ): Promise => { + const pathname = new URL(incoming.url ?? "/", "http://proxy").pathname; + if (incoming.method === "GET" && pathname === `${CONTROL_PATH}/ca.pem`) { + outgoing.writeHead(200, { "content-type": "application/x-pem-file" }); + outgoing.end(authority.caCert); + return; + } + if (incoming.method === "POST" && pathname === `${CONTROL_PATH}/runs`) { + const runConfig = await readJson>(incoming); + const tenant = runConfig.tenant; + if ( + typeof tenant !== "string" || + !Object.hasOwn(config.tenants, tenant) + ) { + sendJson(outgoing, 400, { error: "tenant is not known" }); + return; + } + const error = invalidRunConfig(runConfig, serviceOrigins, valuePatterns); + if (error) { + sendJson(outgoing, 400, { error }); + return; + } + const canWrite = isTenantToken( + config.tenants, + tenant, + 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}`, + }); + return; + } + if (!canWrite) { + let open = 0; + for (const other of runs.values()) { + if (other.replayOnly && other.tenant === tenant) open += 1; + } + if (open >= MAX_PUBLIC_RUNS) { + sendJson(outgoing, 429, { + error: `${tenant} has ${MAX_PUBLIC_RUNS} open runs without a token`, + }); + return; + } + } + sendJson(outgoing, 201, createRun(runConfig as RunConfig, canWrite)); + return; + } + + const runRoute = new RegExp( + `^${CONTROL_PATH}/runs/([0-9a-f]+)(/.*)?$`, + ).exec(pathname); + const run = runRoute && runs.get(runRoute[1]!); + if ( + !run || + !sameToken(incoming.headers.authorization, `Bearer ${run.token}`) + ) { + // The same answer for a run that does not exist, so ids stay secret. + outgoing.writeHead(404).end(); + return; + } + const rest = runRoute[2] ?? ""; + if (incoming.method === "DELETE" && rest === "") { + sendJson(outgoing, 200, await endRun(run)); + return; + } + const handled = await controlRecorder( + run.recorder, + `${incoming.method} ${rest}`, + incoming, + outgoing, + ); + if (!handled) outgoing.writeHead(404).end(); + }; + + const host = config.host ?? "127.0.0.1"; + const listening = await listenProxy({ + host, + port: config.port ?? 0, + authority, + targetFor, + control, + }); + publicUrl ??= `http://${host.includes(":") ? `[${host}]` : host}:${listening.port}`; + + // End runs that their client never ended, such as a CI job that died. + const sweep = setInterval(() => { + const now = Date.now(); + for (const run of runs.values()) { + if (now - run.started > RUN_TIMEOUT_MS) { + process.stderr.write(`[roach] Run ${run.id} timed out\n`); + // A failed close must not stop the service for other runs. + endRun(run).catch((error: unknown) => { + process.stderr.write( + `[roach] Run ${run.id} did not end: ${String(error)}\n`, + ); + }); + } + } + }, 60_000); + sweep.unref(); + + return { + url: publicUrl, + async close() { + clearInterval(sweep); + await listening.close(); + await Promise.all([...runs.values()].map(endRun)); + await authority.close(); + await Sentry.flush(2000); + }, + }; +} diff --git a/src/store.ts b/src/store.ts new file mode 100644 index 0000000..f287d74 --- /dev/null +++ b/src/store.ts @@ -0,0 +1,73 @@ +/** + * Recordings, and the contract of a store that keeps them. + * + * A recording keeps the response of one request, the session (test) that + * recorded it, and the parts of the request (`request-key.ts`). It does not keep + * the request body, so prompts and other inputs are not stored. + * + * A store keeps recordings by key, `/.json`. The file store + * (`recordings.ts`) keeps them in a directory that can be committed. The + * GCS store (`gcs.ts`) keeps them in a bucket, for the shared service + * (`service.ts`). + */ +/** A short hash of each part of a request, by part name. */ +export type RequestParts = Record; + +/** One recorded response. */ +export interface Recording { + /** The session (test) that recorded the response. */ + session?: string | undefined; + request: { method: string; url: string; parts: RequestParts }; + response: { + status: number; + headers: Record; + /** + * A text body keeps each changing value of its request as a + * placeholder (`values.ts`). + */ + body: string; + /** `base64` for a body that is not text, such as an image. */ + bodyEncoding: "base64" | "utf8"; + }; +} + +/** The recording with the most equal parts, and the parts that differ. */ +interface ClosestRecording { + key: string; + differs: string[]; +} + +/** Where the recordings of a proxy are. */ +export interface RecordingStore { + /** The recording of `key`, or `undefined` when there is none. */ + read(key: string): Promise; + /** Write recordings by key. Returns how many were new or changed. */ + write(recordings: Array<[string, Recording]>): Promise; + /** + * The recording of `rule` with the most equal parts. Recordings of the + * same session come first, because a test usually sends the same + * requests as the last time it ran. Only the file store has this: it is a + * hint for debugging a miss, and a bucket has no index for it. + */ + closest?( + rule: string, + parts: RequestParts, + session: string | undefined, + ): Promise; + /** + * The keys that `session` recorded. Only the file store has this, + * because only it supports `usedFile` and prune. + */ + keysOf?(session: string): Promise; +} + +/** A rule name that a store accepts. It is a directory name. */ +export const RULE_NAME = /^[A-Za-z0-9][\w.-]{0,63}$/; + +/** The key of a request of `rule` with the hash `hash`. */ +export const recordingKey = (rule: string, hash: string) => + `${rule}/${hash}.json`; + +/** The text of a recording, as the stores keep it. */ +export const formatRecording = (recording: Recording) => + `${JSON.stringify(recording, null, 2)}\n`; diff --git a/src/types.ts b/src/types.ts index 909ab20..8de368b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -16,7 +16,10 @@ export type RecordingMode = "auto" | "off" | "record" | "replay"; /** One kind of traffic that the proxy records. */ export interface RecordingRule { - /** The name of the rule. Its recordings are in `//`. */ + /** + * The name of the rule, such as `model`. Its keys start with + * `/`. Use letters, digits, `_`, `.`, and `-`. + */ name: string; /** The requests of the rule. Each field that is set must match. */ match: { @@ -39,7 +42,10 @@ export interface RecordingRule { values?: ValuePatterns; } -/** The configuration of one proxy. */ +/** + * The configuration of one local proxy (`server.ts`). The shared service + * (`service.ts`) has its own configuration. + */ export interface RoachConfig { /** The directory of the recordings. Each rule has a subdirectory. */ directory: string; @@ -77,9 +83,9 @@ export interface RecordingMiss { rule: string; /** The session of the request. */ session?: string | undefined; - /** The recording file that the request needed, relative to `directory`. */ + /** The key of the recording that the request needed. */ file: string; - /** The recording with the most equal parts, relative to `directory`. */ + /** The key of the recording with the most equal parts. */ closest?: string | undefined; /** The parts that differ from `closest`, such as `messages[3]`. */ differs: string[]; @@ -111,6 +117,8 @@ export interface RoachAddress { url: string; /** The bearer token of the control API. */ token: string; + /** The base URL of the control API. Default: `/__roach`. */ + controlUrl?: string; /** The PEM certificate of the authority that signs intercepted hosts. */ caCert: string; } diff --git a/tests/ci-job.ts b/tests/ci-job.ts new file mode 100644 index 0000000..f40d11c --- /dev/null +++ b/tests/ci-job.ts @@ -0,0 +1,49 @@ +/** + * One CI job that uses a deployed Roach service. `deployed.test.ts` runs it + * in its own process, because it must trust the TLS certificate of the + * service at startup, as a CI job trusts a public one. + * + * It starts a run, then runs a test process that has only the proxy + * variables of the run. That process sends one HTTPS request with plain + * `fetch`. The job prints the response, the stats, and the CA certificate + * as one JSON line. + * + * Argument: JSON `{ service, token?, config, url, body }`. + */ +import { execFile } from "node:child_process"; +import { readFile, writeFile } from "node:fs/promises"; +import { promisify } from "node:util"; +import { startRemoteRun } from "../src/client.ts"; +import type { RunConfig } from "../src/service.ts"; + +const { service, token, config, url, body } = JSON.parse(process.argv[2]!) as { + service: string; + token?: string; + config: RunConfig; + url: string; + body: string; +}; + +const run = await startRemoteRun({ url: service, token }, config); +// The test process trusts the CA of Roach and the TLS certificate of the +// service, as this job does. +const caFile = run.env.NODE_EXTRA_CA_CERTS!; +await writeFile( + caFile, + `${await readFile(caFile, "utf8")}${await readFile(process.env.NODE_EXTRA_CA_CERTS!, "utf8")}`, +); +const session = await run.startSession("test"); +const script = ` +const response = await fetch(${JSON.stringify(url)}, { method: "POST", body: ${JSON.stringify(body)} }); +console.log(JSON.stringify({ status: response.status, source: response.headers.get("x-roach"), body: await response.text() })); +`; +const { stdout } = await promisify(execFile)( + process.execPath, + ["--input-type=module", "--eval", script], + { env: { ...process.env, ...run.env } }, +); +await session.end(true); +const stats = await run.close(); +process.stdout.write( + `${JSON.stringify({ response: JSON.parse(stdout), stats, caCert: run.caCert })}\n`, +); diff --git a/tests/deployed.test.ts b/tests/deployed.test.ts new file mode 100644 index 0000000..2198cfa --- /dev/null +++ b/tests/deployed.test.ts @@ -0,0 +1,335 @@ +/** + * Roach as it runs in production (`deploy/gcp/`), on one machine. + * + * - The service runs from the command line, with a config file, a fixed + * certificate authority, a GCS bucket, and Sentry. + * - A TLS server in front of it does what the load balancer does: it ends + * TLS and passes the bytes to the service. + * - A local server plays the GCS JSON API and the metadata server of the + * VM. Another one plays Sentry. + * - Each CI job (`ci-job.ts`) sends HTTPS through the service with only the + * proxy variables of its run. + */ +import { execFile, spawn, type ChildProcess } from "node:child_process"; +import { createHash } from "node:crypto"; +import { once } from "node:events"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { createServer } from "node:http"; +import https from "node:https"; +import net, { type AddressInfo } from "node:net"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import tls from "node:tls"; +import { promisify } from "node:util"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import type { RoachServiceConfig, RunConfig } from "../src/service.ts"; + +const execFileAsync = promisify(execFile); +const ROOT = path.join(import.meta.dirname, ".."); +const TOKEN = "junior-write-token"; +const BUCKET = "roach-recordings"; +const GCS_TOKEN = "vm-access-token"; + +let directory: string; +let hostCertFile: string; +let caCert: string; +const objects = new Map(); +const metrics: Array> = []; +let liveRequests = 0; +let upstreamOrigin: string; +let frontUrl: string; +let service: ChildProcess; +const servers: Array<{ close(callback: () => void): unknown }> = []; + +/** The environment of a child process, without proxy variables. */ +function childEnv(extra: Record): NodeJS.ProcessEnv { + const env = Object.fromEntries( + Object.entries(process.env).filter( + ([name]) => !/^(https?_proxy|no_proxy|all_proxy)$/i.test(name), + ), + ); + return { ...env, ...extra }; +} + +async function listen(server: net.Server, host = "127.0.0.1") { + servers.push(server); + server.listen(0, host); + await once(server, "listening"); + return (server.address() as AddressInfo).port; +} + +/** Keep the `roach.recording` metrics of a Sentry envelope. */ +function readEnvelope(envelope: string) { + for (const line of envelope.split("\n")) { + if (!line.includes('"roach.recording"')) continue; + const { items } = JSON.parse(line) as { + items: Array<{ attributes: Record }>; + }; + for (const { attributes } of items) { + metrics.push( + Object.fromEntries( + Object.entries(attributes).map(([name, { value }]) => [name, value]), + ), + ); + } + } +} + +/** Run one CI job, and return what it printed. */ +async function ciJob(token: string | undefined, mode: RunConfig["mode"]) { + const config: RunConfig = { + tenant: "junior", + mode, + name: `${mode}-run`, + rules: [{ name: "model", match: { method: "POST" } }], + }; + const { stdout } = await execFileAsync( + process.execPath, + [ + "--experimental-strip-types", + "--disable-warning=ExperimentalWarning", + path.join(ROOT, "tests/ci-job.ts"), + JSON.stringify({ + service: frontUrl, + token, + config, + url: `${upstreamOrigin}/v1/messages`, + body: JSON.stringify({ prompt: "hi" }), + }), + ], + { env: childEnv({ NODE_EXTRA_CA_CERTS: hostCertFile }) }, + ); + return JSON.parse(stdout) as { + response: { status: number; source: string; body: string }; + stats: { written: number }; + caCert: string; + }; +} + +beforeAll(async () => { + directory = await mkdtemp(path.join(tmpdir(), "roach-deployed-")); + // A fixed authority, as Terraform makes it, and one certificate for the + // TLS server and the upstream. + const caKeyFile = path.join(directory, "ca.key"); + const caCertFile = path.join(directory, "ca.crt"); + hostCertFile = path.join(directory, "host.crt"); + const hostKeyFile = path.join(directory, "host.key"); + await execFileAsync("openssl", [ + "req", + "-x509", + "-newkey", + "rsa:2048", + "-nodes", + "-days", + "1", + "-keyout", + caKeyFile, + "-out", + caCertFile, + "-subj", + "/CN=Test Roach CA", + "-addext", + "basicConstraints=critical,CA:TRUE", + "-addext", + "keyUsage=critical,keyCertSign,cRLSign", + ]); + await execFileAsync("openssl", [ + "req", + "-x509", + "-newkey", + "rsa:2048", + "-nodes", + "-days", + "1", + "-keyout", + hostKeyFile, + "-out", + hostCertFile, + "-subj", + "/CN=localhost", + "-addext", + "subjectAltName=DNS:localhost,IP:127.0.0.1,IP:127.0.0.2", + ]); + caCert = await readFile(caCertFile, "utf8"); + const host = { + cert: await readFile(hostCertFile, "utf8"), + key: await readFile(hostKeyFile, "utf8"), + }; + + // The upstream API. It is not on `localhost`, so `NO_PROXY` keeps it. + const upstream = https.createServer(host, (_incoming, outgoing) => { + liveRequests += 1; + outgoing.writeHead(200, { "content-type": "application/json" }); + outgoing.end(JSON.stringify({ n: liveRequests })); + }); + upstreamOrigin = `https://127.0.0.2:${await listen(upstream, "127.0.0.2")}`; + + // The GCS JSON API and the metadata server of the VM. + const gcs = createServer((incoming, outgoing) => { + const url = new URL(incoming.url!, "http://gcs"); + if (url.pathname.startsWith("/computeMetadata/")) { + if (incoming.headers["metadata-flavor"] !== "Google") { + outgoing.writeHead(403).end(); + return; + } + outgoing.end( + JSON.stringify({ access_token: GCS_TOKEN, expires_in: 3600 }), + ); + return; + } + if (incoming.headers.authorization !== `Bearer ${GCS_TOKEN}`) { + outgoing.writeHead(401).end(); + return; + } + const upload = `/upload/storage/v1/b/${BUCKET}/o`; + const read = `/storage/v1/b/${BUCKET}/o/`; + if (incoming.method === "POST" && url.pathname === upload) { + let body = ""; + incoming.on("data", (chunk: Buffer) => (body += chunk.toString())); + incoming.on("end", () => { + objects.set(url.searchParams.get("name")!, body); + outgoing.end("{}"); + }); + return; + } + if (incoming.method === "GET" && url.pathname.startsWith(read)) { + const object = objects.get( + decodeURIComponent(url.pathname.slice(read.length)), + ); + if (object === undefined) outgoing.writeHead(404).end(); + else outgoing.end(object); + return; + } + outgoing.writeHead(404).end(); + }); + const gcsHost = `127.0.0.1:${await listen(gcs)}`; + + const sentry = createServer((incoming, outgoing) => { + let envelope = ""; + incoming.setEncoding("utf8"); + incoming.on("data", (chunk: string) => (envelope += chunk)); + incoming.on("end", () => { + readEnvelope(envelope); + outgoing.end("{}"); + }); + }); + const sentryPort = await listen(sentry); + + // The service listens on a free port. The TLS server takes its place. + const probe = net.createServer(); + const servicePort = await listen(probe); + await new Promise((resolve) => probe.close(resolve)); + servers.splice(servers.indexOf(probe), 1); + const front = tls.createServer(host, (socket) => { + const backend = net.connect(servicePort, "127.0.0.1"); + socket.pipe(backend).pipe(socket); + backend.on("error", () => socket.destroy()); + socket.on("error", () => backend.destroy()); + }); + frontUrl = `https://localhost:${await listen(front)}`; + + const config: RoachServiceConfig = { + host: "127.0.0.1", + port: servicePort, + publicUrl: frontUrl, + bucket: BUCKET, + allow: [upstreamOrigin], + tenants: { junior: createHash("sha256").update(TOKEN).digest("hex") }, + ca: { cert: caCert, key: await readFile(caKeyFile, "utf8") }, + sentryDsn: `http://public@127.0.0.1:${sentryPort}/1`, + }; + const configFile = path.join(directory, "config.json"); + await writeFile(configFile, JSON.stringify(config)); + service = spawn( + process.execPath, + [ + "--experimental-strip-types", + "--disable-warning=ExperimentalWarning", + path.join(ROOT, "src/cli.ts"), + "service", + configFile, + ], + { + env: childEnv({ + STORAGE_EMULATOR_HOST: `http://${gcsHost}`, + GCE_METADATA_HOST: gcsHost, + // The service trusts the upstream, as it trusts public APIs. + NODE_EXTRA_CA_CERTS: hostCertFile, + }), + stdio: ["ignore", "pipe", "inherit"], + }, + ); + const [line] = (await once(service.stdout!, "data")) as [Buffer]; + const { url } = JSON.parse(line.toString()) as { url: string }; + if (url !== frontUrl) throw new Error(`The service printed ${url}`); +}); + +afterAll(async () => { + if (service.exitCode === null) { + service.kill("SIGTERM"); + await once(service, "exit"); + } + await Promise.all( + servers.map( + (server) => new Promise((resolve) => server.close(() => resolve())), + ), + ); + await rm(directory, { recursive: true, force: true }); +}); + +describe("deployed roach", () => { + it("records with the token, replays for a fork, and counts in Sentry", async () => { + const writer = await ciJob(TOKEN, "auto"); + expect(writer.response).toEqual({ + status: 200, + source: "live", + body: JSON.stringify({ n: 1 }), + }); + expect(writer.stats.written).toBe(1); + // A restart keeps the authority that clients trust. + expect(writer.caCert).toBe(caCert); + expect([...objects.keys()]).toEqual([ + expect.stringMatching(/^junior\/model\/[0-9a-f]{64}\.json$/), + ]); + + const fork = await ciJob(undefined, "replay"); + expect(fork.response).toEqual({ + status: 200, + source: "replayed", + body: JSON.stringify({ n: 1 }), + }); + expect(liveRequests).toBe(1); + + // The service sends its metrics when it stops. + service.kill("SIGTERM"); + expect(await once(service, "exit")).toEqual([0, null]); + const [key] = objects.keys(); + expect( + metrics.map(({ tenant, run, result, key: metricKey }) => ({ + tenant, + run, + result, + key: metricKey, + })), + ).toEqual([ + { + tenant: "junior", + run: "auto-run", + result: "missed", + key: key!.slice(7), + }, + { + tenant: "junior", + run: "auto-run", + result: "written", + key: key!.slice(7), + }, + { + tenant: "junior", + run: "replay-run", + result: "replayed", + key: key!.slice(7), + }, + ]); + }); +}); diff --git a/tests/roach.test.ts b/tests/roach.test.ts index 8268e94..355530a 100644 --- a/tests/roach.test.ts +++ b/tests/roach.test.ts @@ -329,6 +329,8 @@ describe("roach", () => { }); it("writes nothing for a failed session", async () => { + // A file next to the rule directories is not a rule. + await writeFile(path.join(directory, ".DS_Store"), ""); const running = await start("auto"); await session(running, [{ model: "m" }], false); await session(running, [{ model: "m" }], false); diff --git a/tests/service.test.ts b/tests/service.test.ts new file mode 100644 index 0000000..ce82641 --- /dev/null +++ b/tests/service.test.ts @@ -0,0 +1,279 @@ +import { createHash } from "node:crypto"; +import { createServer, type Server } from "node:http"; +import { mkdtemp, readdir, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { ProxyAgent, request } from "undici"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { startRemoteRun, type RemoteRoach } from "../src/client.ts"; +import { + startRoachService, + type RoachService, + type RunConfig, +} from "../src/service.ts"; +import { VALUE_PATTERNS } from "../src/values.ts"; + +let upstream: Server; +let origin: string; +let liveRequests: number; +let directory: string; +let service: RoachService; +const agents: ProxyAgent[] = []; +const runs: RemoteRoach[] = []; + +const hash = (token: string) => + createHash("sha256").update(token).digest("hex"); + +/** Start a run, with one recorded rule. Without `token`, it has no token. */ +async function startRun( + token: string | undefined, + mode: RunConfig["mode"], + config: Partial = {}, +): Promise { + const run = await startRemoteRun( + { url: service.url, token }, + { + tenant: "alpha", + mode, + rules: [ + { name: "model", match: { method: "POST", url: `${origin}/v1/` } }, + ], + ...config, + }, + ); + runs.push(run); + return run; +} + +/** Send one request through the proxy URL of a run. */ +async function send( + proxyUrl: string, + body: unknown, + headers: Record = {}, +) { + // Tunnel plain HTTP too, so the tests use `CONNECT` as HTTPS clients do. + const agent = new ProxyAgent({ uri: proxyUrl, proxyTunnel: true }); + agents.push(agent); + const response = await request(`${origin}/v1/messages`, { + body: JSON.stringify(body), + dispatcher: agent, + headers, + method: "POST", + }); + return { + status: response.statusCode, + body: await response.body.text(), + source: response.headers["x-roach"], + }; +} + +async function recordings(tenant: string): Promise { + return readdir(path.join(directory, tenant, "model")).catch(() => []); +} + +beforeEach(async () => { + liveRequests = 0; + upstream = createServer((incoming, outgoing) => { + liveRequests += 1; + if (incoming.headers["x-large"]) { + outgoing.end("x".repeat(65 * 1024 * 1024)); + return; + } + // Echo a credential, as an API that shows the key of the caller does. + outgoing.writeHead(200, { "content-type": "application/json" }); + outgoing.end( + JSON.stringify({ + n: liveRequests, + key: incoming.headers["x-api-key"] ?? null, + serviceSecret: "service-secret-value-123", + }), + ); + }); + await new Promise((resolve) => + upstream.listen(0, "127.0.0.1", resolve), + ); + const address = upstream.address(); + if (!address || typeof address === "string") throw new Error("No port"); + origin = `http://127.0.0.1:${address.port}`; + directory = await mkdtemp(path.join(tmpdir(), "roach-service-")); + service = await startRoachService({ + directory, + allow: [origin], + tenants: { alpha: hash("alpha-token"), beta: hash("beta-token") }, + secrets: ["service-secret-value-123"], + }); +}); + +afterEach(async () => { + await Promise.all(agents.splice(0).map((agent) => agent.close())); + await Promise.all(runs.splice(0).map((run) => run.close().catch(() => {}))); + await service.close(); + await new Promise((resolve) => upstream.close(() => resolve())); + await rm(directory, { recursive: true, force: true }); +}); + +describe("roach service", () => { + it("writes with the tenant token and replays for anyone", async () => { + const writer = await startRun("alpha-token", "auto"); + expect(await send(writer.url, { prompt: "hi" })).toMatchObject({ + source: "live", + }); + expect(await writer.close()).toMatchObject({ written: 1 }); + + // A fork has no token. It can still replay the recordings of a tenant. + const reader = await startRun(undefined, "replay"); + expect(await send(reader.url, { prompt: "hi" })).toMatchObject({ + status: 200, + source: "replayed", + }); + expect(await send(reader.url, { prompt: "new" })).toMatchObject({ + status: 412, + source: "missed", + }); + // A request that no rule records would go live, so a public run refuses it. + const agent = new ProxyAgent({ uri: reader.url, proxyTunnel: true }); + agents.push(agent); + const unmatched = await request(`${origin}/other`, { dispatcher: agent }); + expect(unmatched.statusCode).toBe(403); + await unmatched.body.dump(); + // Without the token, no mode that can write. + for (const mode of ["auto", "record", "off"] as const) { + await expect(startRun(undefined, mode)).rejects.toThrow(/401/); + } + // The token of another tenant does not count. + await expect(startRun("beta-token", "auto")).rejects.toThrow(/401/); + + // Another tenant has its own recordings. + const beta = await startRun(undefined, "replay", { tenant: "beta" }); + expect(await send(beta.url, { prompt: "hi" })).toMatchObject({ + status: 412, + }); + expect(liveRequests).toBe(1); + expect(await recordings("alpha")).toHaveLength(1); + expect(await recordings("beta")).toEqual([]); + }); + + it("keeps the sessions of concurrent runs apart", async () => { + const first = await startRun("alpha-token", "auto"); + const second = await startRun("alpha-token", "auto"); + const failing = await first.startSession("test"); + const passing = await second.startSession("test"); + + // Both sessions are open at once. Each request belongs to its own run. + await send(first.url, { prompt: "from first" }); + await send(second.url, { prompt: "from second" }); + await failing.end(false); + await passing.end(true); + + expect(await recordings("alpha")).toHaveLength(1); + const [file] = await recordings("alpha"); + const recording = JSON.parse( + await readFile(path.join(directory, "alpha", "model", file!), "utf8"), + ); + expect(recording.session).toBe("test"); + expect(JSON.parse(recording.response.body).n).toBe(2); + expect((await first.stats()).discarded).toBe(1); + expect((await second.stats()).written).toBe(1); + }); + + it("refuses callers without the right credentials", async () => { + const run = await startRun("alpha-token", "auto"); + const bare = new URL(run.url); + bare.username = ""; + bare.password = ""; + await expect(send(bare.href, { prompt: "hi" })).rejects.toThrow(/407/); + // An absolute-form request needs the credentials too. + const absolute = new ProxyAgent({ uri: bare.href, proxyTunnel: false }); + agents.push(absolute); + await expect( + request(`${origin}/v1/messages`, { dispatcher: absolute }), + ).rejects.toThrow(/407/); + const wrong = new URL(run.url); + wrong.password = "not-the-token"; + await expect(send(wrong.href, { prompt: "hi" })).rejects.toThrow(/407/); + + await expect( + startRun("alpha-token", "auto", { tenant: "nobody" }), + ).rejects.toThrow(/tenant is not known/); + await expect( + startRun("alpha-token", "auto", { allow: ["https://example.com"] }), + ).rejects.toThrow(/does not allow/); + await expect( + startRun("alpha-token", "auto", { + rules: [{ name: "../escape", match: {} }], + }), + ).rejects.toThrow(/rule name/); + // A pattern runs for all runs, so only known patterns are allowed. + await expect( + startRun("alpha-token", "auto", { + rules: [{ name: "model", match: {}, values: { slow: "(a+)+$" } }], + }), + ).rejects.toThrow(/not a pattern that the service allows/); + await startRun("alpha-token", "auto", { + rules: [ + { name: "model", match: {}, values: { id: VALUE_PATTERNS.uuid } }, + ], + }); + + // The control API answers a bad body with the fault of the client. + const runsUrl = `${service.url}/__roach/runs`; + const notJson = await fetch(runsUrl, { method: "POST", body: "{" }); + expect(notJson.status).toBe(400); + const notObject = await fetch(runsUrl, { method: "POST", body: "null" }); + expect(notObject.status).toBe(400); + const tooLarge = await fetch(runsUrl, { + method: "POST", + body: "x".repeat(65 * 1024 * 1024), + }); + expect(tooLarge.status).toBe(413); + + // Anyone can start a run without a token, so a tenant has a cap on them. + const open = await Promise.all( + Array.from({ length: 50 }, () => startRun(undefined, "replay")), + ); + await expect(startRun(undefined, "replay")).rejects.toThrow(/429/); + // The cap is per tenant, and runs with the token have none. + await startRun(undefined, "replay", { tenant: "beta" }); + await startRun("alpha-token", "auto"); + // An ended run frees its place. + await open[0]!.close(); + await startRun(undefined, "replay"); + + // A body over the limit gets HTTP 413 and never goes live. + const large = await send(run.url, { prompt: "x".repeat(65 * 1024 * 1024) }); + expect(large.status).toBe(413); + // The limit is only for requests. A large response is recorded. + const response = await send(run.url, { prompt: "hi" }, { "x-large": "1" }); + expect(response).toMatchObject({ status: 200, source: "live" }); + expect(response.body).toHaveLength(65 * 1024 * 1024); + + // An ended run refuses its proxy URL. + await run.close(); + await expect(send(run.url, { prompt: "hi" })).rejects.toThrow(/407/); + // Only the large response went live. + expect(liveRequests).toBe(1); + }); + + it("redacts request credentials and the secrets of the service", async () => { + const run = await startRun("alpha-token", "auto"); + const response = await send( + run.url, + { prompt: "hi" }, + { "x-api-key": "sk-live-request-key-456" }, + ); + + // The client gets the redacted body, as a replay would give it. + expect(JSON.parse(response.body)).toEqual({ + n: 1, + key: "<>", + serviceSecret: "<>", + }); + const [file] = await recordings("alpha"); + const text = await readFile( + path.join(directory, "alpha", "model", file!), + "utf8", + ); + expect(text).not.toContain("sk-live-request-key-456"); + expect(text).not.toContain("service-secret-value-123"); + }); +});