--- title: "MITM TPROXY Transparent Decrypt" version: 3.8.40 lastUpdated: 2026-06-28 --- # MITM TPROXY Transparent Decrypt TPROXY transparent decrypt is OmniRoute's **5th capture mode** for the [Traffic Inspector](../frameworks/TRAFFIC_INSPECTOR.md) / [AgentBridge](../frameworks/AGENTBRIDGE.md) 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](#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`](../frameworks/TRAFFIC_INSPECTOR.md), [`docs/frameworks/AGENTBRIDGE.md`](../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](#4-the-per-sni-dynamic-ca-and-trust-store-installer)). --- ## §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](#3-the-native-ip_transparent-addon). | | **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 ```bash 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 (`/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 `/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](#5-how-decrypt-and-capture-work)). - 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 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 `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 `), 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) ```bash ip rule add fwmark lookup ip route add local 0.0.0.0/0 dev lo table iptables -t mangle -A OUTPUT -p tcp --dport -m mark ! --mark -j MARK --set-mark iptables -t mangle -A PREROUTING -p tcp --dport -m mark --mark -j TPROXY --on-port --tproxy-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](#2-requirements) and [§3](#3-the-native-ip_transparent-addon). 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 (`· `). 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`](../frameworks/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](#anti-loop-so_mark). --- ## §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`) |