Spaces:
Runtime error
Runtime error
File size: 20,696 Bytes
cd8bd0a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 | ---
title: "MITM TPROXY Transparent Decrypt"
version: 3.8.31
lastUpdated: 2026-06-20
---
# 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 (`<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](#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 <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)
```bash
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](#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
(`Β· <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`](../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`) |
|