Architecture
Overview
Section titled “Overview”go-egress-proxy is a transparent MITM (Man-in-the-Middle) proxy that implements split-brain DNS at the TCP layer. Instead of modifying DNS infrastructure, it intercepts outbound connections and routes them to configured IP addresses or hostnames based on domain and path rules.
Request Flow
Section titled “Request Flow”sequenceDiagram
participant Client
participant Proxy
participant Target as Target Server
Client->>Proxy: CONNECT example.com:443
alt PASSTHROUGH (matches acl.passthrough)
Proxy-->>Client: 200 OK (tunnel, no MITM)
Note right of Proxy: TCP tunnel established<br/>No TLS interception<br/>No request inspection
Client->>Target: Direct TLS to upstream
Target-->>Client: Direct TLS response
else Normal (MITM)
Proxy-->>Client: 200 OK
Client->>Proxy: TLS Handshake
Note right of Proxy: Proxy presents cert signed<br/>by internal CA
Client->>Proxy: GET /api
Note right of Proxy: 1. Generate X-Request-ID<br/>2. Check ACL blacklist<br/> (short-circuits rewrites)<br/>3. Check rewrite rules<br/> (exact → wildcard → path)<br/>4. Check ACL whitelist<br/>5. Apply default policy
alt BLOCKED / BLACK-LISTED
Proxy-->>Client: 403 Forbidden
Note right of Proxy: Log to blocked request<br/>log (if configured)
else REWRITTEN
Note right of Proxy: Drop headers (drop_headers)<br/>Inject headers (headers)<br/>Rewrite scheme (target_scheme)
Proxy->>Target: TCP dial to target_ip/target_host<br/>(with optional TLS bypass)
Target-->>Proxy: Response
Proxy-->>Client: Response
else WHITE-LISTED / ALLOWED-BY-DEFAULT
Proxy->>Target: Forward request
Target-->>Proxy: Response
Proxy-->>Client: Response
end
end
Response Status Codes
Section titled “Response Status Codes”| 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 |
The proxy distinguishes timeout errors (net.Error.Timeout(), context.DeadlineExceeded) from all other upstream failures. This applies to both plain HTTP requests (via the OnResponse handler) and CONNECT-level failures (via ConnectionErrHandler).
Components
Section titled “Components”RuntimeConfig
Section titled “RuntimeConfig”Thread-safe configuration holder that enables hot-reload without restart:
type RuntimeConfig struct { mu sync.RWMutex config Config acl CompiledACL rewrites []CompiledRewriteRule rewriteExact map[string]*CompiledRewriteRule // O(1) lookup, normalized keys tlsConfig *tls.Config // outbound TLS (rebuilt on reload) blockedLogger *slog.Logger // nil when disabled blockedFile *os.File // underlying file for Close() trace CompiledTrace // compiled trace rules traceLogger *slog.Logger // nil when trace log undirected traceFile *os.File // underlying trace file for Close()}- Uses
sync.RWMutexfor concurrent read access Update()atomically replaces all config; excludes domains withpath_patternrules from the exact map so they are evaluated sequentially (preserving YAML order)Get()returns current config with read lockGetBlockedLogger()returns the blocked request logger (nil when disabled)CloseBlockedLog()closes the log file handle on shutdown
Certificate Generator (gencert)
Section titled “Certificate Generator (gencert)”Built-in CLI subcommand for generating root and intermediate CA certificates:
- Supports RSA (2048/4096), ECDSA (P-256/P-384), and Ed25519 key algorithms
- Root CA: self-signed with configurable subject and validity
- Intermediate CA: signed by a parent CA, with
MaxPathLencontrol - Output formats: PEM cert+key, PEM chain, PKCS#12 keystore (cert+key)
- Client trust bundles: PEM trust bundle, PKCS#12 truststore (for Java keystore import)
generateKeyPair()creates keys by algorithm nameRunGencert()orchestrates flag parsing, key generation, cert creation, and file output
Configuration Loader
Section titled “Configuration Loader”Loads and validates YAML configuration at startup and on SIGHUP:
- Read YAML file from
CONFIG_PATH(default:config.yaml) - Apply environment variable overrides
- Validate required fields and values
- Compile patterns (ACL and rewrites) via
WildcardToRegex(); patterns are lowercased and anchored, and every host is normalized withconfig.NormalizeHost()(lowercase, trailing dot stripped) before matching, soEVIL.example.comandevil.example.com.cannot slip past a blacklist
Environment variable overrides follow 12-factor app principles:
PROXY_PORT,PROXY_METRICS_PORT,PROXY_DEFAULT_POLICYPROXY_MITM_CERT_PATH,PROXY_MITM_KEY_PATH(PEM cert+key)PROXY_MITM_KEYSTORE_PATH,PROXY_MITM_KEYSTORE_PASSWORD(PKCS#12 alternative)PROXY_OUTGOING_CA_BUNDLE,PROXY_OUTGOING_TRUSTSTORE_PATH,PROXY_OUTGOING_TRUSTSTORE_PASSWORDPROXY_INSECURE_SKIP_VERIFY,PROXY_BLOCKED_LOG_PATHPROXY_MITM_ORG(Organization on MITM leaf certificates)PROXY_MAX_CONNECTIONS(ceiling on concurrent client connections; 0 = unlimited)PROXY_PRESTOP_GRACE(read bymain, not the config loader: how long to keep serving after/readyzstarts failing on SIGTERM)
ACL Engine
Section titled “ACL Engine”Pre-compiles patterns at startup for efficient runtime matching. ACL patterns support the same syntax as rewrite rules: exact match, wildcards (*.example.com), and raw regex (~<pattern>). Evaluation order:
At the CONNECT stage (before TLS), proxy.DecideConnect chooses how the
tunnel is handled:
0a. Passthrough + blacklist - Rejected outright. A passthrough tunnel is never inspected, so a passthrough pattern overlapping a blacklist entry would otherwise hand out an uninspected tunnel to a denied host. This is the only case refused at CONNECT time. 0b. Passthrough - Tunneled without MITM interception. No request inspection occurs, so later ACL stages never see it. 0c. Everything else - Intercepted, including blacklisted hosts. Nothing is forwarded upstream until the request stages below have run, so a blacklisted host is still blocked — but the client receives a readable 403 rather than a rejected CONNECT, which most clients surface as an opaque transport error.
Per request (plain HTTP, and every intercepted HTTPS request):
- Blacklist - Blocks the request with 403 and short-circuits the rewrite table
- Rewrite rules - Exact match first (O(1) map lookup), then wildcard/regex patterns with optional
path_patternfiltering - Whitelist - Allows request if matched
- Default policy -
ALLOWorBLOCKfor unmatched domains
A rewrite otherwise bypasses the ACL: a rewritten host is implicitly allowed and
needs no whitelist entry. The blacklist is the exception, and deliberately so — a
host in both tables was previously forwarded to its target_ip carrying the
rule’s injected headers.
Hostnames are normalized with config.NormalizeHost (lowercased, trailing dot
stripped) before every one of these comparisons.
Domain Pattern Matching
Section titled “Domain Pattern Matching”Rewrite rules support wildcards and raw regex for domain matching:
// WildcardToRegex converts patterns:// "example.com" -> "(?i)^example\.com$" (exact)// "*.example.com" -> "(?i)^.+\.example\.com$" (any subdomain depth, NOT the apex)// "*" -> ".*" (match all)// "~<regex>" -> "(?i)^(?:<regex>)$" (raw regex: not escaped, but anchored)Two properties are easy to get wrong:
Raw ~ patterns are anchored, not substring matches. Matching uses
MatchString, which succeeds on any substring, so an unanchored
~api\.corp\.com would also match api.corp.com.attacker.net — a whitelist
entry that fails open. The non-capturing group keeps alternations intact and is
a no-op for patterns that already carry their own anchors.
A *. wildcard does not cover the apex. *.example.com matches
a.example.com and a.b.example.com but not example.com. Denylists need
both forms; a lone wildcard entry leaves the bare domain
ALLOWED-BY-DEFAULT.
All patterns compile case-insensitively. Hosts are lowercased by
NormalizeHost before matching, so (?i) is redundant on that path — it is
there so a future call site that forgets to normalize fails closed.
Request Handler
Section titled “Request Handler”The HandleRequest() function processes every HTTP request:
- Generate unique
X-Request-IDfor tracing - Evaluate against rules (blacklist → rewrite → whitelist → default)
- Rewrite matching: exact map lookup, then sequential pattern + path matching
- Path-based rules use
path_patternregex againstr.URL.Path(first match wins)
- Store matched rewrite in request context (for dialer access)
- Log access with structured JSON (includes request ID)
- Record Prometheus metrics with bounded cardinality
- Log blocked requests to JSON file (if
blocked_log_pathis configured) - Return 403 for blocked requests (
BLACK-LISTEDorBLOCKED) - For rewritten requests:
- Drop headers listed in
drop_headers - Inject headers from
headersmap - Rewrite
r.URL.Schemeiftarget_schemeis set
- Drop headers listed in
Dialers (Split-Brain DNS)
Section titled “Dialers (Split-Brain DNS)”Split-brain DNS is implemented by two custom dial functions, and each request is routed to a transport dedicated to its upstream identity.
http.Transport keys its idle-connection pool on the request URL’s scheme and
host:port, both fixed before the dial functions run. Because the dialers
substitute the target address inside the dial, rules for one domain differing
only by target_ip, target_host or insecure would share a pool key and reuse
each other’s connections — misrouting traffic, and handing a connection
negotiated with InsecureSkipVerify to a request that required verification.
proxy.TransportPool therefore hands out one *http.Transport per
(target_ip | target_host | insecure) identity, selected per request via
goproxy’s ctx.RoundTripper. Within one transport, every connection under a
given key really is interchangeable, so Go’s pooling and HTTP/2 stay intact.
TransportPool.Reset() is called on SIGHUP: the map swap makes stale transports
unreachable, and closing their idle connections releases sockets immediately
rather than at IdleConnTimeout.
The dial functions themselves:
MakeDialer() (plain TCP)
For HTTP upstream connections. Checks request context first (for path-based rewrites set by HandleRequest), then falls back to LookupRewrite() for domain-only rules.
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { host, port, _ := net.SplitHostPort(addr)
// Check context first (path-based rewrites) rw, ok := ctx.Value(config.RewriteCtxKey).(proxy.RewriteResult) if !ok { rw = proxy.LookupRewrite(host, rewrites, rewriteExact) }
if rw.TargetIP != "" { addr = net.JoinHostPort(rw.TargetIP, port) } else if rw.TargetHost != "" { addr = net.JoinHostPort(rw.TargetHost, port) }
return dialer.DialContext(ctx, network, addr)}MakeTLSDialer() (TLS)
For HTTPS upstream connections. Performs TCP dial with IP/host substitution, then a separate TLS handshake with per-connection configuration:
- SNI set to the original hostname (preserving TLS verification)
InsecureSkipVerifyenabled if globalinsecure_skip_verifyis set or per-rewriteinsecureflag is true- Custom
RootCAspool combining system CAs, optional PEM CA bundle, and optional PKCS#12 truststore
Both dialers support target_ip (direct IP routing) and target_host (DNS-resolved hostname routing).
Blocked Request Log
Section titled “Blocked Request Log”Optional JSON log file for auditing blocked requests:
- Configured via
blocked_log_path(YAML) orPROXY_BLOCKED_LOG_PATH(env) - Only logs requests with action
BLACK-LISTEDorBLOCKED - Each entry:
request_id,client,host,method,target,action targetholds the request path for a forwarded request andhost:portfor a rejected CONNECT, whose request-target carries no path- File created with 0600 permissions
- Reopened on SIGHUP for log rotation support
- Old file handle closed after successful reload
Request Tracing (internal/trace)
Section titled “Request Tracing (internal/trace)”Opt-in, full-detail tracing of a subset of requests (configured via the trace: block), emitted as one aggregated JSON record per request/tunnel:
-
A
*trace.Recordis created inHandleRequestwhen a request matches a trace rule, before any header mutation (so the inbound snapshot is what the client sent) -
The record is threaded to the dialers via the request context (
trace.CtxKey) — which populate the connected IP, dial timing, and TLS version/cipher — and to the response handler via goproxyctx.UserData -
The record is emitted exactly once (
sync.Once). When depends on body capture:- Capture off (the default): emitted at header time. The record is byte-identical either way — no field is populated after the response headers — but a failure occurring mid-body is therefore not reflected in it. Enable body capture for the rule if that matters.
- Capture on: the response body is wrapped, and the record is emitted when that body finishes streaming.
The body is deliberately left untouched with capture off. goproxy treats a changed
resp.Bodyas a modified response and re-frames it as chunked, so wrapping unconditionally would make an observability switch alter the bytes the client receives.http.NoBody(204/304) and101 Switching Protocolsare never wrapped for the same reason — a wrapped 101 also fails goproxy’sio.ReadWriterassertion and drops the WebSocket tunnel. -
Blocked (403) and upstream-error (502/504) paths emit via their synthetic responses
-
Passthrough (non-MITM) hosts are traced TCP-only via goproxy’s per-request
ctx.Dialer(connected IP, dial timing, bytes up/down) -
Redaction is secure-by-default. Always masked:
Authorization,Proxy-Authorization,Cookie,Set-Cookie, and the URL-bearingLocation,Content-Location,Referer(an OAuth 302 carries the authorization code inLocation, andredact_queryonly ever saw the request URL).redactURLalso stripsuser:password@.redact_headersextends the set, and a-prefix removes one built-in without touching the rest;log_secretsdisables all of it -
Bodies are not redacted.
renderBodyreceives no redactor, so enablingbodieswrites payloads verbatim within the size caps and content-type gate -
Independent of the
-v/-vv/-vvvlevel; trace log file reopened on SIGHUP for rotation
Metrics System
Section titled “Metrics System”Prometheus metrics with bounded cardinality:
| Metric | Type | Labels | Description |
|---|---|---|---|
proxy_traffic_total |
Counter | domain, action | Requests by domain/action. A rewritten request is labelled with the matching rule’s configured pattern rather than the host, so wildcard and regex rewrites are attributable per rule instead of collapsing into _other |
proxy_request_duration_seconds |
Histogram | action | Request latency. Forwarded requests are observed once the upstream round-trip returns, so the span includes DNS, dial, TLS and upstream think-time; blocked requests are recorded in the handler |
proxy_active_connections |
Gauge | - | Client connections open at the listener, including hijacked CONNECT tunnels. A GaugeFunc read at scrape time |
proxy_config_load_errors_total |
Counter | - | Config load failures |
proxy_config_reloads_total |
Counter | - | Successful reloads |
proxy_upstream_errors_total |
Counter | type | Upstream errors |
proxy_response_status_total |
Counter | class | Response codes |
proxy_bytes_total |
Counter | direction | Bytes transferred |
proxy_trace_records_total |
Counter | mode | Emitted trace records (mitm/passthrough). Only advances when a record is actually written |
proxy_listener_saturated_total |
Counter | - | Times the client connection ceiling (proxy.max_connections, or its PROXY_MAX_CONNECTIONS override) was reached and accepting paused |
Domain normalization prevents cardinality explosion:
- Known rewrite domains: tracked individually
- ACL-matched domains (whitelist, blacklist, passthrough): tracked by base domain (TLD+1)
- Unknown domains: grouped as
_other
Health Server
Section titled “Health Server”Separate HTTP server on metrics port exposing:
/metrics- Prometheus metrics/healthz- Liveness probe (process is running)/readyz- Readiness probe (ready for traffic)
Signal Handling
Section titled “Signal Handling”| Signal | Action |
|---|---|
SIGINT / SIGTERM |
Graceful shutdown (drain() in cmd/mitm-proxy/drain.go): fail /readyz, keep serving for PROXY_PRESTOP_GRACE (default 10s), then drain in-flight requests and CONNECT tunnels within a 30s budget. Default signal handling is restored as soon as the first signal arrives, so a second one terminates immediately instead of being swallowed for the whole window |
SIGHUP |
Hot reload configuration, reopen blocked and trace log files, rebuild outbound TLS, and TransportPool.Reset(). Warns about changed mitm_* and port settings, which need a restart. |
Data Flow
Section titled “Data Flow”flowchart LR
subgraph Inputs
config["config.yaml<br/>+ ENV overrides"]
sighup["SIGHUP"]
client["Client Request"]
end
subgraph main.go
load["LoadConfig()<br/>Read YAML → ApplyEnvOverrides() → Validate()"]
compile["CompileACL() → CompiledACL<br/>CompileRewrites() → []CompiledRewriteRule"]
bllog["OpenBlockedLog()"]
rtcfg["RuntimeConfig.Update()"]
subgraph proxy ["goproxy.ProxyHttpServer"]
onreq["OnRequest().DoFunc()<br/>- Generate X-Request-ID<br/>- RuntimeConfig.Get() (read lock)<br/>- Rule evaluation (domain + path)<br/>- Drop headers / inject headers<br/>- Rewrite scheme<br/>- Log blocked requests<br/>- Metrics recording"]
onresp["OnResponse().DoFunc()<br/>- Record response metrics<br/>- Handle upstream errors (502/504)"]
dial["Transport.DialContext()<br/>- IP/host rewriting (split-brain DNS)"]
tlsdial["Transport.DialTLSContext()<br/>- TLS with per-rewrite InsecureSkipVerify"]
end
metrics["metricsServer<br/>/metrics, /healthz, /readyz"]
end
target["Target Server"]
prom["Prometheus"]
blockedlog["blocked.log"]
config --> load --> compile --> rtcfg
load --> bllog --> rtcfg
sighup --> rtcfg
rtcfg --> proxy
client --> onreq --> onresp --> dial --> target
onresp --> tlsdial --> target
onreq -.-> blockedlog
metrics --> prom
Security Considerations
Section titled “Security Considerations”- Internal CA certificate must be trusted by all clients
- Outbound connections enforce TLS 1.2 minimum with ALPN (
h2,http/1.1) - System CA pool used for upstream verification, plus optional PEM CA bundle and/or PKCS#12 truststore (additive)
- Global
insecure_skip_verifydisables all upstream TLS verification (dev/test only) - Per-rewrite
insecureflag skips verification for specific internal targets (e.g., self-signed services)
Attack Surface
Section titled “Attack Surface”- Proxy terminates TLS, has access to plaintext traffic
- Deploy in trusted network segment (sidecar pattern recommended)
- Metrics cardinality bounded to prevent OOM attacks
- Request IDs are cryptographically random (8 bytes)
- Blocked request log file created with 0600 permissions
- Trace log file created with 0600 permissions. Trace redaction is
secure-by-default: the built-in sensitive headers and URL query values are
masked unless explicitly disabled (
redact_query: false) or globally disabled (log_secrets: true). Request and response bodies are not redacted — body capture is opt-in per rule and logs payloads verbatim, which is documented at thebodiessetting.
Configuration Security
Section titled “Configuration Security”- Certificate paths validated at startup and on
validatesubcommand gencertwrites private keys with 0600 permissions; certificates and trust bundles with 0644- Invalid regex patterns cause startup failure (fail-fast)
target_ip/target_hostandtarget_schemevalidated at load time- Environment overrides allow secrets management integration
Deployment Patterns
Section titled “Deployment Patterns”Sidecar (Recommended)
Section titled “Sidecar (Recommended)”Deploy alongside each application pod:
flowchart LR
subgraph Pod
app["App Container"] --> proxy["Proxy Container"]
end
proxy --> external["External"]
Application sets HTTPS_PROXY=http://localhost:8080.
Gateway
Section titled “Gateway”Central proxy for multiple services:
flowchart LR
svc1["Service"] --> gw["Proxy Gateway"]
svc2["Service"] --> gw
gw --> external["External"]
Requires network policies to enforce traffic flow.
Limitations
Section titled “Limitations”- No client-facing HTTP/2: Clients connect via HTTP/1.1 through the MITM layer; outbound connections support HTTP/2
- No WebSocket inspection: Passes through after CONNECT
- Single CA: All MITM certificates signed by one CA
- No request body inspection: Header-level filtering only
Docs for v4.1.1 · built from the release tag