feat(14-01): record vendor calls with summer parity:upstream and replay them offline

- WriteUpstream masks vars, hashes long base64 JSON strings and refuses unmasked Authorization/X-Api-Key
- multipart requests recorded as ordered parts; the fake compares parts and hashed payloads
- loopback CONNECT recording proxy with a local ECDSA parity CA, script and forward modes
- parity:upstream command, README and parity docs
This commit is contained in:
Jakub Zych
2026-10-03 19:55:42 +02:00
parent e6a67134d1
commit ee0004fb65
11 changed files with 1647 additions and 15 deletions

View File

@@ -39,6 +39,7 @@ func toolCommands() []bonfire.Command {
parityRecordCommand(), parityRecordCommand(),
parityReplayCommand(), parityReplayCommand(),
parityBroadcastsCommand(), parityBroadcastsCommand(),
parityUpstreamCommand(),
delegateCommand("migrate", "Run plugin migrations in the app binary"), delegateCommand("migrate", "Run plugin migrations in the app binary"),
delegateRollbackCommand(), delegateRollbackCommand(),
delegateCommand("migrate:status", "Show per-plugin migration history in the app binary"), delegateCommand("migrate:status", "Show per-plugin migration history in the app binary"),

View File

@@ -19,7 +19,7 @@ func TestToolCommandNames(t *testing.T) {
for _, c := range toolCommands() { for _, c := range toolCommands() {
names = append(names, c.Name) names = append(names, c.Name)
} }
for _, want := range []string{"build", "make:plugin", "make:model", "make:migration", "make:command", "make:job", "make:admin-controller", "plugin:add", "dev", "migrate", "migrate:rollback", "migrate:status", "serve", "queue:work", "queue:clear", "schedule:run", "parity:broadcasts", "docs:build", "docs:sync", "docs:serve"} { for _, want := range []string{"build", "make:plugin", "make:model", "make:migration", "make:command", "make:job", "make:admin-controller", "plugin:add", "dev", "migrate", "migrate:rollback", "migrate:status", "serve", "queue:work", "queue:clear", "schedule:run", "parity:broadcasts", "parity:upstream", "docs:build", "docs:sync", "docs:serve"} {
if !slices.Contains(names, want) { if !slices.Contains(names, want) {
t.Fatalf("missing %s in %v", want, names) t.Fatalf("missing %s in %v", want, names)
} }
@@ -34,6 +34,7 @@ func TestToolCommandNames(t *testing.T) {
"make:admin-controller": {"[plugin] [name]"}, "make:admin-controller": {"[plugin] [name]"},
"schedule:run": {"--once"}, "schedule:run": {"--once"},
"parity:broadcasts": {"--flow", "--step", "--ids", "127.0.0.1:8424"}, "parity:broadcasts": {"--flow", "--step", "--ids", "127.0.0.1:8424"},
"parity:upstream": {"--listen", "--ca-dir", "--out", "--mode", "--script", "--vars", "127.0.0.1:8425"},
"docs:build": {"--out", "--src", "--root", "--base-url", "--check"}, "docs:build": {"--out", "--src", "--root", "--base-url", "--check"},
"docs:sync": {"--src", "--root"}, "docs:sync": {"--src", "--root"},
"docs:serve": {"--root", "--src", "--base-url", "--addr", "--allow-remote", "127.0.0.1:8088"}, "docs:serve": {"--root", "--src", "--base-url", "--addr", "--allow-remote", "127.0.0.1:8088"},

View File

@@ -89,6 +89,67 @@ func parityBroadcastsCommand() bonfire.Command {
} }
} }
func parityUpstreamCommand() bonfire.Command {
return bonfire.Command{
Name: "parity:upstream",
Description: "Record the vendor HTTPS calls a reference backend sends through a loopback recording proxy into an upstream sidecar",
Flags: []bonfire.Flag{
{Name: "listen", Description: "Loopback address of the recording proxy", Default: tide.DefaultUpstreamProxyListen},
{Name: "ca-dir", Description: "Directory of the local parity CA, outside the fixtures tree (key kept mode 0600)"},
{Name: "out", Description: "Destination sidecar path (<fixture>.upstream.yaml)"},
{Name: "mode", Description: "script (answer from --script) or forward (send once to the real vendor)", Default: "script"},
{Name: "script", Description: "YAML file of scripted vendor responses (script mode)"},
{Name: "vars", Description: "Private mode-0600 variable store outside fixtures; its values are masked in the sidecar"},
},
Run: runParityUpstream,
}
}
func runParityUpstream(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
caDir, err := requireFlag(in, "ca-dir", "parity:upstream")
if err != nil {
return err
}
outPath, err := requireFlag(in, "out", "parity:upstream")
if err != nil {
return err
}
varsPath, err := requireFlag(in, "vars", "parity:upstream")
if err != nil {
return err
}
listen := flagValue(in, "listen")
if listen == "" {
listen = tide.DefaultUpstreamProxyListen
}
proxy, err := tide.NewUpstreamProxy(tide.UpstreamProxyConfig{
Listen: listen,
CADir: caDir,
Out: outPath,
Mode: flagValue(in, "mode"),
Script: flagValue(in, "script"),
VarsPath: varsPath,
})
if err != nil {
return err
}
certPath := filepath.Join(caDir, "parity-ca.pem")
if abs, err := filepath.Abs(certPath); err == nil {
certPath = abs
}
out.Info(fmt.Sprintf("upstream proxy listening on %s", listen))
out.Info(fmt.Sprintf("parity CA certificate: %s", certPath))
out.Info(fmt.Sprintf("point the reference backend at it: HTTPS_PROXY=http://%s, curl.cainfo and openssl.cafile=%s", listen, certPath))
if err := proxy.ListenAndServe(ctx); err != nil {
return err
}
if err := proxy.Flush(); err != nil {
return err
}
out.Success(fmt.Sprintf("wrote %s", outPath))
return nil
}
func runParityBroadcasts(ctx context.Context, in bonfire.Input, out bonfire.Output) error { func runParityBroadcasts(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
flowPath, err := requireFlag(in, "flow", "parity:broadcasts") flowPath, err := requireFlag(in, "flow", "parity:broadcasts")
if err != nil { if err != nil {

View File

@@ -15,7 +15,7 @@ import (
func TestParityCommandContract(t *testing.T) { func TestParityCommandContract(t *testing.T) {
names := commandNames() names := commandNames()
for _, want := range []string{"parity:record", "parity:proxy", "parity:replay"} { for _, want := range []string{"parity:record", "parity:proxy", "parity:replay", "parity:upstream"} {
if !containsName(names, want) { if !containsName(names, want) {
t.Fatalf("missing %s in %v", want, names) t.Fatalf("missing %s in %v", want, names)
} }
@@ -82,6 +82,29 @@ func TestParityCommandContract(t *testing.T) {
} }
assertNoSecrets(t, out+"\n"+errString(err), jwt, inv) assertNoSecrets(t, out+"\n"+errString(err), jwt, inv)
upDir := t.TempDir()
upScript := filepath.Join(upDir, "script.yaml")
if err := os.WriteFile(upScript, []byte("responses: []\n"), 0o644); err != nil {
t.Fatal(err)
}
upOut := filepath.Join(outDir, "routes", "POST_x__ok.upstream.yaml")
out, err = runParityCapture("parity:upstream", "--listen", "0.0.0.0:8425", "--ca-dir", filepath.Join(upDir, "ca"), "--out", upOut, "--script", upScript, "--vars", filepath.Join(upDir, "vars.yaml"))
if err == nil || !strings.Contains(err.Error(), "loopback") {
t.Fatalf("upstream non-loopback: %v %s", err, out)
}
out, err = runParityCapture("parity:upstream", "--ca-dir", filepath.Join(upDir, "ca"), "--out", upOut, "--script", upScript, "--vars", filepath.Join(outDir, "routes", "vars.yaml"))
if err == nil || !strings.Contains(err.Error(), "vars file") {
t.Fatalf("upstream vars inside output dir: %v %s", err, out)
}
out, err = runParityCapture("parity:upstream", "--ca-dir", filepath.Join(outDir, "routes", "ca"), "--out", upOut, "--script", upScript, "--vars", filepath.Join(upDir, "vars.yaml"))
if err == nil || !strings.Contains(err.Error(), "ca dir") {
t.Fatalf("upstream CA inside output dir: %v %s", err, out)
}
out, err = runParityCapture("parity:upstream", "--out", upOut, "--vars", filepath.Join(upDir, "vars.yaml"))
if err == nil || !strings.Contains(err.Error(), "--ca-dir") {
t.Fatalf("upstream missing --ca-dir: %v %s", err, out)
}
missing := filepath.Join(outDir, "missing-var.yaml") missing := filepath.Join(outDir, "missing-var.yaml")
if err := os.WriteFile(missing, []byte("version: 1\nname: miss\nsteps:\n - id: a\n request:\n method: GET\n path: /x/{{missing}}\n response:\n status: 200\n"), 0o644); err != nil { if err := os.WriteFile(missing, []byte("version: 1\nname: miss\nsteps:\n - id: a\n request:\n method: GET\n path: /x/{{missing}}\n response:\n status: 200\n"), 0o644); err != nil {
t.Fatal(err) t.Fatal(err)

View File

@@ -10,7 +10,7 @@ Besides building and scaffolding, the `summer` tool carries two groups of utilit
## API parity commands ## API parity commands
When you port an existing backend to SummerCMS, its real responses are the contract your port must meet. The parity commands wrap [tide](../../modules/tide/README.md): they record fixtures from the reference backend and replay them against the port. Every address they listen on or connect to must be a loopback address, and captured secrets go to a variables file with mode 0600, outside the committed fixtures. When you port an existing backend to SummerCMS, its real responses are the contract your port must meet. The parity commands wrap [tide](../../modules/tide/README.md): they record fixtures from the reference backend and replay them against the port. Every address they listen on or connect to must be a loopback address (the one exception is `parity:upstream --mode forward`, which sends each recorded vendor call once to the real vendor), and captured secrets go to a variables file with mode 0600, outside the committed fixtures.
| Command | Flags | Purpose | | Command | Flags | Purpose |
|---------|-------|---------| |---------|-------|---------|
@@ -18,6 +18,7 @@ When you port an existing backend to SummerCMS, its real responses are the contr
| `summer parity:record` | `--spec`, `--target`, `--output`, `--rules`, `--vars`, `--update`; `--manifest`, `--fixtures`, `--next-batch`, `--resume`, `--allow-incomplete`, `--require-recorded` | Sends the requests of a YAML spec to a target and records the responses as a fixture, or records the missing cases of a route manifest in batches of at most 15. | | `summer parity:record` | `--spec`, `--target`, `--output`, `--rules`, `--vars`, `--update`; `--manifest`, `--fixtures`, `--next-batch`, `--resume`, `--allow-incomplete`, `--require-recorded` | Sends the requests of a YAML spec to a target and records the responses as a fixture, or records the missing cases of a route manifest in batches of at most 15. |
| `summer parity:replay` | `--fixtures`, `--target`, `--vars`, `--manifest`, `--self-check`, `--require-recorded` | Replays recorded fixtures against a backend and reports the differences after masking IDs and timestamps. | | `summer parity:replay` | `--fixtures`, `--target`, `--vars`, `--manifest`, `--self-check`, `--require-recorded` | Replays recorded fixtures against a backend and reports the differences after masking IDs and timestamps. |
| `summer parity:broadcasts` | `--flow`, `--target`, `--vars`, `--listen` (default `127.0.0.1:8424`), `--out`, `--name`, `--step`, `--ids`, `--rules`, `--api-key`, `--settle` (default `500ms`), `--pending` | Runs a flow against the reference backend with a fake Centrifugo server and records the realtime publications it sends into a golden file. | | `summer parity:broadcasts` | `--flow`, `--target`, `--vars`, `--listen` (default `127.0.0.1:8424`), `--out`, `--name`, `--step`, `--ids`, `--rules`, `--api-key`, `--settle` (default `500ms`), `--pending` | Runs a flow against the reference backend with a fake Centrifugo server and records the realtime publications it sends into a golden file. |
| `summer parity:upstream` | `--listen` (default `127.0.0.1:8425`), `--ca-dir`, `--out`, `--mode` (default `script`; or `forward`), `--script`, `--vars` | Runs a loopback HTTPS recording proxy with a locally generated CA. The reference backend sends its vendor API calls through it; each call is answered from the `--script` file (or forwarded once to the vendor in `forward` mode) and written, with credentials masked, into an upstream sidecar (`<fixture>.upstream.yaml`) when the command is interrupted. |
A typical port records once against the reference backend and replays against the Go backend on every change: A typical port records once against the reference backend and replays against the Go backend on every change:

View File

@@ -1,6 +1,6 @@
--- ---
title: Parity testing title: Parity testing
description: Record the reference backend's responses and broadcasts with tide, replay them against the Go port and diff them after masking IDs and timestamps. description: Record the reference backend's responses, broadcasts and vendor calls with tide, replay them against the Go port and diff them after masking IDs and timestamps.
section: services section: services
order: 150 order: 150
--- ---
@@ -124,3 +124,47 @@ summer parity:broadcasts --flow testdata/broadcasts/flows/post-lifecycle.yaml --
Point the reference backend's Centrifugo API URL at the recorder (`127.0.0.1:8424` by default). `--step` keeps only the publications of one step, running the earlier steps as setup. Timestamps, the actor, the `*_at` dates inside a published album, a notification's own `created_at` and `id` under `$.data.payload` (the id only when it is a positive integer, as `{{id}}` unless a captured ID names it) and captured IDs are masked (`tide.NormalizePublications`), so the Go port's publications, recorded the same way, compare with `tide.DiffPublications`. A golden with `--pending` set is recorded but not yet asserted. Point the reference backend's Centrifugo API URL at the recorder (`127.0.0.1:8424` by default). `--step` keeps only the publications of one step, running the earlier steps as setup. Timestamps, the actor, the `*_at` dates inside a published album, a notification's own `created_at` and `id` under `$.data.payload` (the id only when it is a positive integer, as `{{id}}` unless a captured ID names it) and captured IDs are masked (`tide.NormalizePublications`), so the Go port's publications, recorded the same way, compare with `tide.DiffPublications`. A golden with `--pending` set is recorded but not yet asserted.
On the Go side, the memory realtime driver records publications the same way in tests; see [Realtime](realtime.md). On the Go side, the memory realtime driver records publications the same way in tests; see [Realtime](realtime.md).
## Upstream exchanges
Some routes call outside services: a metadata API, a model vendor, a ticketing system. Two things must hold for them: the port must send the vendor the same requests the reference backend sent, and tests must never call the vendor. Upstream sidecars cover both.
A sidecar sits next to its fixture as `<fixture>.upstream.yaml` (`tide.UpstreamPath`) and holds the vendor exchanges of that fixture in recorded order:
```yaml
version: 1
exchanges:
- request:
method: POST
url: https://api.example.com/v1/items?mode=fast
headers:
Authorization: Bearer {{secret:example-token}}
Content-Type: application/json
User-Agent: example-client/1.0
body: '{"name":"widget"}'
response:
status: 201
headers:
Content-Type: application/json
body: '{"id":7}'
```
Only the compared request headers are kept (`tide.UpstreamCompareHeaders`: User-Agent, Accept, Content-Type, Authorization, X-Api-Key and the model vendors' version headers). A multipart request keeps an ordered list of `parts` instead of a body, with the SHA-256 of each file instead of its bytes, and a JSON string longer than 1024 characters that is base64 (an uploaded photo, for example) is stored as `{{sha256:<hex>}}`.
### Recording through the proxy
The reference backend's vendor URLs are usually code literals, so the way to capture what it really sends is a recording HTTPS proxy. `summer parity:upstream` runs one on `127.0.0.1:8425`:
```sh
summer parity:upstream --ca-dir /tmp/parity/ca --vars /tmp/parity/vars.yaml \
--script testdata/parity/vendor-script.yaml \
--out testdata/parity/routes/POST_items_jwt__ok.upstream.yaml
```
It prints the path of its CA certificate. Start the reference backend with `HTTPS_PROXY=http://127.0.0.1:8425` and that certificate as its curl and OpenSSL CA file, run the case, then interrupt the command to write the sidecar. In the default `script` mode each request is answered from the script file, a list of `responses` entries (`tide.UpstreamScriptResponse`: `method`, `host`, `path` and the `response` to send), so recording needs no real vendor and no real credential; a request no entry matches is answered with status 599 and the sidecar is not written. `--mode forward` sends each request once to the real vendor through a guarded client instead. Use it only by hand, never in CI.
The rules match the other recorders: the proxy listens on loopback only, the CA key is kept with mode 0600 in `--ca-dir` outside the fixtures tree, and every value from the variables file is replaced by its `{{name}}` placeholder. `tide.WriteUpstream` refuses to write an Authorization or X-Api-Key header that no variable masks.
### Replaying offline
In a Go test, load the sidecar with `tide.LoadUpstream`, build the fake with `tide.NewUpstreamFake` and hand it to the code under test through `fetchguard.WithTransport` (see [Outbound HTTP](outbound-http.md#testing-outbound-calls)). The fake answers each request from the next recorded exchange without dialing and asserts what the port sent: method, scheme, host, path, query (in any order), the compared headers with placeholders expanded from the variables store, and the body (JSON semantically, multipart part by part, base64 payloads by hash). `tide.UpstreamFake.Verify` then fails on any mismatch, any extra request and any recorded exchange the port never sent.

View File

@@ -6,7 +6,7 @@ HTTP parity toolkit that records request and response fixtures from a reference
## Overview ## Overview
`tide` is the acceptance-test engine for porting an existing WinterCMS or other PHP backend to SummerCMS: the reference backend's real responses define the contract, and the Go port must reproduce them. It records YAML fixtures (flows of request and response steps) either by driving a spec against a target or by sitting as a loopback reverse proxy in front of the reference backend while a real client uses it, then replays those fixtures against the port and diffs the responses after masking values that legitimately differ, such as IDs and timestamps. It also checks realtime side effects: a fake Centrifugo recorder captures the publications a backend sends while a flow runs, and broadcast golden files hold them normalised for comparison. The `summer` CLI's parity:proxy, parity:record, parity:replay and parity:broadcasts commands are thin wrappers around this package. It has no WinterCMS counterpart. `tide` is the acceptance-test engine for porting an existing WinterCMS or other PHP backend to SummerCMS: the reference backend's real responses define the contract, and the Go port must reproduce them. It records YAML fixtures (flows of request and response steps) either by driving a spec against a target or by sitting as a loopback reverse proxy in front of the reference backend while a real client uses it, then replays those fixtures against the port and diffs the responses after masking values that legitimately differ, such as IDs and timestamps. It also checks realtime side effects: a fake Centrifugo recorder captures the publications a backend sends while a flow runs, and broadcast golden files hold them normalised for comparison. Upstream sidecars record the calls a backend makes to outside vendors, through a loopback HTTPS recording proxy, and replay them offline through an asserting fake. The `summer` CLI's parity:proxy, parity:record, parity:replay, parity:broadcasts and parity:upstream commands are thin wrappers around this package. It has no WinterCMS counterpart.
## Features ## Features
@@ -20,6 +20,9 @@ HTTP parity toolkit that records request and response fixtures from a reference
- Fake Centrifugo: `tide.NewCentrifugoRecorder` returns an `http.Handler` that records every POST to a path ending in `/publish` or `/broadcast` as a `tide.Publication` (method, path, whether `Authorization: apikey <key>` carried the configured key, JSON body) and answers `{"result":{}}`. Paths ending in `/presence` answer `{"result":{"presence":{}}}`, `/unsubscribe` and `/info` answer `{"result":{}}`, anything else is 404. Bodies are capped at `tide.MaxPublicationBody` (1 MiB). The API key is only compared, never stored. `tide.CentrifugoRecorder.ListenAndServe` binds loopback addresses only, like the recording proxy. - Fake Centrifugo: `tide.NewCentrifugoRecorder` returns an `http.Handler` that records every POST to a path ending in `/publish` or `/broadcast` as a `tide.Publication` (method, path, whether `Authorization: apikey <key>` carried the configured key, JSON body) and answers `{"result":{}}`. Paths ending in `/presence` answer `{"result":{"presence":{}}}`, `/unsubscribe` and `/info` answer `{"result":{}}`, anything else is 404. Bodies are capped at `tide.MaxPublicationBody` (1 MiB). The API key is only compared, never stored. `tide.CentrifugoRecorder.ListenAndServe` binds loopback addresses only, like the recording proxy.
- Broadcast goldens: `tide.RecordBroadcasts` runs a flow against a loopback reference backend whose Centrifugo API URL points at a recorder on `tide.DefaultCentrifugoListen` (`127.0.0.1:8424`). With `tide.BroadcastConfig` `Step` set, earlier steps run as setup and only that step's publications are kept. The result is a `tide.BroadcastGolden`, written with `tide.WriteBroadcastGolden` (which refuses token-shaped bodies) and read strictly with `tide.LoadBroadcastGolden`. A golden with `pending` set is recorded but not yet asserted. - Broadcast goldens: `tide.RecordBroadcasts` runs a flow against a loopback reference backend whose Centrifugo API URL points at a recorder on `tide.DefaultCentrifugoListen` (`127.0.0.1:8424`). With `tide.BroadcastConfig` `Step` set, earlier steps run as setup and only that step's publications are kept. The result is a `tide.BroadcastGolden`, written with `tide.WriteBroadcastGolden` (which refuses token-shaped bodies) and read strictly with `tide.LoadBroadcastGolden`. A golden with `pending` set is recorded but not yet asserted.
- Broadcast normalisation: `tide.NormalizePublications` masks only `$.data.timestamp` and `$.data.payload.timestamp` (ISO 8601 with an offset) as `"{{timestamp}}"`, `$.data.payload.actor` (an object of exactly `user_id` and `name`) as `"{{actor}}"`, and values equal to an `id:*` variable of a `tide.Store`: numbers or strings under `id`, `*_id` or `*_ids` keys, and the numeric last segment of a channel name such as `room:12`. Carbon `+00:00` values of `*_at` keys anywhere under `$.data.payload.album` become `"{{datetime}}"`. A notification publication's own row fields are masked too: a Carbon `+00:00` `$.data.payload.created_at` becomes `"{{datetime}}"`, and a positive integer `$.data.payload.id` that no `id:*` variable names becomes a bare `{{id}}` (a captured one keeps `{{id:name}}`); a date of another shape is left as it is, so a format change shows as a difference. A masked number is written as a bare `{{id:name}}`, so a number that becomes a string still differs. A value matching two id variables is an error. `tide.DiffPublications` compares the count, method, path, authorization flag and body (structurally, key order ignored) and reports paths such as `$[0].body.data.payload.id`. - Broadcast normalisation: `tide.NormalizePublications` masks only `$.data.timestamp` and `$.data.payload.timestamp` (ISO 8601 with an offset) as `"{{timestamp}}"`, `$.data.payload.actor` (an object of exactly `user_id` and `name`) as `"{{actor}}"`, and values equal to an `id:*` variable of a `tide.Store`: numbers or strings under `id`, `*_id` or `*_ids` keys, and the numeric last segment of a channel name such as `room:12`. Carbon `+00:00` values of `*_at` keys anywhere under `$.data.payload.album` become `"{{datetime}}"`. A notification publication's own row fields are masked too: a Carbon `+00:00` `$.data.payload.created_at` becomes `"{{datetime}}"`, and a positive integer `$.data.payload.id` that no `id:*` variable names becomes a bare `{{id}}` (a captured one keeps `{{id:name}}`); a date of another shape is left as it is, so a format change shows as a difference. A masked number is written as a bare `{{id:name}}`, so a number that becomes a string still differs. A value matching two id variables is an error. `tide.DiffPublications` compares the count, method, path, authorization flag and body (structurally, key order ignored) and reports paths such as `$[0].body.data.payload.id`.
- Upstream sidecars: `tide.UpstreamSidecar` (version 1) holds the ordered `tide.UpstreamExchange` values a backend sent to outside services during one fixture, stored as `<fixture>.upstream.yaml` (`tide.UpstreamPath`). `tide.LoadUpstream` reads one strictly (a missing file wraps `fs.ErrNotExist`). `tide.WriteUpstream` masks every variable value as `{{name}}`, replaces a JSON string longer than 1024 characters that decodes as base64 (also behind a `data:` URL prefix) with `{{sha256:<hex>}}`, and refuses an Authorization or X-Api-Key value that no variable masks. A multipart request keeps ordered `tide.UpstreamPart` values: plain fields by value, files by SHA-256. Bodies are capped at `tide.MaxUpstreamBody` (32 MiB).
- Upstream fake: `tide.NewUpstreamFake` returns an `http.RoundTripper` that answers each request from the next recorded exchange without dialing and asserts it: method, scheme, host, path, query (order-insensitive), the `tide.UpstreamCompareHeaders` with placeholders expanded from a `tide.Store`, and the body (JSON semantically, multipart part by part, hashed base64 by digest). Mismatch messages never print credential header values. `tide.UpstreamFake.Verify` joins every mismatch, extra request and unconsumed exchange. Hand the fake to code under test with `fetchguard.WithTransport`.
- Upstream recording proxy: `tide.NewUpstreamProxy` builds a loopback-only CONNECT proxy (`tide.DefaultUpstreamProxyListen`, `127.0.0.1:8425`) that terminates TLS with per-host certificates signed by a local parity CA (`tide.EnsureParityCA`: ECDSA P-256, certificate mode 0644, key mode 0600, standard library crypto only). In script mode it answers from a `tide.UpstreamScript` (the first unused entry whose method, host and path match; no match answers 599 and fails the recording); in forward mode it sends each request once to the vendor through a `fetchguard.Client` in `fetchguard.PublicOnlyMode`. `tide.UpstreamProxy.Flush` writes the sidecar with `tide.WriteUpstream` and refuses when any request failed. The CA directory and the vars file must be outside the sidecar's directory.
- Manifests: `tide.Manifest` lists routes with auth groups, a pending or ported status, cases and fixture paths; `tide.RecordManifest` records missing cases in batches of at most `tide.MaxBatch`, and `tide.ReplayManifest` replays every recorded case into a `tide.Coverage` table. - Manifests: `tide.Manifest` lists routes with auth groups, a pending or ported status, cases and fixture paths; `tide.RecordManifest` records missing cases in batches of at most `tide.MaxBatch`, and `tide.ReplayManifest` replays every recorded case into a `tide.Coverage` table.
## Usage ## Usage
@@ -119,6 +122,25 @@ for _, d := range tide.DiffPublications(golden.Publications, norm) {
| `tide.NormalizePublications` | Masks timestamps, the actor, album dates and captured ids in publication bodies. | | `tide.NormalizePublications` | Masks timestamps, the actor, album dates and captured ids in publication bodies. |
| `tide.DiffPublications` | Structural diff of two publication lists. | | `tide.DiffPublications` | Structural diff of two publication lists. |
| `tide.DefaultCentrifugoListen` | Default recorder address, `127.0.0.1:8424`. | | `tide.DefaultCentrifugoListen` | Default recorder address, `127.0.0.1:8424`. |
| `tide.UpstreamSidecar` | Versioned list of recorded vendor exchanges for one fixture. |
| `tide.UpstreamExchange` | One recorded vendor request and its response. |
| `tide.UpstreamRequest` | Method, absolute URL, compared headers and a body or multipart parts. |
| `tide.UpstreamPart` | One recorded multipart part: name and value, or filename, content type and SHA-256. |
| `tide.UpstreamResponse` | Status, headers and body the fake replays. |
| `tide.UpstreamPath` | Maps `x.yaml` to its sidecar path `x.upstream.yaml`. |
| `tide.LoadUpstream` | Reads a version-1 sidecar strictly. |
| `tide.WriteUpstream` | Masks variables, hashes long base64 strings, refuses unmasked credentials and writes the sidecar (mode 0644). |
| `tide.UpstreamCompareHeaders` | The request headers the fake asserts. |
| `tide.MaxUpstreamBody` | Body cap for the fake and the recording proxy, 32 MiB. |
| `tide.UpstreamFake` | Asserting `http.RoundTripper` over a sidecar: `tide.UpstreamFake.RoundTrip`, `tide.UpstreamFake.Verify`. |
| `tide.NewUpstreamFake` | Builds the fake from a sidecar and a variables store. |
| `tide.UpstreamProxyConfig` | Listen address, CA directory, output path, mode, script path and vars path for the recording proxy. |
| `tide.UpstreamProxy` | The recording proxy: `tide.UpstreamProxy.ListenAndServe`, `tide.UpstreamProxy.Flush`. |
| `tide.NewUpstreamProxy` | Validates the config, loads the script and vars, and creates or reuses the parity CA. |
| `tide.UpstreamScript` | Hand-authored vendor responses for script mode. |
| `tide.UpstreamScriptResponse` | One scripted answer: method, host, path and `tide.UpstreamResponse`; each answers once. |
| `tide.EnsureParityCA` | Creates or reuses the parity CA in a directory and returns the certificate path. |
| `tide.DefaultUpstreamProxyListen` | Default recording proxy address, `127.0.0.1:8425`. |
| `tide.Coverage` | Recorded, passing, failing and unrecorded counts, with table rows and a summary line. | | `tide.Coverage` | Recorded, passing, failing and unrecorded counts, with table rows and a summary line. |
## CLI commands ## CLI commands
@@ -149,11 +171,23 @@ publications:
{"channel":"room:{{id:room}}","data":{"event":"deleted","payload":{"id":{{id:item}},"actor":"{{actor}}","timestamp":"{{timestamp}}"},"timestamp":"{{timestamp}}"}} {"channel":"room:{{id:room}}","data":{"event":"deleted","payload":{"id":{{id:item}},"actor":"{{actor}}","timestamp":"{{timestamp}}"},"timestamp":"{{timestamp}}"}}
``` ```
`summer parity:upstream` wraps `tide.NewUpstreamProxy` and `tide.UpstreamProxy.Flush`:
```sh
summer parity:upstream \
--ca-dir /tmp/parity/ca \
--vars /tmp/parity/vars.yaml \
--script testdata/parity/vendor-script.yaml \
--out testdata/parity/routes/POST_items_jwt__ok.upstream.yaml
```
`--listen` defaults to `127.0.0.1:8425` and `--mode` to `script`. The command prints the CA certificate path; run the reference backend with `HTTPS_PROXY` pointing at the proxy and that certificate as its CA file, then interrupt the command to write the sidecar. Security rules: the proxy listens on loopback only; the CA key stays mode 0600 in `--ca-dir`, outside the fixtures tree; the vars file is outside the sidecar's directory and every value in it is masked; an unmasked Authorization or X-Api-Key value refuses the write; `--mode forward` reaches the real vendor and is for hand recording only, never CI.
## Dependencies ## Dependencies
- SummerCMS modules: none. - SummerCMS modules: [fetchguard](../fetchguard/README.md) (the recording proxy's forward mode).
- Third-party: `github.com/goccy/go-yaml` (fixture, rules and manifest parsing). - Third-party: `github.com/goccy/go-yaml` (fixture, rules, manifest and sidecar parsing).
- Standard library: `net/http`, `net/http/httputil`, `encoding/json`, `crypto/sha256`, `crypto/subtle`, among others. - Standard library: `net/http`, `net/http/httputil`, `encoding/json`, `crypto/sha256`, `crypto/subtle`, `crypto/tls`, `crypto/x509`, `crypto/ecdsa`, among others.
## Testing ## Testing
@@ -161,4 +195,4 @@ publications:
go test ./modules/tide/... go test ./modules/tide/...
``` ```
The tests run recording, the proxy, replay and the fake Centrifugo recorder against local `net/http/httptest` servers and use the sample spec in `modules/tide/testdata/`; they need no external services. The tests run recording, the proxies, replay, the upstream fake and the fake Centrifugo recorder against local `net/http/httptest` servers and use the sample spec in `modules/tide/testdata/`; they need no external services.

View File

@@ -2,12 +2,20 @@ package tide
import ( import (
"bytes" "bytes"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"io" "io"
"mime"
"mime/multipart"
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
"path/filepath"
"regexp"
"slices" "slices"
"strings" "strings"
"sync" "sync"
@@ -33,13 +41,36 @@ type UpstreamExchange struct {
// UpstreamRequest is the request the backend sent upstream. URL is absolute // UpstreamRequest is the request the backend sent upstream. URL is absolute
// (scheme, host, path and query). Headers holds only the compared headers // (scheme, host, path and query). Headers holds only the compared headers
// (UpstreamCompareHeaders); credential values are {{name}} placeholders. // (UpstreamCompareHeaders); credential values are {{name}} placeholders.
// A multipart/form-data request keeps Parts instead of Body.
type UpstreamRequest struct { type UpstreamRequest struct {
Method string `yaml:"method"` Method string `yaml:"method"`
URL string `yaml:"url"` URL string `yaml:"url"`
Headers map[string]string `yaml:"headers,omitempty"` Headers map[string]string `yaml:"headers,omitempty"`
Body string `yaml:"body,omitempty"` Body string `yaml:"body,omitempty"`
Parts []UpstreamPart `yaml:"parts,omitempty"`
} }
// UpstreamPart is one multipart/form-data part of a recorded request, in
// order. A plain field keeps its Value (credentials masked as {{name}}); a
// file part keeps Filename, ContentType and the SHA256 of its bytes, never
// the bytes.
type UpstreamPart struct {
Name string `yaml:"name"`
Value string `yaml:"value,omitempty"`
Filename string `yaml:"filename,omitempty"`
ContentType string `yaml:"content_type,omitempty"`
SHA256 string `yaml:"sha256,omitempty"`
}
// MaxUpstreamBody caps one upstream request or response body the fake and
// the recording proxy read. Vision requests carry a base64 photo, so it is
// larger than DefaultMaxBody.
const MaxUpstreamBody = 32 << 20
// upstreamHashMin is the length above which a base64 JSON string value is
// stored as a {{sha256:<hex>}} placeholder.
const upstreamHashMin = 1024
// UpstreamResponse is the recorded vendor answer the fake replays. // UpstreamResponse is the recorded vendor answer the fake replays.
type UpstreamResponse struct { type UpstreamResponse struct {
Status int `yaml:"status"` Status int `yaml:"status"`
@@ -133,7 +164,7 @@ func (f *UpstreamFake) RoundTrip(req *http.Request) (*http.Response, error) {
var body []byte var body []byte
if req.Body != nil { if req.Body != nil {
var err error var err error
body, err = io.ReadAll(io.LimitReader(req.Body, DefaultMaxBody+1)) body, err = io.ReadAll(io.LimitReader(req.Body, MaxUpstreamBody+1))
_ = req.Body.Close() _ = req.Body.Close()
if err != nil { if err != nil {
return nil, f.fail(fmt.Errorf("tide: upstream %s %s: read body: %w", req.Method, req.URL, err)) return nil, f.fail(fmt.Errorf("tide: upstream %s %s: read body: %w", req.Method, req.URL, err))
@@ -213,7 +244,7 @@ func (f *UpstreamFake) compare(want UpstreamRequest, got *http.Request, body []b
continue continue
} }
gv := got.Header.Get(name) gv := got.Header.Get(name)
if wv == gv { if wv == gv || (strings.EqualFold(name, "Content-Type") && sameMultipart(wv, gv)) {
continue continue
} }
if isCredentialHeader(name) { if isCredentialHeader(name) {
@@ -223,16 +254,18 @@ func (f *UpstreamFake) compare(want UpstreamRequest, got *http.Request, body []b
problems = append(problems, fmt.Sprintf("header %s: want %q, got %q", name, wv, gv)) problems = append(problems, fmt.Sprintf("header %s: want %q, got %q", name, wv, gv))
} }
wantBody, err := f.store.Expand(want.Body) if len(want.Parts) > 0 {
return append(problems, f.compareParts(want.Parts, got.Header.Get("Content-Type"), body)...)
}
wantBody, err := f.store.expandKeeping(want.Body, isHashPlaceholder)
if err != nil { if err != nil {
return append(problems, "body: "+err.Error()) return append(problems, "body: "+err.Error())
} }
jsonBody := isJSONContentType(want.Headers) || isJSONContentType(map[string]string{"Content-Type": got.Header.Get("Content-Type")}) jsonBody := isJSONContentType(want.Headers) || isJSONContentType(map[string]string{"Content-Type": got.Header.Get("Content-Type")})
switch { switch {
case jsonBody && (len(wantBody) > 0 || len(body) > 0): case jsonBody && (len(wantBody) > 0 || len(body) > 0):
if diffs := diffJSON([]byte(wantBody), body); len(diffs) > 0 { if d, ok := diffUpstreamJSON([]byte(wantBody), body); !ok {
d := diffs[0] problems = append(problems, fmt.Sprintf("body %s: want %s, got %s", d.Path, clip(d.Expected), clip(d.Actual)))
problems = append(problems, fmt.Sprintf("body %s: want %s, got %s", d.Path, d.Expected, d.Actual))
} }
case wantBody != string(body): case wantBody != string(body):
problems = append(problems, fmt.Sprintf("body: want %d bytes, got %d bytes", len(wantBody), len(body))) problems = append(problems, fmt.Sprintf("body: want %d bytes, got %d bytes", len(wantBody), len(body)))
@@ -303,3 +336,347 @@ func redactURL(u *url.URL) string {
c.User = nil c.User = nil
return c.String() return c.String()
} }
// sameMultipart reports whether both values are multipart/form-data, whose
// boundary differs on every request.
func sameMultipart(a, b string) bool {
return isMultipart(a) && isMultipart(b)
}
func isMultipart(contentType string) bool {
media, _, err := mime.ParseMediaType(contentType)
return err == nil && media == "multipart/form-data"
}
func (f *UpstreamFake) compareParts(want []UpstreamPart, contentType string, body []byte) []string {
got, err := upstreamParts(contentType, body)
if err != nil {
return []string{"parts: " + err.Error()}
}
var problems []string
if len(want) != len(got) {
problems = append(problems, fmt.Sprintf("parts: want %d, got %d", len(want), len(got)))
}
for i := range min(len(want), len(got)) {
w, g := want[i], got[i]
value, err := f.store.Expand(w.Value)
if err != nil {
problems = append(problems, fmt.Sprintf("part %d %s: %v", i, w.Name, err))
continue
}
switch {
case w.Name != g.Name:
problems = append(problems, fmt.Sprintf("part %d name: want %q, got %q", i, w.Name, g.Name))
case w.Filename != g.Filename:
problems = append(problems, fmt.Sprintf("part %d %s filename: want %q, got %q", i, w.Name, w.Filename, g.Filename))
case w.ContentType != g.ContentType:
problems = append(problems, fmt.Sprintf("part %d %s content type: want %q, got %q", i, w.Name, w.ContentType, g.ContentType))
case !strings.EqualFold(w.SHA256, g.SHA256):
problems = append(problems, fmt.Sprintf("part %d %s sha256: want %s, got %s", i, w.Name, w.SHA256, g.SHA256))
case value != g.Value:
problems = append(problems, fmt.Sprintf("part %d %s value differs", i, w.Name))
}
}
return problems
}
// upstreamParts splits a multipart/form-data body into recorded parts: plain
// fields keep their value, file parts their sha256.
func upstreamParts(contentType string, body []byte) ([]UpstreamPart, error) {
media, params, err := mime.ParseMediaType(contentType)
if err != nil || media != "multipart/form-data" || params["boundary"] == "" {
return nil, fmt.Errorf("content type %q is not multipart/form-data", contentType)
}
mr := multipart.NewReader(bytes.NewReader(body), params["boundary"])
var parts []UpstreamPart
for {
p, err := mr.NextRawPart()
if errors.Is(err, io.EOF) {
return parts, nil
}
if err != nil {
return nil, err
}
raw, err := io.ReadAll(p)
if err != nil {
return nil, err
}
up := UpstreamPart{Name: p.FormName(), Filename: p.FileName()}
if up.Filename != "" || p.Header.Get("Content-Type") != "" {
up.ContentType = p.Header.Get("Content-Type")
sum := sha256.Sum256(raw)
up.SHA256 = hex.EncodeToString(sum[:])
} else {
up.Value = string(raw)
}
parts = append(parts, up)
}
}
var hashPlaceholderRe = regexp.MustCompile(`^(data:[^,]*;base64,)?\{\{sha256:([0-9a-f]{64})\}\}$`)
func isHashPlaceholder(name string) bool { return strings.HasPrefix(name, "sha256:") }
// expandKeeping is Expand that leaves placeholders whose name keep accepts.
func (s *Store) expandKeeping(text string, keep func(string) bool) (string, error) {
if !strings.Contains(text, "{{") {
return text, nil
}
var missing []string
out := placeholderRe.ReplaceAllStringFunc(text, func(m string) string {
name := m[2 : len(m)-2]
if keep(name) {
return m
}
v, ok := s.Get(name)
if !ok {
missing = append(missing, name)
return m
}
return v
})
if len(missing) > 0 {
return "", fmt.Errorf("tide: unresolved placeholder %q", missing[0])
}
return out, nil
}
// diffUpstreamJSON compares JSON semantically. A recorded string of the form
// {{sha256:<hex>}} (optionally behind a data: URL prefix) matches a sent
// base64 string whose decoded bytes hash to <hex>.
func diffUpstreamJSON(want, got []byte) (Diff, bool) {
wv, err := decodeJSON(want)
if err != nil {
return Diff{Path: "$", Expected: "valid JSON", Actual: err.Error()}, false
}
gv, err := decodeJSON(got)
if err != nil {
return Diff{Path: "$", Expected: formatValue(wv), Actual: err.Error()}, false
}
gv = resolveHashes(wv, gv)
var diffs []Diff
compareValue("$", wv, gv, &diffs)
if len(diffs) > 0 {
return diffs[0], false
}
return Diff{}, true
}
func resolveHashes(want, got any) any {
switch w := want.(type) {
case map[string]any:
g, ok := got.(map[string]any)
if !ok {
return got
}
out := make(map[string]any, len(g))
for k, v := range g {
if wv, ok := w[k]; ok {
v = resolveHashes(wv, v)
}
out[k] = v
}
return out
case []any:
g, ok := got.([]any)
if !ok {
return got
}
out := slices.Clone(g)
for i := range min(len(w), len(g)) {
out[i] = resolveHashes(w[i], g[i])
}
return out
case string:
m := hashPlaceholderRe.FindStringSubmatch(w)
g, ok := got.(string)
if m == nil || !ok || !strings.HasPrefix(g, m[1]) {
return got
}
raw, ok := decodeBase64(strings.TrimPrefix(g, m[1]))
if !ok {
return got
}
sum := sha256.Sum256(raw)
if hex.EncodeToString(sum[:]) == m[2] {
return w
}
return got
}
return got
}
func decodeBase64(s string) ([]byte, bool) {
for _, enc := range []*base64.Encoding{base64.StdEncoding, base64.RawStdEncoding, base64.URLEncoding, base64.RawURLEncoding} {
if raw, err := enc.DecodeString(s); err == nil {
return raw, true
}
}
return nil, false
}
func clip(s string) string {
const max = 120
if len(s) <= max {
return s
}
return s[:max] + fmt.Sprintf("... (%d bytes)", len(s))
}
// WriteUpstream masks s and writes it to path (mode 0644). Every header value,
// URL and body substring equal to a store variable value becomes {{name}};
// a JSON string longer than 1024 characters that decodes as base64 (also
// behind a data: URL prefix) becomes {{sha256:<hex of the decoded bytes>}}.
// An Authorization or X-Api-Key request header that is not fully masked
// refuses the write, naming the header but never its value.
func WriteUpstream(path string, s UpstreamSidecar, store *Store) error {
if s.Version == 0 {
s.Version = CurrentVersion
}
masked, err := maskUpstream(s, store)
if err != nil {
return err
}
if err := validateUpstream(masked); err != nil {
return fmt.Errorf("tide: upstream %s: %w", path, err)
}
raw, err := yaml.MarshalWithOptions(masked, yaml.UseLiteralStyleIfMultiline(true))
if err != nil {
return fmt.Errorf("tide: marshal upstream: %w", err)
}
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("tide: create upstream dir: %w", err)
}
tmp, err := os.CreateTemp(dir, ".upstream-*.tmp")
if err != nil {
return fmt.Errorf("tide: upstream temp: %w", err)
}
tmpName := tmp.Name()
if _, err := tmp.Write(raw); err != nil {
_ = tmp.Close()
_ = os.Remove(tmpName)
return fmt.Errorf("tide: write upstream: %w", err)
}
if err := tmp.Chmod(0o644); err != nil {
_ = tmp.Close()
_ = os.Remove(tmpName)
return err
}
if err := tmp.Close(); err != nil {
_ = os.Remove(tmpName)
return err
}
if err := os.Rename(tmpName, path); err != nil {
_ = os.Remove(tmpName)
return fmt.Errorf("tide: write upstream: %w", err)
}
return nil
}
func maskUpstream(s UpstreamSidecar, store *Store) (UpstreamSidecar, error) {
pairs := store.replacements()
out := UpstreamSidecar{Version: s.Version, Exchanges: make([]UpstreamExchange, len(s.Exchanges))}
for i, ex := range s.Exchanges {
req := ex.Request
req.URL = replaceAll(req.URL, pairs, true)
req.Headers = scrubMap(req.Headers, pairs, true)
if isJSONContentType(req.Headers) {
req.Body = hashBase64Strings(req.Body)
}
req.Body = replaceAll(req.Body, pairs, false)
if len(req.Parts) > 0 {
req.Parts = slices.Clone(req.Parts)
for j := range req.Parts {
req.Parts[j].Value = replaceAll(req.Parts[j].Value, pairs, false)
}
}
for name, v := range req.Headers {
if isCredentialHeader(name) && !fullyMasked(v) {
return UpstreamSidecar{}, fmt.Errorf("tide: upstream exchange %d: %s header is not masked by a vars entry; refusing to write a live credential", i, name)
}
}
resp := ex.Response
resp.Headers = scrubMap(resp.Headers, pairs, true)
resp.Body = replaceAll(resp.Body, pairs, false)
out.Exchanges[i] = UpstreamExchange{Request: req, Response: resp}
}
return out, nil
}
// credentialResidueRe is what may remain of a credential header once its
// placeholders are removed: nothing, or a scheme word optionally followed by
// one key= label (for example "Bearer" or "Discogs token=").
var credentialResidueRe = regexp.MustCompile(`^(?:[A-Za-z]+(?:\s+[A-Za-z_]+=)?)?$`)
func fullyMasked(v string) bool {
v = strings.TrimSpace(v)
if v == "" {
return true
}
if !strings.HasSuffix(v, "}}") || !placeholderRe.MatchString(v) {
return false
}
return credentialResidueRe.MatchString(strings.TrimSpace(placeholderRe.ReplaceAllString(v, "")))
}
// hashBase64Strings replaces long base64 JSON string values in body with
// {{sha256:<hex>}}, editing the text in place so the rest of the body keeps
// its recorded bytes.
func hashBase64Strings(body string) string {
if len(body) <= upstreamHashMin {
return body
}
v, err := decodeJSON([]byte(body))
if err != nil {
return body
}
var long []string
collectStrings(v, &long)
for _, str := range long {
prefix, payload := "", str
if strings.HasPrefix(str, "data:") {
if i := strings.Index(str, ";base64,"); i > 0 {
prefix, payload = str[:i+len(";base64,")], str[i+len(";base64,"):]
}
}
raw, ok := decodeBase64(payload)
if !ok {
continue
}
sum := sha256.Sum256(raw)
placeholder := prefix + "{{sha256:" + hex.EncodeToString(sum[:]) + "}}"
for _, enc := range jsonStringForms(str) {
body = strings.ReplaceAll(body, enc, `"`+placeholder+`"`)
}
}
return body
}
func collectStrings(v any, out *[]string) {
switch t := v.(type) {
case map[string]any:
for _, x := range t {
collectStrings(x, out)
}
case []any:
for _, x := range t {
collectStrings(x, out)
}
case string:
if len(t) > upstreamHashMin {
*out = append(*out, t)
}
}
}
// jsonStringForms returns the quoted JSON spellings a backend may have used
// for s: Go's encoding and PHP's, which also escapes "/".
func jsonStringForms(s string) []string {
raw, _ := json.Marshal(s)
forms := []string{string(raw), `"` + s + `"`}
if esc := `"` + phpJSONEscape(s) + `"`; !slices.Contains(forms, esc) {
forms = append(forms, esc)
}
return forms
}

View File

@@ -0,0 +1,593 @@
package tide
import (
"bufio"
"bytes"
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"errors"
"fmt"
"io"
"math/big"
"net"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/goccy/go-yaml"
"git.golem15.com/golem15/summercms/modules/fetchguard"
)
// DefaultUpstreamProxyListen is the loopback address the recording upstream
// proxy binds when no other address is given.
const DefaultUpstreamProxyListen = "127.0.0.1:8425"
const (
parityCAFile = "parity-ca.pem"
parityCAKeyFile = "parity-ca-key.pem"
// upstreamProxyNoMatch is the status a script-mode proxy answers when no
// scripted response matches a request.
upstreamProxyNoMatch = 599
)
// UpstreamScript is a hand-authored list of vendor responses the recording
// proxy answers in script mode, so recording needs no real vendor and no
// real credential.
type UpstreamScript struct {
Responses []UpstreamScriptResponse `yaml:"responses"`
}
// UpstreamScriptResponse answers the first request whose method, host and
// path match. Each entry answers once.
type UpstreamScriptResponse struct {
Method string `yaml:"method"`
Host string `yaml:"host"`
Path string `yaml:"path"`
Response UpstreamResponse `yaml:"response"`
}
// UpstreamProxyConfig configures NewUpstreamProxy.
type UpstreamProxyConfig struct {
// Listen is the loopback address to bind (DefaultUpstreamProxyListen).
Listen string
// CADir holds the locally generated parity CA (EnsureParityCA). It must
// be outside the directory that holds Out.
CADir string
// Out is the sidecar path Flush writes.
Out string
// Mode is "script" (default: answer from Script) or "forward" (send each
// request once to the real vendor through a guarded client).
Mode string
// Script is the UpstreamScript YAML path, required in script mode.
Script string
// VarsPath is the private variable store whose values are masked as
// {{name}} in the sidecar. It must be outside the directory that holds Out.
VarsPath string
}
// UpstreamProxy is a loopback HTTPS recording proxy. The reference backend
// sends its vendor calls through it (HTTPS_PROXY plus the parity CA); the
// proxy terminates TLS with a per-host certificate signed by the parity CA,
// answers each decrypted request from the script or the real vendor, and
// keeps every exchange for Flush.
type UpstreamProxy struct {
cfg UpstreamProxyConfig
store *Store
ca *x509.Certificate
caKey *ecdsa.PrivateKey
leafKey *ecdsa.PrivateKey
forward *fetchguard.Client
mu sync.Mutex
script []UpstreamScriptResponse
used []bool
exchanges []UpstreamExchange
errs []error
leaves map[string]*tls.Certificate
conns map[net.Conn]struct{}
}
// NewUpstreamProxy validates cfg, loads the script and the vars store and
// creates or reuses the parity CA.
func NewUpstreamProxy(cfg UpstreamProxyConfig) (*UpstreamProxy, error) {
if cfg.Listen == "" {
cfg.Listen = DefaultUpstreamProxyListen
}
if err := requireLoopbackAddr(cfg.Listen); err != nil {
return nil, err
}
if cfg.Mode == "" {
cfg.Mode = "script"
}
if cfg.Mode != "script" && cfg.Mode != "forward" {
return nil, fmt.Errorf("tide: upstream proxy mode %q must be script or forward", cfg.Mode)
}
for _, req := range []struct{ name, val string }{{"out", cfg.Out}, {"ca-dir", cfg.CADir}, {"vars", cfg.VarsPath}} {
if strings.TrimSpace(req.val) == "" {
return nil, fmt.Errorf("tide: upstream proxy requires %s", req.name)
}
}
outDir := filepath.Dir(cfg.Out)
if err := pathOutside("ca dir", cfg.CADir, outDir); err != nil {
return nil, err
}
if err := pathOutside("vars file", cfg.VarsPath, outDir); err != nil {
return nil, err
}
p := &UpstreamProxy{cfg: cfg, leaves: map[string]*tls.Certificate{}, conns: map[net.Conn]struct{}{}}
if cfg.Mode == "script" {
if strings.TrimSpace(cfg.Script) == "" {
return nil, fmt.Errorf("tide: upstream proxy script mode requires script")
}
script, err := loadUpstreamScript(cfg.Script)
if err != nil {
return nil, err
}
p.script = script.Responses
p.used = make([]bool, len(script.Responses))
} else {
client, err := fetchguard.NewClient(fetchguard.Policy{
Mode: fetchguard.PublicOnlyMode,
MaxBytes: MaxUpstreamBody,
Timeout: 120 * time.Second,
}, nil)
if err != nil {
return nil, err
}
p.forward = client
}
certPath, err := EnsureParityCA(cfg.CADir)
if err != nil {
return nil, err
}
if err := p.loadCA(certPath); err != nil {
return nil, err
}
p.leafKey, err = ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, fmt.Errorf("tide: leaf key: %w", err)
}
p.store, err = OpenStore(cfg.VarsPath)
if err != nil {
return nil, err
}
return p, nil
}
func loadUpstreamScript(path string) (UpstreamScript, error) {
raw, err := os.ReadFile(path)
if err != nil {
return UpstreamScript{}, fmt.Errorf("tide: read upstream script: %w", err)
}
var s UpstreamScript
dec := yaml.NewDecoder(bytes.NewReader(raw), yaml.DisallowUnknownField())
if err := dec.Decode(&s); err != nil {
return UpstreamScript{}, fmt.Errorf("tide: parse upstream script %s: %w", path, err)
}
for i, r := range s.Responses {
if r.Method == "" || r.Host == "" || r.Path == "" || r.Response.Status == 0 {
return UpstreamScript{}, fmt.Errorf("tide: upstream script %s: response %d needs method, host, path and response.status", path, i)
}
}
return s, nil
}
func pathOutside(label, path, dir string) error {
absPath, err := resolvePath(path)
if err != nil {
return fmt.Errorf("tide: %s: %w", label, err)
}
absDir, err := resolvePath(dir)
if err != nil {
return fmt.Errorf("tide: %s: %w", label, err)
}
if absPath == absDir || strings.HasPrefix(absPath, absDir+string(os.PathSeparator)) {
return fmt.Errorf("tide: %s %q must be outside the output directory %q", label, path, dir)
}
return nil
}
// EnsureParityCA creates, or reuses when both files exist, the ECDSA P-256
// self-signed parity CA in dir: parity-ca.pem (mode 0644) and
// parity-ca-key.pem (mode 0600). It returns the certificate path, which the
// reference backend trusts while recording. Keep dir outside the fixtures
// tree; the key never belongs in git.
func EnsureParityCA(dir string) (certPath string, err error) {
if strings.TrimSpace(dir) == "" {
return "", fmt.Errorf("tide: parity CA dir is required")
}
if err := os.MkdirAll(dir, 0o700); err != nil {
return "", fmt.Errorf("tide: parity CA dir: %w", err)
}
certPath = filepath.Join(dir, parityCAFile)
keyPath := filepath.Join(dir, parityCAKeyFile)
_, certErr := os.Stat(certPath)
_, keyErr := os.Stat(keyPath)
if certErr == nil && keyErr == nil {
if _, _, err := readParityCA(certPath, keyPath); err != nil {
return "", err
}
if err := os.Chmod(keyPath, 0o600); err != nil {
return "", fmt.Errorf("tide: chmod parity CA key: %w", err)
}
return certPath, nil
}
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return "", fmt.Errorf("tide: parity CA key: %w", err)
}
serial, err := randomSerial()
if err != nil {
return "", err
}
now := time.Now()
tmpl := &x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{CommonName: "SummerCMS parity upstream CA", Organization: []string{"SummerCMS parity"}},
NotBefore: now.Add(-time.Hour),
NotAfter: now.AddDate(2, 0, 0),
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign | x509.KeyUsageDigitalSignature,
BasicConstraintsValid: true,
IsCA: true,
MaxPathLenZero: true,
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
return "", fmt.Errorf("tide: parity CA certificate: %w", err)
}
keyDER, err := x509.MarshalECPrivateKey(key)
if err != nil {
return "", fmt.Errorf("tide: parity CA key: %w", err)
}
if err := writeFileMode(keyPath, pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}), 0o600); err != nil {
return "", err
}
if err := writeFileMode(certPath, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0o644); err != nil {
return "", err
}
return certPath, nil
}
func writeFileMode(path string, data []byte, mode os.FileMode) error {
if err := os.WriteFile(path, data, mode); err != nil {
return fmt.Errorf("tide: write %s: %w", path, err)
}
if err := os.Chmod(path, mode); err != nil {
return fmt.Errorf("tide: chmod %s: %w", path, err)
}
return nil
}
func readParityCA(certPath, keyPath string) (*x509.Certificate, *ecdsa.PrivateKey, error) {
certPEM, err := os.ReadFile(certPath)
if err != nil {
return nil, nil, fmt.Errorf("tide: read parity CA: %w", err)
}
keyPEM, err := os.ReadFile(keyPath)
if err != nil {
return nil, nil, fmt.Errorf("tide: read parity CA key: %w", err)
}
cb, _ := pem.Decode(certPEM)
kb, _ := pem.Decode(keyPEM)
if cb == nil || kb == nil {
return nil, nil, fmt.Errorf("tide: parity CA files in %s are not PEM", filepath.Dir(certPath))
}
cert, err := x509.ParseCertificate(cb.Bytes)
if err != nil {
return nil, nil, fmt.Errorf("tide: parse parity CA: %w", err)
}
key, err := x509.ParseECPrivateKey(kb.Bytes)
if err != nil {
return nil, nil, fmt.Errorf("tide: parse parity CA key: %w", err)
}
if !cert.IsCA {
return nil, nil, fmt.Errorf("tide: %s is not a CA certificate", certPath)
}
return cert, key, nil
}
func (p *UpstreamProxy) loadCA(certPath string) error {
cert, key, err := readParityCA(certPath, filepath.Join(filepath.Dir(certPath), parityCAKeyFile))
if err != nil {
return err
}
p.ca, p.caKey = cert, key
return nil
}
func randomSerial() (*big.Int, error) {
n, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 126))
if err != nil {
return nil, fmt.Errorf("tide: certificate serial: %w", err)
}
return n, nil
}
// leaf returns a certificate for host signed by the parity CA.
func (p *UpstreamProxy) leaf(host string) (*tls.Certificate, error) {
p.mu.Lock()
defer p.mu.Unlock()
if c, ok := p.leaves[host]; ok {
return c, nil
}
serial, err := randomSerial()
if err != nil {
return nil, err
}
now := time.Now()
tmpl := &x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{CommonName: host},
NotBefore: now.Add(-time.Hour),
NotAfter: now.Add(24 * time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
}
if ip := net.ParseIP(host); ip != nil {
tmpl.IPAddresses = []net.IP{ip}
} else {
tmpl.DNSNames = []string{host}
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, p.ca, &p.leafKey.PublicKey, p.caKey)
if err != nil {
return nil, fmt.Errorf("tide: leaf certificate for %s: %w", host, err)
}
c := &tls.Certificate{Certificate: [][]byte{der, p.ca.Raw}, PrivateKey: p.leafKey}
p.leaves[host] = c
return c, nil
}
// ServeHTTP accepts CONNECT tunnels only; every other request is refused.
func (p *UpstreamProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodConnect {
http.Error(w, "tide upstream proxy accepts CONNECT only", http.StatusMethodNotAllowed)
return
}
host, port, err := net.SplitHostPort(r.Host)
if err != nil {
host, port = r.Host, "443"
}
hj, ok := w.(http.Hijacker)
if !ok {
http.Error(w, "hijacking unsupported", http.StatusInternalServerError)
return
}
conn, rw, err := hj.Hijack()
if err != nil {
return
}
p.track(conn, true)
defer func() {
p.track(conn, false)
_ = conn.Close()
}()
if _, err := rw.WriteString("HTTP/1.1 200 Connection Established\r\n\r\n"); err != nil {
return
}
if err := rw.Flush(); err != nil {
return
}
tlsConn := tls.Server(conn, &tls.Config{
MinVersion: tls.VersionTLS12,
NextProtos: []string{"http/1.1"},
GetCertificate: func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
name := hello.ServerName
if name == "" {
name = host
}
return p.leaf(name)
},
})
if err := tlsConn.HandshakeContext(r.Context()); err != nil {
return
}
authority := host
if port != "443" {
authority = net.JoinHostPort(host, port)
}
br := bufio.NewReader(tlsConn)
for {
req, err := http.ReadRequest(br)
if err != nil {
return
}
resp := p.exchange(r.Context(), authority, req)
werr := resp.Write(tlsConn)
if werr != nil || req.Close || resp.Close {
return
}
}
}
func (p *UpstreamProxy) track(c net.Conn, add bool) {
p.mu.Lock()
defer p.mu.Unlock()
if add {
p.conns[c] = struct{}{}
} else {
delete(p.conns, c)
}
}
// exchange answers one decrypted request and records it.
func (p *UpstreamProxy) exchange(ctx context.Context, authority string, req *http.Request) *http.Response {
body, err := io.ReadAll(io.LimitReader(req.Body, MaxUpstreamBody+1))
_ = req.Body.Close()
if err == nil && len(body) > MaxUpstreamBody {
err = fmt.Errorf("request body exceeds %d bytes", MaxUpstreamBody)
}
rawURL := "https://" + authority + req.URL.RequestURI()
if err != nil {
return p.failure(req, fmt.Errorf("tide: upstream proxy %s %s: %w", req.Method, redactURL(req.URL), err), http.StatusBadGateway)
}
recorded := UpstreamRequest{Method: req.Method, URL: rawURL, Headers: map[string]string{}}
for _, name := range UpstreamCompareHeaders {
if v := req.Header.Get(name); v != "" {
recorded.Headers[name] = v
}
}
if isMultipart(req.Header.Get("Content-Type")) {
parts, err := upstreamParts(req.Header.Get("Content-Type"), body)
if err != nil {
return p.failure(req, fmt.Errorf("tide: upstream proxy %s %s: %w", req.Method, rawURL, err), http.StatusBadGateway)
}
recorded.Parts = parts
recorded.Headers["Content-Type"] = "multipart/form-data"
} else {
recorded.Body = string(body)
}
var answer UpstreamResponse
if p.cfg.Mode == "forward" {
answer, err = p.forwardOnce(ctx, req, rawURL, body)
if err != nil {
return p.failure(req, fmt.Errorf("tide: upstream proxy forward %s %s: %w", req.Method, redactURL(req.URL), err), http.StatusBadGateway)
}
} else {
var ok bool
answer, ok = p.scripted(req.Method, strings.Split(authority, ":")[0], req.URL.Path)
if !ok {
return p.failure(req, fmt.Errorf("tide: upstream proxy: no scripted response for %s https://%s%s", req.Method, authority, req.URL.Path), upstreamProxyNoMatch)
}
}
p.mu.Lock()
p.exchanges = append(p.exchanges, UpstreamExchange{Request: recorded, Response: answer})
p.mu.Unlock()
return httpResponse(req, answer)
}
func (p *UpstreamProxy) scripted(method, host, path string) (UpstreamResponse, bool) {
p.mu.Lock()
defer p.mu.Unlock()
for i, s := range p.script {
if p.used[i] || !strings.EqualFold(s.Method, method) || !strings.EqualFold(s.Host, host) || s.Path != path {
continue
}
p.used[i] = true
return s.Response, true
}
return UpstreamResponse{}, false
}
func (p *UpstreamProxy) forwardOnce(ctx context.Context, in *http.Request, rawURL string, body []byte) (UpstreamResponse, error) {
out, err := http.NewRequestWithContext(ctx, in.Method, rawURL, bytes.NewReader(body))
if err != nil {
return UpstreamResponse{}, err
}
for k, vs := range in.Header {
switch http.CanonicalHeaderKey(k) {
case "Connection", "Proxy-Connection", "Proxy-Authorization", "Keep-Alive", "Te", "Trailer", "Transfer-Encoding", "Upgrade", "Accept-Encoding":
continue
}
out.Header[k] = append([]string(nil), vs...)
}
res, err := p.forward.Send(out)
if err != nil {
return UpstreamResponse{}, err
}
headers := map[string]string{}
for k := range res.Header {
if keepUpstreamResponseHeader(k) {
headers[http.CanonicalHeaderKey(k)] = res.Header.Get(k)
}
}
return UpstreamResponse{Status: res.StatusCode, Headers: headers, Body: string(res.Body)}, nil
}
// keepUpstreamResponseHeader keeps the response headers vendor clients read.
func keepUpstreamResponseHeader(name string) bool {
l := strings.ToLower(name)
return l == "content-type" || l == "retry-after" || l == "location" || strings.Contains(l, "ratelimit")
}
func (p *UpstreamProxy) failure(req *http.Request, err error, status int) *http.Response {
p.mu.Lock()
p.errs = append(p.errs, err)
p.mu.Unlock()
return httpResponse(req, UpstreamResponse{
Status: status,
Headers: map[string]string{"Content-Type": "text/plain; charset=utf-8"},
Body: "tide upstream proxy: " + err.Error() + "\n",
})
}
func httpResponse(req *http.Request, r UpstreamResponse) *http.Response {
h := http.Header{}
for k, v := range r.Headers {
h.Set(k, v)
}
return &http.Response{
Status: fmt.Sprintf("%d %s", r.Status, http.StatusText(r.Status)),
StatusCode: r.Status,
Proto: "HTTP/1.1",
ProtoMajor: 1,
ProtoMinor: 1,
Header: h,
Body: io.NopCloser(strings.NewReader(r.Body)),
ContentLength: int64(len(r.Body)),
Request: req,
}
}
// ListenAndServe binds the loopback listener and serves until ctx is
// cancelled. It does not write the sidecar; call Flush afterwards.
func (p *UpstreamProxy) ListenAndServe(ctx context.Context) error {
ln, err := net.Listen("tcp", p.cfg.Listen)
if err != nil {
return fmt.Errorf("tide: listen %s: %w", p.cfg.Listen, err)
}
if err := requireLoopbackAddr(ln.Addr().String()); err != nil {
_ = ln.Close()
return err
}
srv := &http.Server{Handler: p, ReadHeaderTimeout: 10 * time.Second}
errCh := make(chan error, 1)
go func() { errCh <- srv.Serve(ln) }()
select {
case <-ctx.Done():
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = srv.Shutdown(shutdownCtx)
p.closeTunnels()
<-errCh
return nil
case err := <-errCh:
p.closeTunnels()
if errors.Is(err, http.ErrServerClosed) {
return nil
}
return err
}
}
func (p *UpstreamProxy) closeTunnels() {
p.mu.Lock()
defer p.mu.Unlock()
for c := range p.conns {
_ = c.Close()
}
}
// Flush writes every recorded exchange to Out with WriteUpstream. It refuses
// to write when any request failed (an unscripted request, a forward error),
// so a partial recording never lands next to a fixture.
func (p *UpstreamProxy) Flush() error {
p.mu.Lock()
exchanges := append([]UpstreamExchange(nil), p.exchanges...)
errs := append([]error(nil), p.errs...)
p.mu.Unlock()
if len(errs) > 0 {
return fmt.Errorf("tide: upstream proxy recorded failures, %s not written: %w", p.cfg.Out, errors.Join(errs...))
}
return WriteUpstream(p.cfg.Out, UpstreamSidecar{Version: CurrentVersion, Exchanges: exchanges}, p.store)
}

View File

@@ -0,0 +1,359 @@
package tide
import (
"bytes"
"crypto/tls"
"crypto/x509"
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
type upstreamProxyEnv struct {
cfg UpstreamProxyConfig
caPEM []byte
proxy *UpstreamProxy
server *httptest.Server
client *http.Client
}
func newUpstreamProxyEnv(t *testing.T, mode, script string) *upstreamProxyEnv {
t.Helper()
root := t.TempDir()
cfg := UpstreamProxyConfig{
Listen: DefaultUpstreamProxyListen,
CADir: filepath.Join(root, "ca"),
Out: filepath.Join(root, "fixtures", "routes", "POST_things__ok.upstream.yaml"),
Mode: mode,
VarsPath: filepath.Join(root, "private", "vars.yaml"),
}
if script != "" {
cfg.Script = filepath.Join(root, "script.yaml")
if err := os.WriteFile(cfg.Script, []byte(script), 0o644); err != nil {
t.Fatal(err)
}
}
store, err := OpenStore(cfg.VarsPath)
if err != nil {
t.Fatal(err)
}
store.Set("secret:example-token", "example-token-value")
if err := store.Save(); err != nil {
t.Fatal(err)
}
proxy, err := NewUpstreamProxy(cfg)
if err != nil {
t.Fatalf("NewUpstreamProxy: %v", err)
}
srv := httptest.NewServer(proxy)
t.Cleanup(srv.Close)
caPEM, err := os.ReadFile(filepath.Join(cfg.CADir, parityCAFile))
if err != nil {
t.Fatal(err)
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(caPEM) {
t.Fatal("parity CA did not parse")
}
proxyURL, _ := url.Parse(srv.URL)
client := &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
Proxy: http.ProxyURL(proxyURL),
TLSClientConfig: tlsConfigWithRoots(pool),
},
}
return &upstreamProxyEnv{cfg: cfg, caPEM: caPEM, proxy: proxy, server: srv, client: client}
}
const exampleScript = `responses:
- method: POST
host: api.example.test
path: /v1/things
response:
status: 201
headers:
Content-Type: application/json
X-Ratelimit-Remaining: "59"
body: '{"id":7,"owner":"example-token-value"}'
`
func TestUpstreamProxyScriptMode(t *testing.T) {
env := newUpstreamProxyEnv(t, "script", exampleScript)
req, _ := http.NewRequest(http.MethodPost, "https://api.example.test/v1/things?lang=en", strings.NewReader(`{"name":"widget"}`))
req.Header.Set("Authorization", "Bearer example-token-value")
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "example-client/1.0")
resp, err := env.client.Do(req)
if err != nil {
t.Fatalf("request through proxy: %v", err)
}
body, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
if resp.StatusCode != http.StatusCreated || string(body) != `{"id":7,"owner":"example-token-value"}` {
t.Fatalf("scripted response = %d %s", resp.StatusCode, body)
}
if resp.Header.Get("X-Ratelimit-Remaining") != "59" {
t.Fatalf("scripted header missing: %v", resp.Header)
}
if err := env.proxy.Flush(); err != nil {
t.Fatalf("Flush: %v", err)
}
raw, err := os.ReadFile(env.cfg.Out)
if err != nil {
t.Fatal(err)
}
if bytes.Contains(raw, []byte("example-token-value")) {
t.Fatalf("sidecar leaks the credential:\n%s", raw)
}
st, _ := os.Stat(env.cfg.Out)
if st.Mode().Perm() != 0o644 {
t.Fatalf("sidecar mode = %v", st.Mode().Perm())
}
s, err := LoadUpstream(env.cfg.Out)
if err != nil {
t.Fatalf("LoadUpstream: %v\n%s", err, raw)
}
if len(s.Exchanges) != 1 {
t.Fatalf("exchanges = %d", len(s.Exchanges))
}
ex := s.Exchanges[0]
if got := ex.Request.Headers["Authorization"]; got != "Bearer {{secret:example-token}}" {
t.Fatalf("Authorization = %q", got)
}
if ex.Request.URL != "https://api.example.test/v1/things?lang=en" || ex.Request.Method != "POST" {
t.Fatalf("request = %+v", ex.Request)
}
if ex.Request.Body != `{"name":"widget"}` || ex.Request.Headers["User-Agent"] != "example-client/1.0" {
t.Fatalf("request body/headers = %+v", ex.Request)
}
if ex.Response.Body != `{"id":7,"owner":"{{secret:example-token}}"}` {
t.Fatalf("response body = %q", ex.Response.Body)
}
// The recorded sidecar replays offline.
store, _ := OpenStore(env.cfg.VarsPath)
fake := NewUpstreamFake(s, store)
again, _ := http.NewRequest(http.MethodPost, "https://api.example.test/v1/things?lang=en", strings.NewReader(`{"name":"widget"}`))
again.Header = req.Header.Clone()
got, err := fake.RoundTrip(again)
if err != nil {
t.Fatalf("replay: %v", err)
}
replayed, _ := io.ReadAll(got.Body)
if string(replayed) != `{"id":7,"owner":"example-token-value"}` {
t.Fatalf("replayed body = %s", replayed)
}
if err := fake.Verify(); err != nil {
t.Fatal(err)
}
}
func TestUpstreamProxyUnscriptedRequestFailsFlush(t *testing.T) {
env := newUpstreamProxyEnv(t, "script", exampleScript)
resp, err := env.client.Get("https://api.example.test/v1/unknown")
if err != nil {
t.Fatal(err)
}
_ = resp.Body.Close()
if resp.StatusCode != upstreamProxyNoMatch {
t.Fatalf("status = %d, want %d", resp.StatusCode, upstreamProxyNoMatch)
}
if err := env.proxy.Flush(); err == nil || !strings.Contains(err.Error(), "no scripted response") {
t.Fatalf("Flush = %v, want the unscripted request reported", err)
}
if _, err := os.Stat(env.cfg.Out); !os.IsNotExist(err) {
t.Fatalf("sidecar written despite failures: %v", err)
}
// Plain requests are refused: the proxy only tunnels.
plain, err := http.Get(env.server.URL + "/x")
if err != nil {
t.Fatal(err)
}
_ = plain.Body.Close()
if plain.StatusCode != http.StatusMethodNotAllowed {
t.Fatalf("plain request status = %d", plain.StatusCode)
}
}
func TestUpstreamProxyMultipartAndForwardGuard(t *testing.T) {
script := `responses:
- method: POST
host: files.example.test
path: /upload
response:
status: 202
`
env := newUpstreamProxyEnv(t, "script", script)
var buf bytes.Buffer
body := "--b1\r\nContent-Disposition: form-data; name=\"title\"\r\n\r\nHello\r\n" +
"--b1\r\nContent-Disposition: form-data; name=\"file\"; filename=\"a.png\"\r\nContent-Type: image/png\r\n\r\nPNGDATA\r\n--b1--\r\n"
buf.WriteString(body)
req, _ := http.NewRequest(http.MethodPost, "https://files.example.test/upload", &buf)
req.Header.Set("Content-Type", "multipart/form-data; boundary=b1")
resp, err := env.client.Do(req)
if err != nil {
t.Fatal(err)
}
_ = resp.Body.Close()
if err := env.proxy.Flush(); err != nil {
t.Fatal(err)
}
s, err := LoadUpstream(env.cfg.Out)
if err != nil {
t.Fatal(err)
}
parts := s.Exchanges[0].Request.Parts
if len(parts) != 2 || parts[0].Value != "Hello" || parts[1].Filename != "a.png" || parts[1].ContentType != "image/png" || len(parts[1].SHA256) != 64 {
t.Fatalf("parts = %+v", parts)
}
// The fake accepts the same parts under another boundary.
fake := NewUpstreamFake(s, nil)
other := strings.ReplaceAll(body, "b1", "zz9")
again, _ := http.NewRequest(http.MethodPost, "https://files.example.test/upload", strings.NewReader(other))
again.Header.Set("Content-Type", "multipart/form-data; boundary=zz9")
again.Header.Set("User-Agent", "Go-http-client/1.1")
if _, err := fake.RoundTrip(again); err != nil {
t.Fatalf("replay multipart: %v", err)
}
if err := fake.Verify(); err != nil {
t.Fatal(err)
}
fake = NewUpstreamFake(s, nil)
changed, _ := http.NewRequest(http.MethodPost, "https://files.example.test/upload", strings.NewReader(strings.Replace(other, "PNGDATA", "PNGDATX", 1)))
changed.Header.Set("Content-Type", "multipart/form-data; boundary=zz9")
changed.Header.Set("User-Agent", "Go-http-client/1.1")
if _, err := fake.RoundTrip(changed); err == nil || !strings.Contains(err.Error(), "sha256") {
t.Fatalf("changed file bytes: %v", err)
}
// Forward mode sends through a PublicOnlyMode client: a loopback target
// is refused at dial and the recording fails.
target := httptest.NewTLSServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
t.Error("forward mode must not reach a loopback target")
}))
t.Cleanup(target.Close)
fwd := newUpstreamProxyEnv(t, "forward", "")
resp, err = fwd.client.Get(target.URL + "/secret")
if err != nil {
t.Fatal(err)
}
_ = resp.Body.Close()
if resp.StatusCode != http.StatusBadGateway {
t.Fatalf("forward to loopback = %d, want 502", resp.StatusCode)
}
if err := fwd.proxy.Flush(); err == nil || !strings.Contains(err.Error(), "private_ip") {
t.Fatalf("Flush = %v, want private_ip", err)
}
}
func TestUpstreamProxyRefusesNonLoopback(t *testing.T) {
root := t.TempDir()
base := UpstreamProxyConfig{
CADir: filepath.Join(root, "ca"),
Out: filepath.Join(root, "fixtures", "x.upstream.yaml"),
Script: filepath.Join(root, "script.yaml"),
VarsPath: filepath.Join(root, "vars.yaml"),
}
if err := os.WriteFile(base.Script, []byte("responses: []\n"), 0o644); err != nil {
t.Fatal(err)
}
for _, listen := range []string{"0.0.0.0:8425", "192.0.2.10:8425", ":8425"} {
cfg := base
cfg.Listen = listen
if _, err := NewUpstreamProxy(cfg); err == nil || !strings.Contains(err.Error(), "loopback") {
t.Fatalf("listen %s: err = %v, want loopback refusal", listen, err)
}
}
cases := []struct {
name string
edit func(*UpstreamProxyConfig)
want string
}{
{"bad mode", func(c *UpstreamProxyConfig) { c.Mode = "replay" }, "script or forward"},
{"no out", func(c *UpstreamProxyConfig) { c.Out = "" }, "requires out"},
{"no ca", func(c *UpstreamProxyConfig) { c.CADir = "" }, "requires ca-dir"},
{"no vars", func(c *UpstreamProxyConfig) { c.VarsPath = "" }, "requires vars"},
{"no script", func(c *UpstreamProxyConfig) { c.Script = "" }, "requires script"},
{"ca inside out dir", func(c *UpstreamProxyConfig) { c.CADir = filepath.Join(root, "fixtures", "ca") }, "ca dir"},
{"vars inside out dir", func(c *UpstreamProxyConfig) { c.VarsPath = filepath.Join(root, "fixtures", "vars.yaml") }, "vars file"},
{"missing script", func(c *UpstreamProxyConfig) { c.Script = filepath.Join(root, "nope.yaml") }, "read upstream script"},
}
for _, tc := range cases {
cfg := base
tc.edit(&cfg)
if _, err := NewUpstreamProxy(cfg); err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("%s: err = %v, want %q", tc.name, err, tc.want)
}
}
if _, err := os.Stat(filepath.Join(root, "fixtures", "ca", parityCAKeyFile)); err == nil {
t.Fatal("a refused config must not create a CA key inside the fixtures tree")
}
bad := filepath.Join(root, "bad-script.yaml")
_ = os.WriteFile(bad, []byte("responses:\n - method: GET\n host: a.test\n path: /x\n response: {}\n"), 0o644)
cfg := base
cfg.Script = bad
if _, err := NewUpstreamProxy(cfg); err == nil || !strings.Contains(err.Error(), "response.status") {
t.Fatalf("incomplete script entry: %v", err)
}
}
func TestEnsureParityCA(t *testing.T) {
dir := filepath.Join(t.TempDir(), "ca")
certPath, err := EnsureParityCA(dir)
if err != nil {
t.Fatal(err)
}
keyPath := filepath.Join(dir, parityCAKeyFile)
kst, err := os.Stat(keyPath)
if err != nil {
t.Fatal(err)
}
if kst.Mode().Perm() != 0o600 {
t.Fatalf("key mode = %v, want 0600", kst.Mode().Perm())
}
cst, _ := os.Stat(certPath)
if cst.Mode().Perm() != 0o644 {
t.Fatalf("cert mode = %v, want 0644", cst.Mode().Perm())
}
first, _ := os.ReadFile(certPath)
firstKey, _ := os.ReadFile(keyPath)
_ = os.Chmod(keyPath, 0o644)
again, err := EnsureParityCA(dir)
if err != nil || again != certPath {
t.Fatalf("second call = %q, %v", again, err)
}
second, _ := os.ReadFile(certPath)
secondKey, _ := os.ReadFile(keyPath)
if !bytes.Equal(first, second) || !bytes.Equal(firstKey, secondKey) {
t.Fatal("second call must reuse the CA files")
}
if st, _ := os.Stat(keyPath); st.Mode().Perm() != 0o600 {
t.Fatalf("reused key mode = %v, want 0600", st.Mode().Perm())
}
cert, _, err := readParityCA(certPath, keyPath)
if err != nil || !cert.IsCA {
t.Fatalf("CA = %v, %v", cert, err)
}
if err := os.WriteFile(certPath, []byte("not pem"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := EnsureParityCA(dir); err == nil {
t.Fatal("corrupt CA must be reported, not silently replaced")
}
if _, err := EnsureParityCA(""); err == nil {
t.Fatal("empty dir must fail")
}
}
func tlsConfigWithRoots(pool *x509.CertPool) *tls.Config {
return &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12}
}

View File

@@ -2,7 +2,13 @@ package tide
import ( import (
"bytes" "bytes"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"net/http" "net/http"
"os"
"path/filepath"
"slices"
"strings" "strings"
"testing" "testing"
) )
@@ -148,3 +154,135 @@ func TestLoadUpstreamAndPath(t *testing.T) {
t.Fatalf("sidecar = %+v", s) t.Fatalf("sidecar = %+v", s)
} }
} }
func TestWriteUpstreamRefusesUnmaskedCredential(t *testing.T) {
store := upstreamTestStore(t)
dir := t.TempDir()
base := func(h map[string]string) UpstreamSidecar {
return UpstreamSidecar{Version: 1, Exchanges: []UpstreamExchange{{
Request: UpstreamRequest{Method: "GET", URL: "https://api.example.test/v1/me", Headers: h},
Response: UpstreamResponse{Status: 200},
}}}
}
refused := []map[string]string{
{"Authorization": "Bearer live-unknown-token"},
{"X-Api-Key": "sk-live-unknown"},
{"x-api-key": "plainvalue"},
{"Authorization": "Bearer abc{{secret:example-token}}"},
{"Authorization": "Bearer example-token-value extra"},
}
for _, h := range refused {
path := filepath.Join(dir, "refused.upstream.yaml")
err := WriteUpstream(path, base(h), store)
if err == nil {
t.Fatalf("%v: unmasked credential written", h)
}
for name, v := range h {
if !strings.Contains(err.Error(), name) {
t.Fatalf("error %q does not name header %s", err, name)
}
if strings.Contains(err.Error(), v) {
t.Fatalf("error leaks the value: %v", err)
}
}
if _, statErr := os.Stat(path); !os.IsNotExist(statErr) {
t.Fatalf("%v: file written despite refusal", h)
}
}
accepted := []map[string]string{
{"Authorization": "Bearer example-token-value"},
{"Authorization": "Discogs token=example-token-value"},
{"X-Api-Key": "example-token-value"},
{"Authorization": ""},
}
for _, h := range accepted {
path := filepath.Join(dir, "ok.upstream.yaml")
if err := WriteUpstream(path, base(h), store); err != nil {
t.Fatalf("%v: %v", h, err)
}
raw, _ := os.ReadFile(path)
if strings.Contains(string(raw), "example-token-value") {
t.Fatalf("written sidecar leaks the value:\n%s", raw)
}
if _, err := LoadUpstream(path); err != nil {
t.Fatalf("written sidecar does not load: %v", err)
}
}
// The masked URL query and response body round-trip through the fake.
s := base(map[string]string{"Authorization": "Bearer example-token-value"})
s.Exchanges[0].Request.URL = "https://api.example.test/v1/me?token=example-token-value"
s.Exchanges[0].Response.Body = `{"token":"example-token-value"}`
path := filepath.Join(dir, "query.upstream.yaml")
if err := WriteUpstream(path, s, store); err != nil {
t.Fatal(err)
}
raw, _ := os.ReadFile(path)
if strings.Contains(string(raw), "example-token-value") || !strings.Contains(string(raw), "token={{secret:example-token}}") {
t.Fatalf("query not masked:\n%s", raw)
}
if err := WriteUpstream(path, UpstreamSidecar{Exchanges: []UpstreamExchange{{Request: UpstreamRequest{Method: "GET", URL: "relative"}}}}, store); err == nil {
t.Fatal("invalid sidecar must not be written")
}
}
func TestUpstreamFakeHashesBase64Bodies(t *testing.T) {
img := make([]byte, 1536) // 2 KiB of base64
for i := range img {
img[i] = byte(i * 7)
}
b64 := base64.StdEncoding.EncodeToString(img)
if len(b64) < 2000 {
t.Fatalf("fixture too small: %d", len(b64))
}
sent := `{"model":"m","messages":[{"content":[{"type":"image","source":{"data":"` + b64 + `"}},{"type":"image_url","url":"data:image/png;base64,` + b64 + `"}]}]}`
s := UpstreamSidecar{Version: 1, Exchanges: []UpstreamExchange{{
Request: UpstreamRequest{
Method: "POST",
URL: "https://api.example.test/v1/messages",
Headers: map[string]string{"Content-Type": "application/json", "X-Api-Key": "example-token-value"},
// PHP escapes "/" in JSON strings; the stored form must still hash.
Body: strings.ReplaceAll(sent, "/", `\/`),
},
Response: UpstreamResponse{Status: 200, Body: `{"ok":true}`},
}}}
path := filepath.Join(t.TempDir(), "img.upstream.yaml")
store := upstreamTestStore(t)
if err := WriteUpstream(path, s, store); err != nil {
t.Fatal(err)
}
raw, _ := os.ReadFile(path)
if strings.Contains(string(raw), b64[:64]) {
t.Fatalf("base64 payload kept in the sidecar")
}
sum := sha256.Sum256(img)
ph := "{{sha256:" + hex.EncodeToString(sum[:]) + "}}"
if !strings.Contains(string(raw), ph) || !strings.Contains(string(raw), "data:image\\\\/png;base64,"+ph) && !strings.Contains(string(raw), "data:image/png;base64,"+ph) {
t.Fatalf("sidecar lacks the hash placeholders:\n%s", raw)
}
loaded, err := LoadUpstream(path)
if err != nil {
t.Fatal(err)
}
send := func(body string) error {
req, _ := http.NewRequest("POST", "https://api.example.test/v1/messages", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Api-Key", "example-token-value")
f := NewUpstreamFake(loaded, store)
_, err := f.RoundTrip(req)
return err
}
if err := send(sent); err != nil {
t.Fatalf("same bytes rejected: %v", err)
}
changed := slices.Clone(img)
changed[100] ^= 1
b64c := base64.StdEncoding.EncodeToString(changed)
err = send(strings.Replace(sent, b64, b64c, 1))
if err == nil || !strings.Contains(err.Error(), "body $.messages[0].content[0].source.data") {
t.Fatalf("one changed byte accepted or misreported: %v", err)
}
if len(err.Error()) > 1000 {
t.Fatalf("mismatch message not clipped: %d bytes", len(err.Error()))
}
}