-
-
Notifications
You must be signed in to change notification settings - Fork 1.1k
MITM Tproxy 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
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).
| 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.
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.
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.mjsrunsnode-gyp rebuild. It is Linux-only and non-fatal — a missing toolchain just leaves the capture mode unavailable. -
assembleStandalone.mjscopiesbuild/Release/transparent.nodeinto the standalone bundle;transparentSocket.tsresolves it both module-relative and cwd-relative (<cwd>/src/mitm/tproxy/native/...). -
build/andprebuilds/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/).
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.
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 onetls.SecureContextper hostname. - Exposes
createSNICallback()for the TLS-terminating server (see §5). - Can be constructed with an
existingCato keep the CA stable across restarts (so the trust store does not need re-installing).
The CA private key never leaves the machine.
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 slot —
omniroute-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
sudoPasswordis supplied in the POST body to authorize the trust-store install; it is ignored entirely when the process is root.
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-sidetls.TLSSocketusing the dynamic CA's SNI callback, then hands the decrypted stream to an internalhttp.Server(the standard MITM termination trick). Socket lifetimes are bounded byMITM_IDLE_TIMEOUT_MSso a hung tunnel cannot exhaust file descriptors. -
Capture (
handleDecryptedRequest): pushes anInterceptedRequestwithsource: "tproxy", status starting"in-flight", headers run throughsanitizeHeaders()and bodies throughmaskSecret()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.rejectUnauthorizeddefaults totrue(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.
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):
-
realForwardopens its upstream socket viaconnectMarked(ip, port, DEFAULT_BYPASS_MARK)—DEFAULT_BYPASS_MARK = 0x539— which sets the SO_MARK beforeconnect(), so the forward's SYN carries the bypass mark. - The
mangle OUTPUTrule 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.
| 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.
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).
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
PREROUTINGalone does not see —PREROUTINGonly sees forwarded traffic. TheOUTPUTchain marks new local connections, theip rulereroutes them to local delivery (lo), andPREROUTINGthen assigns them to the transparent listener.
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.
- Open the Traffic Inspector (
/dashboard/tools/traffic-inspector). - In the capture-modes toolbar, find the "TPROXY Decrypt" ⚠ button
(
src/app/(dashboard)/dashboard/tools/traffic-inspector/components/CaptureModesToolbar.tsx). - Click the button. It calls
POST /api/tools/agent-bridge/tproxyviastartTproxyCaptureMode()(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. - When running, the toggle turns amber and shows the live intercept count
(
· <interceptCount>). Intercepted requests appear in the request list withsource: "tproxy". - Click again to stop —
DELETE /api/tools/agent-bridge/tproxyviastopTproxyCaptureMode()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".
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.
- Confirm the intercepted process actually connects to the configured
dport(default443). - 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.
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.
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.
| 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) |
OmniRoute · Website · npm · Docker Hub
- Setup Guide
- User Guide
- Features
- Quick Start (Docker)
- Electron Desktop App
- Termux (Android)
- PWA Guide
- MCP Server
- A2A Server
- Agent Protocols
- OpenCode Plugin
- Webhooks
- Cloud Agents
- Skills
- Memory
- Evals
- Gamification
- Guardrails
- Compliance
- Error Sanitization
- Public Credentials
- Route Guard Tiers
- Stealth Guide
- CLI Token Auth