TLS Interception & Fingerprint Impersonation
The agent records and replays HTTPS by terminating TLS on a dedicated MITM listener and signing a leaf certificate per upstream from an embedded CA your machines trust once. This guide covers two distinct things that are easy to conflate: inbound interception(decrypt your app's HTTPS so the proxy can learn and mock it) and outbound fingerprint impersonation (make the proxy's egress handshake look like a real browser). They are separate features with separate switches.
Two listeners, one engine
The agent runs two inbound listeners side by side. Both feed the same mode register, the same mock library, and the same capture buffer — so LEARN, MOCK, PASSTHROUGH, and the brief TRANSITIONING interstitial behave identically whether traffic arrives in cleartext or over TLS.
:8080Plain HTTP — always on
The default path, unchanged. Your app talks HTTP to the proxy; the proxy talks HTTPS to the upstream. Also hosts the control plane and the GET /ca.crt download. This is where pinned clients stay.
:8443TLS MITM — opt-in
A CONNECT/MITM listener that decrypts your app's HTTPS, mints a leaf cert for the requested SNI from the embedded CA, and feeds the decrypted request into the same serve path. Binds only when ENABLE_TLS_INTERCEPTION is set.
Port 8443 is published unconditionally in your compose file, so turning interception on is a .envflip plus a restart — not a compose edit. The match cascade behind both listeners is unchanged: session-verbatim → exact → resource-store → smart-swap → AI inference at the edge. No LLM ever runs in the request hot path — that is an architectural invariant of the proxy, independent of which listener the request came in on. See How It Works for the cascade.
Capture vs. impersonation
Enabling the :8443 listener
ENABLE_TLS_INTERCEPTION is a tri-state. It decides whether the listener spawns at all, and what happens if the listener fails to come up:
(unset / anything else)→ off
Default. The :8443 listener never binds. The port stays published but accepts no connections, and GET /ca.crt returns 503. Runtime behaviour is byte-identical to the plain-HTTP-only build.
lax · true · 1→ lax
Spawn the listener. If it fails to bind or crashes post-bind, the agent logs the failure, flips the gostly_tls_listener_state gauge to lax_failed, and keeps serving plain HTTP on :8080. Degraded but operational.
strict→ strict
Spawn the listener. A listener failure exits the process — you have declared TLS load-bearing, so a half-up agent serving only plain HTTP would mask the breakage rather than surface it.
Flip the value in .env and restart the agent:
# .env ENABLE_TLS_INTERCEPTION=lax # or: strict # Optional listener tuning (defaults shown) TLS_LISTEN_ADDR=0.0.0.0:8443 TLS_CACHE_SIZE=8192 # SNI -> leaf-cert cache capacity TLS_CACHE_TTL_DAYS=7 # per-entry TTL TLS_PRELOAD_HOSTS=api.stripe.com,api.github.com # warm hot SNIs at boot
On the first connection to a new hostname the agent mints a leaf cert (the cold-cert path); subsequent connections hit the in-memory SNI cache. TLS_PRELOAD_HOSTS pre-mints the hostnames you already know are hot so the first real request skips the cold path. Setting TLS_CACHE_SIZE or TLS_CACHE_TTL_DAYS to an unparseable or non-positive value is a hard error at boot — the agent never silently falls back to a default.
Installing the CA — GET /ca.crt
Because the agent signs a leaf for every intercepted SNI, your clients have to trust the agent's embedded CA — once per developer machine or CI image. The public certificate is served from the plain control plane on :8080:
# Download the CA public cert (PEM). Returns 503 until interception is on. curl http://<agent-host>:8080/ca.crt > gostly-ca.crt # Then run your platform's trust-install step, e.g. on Debian/Ubuntu: sudo cp gostly-ca.crt /usr/local/share/ca-certificates/gostly-ca.crt sudo update-ca-certificates
The route is stateless — it reads ${TLS_CA_DIR:-${DATA_DIR}/ca}/ca.crt on each request, so a CA rotated mid-process (delete the directory and restart) is picked up without redeploying. The response is served as application/x-pem-file with cache-control: no-store. While interception is off — or before the CA has been generated — the endpoint returns 503 with a plaintext hint to set the env var and restart.
Generated at first boot under ${TLS_CA_DIR:-${DATA_DIR}/ca} and loaded on every subsequent boot. The private key never leaves the agent's data volume.
ECDSA P-256, 10-year validity. P-256 is accepted by every modern OS, Node, Python, and JVM trust store, and signs faster than RSA on the cold-cert path.
No scheduled rotation. To roll the CA, delete the CA directory and restart — the agent generates a fresh CA and clients re-install the new ca.crt.
The CA key is guarded structurally
ca.key is not mode 0600. A leaked CA private key lets an attacker mint trusted leaves for any host on every machine that installed the CA, so this is a hard fail, not a warning. The certificate subject is deliberately stamped “DO NOT TRUST ON PUBLIC INTERNET” so it is obvious in trust-store UIs that this root is local-only.Each successful download emits one structured INFO log with user_agent and x-forwarded-for(when present) — so a procurement reviewer asking “when was the CA installed and where” has an answer in your existing log stack.
Upstream verification & pinned clients
Interception only touches the leg between your app and the proxy. The proxy's own leg to the real upstream still validates the upstream's certificate normally:
ACCEPT_INVALID_CERTSDefaults to false — the proxy verifies upstream TLS. Set true only for self-signed or internal upstreams; when enabled the agent logs it and emits a metric so the bypass is never silent.
Certificate pinningA client that pins its upstream's certificate will reject the leaf the proxy mints on :8443. Pinned applications should stay on the plain-HTTP :8080 path rather than route through interception.
Recorded bodies on diskDecrypted request/response bodies are written unencrypted to the host volume, same as the plain-HTTP path. Treat the data directory as you would any store of recorded traffic.
Credential headers are stripped before anything is written to disk — the 16-header redaction floor applies to the TLS path exactly as it does to plain HTTP. See the header redaction reference for the full list.
WebSocket frames over the MITM listener
When the TLS listener is up it also tees WebSocket frames (over HTTP/1.1 and HTTP/2) into the capture sink for observability. WebSocket capture is forwarding-transparent — frames are recorded byte-for-byte and passed through untouched, never modified or dropped.
Capture, not replay
ENABLE_WS_CAPTURE=false to disable frame capture (e.g. when frame volume is high or frame bodies are sensitive — v1 has no per-frame body redaction) without turning off the TLS listener itself.Outbound fingerprint impersonation (Pro+)
Independent of inbound interception, the agent can make its outboundhandshake to a real upstream look like a specific browser. By default the proxy's connection to your upstream uses a standard TLS client and a generic ClientHello. Set a per-service fingerprint_profile to swap that service onto a browser-grade TLS client whose ClientHello, HTTP/2 settings, and default headers match a real browser version — useful when an upstream gates or shapes traffic by TLS fingerprint and you need LEARN/PASSTHROUGH forwarding to get through.
Supported profile values (parsing is case- and separator-insensitive, so chrome-120 and chrome120 are equivalent):
chrome120 chrome131 firefox128 safari17 safari18 edge122
The profile is a column on the service's upstream config; a null profile keeps the service on the default client. Each request that uses a profile is enforced by three independent gates — if any one denies, the request falls back to the default client and emits a deny metric:
Tier claim
Fingerprint impersonation is Pro+. The capability is read from your validated license; a free-tier license can never enable it, regardless of what profile is configured on the service.
Kill switch
GOSTLY_DISABLE_WREQ=1 forces every service back onto the default client regardless of profile or tier — an operator escape hatch.
Profile parse
The stored string must map to a known profile. Unknown values degrade silently to the default client (with a deny metric) rather than erroring the request.
The control plane never impersonates
Observability
The TLS subsystem exposes its own Prometheus family on the agent's /metrics endpoint:
gostly_tls_listener_state{state}Flattened gauge — exactly one of off | lax_running | strict_running | lax_failed is 1. Recover the active state with max by (state).
gostly_alpn_negotiated{protocol}Negotiated HTTP version per intercepted request: h2, http/1.1, or other. Counts requests, not handshakes — HTTP/2 multiplexes many requests over one.
gostly_tls_cert_cache_hit_total / _miss_totalSNI leaf-cert cache hits and misses. A sustained high miss rate at steady state means TLS_CACHE_SIZE is undersized or the hostname distribution is long-tailed.
gostly_tls_cert_cache_evictions_total{cause}Cache evictions by cause (size | expired). Sustained cause=size after warm-up signals an undersized cache.
outbound_requests_total{backend, profile}Outbound request count by transport backend and fingerprint profile — confirms which services actually egress under impersonation.
outbound_fingerprint_denied_total{service_id, reason}One increment per fingerprint fallback, labeled by the gate that denied (tier | kill_switch | unknown_profile).
There is deliberately no per-handshake outcome counter — a flat-zero handshake-failure metric would mislead a dashboard more than it helps. Successful-handshake volume is observable through the request counters on the MITM path.
Licensed product vs. the OSS proxy
Everything on this page describes the licensed Gostly product, which ships as Docker Compose plus container images — you enable interception by setting ENABLE_TLS_INTERCEPTION in .envand restarting; there is no host CLI. The separate open-source Gostly proxy is a distinct product distributed via Homebrew and a container registry, and it does ship a host CLI. The two are configured differently — don't mix CLI flags from the OSS proxy into the licensed product's compose deployment.