Skip to content

MITM Tproxy Decrypt

github-actions[bot] edited this page Jun 20, 2026 · 1 revision

🌍 View in other languages

MITM TPROXY Transparent Decrypt

TPROXY transparent decrypt is OmniRoute's 5th capture mode for the Traffic Inspector / AgentBridge MITM stack. It intercepts and decrypts local outbound HTTPS traffic on Linux using kernel TPROXY + policy routing — without spoofing /etc/hosts and without mutating OS-wide system-proxy settings. It is headless-friendly (no DNS edits to clean up) and the firewall rules auto-flush on reboot.

Unlike the other capture modes, TPROXY needs no per-host setup: it transparently intercepts arbitrary destination hosts on a target port, terminates TLS with a leaf certificate it issues on the fly per SNI hostname, captures the decrypted exchange, and re-encrypts the request to the original destination.

Linux-only, root-only, opt-in. This mode requires Linux, a native addon built with a C toolchain, and the CAP_NET_ADMIN capability (typically root). It is gated behind the loopback-only AgentBridge API and disabled by default. A trusted MITM CA that can sign any host is a powerful capability — see §6 Security.

Source: src/mitm/tproxy/ API route: GET / POST / DELETE /api/tools/agent-bridge/tproxy Dashboard toggle: Traffic Inspector → capture-modes toolbar → "TPROXY Decrypt"See also: docs/frameworks/TRAFFIC_INSPECTOR.md, docs/frameworks/AGENTBRIDGE.md


§1 What it is and when to use it

The other four capture modes each have a limitation:

Mode How traffic is steered Limitation
AgentBridge /etc/hosts DNS spoof of a fixed host set only the registered IDE-agent hosts
Custom Hosts /etc/hosts DNS spoof per host one entry per host; sudo to edit hosts
HTTP_PROXY HTTP_PROXY/HTTPS_PROXY env only apps that honor the env var
System-wide proxy OS proxy settings mutates global state; needs revert

TPROXY transparent decrypt steers traffic at the kernel layer instead. It marks new local outbound TCP connections to a target port (default 443) in the mangle OUTPUT chain, an ip rule reroutes the marked packets to local delivery, and on re-entry the mangle PREROUTING TPROXY target hands them to an IP_TRANSPARENT listener — which then terminates TLS and captures the plaintext.

Use it when you want to capture and decrypt traffic from a process that:

  • talks to a host AgentBridge does not register, and
  • does not honor HTTP_PROXY, and
  • you do not want to disturb with a system-wide proxy change.

Because interception happens in the kernel, the originating process needs no configuration change — but the process must trust the dynamic CA OmniRoute installs (see §4).


§2 Requirements

Requirement Detail
OS Linux only — IP_TRANSPARENT is a Linux-only socket option. The loader returns "unavailable" on every other platform.
Privilege The CAP_NET_ADMIN capability to create the transparent socket and apply iptables/ip rules — in practice, run as root.
Native addon A tiny N-API addon (src/mitm/tproxy/native/transparent.c) must be built or shipped as a prebuild. See §3.
Kernel modules iptables with the TPROXY, mangle, and mark match support (validated against kernel 6.8.0).

Graceful degradation: if any requirement is missing (non-Linux, no toolchain, addon not built), the addon loader (src/mitm/tproxy/transparentSocket.ts::loadTransparentAddon) returns null rather than throwing. The capture-mode status then reports available: false, the dashboard toggle is disabled with the tooltip "TPROXY decrypt requires Linux + root + the native addon", and the rest of OmniRoute keeps working.


§3 The native IP_TRANSPARENT addon

Node's net module cannot setsockopt(IP_TRANSPARENT) before bind(), which TPROXY requires (otherwise the kernel drops the redirected packets). The addon (src/mitm/tproxy/native/transparent.c, built via binding.gyp) is a small N-API module exposing three functions, consumed through transparentSocket.ts:

Addon function Socket work Used for
createTransparentListener(ip, port) socket() + SO_REUSEADDR + IP_TRANSPARENT + bind() + listen(), returns the raw fd the transparent capture listener (Node adopts the fd via server.listen({ fd }))
setSocketMark(fd, mark) setsockopt SO_MARK on an existing fd anti-loop (mark the proxy's own sockets)
connectMarked(ip, port, mark) socket() + SO_MARK before a non-blocking connect(), returns fd the re-encrypted upstream forward (the SYN carries the mark)

The original destination is read from socket.localAddress/localPort — TPROXY preserves it, so there is no SO_ORIGINAL_DST/NAT lookup.

Building the addon

npm run build:native:tproxy      # cd src/mitm/tproxy/native && node-gyp rebuild
                                 # -> native/build/Release/transparent.node
  • During npm run build, scripts/build/build-tproxy-native.mjs runs node-gyp rebuild. It is Linux-only and non-fatal — a missing toolchain just leaves the capture mode unavailable.
  • assembleStandalone.mjs copies build/Release/transparent.node into the standalone bundle; transparentSocket.ts resolves it both module-relative and cwd-relative (<cwd>/src/mitm/tproxy/native/...).
  • build/ and prebuilds/ are git-ignored — the binary is built, never committed.

The loader probes, in priority order: native/build/Release/transparent.node, then native/prebuilds/transparent.node (both module-relative and under <cwd>/src/mitm/tproxy/).


§4 The per-SNI dynamic CA and trust-store installer

The static AgentBridge MITM cert works only because AgentBridge DNS-spoofs a fixed host set. TPROXY intercepts arbitrary hosts, so the listener must present a valid leaf for whatever SNI the client requests.

Dynamic CA (src/mitm/tproxy/dynamicCert.ts)

DynamicCertStore runs a local CA (built on the selfsigned dependency) that:

  • Generates a long-lived CA via generateMitmCa() (CN "OmniRoute MITM CA", 10-year validity, basicConstraints CA=true + keyUsage keyCertSign,cRLSign, 2048-bit RSA / SHA-256).
  • Issues a leaf per SNI hostname on demand via issueLeafCert() (1-year validity, subjectAltName = the SNI host) and caches one tls.SecureContext per hostname.
  • Exposes createSNICallback() for the TLS-terminating server (see §5).
  • Can be constructed with an existingCa to keep the CA stable across restarts (so the trust store does not need re-installing).

The CA private key never leaves the machine.

Trust-store installer (src/mitm/tproxy/caTrust.ts)

The intercepted client must trust the dynamic CA, so starting the capture mode installs the CA cert into the OS trust store under a dedicated slotomniroute-tproxy-ca.crt (constant TPROXY_CA_CERT_NAME) — kept separate from the static MITM cert's slot (omniroute-mitm.crt) so the two never clobber each other.

installTproxyCa(caPem, sudoPassword?) detects the distro's anchor directory (in order: Debian-style first) and runs the matching refresh command:

Anchor directory Refresh command
/usr/local/share/ca-certificates update-ca-certificates
/etc/ca-certificates/trust-source/anchors update-ca-trust
/etc/pki/ca-trust/source/anchors update-ca-trust
/etc/pki/trust/anchors update-ca-certificates

Install stages the PEM to a temp file, then (privileged) mkdir -p the anchor dir, cp the staged file into it, and runs the refresh command. uninstallTproxyCa() removes the dedicated slot only (leaving the static MITM cert untouched) and refreshes — a no-op on non-Linux.

All privileged commands run via execFileWithPassword (src/mitm/systemCommands.ts) — spawn with arg arrays, no shell, no string interpolation (Hard Rule #13). When the process is root (e.g. the VPS) the target runs directly and no password is needed; on a non-root desktop the sudoPassword is passed via sudo -S on stdin.

The desktop's sudoPassword is supplied in the POST body to authorize the trust-store install; it is ignored entirely when the process is root.


§5 How decrypt and capture work

The pipeline (all under src/mitm/tproxy/):

local app  ──TCP/443──▶  mangle OUTPUT marks the conn (fwmark)
                          ip rule → local route table → lo
                          mangle PREROUTING TPROXY → IP_TRANSPARENT listener (port 8443)
                              │  captureMode.ts: reads orig dest from socket.localAddress
                              ▼
                          tlsCapture.ts:
                            1. TLS-terminate the CLIENT with a per-SNI leaf (dynamicCert)
                            2. internal http.Server parses the decrypted plaintext
                            3. capture → globalTrafficBuffer.push() with source: "tproxy"
                               (sanitizeHeaders + maskSecret applied)
                            4. forward RE-encrypted to the original destination
                               over a bypass-marked socket (connectMarked, anti-loop)
                              │
                              ▼
                          original upstream (api.example.com)
  • TLS termination (createTlsCaptureServer): wraps the raw intercepted socket in a server-side tls.TLSSocket using the dynamic CA's SNI callback, then hands the decrypted stream to an internal http.Server (the standard MITM termination trick). Socket lifetimes are bounded by MITM_IDLE_TIMEOUT_MS so a hung tunnel cannot exhaust file descriptors.
  • Capture (handleDecryptedRequest): pushes an InterceptedRequest with source: "tproxy", status starting "in-flight", headers run through sanitizeHeaders() and bodies through maskSecret() before they enter the buffer. The entry is then updated with the response, sizes, and latency.
  • Re-encrypted forward (createForward / realForward): re-encrypts to the original destination. rejectUnauthorized defaults to true (secure by default) — the upstream cert is verified against the SNI/Host the client requested, so the proxy rejects exactly what the original client would.

Anti-loop (SO_MARK)

Because the rules mark new local outbound connections, the proxy's own re-encrypted forward would normally be re-intercepted — an infinite loop. The forward path defends against this with a bypass socket mark (SO_MARK):

  • realForward opens its upstream socket via connectMarked(ip, port, DEFAULT_BYPASS_MARK)DEFAULT_BYPASS_MARK = 0x539 — which sets the SO_MARK before connect(), so the forward's SYN carries the bypass mark.
  • The mangle OUTPUT rule excludes connections already carrying the bypass mark (-m mark ! --mark <bypassMark>), so the proxy's forward is not re-marked and does not re-enter TPROXY.

Implementation note: the bypass-marked socket must be installed on the agent's createConnection (https.request({ createConnection }) is silently ignored when an agent is present), or the forward would open an unmarked socket and the loop would return. This was the e2e-validated anti-loop fix.


§6 Security

Control Detail
Loopback-only API /api/tools/agent-bridge/tproxy is covered by the /api/tools/agent-bridge/ prefix in LOCAL_ONLY_API_PREFIXES (src/server/authz/routeGuard.ts). Loopback enforcement runs before auth (Hard Rules #15 + #17) — a leaked JWT over a tunnel cannot start TPROXY capture, which applies iptables rules and installs a trust-store CA via child processes.
Dedicated CA slot The dynamic CA installs to omniroute-tproxy-ca.crt, never clobbering the static MITM cert.
CA key never leaves the host DynamicCertStore holds the CA key in memory; it is not exported.
Secret masking maskSecret() on request/response bodies and sanitizeHeaders() on headers run before globalTrafficBuffer.push().
No shell interpolation All iptables/ip/trust-store commands run via execFile/execFileWithPassword with arg arrays (Hard Rule #13).
Upstream cert verification The re-encrypted forward verifies the upstream cert by default (rejectUnauthorized: true).
Error sanitization The route's error responses go through sanitizeErrorMessage() (Hard Rule #12).

The MITM CA is a powerful capability. A CA trusted by the OS that can sign any host means anything OmniRoute intercepts can be decrypted. It is gated behind the explicit, local-only TPROXY capture mode, off by default, and the trust-store entry is removed when you stop the mode.


§7 Transactional firewall apply / revert

A crash must never leave a mangle rule or stale route behind. The command builder (src/mitm/tproxy/commands.ts) and runner (src/mitm/tproxy/setup.ts) guarantee revert is the exact inverse of apply, in reverse order.

applyTproxy(cfg) runs the apply commands in order; on any failure it runs a best-effort full revertTproxy(cfg) and rethrows — so the firewall is either fully applied or fully reverted, never half-applied. revertTproxy(cfg) runs the inverse commands in reverse order and swallows failures (idempotent — safe to call unconditionally, e.g. from the AgentBridge repairMitm() cleanup).

validateTproxyConfig(cfg) runs before any command: ports must be 1–65535, mark/routeTable/bypassMark must be positive integers, and bypassMark must differ from mark (anti-loop).

Apply commands (in order)

ip rule add fwmark <mark> lookup <routeTable>
ip route add local 0.0.0.0/0 dev lo table <routeTable>
iptables -t mangle -A OUTPUT -p tcp --dport <dport> -m mark ! --mark <bypassMark> -j MARK --set-mark <mark>
iptables -t mangle -A PREROUTING -p tcp --dport <dport> -m mark --mark <mark> -j TPROXY --on-port <onPort> --tproxy-mark <mark>

Revert deletes them in reverse: PREROUTING -D, OUTPUT -D, ip route del, ip rule del.

The recipe is OUTPUT-based because the MITM use case is local outbound traffic (apps on the same host), which TPROXY in PREROUTING alone does not see — PREROUTING only sees forwarded traffic. The OUTPUT chain marks new local connections, the ip rule reroutes them to local delivery (lo), and PREROUTING then assigns them to the transparent listener.


§8 Configuration

The start request (POST /api/tools/agent-bridge/tproxy) accepts the following fields, validated by StartTproxyBodySchema (tproxy/route.ts). All are optional and fall back to their defaults:

Field Type Default Notes
dport int (1–65535) 443 Destination TCP port to transparently intercept
mark int (≥1) 0x2333 Firewall mark set on OUTPUT, matched by the ip rule + PREROUTING
onPort int (1–65535) 8443 Port the transparent (IP_TRANSPARENT) listener binds
routeTable int (≥1) 233 Policy-routing table id holding the local 0.0.0.0/0 route
bypassMark int (≥1, ≠ mark) 0x539 The bypass socket mark (SO_MARK) the proxy sets on its own upstream conns; excluded in OUTPUT (anti-loop)
sudoPassword string Non-root desktops only: authorizes the trust-store install; ignored when root

There are no environment variables for TPROXY — all configuration is via the POST body or the defaults above.


§9 Enabling from the Traffic Inspector

  1. Open the Traffic Inspector (/dashboard/tools/traffic-inspector).
  2. In the capture-modes toolbar, find the "TPROXY Decrypt" ⚠ button (src/app/(dashboard)/dashboard/tools/traffic-inspector/components/CaptureModesToolbar.tsx).
    • If it is disabled with the tooltip "TPROXY decrypt requires Linux + root + the native addon", the native addon is unavailable on this host (non-Linux, no toolchain, or addon not built). See §2 and §3.
  3. Click the button. It calls POST /api/tools/agent-bridge/tproxy via startTproxyCaptureMode() (src/lib/inspector/tproxyCaptureApi.ts), which: builds the dynamic CA, opens the transparent listener, applies the firewall rules, and installs the CA in the OS trust store.
  4. When running, the toggle turns amber and shows the live intercept count (· <interceptCount>). Intercepted requests appear in the request list with source: "tproxy".
  5. Click again to stop — DELETE /api/tools/agent-bridge/tproxy via stopTproxyCaptureMode() closes the listener, uninstalls the CA, and reverts the firewall rules.

The capture-mode status (running / available / intercept count / listener port) comes from GET /api/tools/agent-bridge/tproxy (getCaptureStatus() in src/mitm/tproxy/captureManager.ts). Only one TPROXY session runs at a time — starting a second rejects with "TPROXY capture mode is already running".


§10 Troubleshooting

Toggle is disabled

The native addon is not loadable. Confirm: you are on Linux, you built the addon (npm run build:native:tproxy), and the process can load transparent.node. isTransparentSocketAvailable() gates the toggle; GET /api/tools/agent-bridge/tproxy returns available: false when the addon is missing.

Nothing is captured

  • Confirm the intercepted process actually connects to the configured dport (default 443).
  • Confirm the process trusts the dynamic CA. The CA is installed under omniroute-tproxy-ca.crt; apps with their own trust store (Firefox/Chrome NSS) may need the cert added there too.
  • Run the AgentBridge Diagnose self-test (see AGENTBRIDGE.md) for cert-trusted / server health checks.

Stale firewall rules after a crash

revertTproxy() is the exact inverse of apply and is idempotent. Stopping the mode reverts the rules; if OmniRoute was killed mid-session, use the AgentBridge Repair action (POST /api/tools/agent-bridge/repair) to undo orphaned system state (DNS spoof, root CA, system proxy). The TPROXY mangle rules and route also flush automatically on reboot.

Infinite loop / the proxy intercepts its own forward

This is the anti-loop case. Confirm bypassMark differs from mark (validation enforces this) and that the forward uses connectMarked (it does in realForward). See §5 Anti-loop.


§11 Source map

File Responsibility
src/mitm/tproxy/commands.ts Pure iptables/ip apply + revert command builder; validateTproxyConfig
src/mitm/tproxy/setup.ts Transactional applyTproxy / revertTproxy runner (rollback on failure)
src/mitm/tproxy/transparentSocket.ts Native-addon loader (loadTransparentAddon), createTransparentListenerFd, connectMarked, setSocketMark, isTransparentSocketAvailable
src/mitm/tproxy/native/transparent.c N-API addon: createTransparentListener (IP_TRANSPARENT), setSocketMark, connectMarked
src/mitm/tproxy/native/binding.gyp node-gyp build manifest
src/mitm/tproxy/dynamicCert.ts DynamicCertStore — per-SNI dynamic CA + leaf cache
src/mitm/tproxy/caTrust.ts OS trust-store install/uninstall (installTproxyCa / uninstallTproxyCa, dedicated slot)
src/mitm/tproxy/tlsCapture.ts TLS-terminating decrypt engine + re-encrypted anti-loop forward
src/mitm/tproxy/captureMode.ts Transparent-listener orchestration; reads orig dest from socket.localAddress
src/mitm/tproxy/captureManager.ts Singleton lifecycle: startCaptureMode / stopCaptureMode / getCaptureStatus
src/app/api/tools/agent-bridge/tproxy/route.ts GET / POST / DELETE route (LOCAL_ONLY)
src/lib/inspector/tproxyCaptureApi.ts Client fetch helpers (fetchTproxyStatus / startTproxyCaptureMode / stopTproxyCaptureMode)

Clone this wiki locally