Skip to content

Proxy Core

Ghost’s proxy is the heart of the application — a custom Man-in-the-Middle (MITM) HTTP/HTTPS proxy built on Go’s net/http. Every piece of network traffic that Ghost captures flows through this proxy. It handles plain HTTP requests, HTTPS via CONNECT tunneling with TLS interception, WebSocket upgrades, script injection, network throttling, and feeds everything into the interceptor pipeline for breakpoints, map rules, addons, storage, and security analysis.

“Man-in-the-Middle” sounds alarming, but it’s exactly what debugging proxies do — Ghost sits between your browser/app and the server, reading and optionally modifying traffic in both directions. This is how tools like Charles Proxy, Fiddler, and mitmproxy work too. The difference is that Ghost adds AI-powered analysis on top.

What this diagram shows — how the proxy routes every request:

The proxy’s ServeHTTP method is the entry point for all traffic. It makes one decision based on the HTTP method:

  • CONNECT requests (all HTTPS traffic) go to handleConnect, which decides whether to perform TLS interception (MITM) or pass the traffic through as a raw TCP tunnel (for SSL-bypassed hosts). If intercepting, it enters a keep-alive loop that reads HTTP requests from the decrypted TLS connection, checking each for WebSocket upgrades.
  • All other methods (GET, POST, PUT, etc.) are plain HTTP requests that go directly to handleHTTP.

Both paths feed into the same interceptor pipeline and use the same adaptive transport for upstream connections.

When a plain HTTP request arrives (not tunneled through CONNECT), it follows this 16-step process:

  1. Control page intercept — Ghost checks for special URLs before any proxy logic:

    • /ghost-ca.crt on any host → serves the CA certificate as a PEM download
    • /ghost-profile.mobileconfig on any host → generates an iOS Configuration Profile with the CA cert embedded (DER-encoded, base64)
    • Host: ghost.proxy → serves the landing page, or reverse-proxies /api/v1/script/fetch and /api/v1/script/analyze to the API server (for the __ghost helper in injected scripts), or returns connectivity verification at /verify
  2. Host extraction — takes the hostname from the URL or Host header, strips the port, lowercases

  3. Host filteringshouldCapture(host) checks the include/exclude lists. Excluded hosts are forwarded directly without creating a flow (no capture, no interceptors, just pass-through)

  4. Flow creation — generates a ULID, attaches the current session ID, records StartedAt timestamp, sets source to "proxy"

  5. Request capture — reads the request body with io.LimitReader (capped at 10 MB — larger bodies are truncated for storage but forwarded in full), clones all headers, reconstructs the full URL if it was relative

  6. Client metadata — records the client’s IP address (from RemoteAddr) and identifies the source application from the User-Agent header

  7. Device resolution — a 3-layer system identifies which device sent the request:

    • Layer 1 (User-Agent classification): parses the UA string to classify as mac, ios_simulator, ipad_simulator, android_emulator, ios_device, android_device, or unknown
    • Layer 2 (Simulator registry): on macOS, a background goroutine polls xcrun simctl list devices booted every 15 seconds to track running iOS simulators
    • Layer 3 (PID resolution): when multiple simulators are running, uses lsof to find which process owns the TCP connection, then walks the parent process chain (up to 8 levels) to match against simulator process IDs. Results are cached with a 60-second TTL.
  8. Request interceptors — the pipeline runs all interceptors’ OnRequest methods in order. An interceptor can return:

    • ActionDrop — silently drop the request (no response sent)
    • ActionRespond — short-circuit with a synthetic response (used by Map Local rules)
    • nil — continue to the next interceptor
  9. Build upstream request — creates a new HTTP request from the captured body (since step 5 already drained the original body), clones headers, removes hop-by-hop headers, sets GetBody for retry support

  10. Timing instrumentation — attaches a DialTimings struct to the request context. The dialer populates DNS, TCP, and TLS timings as each phase completes.

  11. Transport RoundTrip — sends the request upstream via the adaptive transport. Records the time to first byte (TTFB).

  12. Error handling — if the upstream connection fails, Ghost records the error in flow.Error, evaluates noise detection (the failed request may still be emitted), runs response interceptors, emits the flow, and sends a 502 Bad Gateway to the client.

  13. Script injection check — if injection rules exist and the response has an HTML content type, Ghost buffers and decompresses the response body, injects scripts, strips CSP headers, and updates Content-Length. The original (pre-injection) body is stored in the flow.

  14. Streaming response — for non-injected responses, Ghost uses io.TeeReader to simultaneously stream the response to the client AND capture up to 10 MB for storage. A limitWriter silently discards bytes after the cap, so the client always receives the full response even if the stored copy is truncated.

  15. Build flow response — creates the Response struct from captured data, decompresses the stored body (gzip/deflate/brotli) for display in the inspector

  16. Timing, noise, interceptors, emit:

    • Assembles the timing breakdown: TTFB = rawTTFB - DNS - TCP (no TLS phase for plain HTTP). Negative TTFB (from pooled connections where DNS/TCP are zero) is clamped.
    • Evaluates noise detection — if the flow matches a noisy pattern, it may be suppressed from storage and emission
    • Runs response interceptors (storage, security analysis, addon tagging)
    • Emits the flow via WebSocket broadcast

When a browser wants to access an HTTPS site through a proxy, it sends a CONNECT request asking the proxy to establish a tunnel. Ghost intercepts this tunnel to read the encrypted traffic:

  1. Normalize host — appends :443 if no port specified

  2. Host filtering — uncaptured hosts get tunnelDirect() — a raw TCP relay that forwards bytes bidirectionally without any interception. Ghost acts as a transparent tunnel.

  3. SSL bypass check — hosts in the SSL bypass list also get tunnelDirect(). This preserves the original TLS connection end-to-end — Ghost can’t read the traffic, but the app’s certificate pinning works normally. Bypass patterns support exact matches and wildcard suffixes (e.g., *.example.com stored as .example.com, matched via strings.HasSuffix).

  4. Hijack connection — Ghost takes ownership of the raw TCP connection from Go’s HTTP server, sends HTTP/1.1 200 Connection Established to the client

  5. TLS handshake (server-side MITM) — Ghost presents a dynamically forged certificate for the requested hostname (from the CertIssuer). The client’s TLS library accepts this certificate because it chains to Ghost’s CA, which you installed as trusted. If the handshake fails (the client rejects the cert), Ghost returns silently — no flow is created.

  6. Request loop — Ghost reads HTTP/1.1 requests from the decrypted TLS connection in a keep-alive loop:

    • Re-checks host filters on every request (supports hot-reload — if you change filter settings mid-connection, the new settings take effect immediately)
    • WebSocket detection — if Upgrade: websocket headers are present, Ghost hands off to handleWebSocketUpgrade which takes ownership of the connection for bidirectional frame relay
    • Normal HTTPS requests — processed by handleMITMRequest, which follows the same pipeline as handleHTTP but with additional TLS timing data
  7. Loop termination — when the client closes the connection (io.EOF) or a read error occurs, the loop exits

MITM Request Processing (handleMITMRequest)

Section titled “MITM Request Processing (handleMITMRequest)”

This is essentially the same as handleHTTP but for HTTPS requests read from a decrypted CONNECT tunnel. Key differences:

  • The URL is reconstructed with https:// scheme
  • Timing includes the TLS handshake phase: TTFB = rawTTFB - DNS - TCP - TLS
  • Response streaming uses chunked transfer encoding directly to the raw net.Conn (not Go’s ResponseWriter)
  • On upstream failure, the error response is written directly to the connection

What this diagram shows — how Ghost auto-detects HTTP/2 support:

The adaptiveTransport manages two underlying transports (one for HTTP/1.1, one for HTTP/2) and maintains a per-host protocol cache (host:porttrue for h2, false for h1).

For a known host: Uses the cached transport directly.

For an unknown host (first request):

  1. Tries HTTP/2 first
  2. If successful: caches the host as h2, returns the response
  3. If h2 fails: resets the request body via req.GetBody() (the body was already consumed by the failed h2 attempt), tries HTTP/1.1
  4. If h1 succeeds: caches as h1, returns the response
  5. If both fail: returns a combined error
SettingValuePurpose
Max idle connections200Total connection pool size across all hosts
Max idle per host20Per-host connection reuse limit
Idle timeout90sHow long an idle connection stays in the pool
Response header timeout60sMax wait time for upstream response headers
TCP dial timeout10sMax time to establish a TCP connection
TCP keep-alive30sInterval for TCP keepalive probes
Expect-Continue timeout1sTimeout for 100 Continue response
H2 max read frame size16 MBLarge frame support (for CDNs like Google Video)
H2 read idle timeout30sH2 connection idle detection
H2 ping timeout15sH2 keepalive ping timeout

All upstream TLS connections use uTLS to mimic Chrome’s TLS ClientHello. This prevents servers from detecting that traffic comes from a proxy (some servers block non-browser TLS fingerprints).

The three-phase dial with timing:

  1. DNS lookupnet.DefaultResolver.LookupHost(ctx, host) resolves the hostname to IP addresses. Records DNS duration.

  2. TCP connectnet.Dialer connects to the first resolved IP with a 10-second timeout and 30-second keepalive. Records TCP duration.

  3. TLS handshake — builds Chrome’s ClientHelloSpec from utls.HelloChrome_Auto, overrides the ALPN extension with the requested protocols (either ["h2", "http/1.1"] for the h2 transport or ["http/1.1"] for the h1 transport), creates a utls.UClient with HelloCustom mode. InsecureSkipVerify: true because the proxy is MITM — upstream certificate validation is intentionally skipped. Records TLS duration.

When an upstream proxy is configured (for corporate environments where Ghost sits behind another proxy), the adaptive transport with uTLS is replaced by a standard http.Transport with Proxy: http.ProxyURL(proxyURL). uTLS can’t work through CONNECT tunnels (the upstream proxy terminates TLS). ForceAttemptHTTP2: true is set because Go’s stdlib can see the real *tls.Conn (not a uTLS wrapper).

The pipeline runs a chain of interceptors on every flow, in this order:

Breakpoints → Map Rules → Addons → Storage → Security

Each interceptor implements three methods:

  • Name() — identifier string
  • OnRequest(ctx, *Flow) *Action — runs before the request goes upstream
  • OnResponse(ctx, *Flow) *Action — runs after the response comes back

Action types:

ActionValueMeaning
ActionContinue0Default — continue to the next interceptor
ActionDrop1Silently drop the flow (no response sent to client)
ActionRespond2Short-circuit with a synthetic response (OnRequest only — used by Map Local)

The pipeline is immutable after construction (defensive copy). Updates happen atomically via SetPipeline, so interceptor changes take effect on the next request without race conditions.

Ghost generates its own Certificate Authority (CA) on first run:

PropertyValue
AlgorithmECDSA P-256 (fast, modern, small keys)
Validity10 years
OrganizationGhost Proxy
Common NameGhost Root CA
Path constraintsMaxPathLen: 0 — no intermediate CAs allowed
Serial numberRandom 128-bit (per RFC 5280)
NotBeforeBackdated 1 hour (handles clock skew between devices)
Storage~/.ghost/ghost-ca.crt (0644 permissions) and ~/.ghost/ghost-ca.key (0600 — owner-only read)

For each hostname Ghost intercepts, it dynamically generates a leaf certificate:

PropertyValue
AlgorithmECDSA P-256 (one key per leaf)
Validity24 hours (short-lived for security)
NotBeforeBackdated 1 hour
SAN (Subject Alternative Name)The requested hostname + wildcard for the parent domain (e.g., api.example.com gets api.example.com and *.example.com). IP addresses are supported.
OCSP staplingGhost generates a “Good” OCSP response signed by its CA and staples it to the leaf certificate. This prevents Windows SChannel errors about “revocation status unknown.”
TLS minimumTLS 1.2

Performance: Leaf certificates are cached in an LRU cache with thundering-herd protection — if multiple concurrent requests need a cert for the same host, only one goroutine generates it while the others wait (single-flight pattern).

Request and response bodies are captured with a 10 MB limit (maxBodyCapture). Bodies larger than this are truncated for storage but always forwarded to the client/server in full.

For streaming responses, Ghost uses io.TeeReader — a Go construct that reads from one source and writes to two destinations simultaneously. The response streams to the client at full speed while a limitWriter captures the first 10 MB for the flow database.

Stored response bodies are decompressed for display in the inspector. Ghost supports three compression formats:

FormatLibraryHeader Value
gzipcompress/gzipContent-Encoding: gzip
deflatecompress/flateContent-Encoding: deflate
brotliandybalholm/brotliContent-Encoding: br

On successful decompression, the stored body is replaced with the decoded version, ContentLength is updated, and the Content-Encoding header is removed. On failure, the original compressed bytes are kept unchanged.

For script injection, decompression uses a smaller 5 MB limit with a truncation guard — if the decompressed data exceeds 5 MB, Ghost checks by reading one extra byte. If data remains (truncated), it returns the original compressed body and skips injection rather than injecting into broken HTML.

These headers are connection-level (between client and proxy, or proxy and server) and must not be forwarded:

Connection, Keep-Alive, Proxy-Authenticate, Proxy-Authorization, TE, Trailers, Transfer-Encoding, Upgrade

HeaderWhy It’s Removed
Alt-SvcPrevents QUIC/HTTP3 upgrades that would bypass the proxy entirely
Content-EncodingRemoved from stored responses after decompression (the stored body is now uncompressed)
Content-LengthRemoved from upstream requests (Go recalculates it from the actual body), updated after script injection
CSP headers (4 variants)Stripped during script injection to allow injected scripts to execute

Ghost captures per-request timing broken down into phases:

PhaseWhat It MeasuresHow It’s Captured
DNS LookupTime to resolve the hostname to an IP addressnet.DefaultResolver.LookupHost timing in dialUpstreamTLS
TCP ConnectTime to establish the TCP connectionnet.Dialer.DialContext timing in dialUpstreamTLS
TLS HandshakeTime to complete the TLS negotiation (HTTPS only)utls.UClient.HandshakeContext timing in dialUpstreamTLS
TTFBTime to first byte — server processing timeCalculated as rawTTFB - DNS - TCP - TLS. Clamped to rawTTFB if negative (pooled connections have zero DNS/TCP/TLS).
TransferTime to download the complete response bodytime.Since(ttfbMoment) after body is fully read
TotalEnd-to-end request durationtime.Since(flow.StartedAt)

For plain HTTP requests, there’s no TLS phase. For pooled connections (reusing an existing TCP connection), DNS and TCP are both zero — the connection is already established.

After every response is sent to the client, Ghost evaluates whether the flow is “noise” — repetitive background traffic that clutters the flow list without adding value:

  1. Builds a fingerprint: METHOD:host:pathWithoutQuery
  2. The NoiseDetector tracks request frequency per fingerprint using a sliding window (30 seconds, threshold of 20)
  3. Hysteresis: enters “noisy” state at 20 requests/window, exits when count drops below 10 (half the threshold). This prevents rapid on/off toggling.
  4. Noisy flows are either sampled (1 in 10 kept) or suppressed entirely
  5. Flows with addon-applied tags are never suppressed — if something explicitly tagged a flow, it’s considered important

The client always receives the full response regardless of noise classification. Noise detection only affects whether the flow is stored and emitted via WebSocket.

ResetConnections() closes all idle connections on both transports and clears the per-host protocol cache. Called during:

  • Proxy stop
  • Network recovery (VPN reconnection, interface changes)
  • Manual connection reset

When the proxy stops:

  1. Atomically sets running to false
  2. Stops the device resolver and noise detector
  3. Calls httpServer.Shutdown() with a 10-second timeout — this stops accepting new connections and waits for in-flight requests to complete
  4. If the timeout expires (requests still in flight after 10 seconds), forces httpServer.Close()
  5. Resets connections for clean transport state

Active connections are tracked via Go’s ConnState callback with a sync.WaitGroup — new connections increment, closed/hijacked connections decrement.

The HTTP server uses ReadTimeout: 0 and WriteTimeout: 0 — deliberately no timeouts on reads/writes because proxy connections are long-lived (keep-alive, WebSocket, large uploads/downloads). Only IdleTimeout: 90s is set to clean up stale connections.

ConstantValuePurpose
maxBodyCapture10 MBBody truncation limit for flow storage
idleTimeout90sKeep-alive idle timeout
upstreamTimeout60sUpstream response header timeout
shutdownTimeout10sGraceful shutdown drain time
maxWSPayload1 MBWebSocket frame payload capture cap
maxInjectionDecompressSize5 MBInjection decompression bomb guard
maxLocalFileSize50 MBMap Local file serving cap
maxBodyScanSize1 MBSecurity interceptor body scan cap
leafCertValidity24 hoursDynamic leaf certificate lifetime
leafCertBackdate1 hourCertificate NotBefore clock skew tolerance
caValidityYears10CA certificate lifetime
ScenarioWhat Happens
DNS failureError propagated from dialer, flow gets Error field set, client receives 502
TCP connect failureSame as DNS failure — 502 to client
TLS handshake failure (upstream)Connection closed, flow gets error, 502 to client
TLS handshake failure (client-side MITM)Debug logged, connection closed silently — no flow created (client rejected the proxy cert)
Upstream response timeoutAfter 60 seconds with no response headers, error recorded, 502 to client
Body read failureWarning logged, partial body stored — flow is still emitted
Stream to client failureError recorded in flow.Error, flow is still emitted (the flow was at least partially successful)
Decompression failureOriginal compressed bytes kept — display shows the raw compressed data
Injection decompression exceeds 5 MBInjection skipped entirely, original response forwarded as-is
Pipeline is nilPlain HTTP: forwards directly without capture. MITM: returns silently.
Hijack failure500 Internal Server Error sent to client