Skip to content

Dev Proxy

Test a production publisher hostname against a dev or staging upstream — with a real browser, real TLS, and no DNS change — using ts dev proxy.

What it does

ts dev proxy is a TLS-terminating forward (MITM) proxy that runs on your machine. When you open https://www.example-publisher.com in a browser pointed at it, the address bar shows the production hostname but the request is served by the upstream you specify — a Trusted Server Compute service, a staging instance, or localhost. No production DNS, Fastly service, or certificate is touched, and no other users are affected.

Why a local proxy is necessary. The browser binds TLS SNI to the URL hostname. Fastly routes by SNI to the service that owns that domain. So even if you rewrite /etc/hosts or use --host-resolver-rules, the SNI still delivers the request to the production service. The only way to reach a different upstream while keeping the production hostname in the address bar is to rewrite the SNI in flight — which requires terminating the browser's TLS locally.

To terminate TLS, the proxy presents a certificate for the production hostname. For this to produce a green padlock in Chrome, Firefox, and Safari — and to satisfy HSTS — the certificate must be signed by a CA the browser trusts. ts dev proxy generates a per-machine Certificate Authority on first run; you trust it once.

Primary use case. Validate routing and behavior of a new or changed Trusted Server deployment at the publisher's real domain — cookies, Host-sensitive logic, CMP/consent flows, first-party context — before any DNS cutover.

Non-goals. Not a production proxy or load-testing tool. Does not modify any Fastly service. Local only, developer-facing.

Build and run

ts dev proxy is part of crates/trusted-server-cli, a workspace member that builds for the host. Because the workspace default target is wasm32-wasip1 (where the CLI is an empty shell), build and run it with an explicit native target:

bash
cargo run --manifest-path crates/trusted-server-cli/Cargo.toml \
  --target "$(rustc -vV | sed -n 's/host: //p')" -- dev proxy --help

Passing the rewrite rule

The upstream is always passed explicitly — there is no inference from trusted-server.toml or any config file. Give a single rule with the -f/-t shorthand, or one or more --map FROM=TO rules:

bash
cargo run --manifest-path crates/trusted-server-cli/Cargo.toml \
  --target "$(rustc -vV | sed -n 's/host: //p')" -- dev proxy \
  -f www.example-publisher.com -t trusted-server-example.edgecompute.app

With no --map/-f/-t, the proxy exits with no rewrite rule: pass --map FROM=TO (or -f/--from with -t/--to).

Explicit rule and browser launch

bash
cargo run --manifest-path crates/trusted-server-cli/Cargo.toml \
  --target "$(rustc -vV | sed -n 's/host: //p')" -- dev proxy \
  --map www.example-publisher.com=trusted-server-example.edgecompute.app \
  --launch chrome,firefox,safari

--launch takes a comma list (chrome, firefox, safari) or all. When omitted the proxy runs without opening any browser.

Other examples

bash
# Gated staging upstream, Firefox only:
ts dev proxy \
  -f www.example-publisher.com \
  -t staging.example.net \
  --basic-auth dev:secret \
  --launch firefox

# Local instance over plain HTTP, no browser:
ts dev proxy \
  -f www.example-publisher.com \
  -t localhost:3000 \
  --upstream-plaintext

First run: CA setup

On first run the proxy generates a per-machine Certificate Authority and prints:

generated dev CA at ~/Library/Application Support/trusted-server/dev-proxy/ca-cert.pem
— run `ts dev proxy ca install` to trust it

The CA key is stored with mode 0600 outside the repository and is never committed.

Trust the CA on macOS (Chrome and Safari)

bash
cargo run --manifest-path crates/trusted-server-cli/Cargo.toml \
  --target "$(rustc -vV | sed -n 's/host: //p')" -- dev proxy ca install

This adds the CA to the macOS login keychain (no sudo required; prompts for your login password). Chrome and Safari both consult the macOS keychain and will trust the proxy's certificates immediately.

Trust the CA in Firefox

Firefox does not reliably consult the macOS login keychain. When you use --launch firefox, the proxy automatically imports the CA into the temporary Firefox profile's NSS database using certutil. If you are pointing an existing Firefox profile at the proxy manually, run:

bash
certutil -A -n "Trusted Server DEV-ONLY Proxy CA" \
  -t "CT,," \
  -i "$(cargo run --manifest-path crates/trusted-server-cli/Cargo.toml \
    --target "$(rustc -vV | sed -n 's/host: //p')" -- dev proxy ca path)" \
  -d "$HOME/Library/Application Support/Firefox/Profiles/<profile>"

Revoking trust when done

bash
cargo run --manifest-path crates/trusted-server-cli/Cargo.toml \
  --target "$(rustc -vV | sed -n 's/host: //p')" -- dev proxy ca uninstall

This removes the CA from the macOS keychain. Run it when you are finished — the CA is trusted for ~10 years and its key sits on disk.

Security note

The dev CA is a standing MITM capability on your machine. Its key (ca-key.pem) must be treated like a credential:

  • It is generated per-machine and never committed to the repository.
  • The CA directory has mode 0700 and the key file 0600.
  • The CA CN is Trusted Server DEV-ONLY Proxy CA — DO NOT TRUST IN PRODUCTION.
  • Trust it only on a development machine you control.
  • Run ca uninstall when done; run ca regenerate to rotate.

CA companion commands

bash
ts dev proxy ca path        # print the CA certificate path
ts dev proxy ca install     # trust the CA (macOS login keychain)
ts dev proxy ca uninstall   # remove the CA from the trust store
ts dev proxy ca regenerate  # generate a new CA (invalidates prior trust)

ca path and ca install generate the CA if it does not exist yet, so they work on a freshly cloned machine before the proxy has been run.

ca regenerate first removes the previously-installed CA from the macOS keychain (revoking its trust) before generating fresh key material, so an exfiltrated old key is no longer accepted. If the keychain removal can't be confirmed it aborts without touching the on-disk key, so the stored CA still matches OS trust — remove the old CA manually in Keychain Access, then retry. Run ca install afterward to trust the new CA.

Host header behavior

The proxy always sends X-Forwarded-Host: <FROM> (the production hostname) — the standard "original host" header for a forward proxy. Trusted Server core anchors all HTML/URL rewriting to it (it prefers X-Forwarded-Host, then Host), so first-party URLs always stay on the production domain regardless of the Host header. That decouples routing (Host) from the first-party host.

By default Host: <FROM> too, which works against a Trusted Server Compute upstream because Fastly routes by SNI (= TO) and passes Host through unchanged.

Targeting a specific server by IP. To point at a particular server or load balancer — for example when the TO hostname isn't in DNS yet — keep --to a hostname (so the TLS SNI and certificate stay valid) and pin its connection address with --resolve HOST:IP (like curl's --resolve, repeatable):

bash
ts dev proxy \
  --from www.example-publisher.com \
  --to ts.example-publisher.com \
  --resolve ts.example-publisher.com:192.0.2.10 \
  --launch chrome

The proxy dials 192.0.2.10 while the SNI stays ts.example-publisher.com and X-Forwarded-Host stays www.example-publisher.com — so TS rewrites first-party URLs onto the production domain. This keeps the tool self-contained — no /etc/hosts edit. (Pointing --to at a bare IP instead would make the SNI an IP, which sends no SNI extension at all, so a host-routed endpoint serves its default vhost.) Add --insecure if the endpoint serves a certificate that doesn't match the hostname.

Sending Host: TO. If your upstream routes or validates on its own hostname (e.g. a Fastly Deliver service that rejects an unconfigured Host), pass --rewrite-host to send Host: <TO>. The proxy still stamps X-Forwarded-Host: <FROM>, so first-party URL rewriting stays anchored to FROMas long as the upstream preserves that header.

Caveat with real Trusted Server adapters. The Fastly and Spin adapter request paths strip inbound X-Forwarded-Host before routing, so with --rewrite-host a real Trusted Server upstream falls back to Host (TO) and emits first-party URLs on TO, not FROM. Prefer the default (no --rewrite-host) with Trusted Server upstreams; reach for --rewrite-host only when the upstream rejects an unconfigured Host.

The TLS SNI is always the TO host either way:

FormHost headerX-Forwarded-HostTLS SNI
(omitted)FROMFROMTO host
--rewrite-hostTO hostFROMTO host

Port handling: with --rewrite-host and a non-default TO port (e.g. localhost:3000), the port is included in the Host header but never in the SNI (a bare hostname; a port in SNI is invalid).

Non-loopback listen

The proxy binds 127.0.0.1:18080 by default. A non-loopback --listen is rejected unless you also pass --allow-non-loopback:

bash
ts dev proxy \
  --map www.example-publisher.com=trusted-server-example.edgecompute.app \
  --listen 0.0.0.0:18080 \
  --allow-non-loopback

Even with --allow-non-loopback, unmatched CONNECT authorities are refused (403) rather than blind-tunneled, so the proxy cannot act as an open CONNECT proxy on the LAN.

All options

ts dev proxy [OPTIONS] [COMMAND]

Options:
      --map <FROM=TO>           Rewrite rule (repeatable)
  -f, --from <HOST>             Single-rule FROM (pairs with --to)
  -t, --to <HOST[:PORT]>        Single-rule TO (hostname; pairs with --from)
      --resolve <HOST:IP>       Pin HOST's connection to IP (curl-style, repeatable)
      --listen <ADDR>           Listen address [default: 127.0.0.1:18080]
      --allow-non-loopback      Permit non-loopback --listen (disables blind tunnel)
      --launch <LIST>           Browsers to launch (chrome,firefox,safari or all)
      --rewrite-host            Send Host: <TO> instead of the default <FROM>
      --basic-auth <USER:PASS>  Inject Basic auth (visible in ps — prefer --basic-auth-file)
      --basic-auth-file <PATH>  Read USER:PASS from a file
      --insecure                Skip upstream TLS certificate verification
      --upstream-plaintext      Connect to upstream over plain HTTP
      --ca-dir <PATH>           CA cert/key directory [default: ~/Library/Application Support/
                                trusted-server/dev-proxy on macOS]

The tool is flags-only; there are no environment variable overrides.

Browser details

BrowserHow the proxy is configuredCA trust
ChromeTemp --user-data-dir; --proxy-server="https=127.0.0.1:<port>" (HTTPS only — plain HTTP goes direct)macOS login keychain via ca install
FirefoxTemp profile with user.js setting network.proxy.ssl (HTTPS only — network.proxy.http is unset so plain HTTP goes direct)CA imported into the profile's NSS DB at launch
SafariSystem PAC at http://127.0.0.1:<port>/proxy.pac via networksetup on the active network service, scoped to the configured FROM hosts; then opens Safari at the first rule's FROM URL; prior setting restored on exitmacOS login keychain via ca install

Unlike Chrome/Firefox (which run in a throwaway profile), Safari uses your system proxy settings, so --launch safari sets the macOS auto-proxy on the active network service (e.g. Wi-Fi) and then opens Safari at the FROM URL.

Changing the system network proxy requires admin, so --launch safari runs the networksetup command under sudo — it prompts once for your password in the terminal (only that command is elevated; the proxy keeps running as you). If sudo is declined or there is no terminal (e.g. the proxy is backgrounded), it prints the exact networksetup command and the System Settings path so you can set the PAC manually. The change is system-wide (all apps) but PAC-scoped to the FROM hosts, and only while the proxy runs. The prior setting — including whether auto-proxy was enabled or disabled — is saved and restored. On a clean exit (Ctrl-C) the restore uses sudo and may prompt once more if a long run outlived the cached credential. After a hard kill (SIGKILL) the next ts dev proxy run restores the leftover state non-interactively (sudo -n), or, if it can't, keeps the saved state and prints the exact manual networksetup command.

Troubleshooting

SymptomCauseFix
"unknown domain" or 404 from upstreamThe upstream service does not accept Host: <FROM> (the default). A domain can be active on only one Fastly service at a time, so you cannot add the production hostname to a dev service.Use a Trusted Server Compute upstream (routes by SNI, not Host), or pass --rewrite-host to send Host: <TO>.
Upstream returns 401Upstream is behind Basic auth.Pass --basic-auth user:pass or --basic-auth-file ./creds.txt.
Upstream unreachable (502 / 503)Upstream service is down or the domain is not provisioned.Verify the upstream URL and its Fastly service health.
Browser shows an untrusted-certificate warningThe dev CA is not trusted in the browser.Run ts dev proxy ca install for Chrome and Safari. For Firefox, use --launch firefox (auto-imports) or run certutil manually (see above). After ca regenerate, re-trust with ca install.
Listen address already in useAnother process holds port 18080.Pass --listen 127.0.0.1:18081 (or another free port).
--listen rejected as non-loopbackA non-loopback address was given without the required flag.Add --allow-non-loopback.

Released under the Apache License 2.0.