Skip to content

go-egress-proxy

A MITM HTTP/HTTPS proxy implementing split-brain DNS for egress traffic control in containerized environments.

  • Split-brain DNS via TCP dial interception (not DNS-level), with target_ip or target_host routing
  • Path-based routing - route different URL paths on the same domain to different backends (path_pattern)
  • Scheme rewriting - change the request scheme before forwarding (target_scheme)
  • ACL with whitelist/blacklist/passthrough support (exact match, wildcards, regex)
  • Header injection on rewritten requests, plus automatic X-Request-ID for tracing
  • Header stripping - remove sensitive headers before forwarding (drop_headers)
  • Per-rewrite TLS bypass - skip upstream TLS verification for specific targets (insecure)
  • Outgoing TLS trust - custom CA bundle (PEM) and/or PKCS#12 truststore for upstream verification
  • Hot reload via SIGHUP signal (no restart required)
  • Blocked request log - optional JSON log file for auditing blocked requests
  • Selective request tracing - opt-in, full-detail aggregated trace records for a configurable subset of requests (by host/URL), spanning the TCP/TLS, request, and response layers (plus optional bodies), with secure-by-default redaction
  • Environment variable overrides for 12-factor app compatibility
  • Outbound HTTP/2 - negotiates HTTP/2 with upstream servers via ALPN
  • Prometheus metrics - request counts, latency histograms, active connections, upstream errors
  • Graceful shutdown - on SIGTERM: fail /readyz, keep serving for PROXY_PRESTOP_GRACE (default 10s) so load balancers route away, then drain in-flight requests and CONNECT tunnels within a 30s budget
  • Health endpoints - /healthz and /readyz for Kubernetes probes
Terminal window
# 1. Generate internal CA certificates
mitm-proxy gencert --type root --cn "My MITM CA" --out-cert certs/ca.crt --out-key certs/ca.key
# 2. Create config.yaml (copy from example)
cp doc/examples/configuration.yaml config.yaml
# 3. Edit config.yaml with your rules
# 4. Run the proxy
make run
# 5. Test (in another terminal)
curl -v -x http://localhost:8080 --cacert certs/ca.crt https://example.com
Terminal window
# Print version
mitm-proxy --version
# Show help
mitm-proxy --help
# Run with default verbosity (warn — errors and warnings only)
mitm-proxy
# Run with info logs (access logs, startup info)
mitm-proxy -v
# Run with debug output
mitm-proxy -vv
# Validate configuration
mitm-proxy validate --config config.yaml
# Generate certificates (see Certificate Generation section below)
mitm-proxy gencert --help
Flag Description
--version Print version and exit
-h, --help Show help message
(none) Warn (default) — errors and warnings only. Highest verbosity wins when multiple flags are given.
-v Info — enables info logs including ACCESS log per request
-vv Debug — adds REQUEST_DETAIL per request (scheme, full URL, proto, remote addr, content-length, user-agent, content-type, rewrite target)
-vvv Trace — adds full request headers to REQUEST_DETAIL

Subcommands (validate, gencert) default to info level for user-friendly output.

Subcommand Description
validate Validate configuration file and exit
gencert Generate CA certificates (root or intermediate)

The version is injected at build time. Use VERSION=1.0.0 make build to set a specific version, otherwise it defaults to the git describe output or dev.

Create a config.yaml file (or set CONFIG_PATH environment variable):

proxy:
port: "8080" # Proxy listen port
metrics_port: "9090" # Metrics/health endpoint port
default_policy: "BLOCK" # ALLOW or BLOCK unmatched hosts
outgoing_ca_bundle: "" # Optional: PEM CA bundle for upstream TLS
outgoing_ca: # Optional: list of individual CA cert files
- "certs/internal-ca.crt"
- "certs/partner-ca.crt"
blocked_log_path: "" # Optional: JSON log file for blocked requests
# Optional: ceiling on concurrent client connections (0/unset = unlimited).
# A hijacked CONNECT tunnel has no deadline at any layer, so this is what
# bounds a client that connects and then goes silent. At the cap the proxy
# pauses accepting until one closes and increments
# proxy_listener_saturated_total. Overridable with PROXY_MAX_CONNECTIONS.
# max_connections: 2048
# Optional: PKCS#12 truststore for upstream TLS (additive with outgoing_ca_bundle)
# outgoing_truststore_path: "certs/upstream-cas.p12"
# outgoing_truststore_password: "changeit"
# Disable upstream TLS certificate verification globally (for dev/test only!)
# insecure_skip_verify: false
# MITM CA certificate - provide either PEM cert+key or a PKCS#12 keystore (not both)
# Option A: PEM cert + key (default)
mitm_cert_path: "certs/ca.crt"
mitm_key_path: "certs/ca.key"
# Option B: PKCS#12 keystore (mutually exclusive with cert+key)
# mitm_keystore_path: "certs/ca.p12"
# mitm_keystore_password: "changeit"
rewrites:
# Route by IP address
- domain: "api.production.com"
target_ip: "10.20.30.40"
headers:
X-Proxy-Source: "egress-gateway"
# Route by hostname (DNS-resolved at dial time)
- domain: "external-api.partner.com"
target_host: "internal-gateway.corp.example.com"
# Wildcard match (any subdomain depth)
- domain: "*.internal.example.com"
target_ip: "10.20.30.50"
# Regex match (prefix with ~)
- domain: "~^api[0-9]+\\.example\\.com$"
target_ip: "10.20.30.60"
# Per-rewrite TLS bypass for self-signed internal services
- domain: "self-signed.internal.com"
target_ip: "10.20.30.70"
insecure: true
# Path-based routing (first match wins, evaluated in YAML order)
- domain: "api.example.com"
path_pattern: "^/v1/"
target_ip: "10.20.30.80"
headers:
X-Backend: "v1"
- domain: "api.example.com"
path_pattern: "^/v2/"
target_ip: "10.20.30.81"
- domain: "api.example.com" # Catch-all (no path_pattern)
target_ip: "10.20.30.82"
# Scheme rewriting: forward HTTPS client requests as HTTP to backend
- domain: "legacy-backend.internal.com"
target_ip: "10.20.30.90"
target_scheme: "http"
# Strip sensitive headers before forwarding
- domain: "sanitized-api.internal.com"
target_ip: "10.20.30.91"
drop_headers:
- "Authorization"
- "Cookie"
acl:
whitelist:
- "*.google.com"
- "github.com"
blacklist:
- "*.tiktok.com"
passthrough: # Tunnel without MITM (for services with their own PKI)
- "kubernetes.default.svc"
- "*.vault.internal"

See doc/examples/configuration.yaml for a complete example.

Each rewrite rule supports these fields:

Field Required Description
domain yes Domain pattern: exact, wildcard (*.example.com), or regex (~<pattern>)
target_ip one of IP address to route to (mutually exclusive with target_host)
target_host one of Hostname to route to, resolved via DNS at dial time
path_pattern no Regex matched against r.URL.Path for path-based routing
target_scheme no "http" or "https" to change the request scheme before forwarding. The port moves with the scheme when the client used the old scheme’s default (an intercepted HTTPS request carries :443, so a downgrade to http dials :80). A port the client chose explicitly is kept.
target_port no TCP port to connect to, overriding both the client’s port and the scheme default. Needed when the backend listens somewhere other than 80/443 (e.g. a legacy service on 8080).
headers no Map of headers to inject into the request
drop_headers no List of header names to remove before forwarding
insecure no Skip upstream TLS verification for this target only

ACL entries, rewrite domain values and trace host values share one syntax. Two properties matter for denylists, because getting them wrong fails open silently — the config loads, validate passes, and the entry enforces nothing.

Pattern Matches Does not match
example.com example.com, EXAMPLE.com, example.com. api.example.com
*.example.com api.example.com, a.b.example.com example.com (the apex)
* every host —
~^(.+\.)?example\.com$ the apex and any subdomain example.com.evil.net
  • A *. wildcard does not cover the apex. To deny a domain and everything under it, list both example.com and *.example.com, or use one ~ pattern.
  • ~ patterns are anchored, wrapped as (?i)^(?:...)$. They are not substring matches: ~api\.corp\.com does not match api.corp.com.attacker.net. Patterns that already carry ^/$ are unaffected. (Trace url patterns are the deliberate exception and remain substring matches.)
  • Matching is case-insensitive, and a trailing dot is stripped before matching, so EXAMPLE.com. cannot slip past an entry for example.com.
  • A * anywhere other than a leading *. label is a load error rather than a pattern that matches nothing.
Code Meaning When
200 OK Request succeeded through to upstream
403 Forbidden Request blocked by ACL (blacklisted or default BLOCK policy)
502 Bad Gateway Upstream unreachable: DNS lookup failed, connection refused, or connection reset
504 Gateway Timeout Upstream accepted the connection but did not respond in time

Validate your configuration file without starting the proxy:

Terminal window
# Use --config flag
mitm-proxy validate --config config.yaml
# Or use CONFIG_PATH environment variable
CONFIG_PATH=config.yaml mitm-proxy validate
# Default: reads config.yaml from current directory
mitm-proxy validate

The validate subcommand checks:

  • YAML syntax and structure
  • Required fields and valid values (including target_scheme, path_pattern regex)
  • Mutual exclusivity of target_ip/target_host and cert+key/keystore
  • ACL and rewrite pattern compilation
  • Referenced files exist and are readable (certificates, CA bundles, truststores)
  • Parent directory exists for blocked_log_path

Exits with code 0 on success, 1 on failure.

The gencert subcommand generates root and intermediate CA certificates with optional client trust bundles. It replaces the need for OpenSSL and the make certs script.

Terminal window
# Generate a root CA (self-signed, ECDSA P-256, 10-year validity)
mitm-proxy gencert --type root \
--cn "My Root CA" --org "ACME Corp" --country CH \
--out-cert certs/root-ca.crt --out-key certs/root-ca.key
# Generate an intermediate CA signed by the root (leaf-signing only)
mitm-proxy gencert --type intermediate \
--signing-cert certs/root-ca.crt --signing-key certs/root-ca.key \
--cn "MITM Proxy CA" --org "ACME Corp" \
--max-path-len 0 --validity 365 \
--out-cert certs/mitm-ca.crt --out-key certs/mitm-ca.key \
--out-chain certs/mitm-chain.crt
# Generate a root CA with client trust bundles
mitm-proxy gencert --type root --cn "My Root CA" \
--out-client-bundle certs/trust.pem \
--out-client-p12 certs/truststore.p12 --client-p12-password changeit
Flag Default Description
--type root root (self-signed) or intermediate (signed by parent)
--key-algo ecdsa-p256 rsa-2048, rsa-4096, ecdsa-p256, ecdsa-p384, ed25519
--cn MITM Proxy CA Certificate CommonName
--org MITM Proxy Certificate Organization
--country (empty) Country code (e.g. CH)
--validity 3650 Validity in days
--max-path-len -1 BasicConstraints PathLen (-1 unlimited, 0 leaf-signing only)
--signing-cert Parent CA certificate (required for intermediate)
--signing-key Parent CA private key (required for intermediate)
--out-cert ca.crt Output certificate (PEM)
--out-key ca.key Output private key (PEM, 0600 permissions)
--out-chain Output full chain: cert + parent certs (PEM)
--out-p12 Output PKCS#12 keystore with cert+key (for mitm_keystore_path)
--p12-password Password for --out-p12
--out-client-bundle Output client trust bundle (PEM, for distribution to clients)
--out-client-p12 Output client PKCS#12 truststore (for Java keystore import)
--client-p12-password changeit Password for --out-client-p12

Production workflow with intermediate CA:

Terminal window
# 1. Generate root CA (store offline / in vault)
mitm-proxy gencert --type root --cn "Corp Root CA" --org "Corp" \
--out-cert root-ca.crt --out-key root-ca.key \
--out-client-p12 client-truststore.p12 --client-p12-password changeit
# 2. Generate intermediate CA for the proxy
mitm-proxy gencert --type intermediate \
--signing-cert root-ca.crt --signing-key root-ca.key \
--cn "Corp MITM Proxy CA" --org "Corp" \
--max-path-len 0 --validity 365 \
--out-cert mitm-ca.crt --out-key mitm-ca.key \
--out-chain mitm-chain.crt
# 3. Configure proxy with the intermediate chain
# mitm_cert_path: mitm-chain.crt (intermediate + root)
# mitm_key_path: mitm-ca.key
# 4. Distribute root CA to clients:
# - PEM: trust.pem (for curl --cacert, system trust stores)
# - PKCS#12: client-truststore.p12 (for Java applications)

Java client trust store usage:

Terminal window
# Use directly as Java truststore
java -Djavax.net.ssl.trustStore=client-truststore.p12 \
-Djavax.net.ssl.trustStoreType=PKCS12 \
-Djavax.net.ssl.trustStorePassword=changeit \
-jar myapp.jar
# Or import into an existing JKS keystore
keytool -importkeystore \
-srckeystore client-truststore.p12 -srcstoretype PKCS12 -srcstorepass changeit \
-destkeystore truststore.jks -deststorepass changeit

Selected proxy: settings can be overridden via environment variables. The rewrites:, acl: and trace: blocks have no environment equivalents — they are structured and must come from the config file.

Variable Description
CONFIG_PATH Path to config file (default: config.yaml)
PROXY_PORT Proxy listen port
PROXY_METRICS_PORT Metrics endpoint port
PROXY_DEFAULT_POLICY ALLOW or BLOCK
PROXY_MITM_ORG Organization on generated MITM leaf certificates (see mitm_org)
PROXY_MAX_CONNECTIONS Overrides proxy.max_connections: ceiling on concurrent client connections (default 0 = unlimited). At the cap the listener pauses accepting until one closes, warns, and increments proxy_listener_saturated_total. A hijacked CONNECT tunnel has no deadline, so this is what bounds fd usage against a client that connects and goes silent.
PROXY_PRESTOP_GRACE How long to keep serving after failing /readyz on SIGTERM, before closing the listener (default 10s; set 0 when a preStop hook already sleeps)
PROXY_MITM_CERT_PATH Path to MITM CA certificate (PEM)
PROXY_MITM_KEY_PATH Path to MITM CA private key (PEM)
PROXY_MITM_KEYSTORE_PATH Path to PKCS#12 keystore (.p12) containing cert and key
PROXY_MITM_KEYSTORE_PASSWORD Password for PKCS#12 keystore
PROXY_OUTGOING_CA_BUNDLE Path to PEM CA bundle for upstream TLS
PROXY_OUTGOING_TRUSTSTORE_PATH Path to PKCS#12 truststore for upstream TLS
PROXY_OUTGOING_TRUSTSTORE_PASSWORD Password for PKCS#12 truststore
PROXY_INSECURE_SKIP_VERIFY Set to true to disable all upstream TLS verification
PROXY_BLOCKED_LOG_PATH Path to JSON log file for blocked requests

Note: Provide either PROXY_MITM_CERT_PATH/PROXY_MITM_KEY_PATH or PROXY_MITM_KEYSTORE_PATH, not both.

Note: outgoing_ca (list of individual CA cert files) is YAML-only and has no environment variable override. It can be used alongside outgoing_ca_bundle — all certificates are merged into one trust pool.

Terminal window
# Install development tools
make install-tools
# Run linter
make lint
# Run tests
make test
# Run end-to-end tests (requires Docker)
make test-e2e
# Build binary
make build
# Format code
make fmt

Images are published to ghcr.io/datarocks-ag/go-egress-mitm-proxy on v* tags and carry OCI metadata, so a running container can be traced back to its source:

Terminal window
docker inspect --format '{{json .Config.Labels}}' ghcr.io/datarocks-ag/go-egress-mitm-proxy:4.1.0 | jq
# org.opencontainers.image.version v4.1.0
# org.opencontainers.image.revision 075e8d2af6cf9f96d3e6da2f36336f25cbbf32da
# org.opencontainers.image.created 2026-08-07T13:00:53Z

make docker-build stamps the same labels from your working tree. An image reading revision=unknown was built by neither path.

Terminal window
# Build
make docker-build
# Run
make docker-run
# Or with docker-compose
docker-compose up

Deploy as a sidecar container. See doc/k8s/ for example manifests.

# In your application deployment
env:
- name: HTTPS_PROXY
value: "http://localhost:8080"
- name: HTTP_PROXY
value: "http://localhost:8080"

Reload configuration without restarting the proxy:

Terminal window
# Find the process ID
pgrep mitm-proxy
# Send SIGHUP to reload config
kill -HUP <pid>

The proxy will log successful reloads and any errors. SIGHUP also reopens the blocked request log file, enabling log rotation.

What SIGHUP applies: ACL lists, rewrite rules, default_policy, trace configuration, outbound CA sources, and both log files. Pooled upstream connections to previous rewrite targets are dropped so new targets take effect immediately rather than after IdleConnTimeout.

What needs a restart: port, metrics_port, and every mitm_* setting (mitm_cert_path, mitm_key_path, mitm_keystore_path, mitm_keystore_password, mitm_org). The MITM CA is loaded once at startup and its leaf-certificate cache outlives a reload, so rotating the CA requires a restart. If any of these change, the reload logs a warning naming them rather than reporting a clean reload that silently ignored them.

All log output is structured JSON via slog. The verbosity flag controls which log levels are emitted:

Level Flag What is logged
Warn (none, default) Errors and warnings only (upstream failures, config errors). Subcommands default to info.
Info -v All info logs including ACCESS line per request: request_id, client, host, action, method, path
Debug -vv Adds REQUEST_DETAIL per request: scheme, full URL, proto, remote_addr, content_length, user_agent, content_type, and rewrite target info (target_ip, target_host, original) when a rewrite matched
Trace -vvv Adds all request headers to REQUEST_DETAIL

Debug and trace logging have zero overhead when not enabled — the log construction is gated behind slog.Default().Enabled().

When blocked_log_path is configured, the proxy writes a JSON log entry for every request with action BLACK-LISTED or BLOCKED. Each entry includes request_id, client, host, method, target, and action. The log file is reopened on SIGHUP for log rotation support.

target is the request path for a forwarded request. A CONNECT carries host:port in its request-target rather than a path, so rejected CONNECTs record the authority there — the field was previously named path and was empty for exactly those entries, which is the case an audit log most needs to show.

Available at http://localhost:9090/metrics:

Metric Type Labels Description
proxy_traffic_total Counter domain, action Request count. For a rewritten request domain is the matching rule’s configured pattern (e.g. *.internal.example.com), not the request host; otherwise it is the ACL base domain, or _other
proxy_request_duration_seconds Histogram action Request latency
proxy_active_connections Gauge - Current connections
proxy_config_load_errors_total Counter - Config load failures
proxy_config_reloads_total Counter - Successful config reloads
proxy_upstream_errors_total Counter type Upstream connection errors
proxy_response_status_total Counter class Response status codes (2xx, 4xx, 5xx)
proxy_bytes_total Counter direction Bytes transferred (request/response)
proxy_trace_records_total Counter mode Emitted trace records (mitm/passthrough)
proxy_listener_saturated_total Counter - Times the client connection ceiling (proxy.max_connections, or its PROXY_MAX_CONNECTIONS override) was reached and accepting paused

Three of these measure something different than a first reading suggests, which matters if you are building alerts on them:

  • proxy_request_duration_seconds is observed for a forwarded request once the upstream round-trip returns, so the span covers DNS, dial, TLS handshake and upstream think-time — not just proxy overhead. Blocked requests are recorded in the handler, where the elapsed time is the whole request.
  • proxy_active_connections counts client connections open at the listener, including hijacked CONNECT tunnels. It is a GaugeFunc reading the live count at scrape time.
  • proxy_trace_records_total only advances when a record is actually written. If the destination logger would filter the record, the proxy warns and does not count it, so the counter cannot disagree with the log.

Actions: REWRITTEN, WHITE-LISTED, BLACK-LISTED, ALLOWED-BY-DEFAULT, BLOCKED, PASSTHROUGH, BLACK-LISTED-CONNECT

BLACK-LISTED-CONNECT is recorded at the CONNECT stage when a blacklisted host is intercepted. Interception is preferred over refusing the tunnel because it lets the handler return a readable 403, but the denial is then only recorded if the client completes the TLS handshake with the proxy CA — a certificate-pinning SDK or a JVM with its own truststore never will. Counting the attempt at CONNECT keeps it observable either way, and the separate label stops it inflating the BLACK-LISTED count that HandleRequest records when the handshake does succeed.

A host that is both passthrough and blacklisted is refused outright, since a passthrough tunnel is never inspected; that case counts as BLACK-LISTED.

  • GET /healthz - Liveness probe (always returns 200 if process is running)
  • GET /readyz - Readiness probe (returns 200 when ready to accept traffic)

The proxy automatically injects an X-Request-ID header into all forwarded requests for distributed tracing. This ID is also logged with each access log entry.

For deep debugging of a specific subset of traffic, configure a trace: block. Matching requests are emitted as a single aggregated JSON record (keyed by trace_id = X-Request-ID) covering every layer at once — independent of the -v/-vv/-vvv log level.

trace:
enabled: true
log_path: "/var/log/mitm-proxy/trace.jsonl" # optional; omit to use the main log stream
redact_query: true # mask ?token=... query values (default: true)
redact_headers: # extends the built-in masked set
- "X-Api-Key"
log_secrets: false # escape hatch: true logs everything verbatim
rules: # OR across rules; within a rule host AND url must both match
- host: "*.internal.com" # WildcardToRegex syntax (~ = raw regex)
bodies:
enabled: true
capture: both # request | response | both
max_request_bytes: 65536
max_response_bytes: 65536
content_types: ["application/json", "text/*"]
on_binary: base64 # base64 | skip
- url: "~/v1/(debug|trace)" # matched against the full scheme://host/path?query

Each record captures:

  • CONNECT/TCP/TLS — host (with port) and SNI always; plus resolved/connected IP, dial timing and negotiated TLS version/cipher only when this request opened a new upstream connection. When a pooled connection is reused those four fields are absent and the record carries tcp.connection_reused: true instead — there is no per-request TCP layer to report. With keep-alive that is the common case, so their absence is normal rather than a sign tracing is broken.
  • Request — method, URL, inbound headers, outbound (post-mutation) headers, and the dropped/added/modified/scheme_changed diff
  • Response — status, proto, headers
  • Bodies (per rule) — size-capped, content-type-gated (text inline, binary base64/skip), truncation marked; streaming is preserved

Matching: rules are OR’d; within a single rule, host and url must both match (use separate rules for OR). host/url use the same convention as ACL/rewrites (wildcard, or ~ for raw regex).

Bodies are not redacted. redact_headers and redact_query apply to headers and the query string only; renderBody receives no redactor. Enabling bodies writes request and response payloads verbatim (subject to the size caps and content-type gate), so treat a trace log with bodies enabled as containing secrets.

Redaction is secure-by-default. Authorization, Proxy-Authorization, Cookie, and Set-Cookie are always masked, as are the URL-bearing Location, Content-Location and Referer — a 302 in an OAuth flow routinely carries the authorization code in Location, and header values are masked by name, so redact_query (which only sees the request URL) never covered it; redact_headers extends the set, and an entry prefixed with - removes one of the built-ins — redact_headers: ["-location"] makes redirect targets visible while Authorization and Cookie stay masked. Removing a credential-bearing default (Authorization, Proxy-Authorization, Cookie, Set-Cookie) is allowed but logs a warning at startup, and removing a header that is not masked is a load error rather than a silent no-op. redact_query masks query-string values and defaults to true — set it to false to opt out. Set log_secrets: true to disable all redaction (use with care — these logs may be shipped/retained).

Passthrough hosts (non-MITM tunnels) are traced TCP-only: connected IP, dial timing, and bytes transferred. Headers and bodies are inherently invisible because the traffic is not intercepted.

The trace log file is reopened on SIGHUP for log rotation, like blocked_log_path. See doc/examples/configuration.yaml for a complete example.

Clients must trust the MITM root CA certificate. You can generate a client trust bundle with gencert (see Certificate Generation) or distribute the CA cert manually:

Terminal window
# Linux (system-wide)
sudo cp certs/ca.crt /usr/local/share/ca-certificates/proxy-ca.crt
sudo update-ca-certificates
# macOS
sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain certs/ca.crt
# curl (per-request)
curl --cacert certs/ca.crt -x http://localhost:8080 https://example.com
# Java (using PKCS#12 truststore from gencert --out-client-p12)
java -Djavax.net.ssl.trustStore=truststore.p12 \
-Djavax.net.ssl.trustStoreType=PKCS12 \
-Djavax.net.ssl.trustStorePassword=changeit \
-jar myapp.jar

See doc/architecture.md for detailed design documentation.

MIT

Docs for v4.1.1 · built from the release tag