# doh-forwarder vs Intra — Architectural Comparison ## Overview This document compares **doh-forwarder** (this project) with [Intra](https://github.com/Jigsaw-Code/Intra) by Jigsaw (Google) to understand why Intra can unblock YouTube and other censored sites while doh-forwarder can only resolve their DNS. ## The Core Problem DNS resolution is only the first layer of censorship. Even when DNS is encrypted and returns the correct IP, the **actual HTTPS connection** to the target server can be blocked by Deep Packet Inspection (DPI) at the TCP/TLS level. ``` doh-forwarder flow: Browser → "youtube.com?" → doh-forwarder → DoH → returns IP ✅ Browser → HTTPS to YouTube IP:443 ──────────── BLOCKED by DPI ❌ Intra flow: Browser → HTTPS to YouTube IP:443 → TUN → Intra split/retry → YouTube ✅ ``` ## Architectural Differences ### doh-forwarder — DNS-only forwarder ``` +---------------------+ | DNS Client | | (browser, app, OS) | +--------+------------+ | +------------+-------------+ | | HTTPS (POST/GET) UDP/TCP (port 53) | | v v +-------------------+ +-------------------+ | Axum HTTP Server | | DnsListener | | (port 8800) | | (UDP + TCP) | +--------+----------+ +--------+----------+ | | +-----------+-------------+ | v +-------------------+ | DohClient | | (reqwest HTTP) | +--------+----------+ | +-----------+-----------+ | | | v v v Cloudflare Google Quad9 (DoH) (DoH) (DoH) ``` - Accepts DNS queries on port 53 (UDP/TCP) and port 8800 (DoH) - Forwards queries to upstream DoH resolvers over HTTPS - Returns DNS responses to clients - **Does not touch any non-DNS traffic** ### Intra — Full traffic interception + circumvention ``` +-----------------------------------------------------+ | TUN Device | | (captures ALL device traffic) | +---------------------------+--------------------------+ | +---------------+----------------+ | | DNS packets (port 53) TCP packets (port 443) | | v v +------------------+ +------------------------+ | DNS Interception | | intraStreamDialer | | → DoH query via | | → DialWithSplitRetry() | | HTTP POST | | → TLS hello splitting | +------------------+ +------------------------+ | +--------+--------+ | | Success? Blocked? | | v v Normal TLS Close + retry with handshake split Client Hello ``` - Creates a **TUN device** that captures all device traffic - Intercepts DNS packets → sends via DoH (like doh-forwarder) - Intercepts TCP connections to port 443 → applies circumvention - Uses **TLS Client Hello splitting** to evade DPI ## Feature Comparison | Feature | doh-forwarder | Intra | |---------|:------------:|:-----:| | DNS-over-HTTPS forwarding | ✅ | ✅ | | Multiple upstream resolvers | ✅ | ✅ | | Round-robin / failover | ✅ | ❌ | | UDP + TCP DNS listener | ✅ | ✅ (via TUN) | | HTTP DoH endpoint | ✅ | ❌ | | TUN device (all traffic) | ❌ | ✅ | | TCP stream interception | ❌ | ✅ | | TLS Client Hello splitting | ❌ | ✅ | | Retry on censorship detection | ❌ | ✅ | | SNI-based error reporting | ❌ | ✅ | | EDNS padding | ❌ | ✅ | | IP map with confirmation tracking | ❌ | ✅ | | Servfail hangover (rate limiting) | ❌ | ✅ | | Written in | Rust | Go + Java | | Platform | Linux (server) | Android (VPN) | ## The Missing Piece: TLS Client Hello Splitting The critical technique Intra uses to bypass censorship is **TCP segment splitting** on the TLS Client Hello. Here's how it works: ### How DPI censorship works When your browser connects to `https://www.youtube.com`, the first thing it sends is a **TLS Client Hello** packet. This packet contains the **SNI (Server Name Indication)** field in plaintext — the domain name `www.youtube.com`. A DPI system reads this field and blocks the connection if the domain is on a blocklist. ### How splitting defeats DPI Intra's `splitHello()` function splits the TLS Client Hello into **two TCP segments** at a random offset (6–64 bytes): ``` Normal Client Hello (one TCP segment): [TLS Header (5 bytes)] [SNI: www.youtube.com ... rest of hello] ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ DPI reads this → finds "youtube.com" → BLOCKS Split Client Hello (two TCP segments): Segment 1: [TLS Header (5 bytes)] [first N bytes...] Segment 2: [remaining bytes... SNI: www.youtube.com ...] DPI can't reassemble fast enough → PASSES THROUGH ✅ ``` For TLS Client Hello specifically, Intra also **properly fragments the TLS record header** — it adjusts the record length field in each segment so the TLS layer itself remains valid: ```go // First segment: adjust record length to split point pkt := hello[:splitLen] binary.BigEndian.PutUint16(pkt[3:5], uint16(recordSplitLen)) // Second segment: copy TLS header, set remaining length pkt = hello[splitLen-5:] copy(pkt, hello[:5]) binary.BigEndian.PutUint16(pkt[3:5], recordLen-uint16(recordSplitLen)) ``` ### The retry mechanism `DialWithSplitRetry()` in `retrier.go`: 1. Opens a normal TCP connection to the target 2. Sends the TLS Client Hello 3. Sets a read deadline (1200ms + 2×RTT) 4. If the connection is dropped or times out → **censorship detected** 5. Closes the connection 6. Opens a **new** TCP connection 7. Replays the Client Hello as **split segments** 8. If this succeeds → the connection proceeds normally ## Why doh-forwarder Can't Unblock YouTube doh-forwarder operates at the **DNS layer only**. It has no visibility into or control over: - TCP connections to resolved IPs - TLS handshakes - HTTP requests The traffic flow is: ``` 1. Browser asks OS: "What's the IP for youtube.com?" 2. OS sends DNS query to doh-forwarder (port 53) 3. doh-forwarder forwards via DoH → gets correct IP (e.g., 142.250.80.46) 4. Browser connects directly to 142.250.80.46:443 5. Browser sends TLS Client Hello with SNI=www.youtube.com 6. ISP's DPI reads SNI → blocks connection 7. Browser shows "connection timed out" or "connection reset" ``` Step 4–7 happen entirely outside doh-forwarder's scope. ## What Would Be Needed To achieve what Intra does, doh-forwarder would need to evolve from a DNS forwarder into a **traffic interception and circumvention proxy**: ### Option A: SOCKS5 Proxy with Split Transport (M6 path) ``` Browser → SOCKS5 proxy (doh-forwarder) → split TCP → target server ``` - Configure browser to use SOCKS5 proxy - Proxy applies TLS splitting on outbound connections - Lighter than full TUN, requires browser config ### Option B: Transparent Proxy with TUN (Intra-like) ``` All traffic → TUN device → doh-forwarder → DNS: DoH / TCP: split → target ``` - Zero browser configuration - Requires root/CAP_NET_ADMIN for TUN - Significantly more complex ### Option C: Hybrid - DNS forwarding via DoH (current functionality) - Optional SOCKS5 proxy for TCP circumvention - Optional TUN mode for transparent interception - User chooses based on their environment ## Lessons from Intra's Codebase ### 1. IP Map with Confirmation Tracking Intra maintains an `ipmap` that tracks which IPs of a DoH resolver actually work. If a confirmed IP starts failing, it's disconfirmed and alternatives are tried. This provides resilience against partial IP blocking. ### 2. EDNS Padding Intra pads DNS queries to standard sizes to prevent traffic analysis that could identify specific domains by query size. ### 3. Servfail Hangover When a DoH server returns errors, Intra enters a 10-second "hangover" where it stops sending queries (returns SERVFAIL immediately). This rate-limits queries to misconfigured or blocked servers. ### 4. SNI Reporter Intra reports which SNIs are being censored (via the Choir library) to contribute to censorship monitoring without revealing individual user activity. ## References - [Intra source code](https://github.com/Jigsaw-Code/Intra) - [Outline SDK](https://github.com/Jigsaw-Code/outline-sdk) — the transport library Intra uses - [RFC 8484 — DNS over HTTPS](https://tools.ietf.org/html/rfc8484) - [TLS Client Hello splitting](https://gfw.report/talks/syria2016/en/) — research on DPI evasion