# Dedicated Residential Egress Operator guide for the WSL2 TUN funnel that routes all hunting traffic through one dedicated residential IP — with zero per-command configuration and a fail-closed no-leak guarantee. ## What it does `tools/egress.sh` runs `tun2socks`, which creates a TUN device (`egress0`) and replaces the WSL2 default route to point at it. Every outbound TCP/UDP flow — regardless of which tool generates it — egresses through the configured residential upstream. No per-tool proxy flag is needed. **Four surfaces covered automatically:** | Surface | How it's covered | |---|---| | CLI tools (`curl`, `httpx`, `nuclei`, `ffuf`, …) | Default route → TUN | | `tools/stealth_fetch.py` | Default route → TUN | | Headful Chrome (`cdp_ctl.sh start`) | Default route → TUN + WebRTC hardening | | Headless Chrome (`cdp_ctl.sh start --headless`) | Default route → TUN + WebRTC hardening | **Fail-closed:** when the tunnel is down, no default route exists. Traffic errors out — tools cannot silently fall back through the real WSL IP. **WSL2-specific:** uses a `tun2socks` TUN funnel; does not require `iptables` or `nftables` (neither is installed in this environment). ## Prerequisites 1. **`tun2socks`** — the TUN→SOCKS5 engine (`xjasonlyu/tun2socks`). It does real tun2socks (translates TUN IP packets into SOCKS5 `CONNECT`/`UDP-ASSOCIATE`), which `gost`'s node-to-node `tun` does not. Because the systemd unit runs as **root** with a minimal PATH (`/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/snap/bin`), put the binary on that PATH (a user-local `go install` into `~/go/bin` is **not** visible to the service): ```bash # via Go (requires go 1.21+): go install github.com/xjasonlyu/tun2socks/v2@latest # then make it visible to the root systemd service: sudo cp "$(command -v tun2socks)" /usr/local/bin/tun2socks # or download a release binary from # https://github.com/xjasonlyu/tun2socks/releases into /usr/local/bin/tun2socks ``` Verify: `tun2socks --version`. Alternatively, point the script at an absolute path with `EGRESS_TUN2SOCKS=/path/to/tun2socks` (settable in the unit via `Environment=`); `egress.sh` also auto-probes `/usr/local/bin/tun2socks` and `/usr/bin/tun2socks`. > **Note:** `egress.sh` runs its config parsing with the system `python3` > (`egress_lib` is pure stdlib) — it does **not** require `uv` at runtime, so > the root systemd service needs nothing user-local beyond `tun2socks`. 2. **`shellcheck`** — optional; only needed if you modify the shell scripts. 3. **Upstream type:** prefer a **SOCKS5** upstream (`socks5://…`). SOCKS5 carries remote DNS (no hostname leak) and UDP (WebRTC capture). An HTTP upstream (`http://…`) is TCP-only — DNS and UDP go through a separate path; see [Troubleshooting](#troubleshooting). ## One-time setup ### 1. Write your upstream config ```bash cp .egress/upstream.conf.example .egress/upstream.conf ``` Edit `.egress/upstream.conf` and fill in two keys: ```bash # Active dedicated upstream — include user:pass credentials. EGRESS_UPSTREAM=socks5://USER:PASS@gateway.provider.example:1080 # The public IP the world should see. `egress check` asserts this. EGRESS_EXPECTED_IP=203.0.113.45 ``` `.egress/upstream.conf` is gitignored and **must never be committed**. The `.example` template carries placeholder values only (see the repo security-hygiene rule: no credentials in source). To switch among your dedicated IPs later, uncomment the appropriate lines and run `tools/egress.sh restart`. ### 2. Disable WSL auto-resolv WSL2 regenerates `/etc/resolv.conf` on restart and will clobber the managed DNS entry the tunnel sets. Prevent this: ```bash # Add to /etc/wsl.conf (create it if absent): [network] generateResolvConf=false ``` Then, from a Windows terminal (PowerShell or CMD), shut down WSL so the setting takes effect: ```powershell wsl --shutdown ``` After restart, `/etc/resolv.conf` is yours to manage. Point it at a real resolver IP, e.g. a static file: ```bash # If systemd-resolved owns resolv.conf with no upstream, DNS will fail; the # simplest reliable setup is a static resolver: sudo systemctl disable --now systemd-resolved sudo rm -f /etc/resolv.conf && echo 'nameserver 1.1.1.1' | sudo tee /etc/resolv.conf ``` `egress.sh` does **not** rewrite `/etc/resolv.conf`. Instead it pins your resolver IP(s) **direct** (via the real uplink) while the tunnel is up, so name resolution works whether the tunnel is up or down. App connections still egress residential; only DNS lookups leave from the real IP. ### 3. Install the systemd unit The systemd unit re-arms the tunnel on every WSL boot (networking resets on restart). Install it once from the repo root: ```bash sudo cp tools/egress.service /etc/systemd/system/egress.service sudo sed -i "s#@ROOT@#$(pwd)#g" /etc/systemd/system/egress.service sudo systemctl daemon-reload sudo systemctl enable --now egress.service ``` The unit runs `egress.sh run` (foreground `tun2socks`) as `ExecStart`, `egress.sh routes up` as `ExecStartPost`, and `egress.sh routes down` as `ExecStop`. It restarts the engine automatically on failure (`Restart=on-failure`). ## Daily use ```bash # Bring the tunnel up (needs root when run manually): sudo tools/egress.sh up # Confirm tunnel is healthy and egress IP is the configured one: tools/egress.sh check # Quick tunnel + engine liveness overview: tools/egress.sh status # Bring the tunnel down (restore original default route + DNS): sudo tools/egress.sh down # Switch to a different dedicated IP (edit upstream.conf first): sudo tools/egress.sh restart ``` > **Note:** `up`, `down`, and `restart` write kernel routes and `/etc/resolv.conf` > — they need root. When the systemd unit is active, these happen automatically; > manual `sudo` is only needed for ad-hoc runs. ### `egress check` exit codes | Exit code | Meaning | |---|---| | `0` | Healthy — observed egress IP equals `EGRESS_EXPECTED_IP` | | `1` | Unhealthy / leak — tunnel configured but egress IP does not match | | `2` | Not configured — `.egress/upstream.conf` absent or `EGRESS_EXPECTED_IP` unset | `check` asserts: fetch the externally observed IP through the active default route (the tunnel, when up) and compare it against `EGRESS_EXPECTED_IP`. ### Logs ```bash tools/egress.sh logs # last 50 engine log lines tools/egress.sh logs 200 # last 200 ``` Where logs live depends on how the engine was started: - **systemd** (`ExecStart=egress.sh run`) — tun2socks logs to the **journal**; `egress.sh logs` runs `journalctl -u egress.service` (use `sudo` if your user can't read the journal). The `.egress/tunnel.log` file stays empty in this mode — that is expected. - **manual** (`egress.sh up`) — tun2socks logs to `.egress/tunnel.log`, which `egress.sh logs` tails. Verbosity defaults to `info` (shows engine startup, the SOCKS5 proxy, and connections). Set `EGRESS_LOGLEVEL=warn` for quiet (problems only) or `EGRESS_LOGLEVEL=debug` for per-packet detail; for the systemd unit, set it via `Environment=EGRESS_LOGLEVEL=...`. ### Switching IPs ```bash # Edit .egress/upstream.conf: update EGRESS_UPSTREAM and EGRESS_EXPECTED_IP, # then: sudo tools/egress.sh restart tools/egress.sh check # confirm rc=0 ``` ## How the four surfaces inherit it ### CLI tools and `stealth_fetch.py` No configuration needed. The default route points at the TUN, so every outbound connection inherits the residential IP transparently. ### Chrome (headful and headless) `tools/cdp_ctl.sh` adds two egress-related behaviors on top of the transparent routing: 1. **Fail-closed launch gate:** before starting Chrome, `cdp_ctl.sh` runs `tools/egress.sh check`. It blocks the launch **only** when exit code is `1` (tunnel configured but unhealthy). Exit code `0` (healthy) and `2` (not configured) both allow launch. If blocked, you see: ``` cdp_ctl: egress check failed — refusing to launch (run 'tools/egress.sh up') ``` 2. **WebRTC leak suppression:** Chrome is launched with `--force-webrtc-ip-handling-policy=disable_non_proxied_udp`, which prevents the WebRTC/STUN mechanism from leaking the real local IP even if UDP somehow bypasses the tunnel. ### SessionStart warning When a session starts, `egress_check.py --session-start` runs as a non-blocking hook. It is silent when the tunnel is healthy (rc 0) or not configured (rc 2). If the tunnel is configured but unhealthy (rc 1), it emits a warning into session context: ``` ⚠ EGRESS LEAK CHECK FAILED: . Run `tools/egress.sh up` before hunting. ``` This is a warning only — it does not block tool use. ## Troubleshooting ### DNS leaks through the Windows resolver **Symptom:** `dig example.com` or `nslookup` resolves via `10.255.255.254` (the Windows host resolver) instead of `1.1.1.1`. **Fix:** Ensure `[network]\ngenerateResolvConf=false` is set in `/etc/wsl.conf` **and** that you ran `wsl --shutdown` from Windows after adding it. After restart, `cat /etc/resolv.conf` should show `nameserver 1.1.1.1` when the tunnel is up. If WSL still rewrites the file, the `wsl.conf` change did not take effect — double-check the syntax and path (`/etc/wsl.conf`, not `~/.wsl.conf`). ### Tunnel is up but no traffic flows / `curl` hangs **Symptom:** `egress.sh status` shows the tunnel UP, but `curl`/`check` hang or time out — even over IPv4. Almost always the TUN engine isn't actually doing tun2socks. This is why we use `xjasonlyu/tun2socks` and **not** `gost`: gost's `tun` builds a node-to-node IP tunnel (VPN between gost instances) and does not translate TUN packets into SOCKS5, so packets enter `egress0` and die. Confirm the engine and that it dialed the proxy: ```bash tun2socks --version tools/egress.sh logs 30 # engine log (journald under systemd) — SOCKS5 dial / errors ``` `egress.sh` runs `tun2socks -device egress0 -interface -proxy socks5://…`. The `-interface` bind sends the proxy connection out the real uplink so it never loops into the tunnel. A drop-in alternative engine is **`hev-socks5-tunnel`** (YAML config; set the TUN device name to `egress0` and point it at the same SOCKS5 upstream). ### IPv6 leak / `curl` hangs on a dual-stack host **Symptom:** names resolve to IPv6 (AAAA) and `curl`/`check` hang or the egress check fails, because the tunnel is IPv4-only and traffic tries IPv6 first. `egress.sh` installs a metric-1 IPv6 blackhole default while the tunnel is up (removed on `down`), forcing apps to fall back to IPv4 — which the tunnel carries — and preventing any IPv6 leak. Confirm with `ip -6 route show default` (expect `blackhole default metric 1`). The leak-test uses `curl -4`. ### SOCKS5 UDP / remote DNS not working **Symptom:** DNS queries time out or resolve to wrong IPs while the tunnel is up; WebRTC capture returns no remote candidates. **Cause:** the dedicated gateway upstream is HTTP-only and does not support SOCKS5 UDP ASSOCIATE. HTTP proxies are TCP-only. **Fix:** - For DNS: run a small local TCP-DNS forwarder (e.g. `socat UDP-LISTEN:53,fork TCP:1.1.1.1:53` or a lightweight `dnscrypt-proxy` TCP-only config) and point `/etc/resolv.conf` at `127.0.0.1`. DNS flows over TCP through the tunnel. - For WebRTC capture: UDP is blackholed by an HTTP upstream — WebRTC ICE gathering will fail rather than leak the real IP (still fail-closed, no credential leak). - The cleanest fix is to obtain a SOCKS5-capable upstream; most residential providers offer it. ### Dynamic `eth0` subnet changes after `wsl --shutdown` **Symptom:** after restarting WSL, `egress.sh up` fails or routes the wrong subnet directly. **Cause:** WSL2 reassigns `eth0`'s subnet on restart. The gateway and LAN prefix are different from the last session. **Fix:** `egress.sh up` (and `restart`) detects the current gateway and LAN subnet dynamically at runtime — it never hardcodes them. Simply re-run `sudo tools/egress.sh up`, or rely on the systemd unit to do this automatically on each boot. Do not hardcode gateway or subnet values in config files.