Files
doh-forwarder/docs/intra-comparison.md
Mohammadreza Khani d28f861075 docs: add Mermaid diagrams throughout docs
- plan.md: architecture flow diagram
- README.md: simple forwarder overview diagram
- story.md: DNS censorship and SOCKS5 proxy diagrams
- intra-comparison.md: all ASCII diagrams converted to Mermaid
2026-07-06 23:09:06 +03:30

8.8 KiB
Raw Blame History

doh-forwarder vs Intra — Architectural Comparison

Overview

This document compares doh-forwarder (this project) with 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.

sequenceDiagram
    participant Browser
    participant DNS as doh-forwarder
    participant DPI as ISP Firewall
    participant YT as YouTube

    Note over Browser,YT: doh-forwarder flow (DNS only)
    Browser->>DNS: youtube.com?
    DNS-->>Browser: 142.250.80.46 ✅
    Browser->>DPI: HTTPS to 142.250.80.46:443
    DPI-->>Browser: ❌ BLOCKED (SNI inspection)

    Note over Browser,YT: Intra flow (full traffic)
    Browser->>DPI: HTTPS to 142.250.80.46:443 (via TUN)
    DPI->>DPI: Split/retry Client Hello
    DPI-->>YT: ✅ PASSED
    YT-->>Browser: Response

Architectural Differences

doh-forwarder — DNS-only forwarder

flowchart TD
    subgraph clients["DNS Clients"]
        browser["Browser / App"]
        os["OS DNS Client"]
    end

    subgraph forwarder["doh-forwarder"]
        doh["Axum HTTP Server\n(port 8800)"]
        dns["DnsListener\n(UDP + TCP)"]
        client["DohClient\n(reqwest HTTP)"]
    end

    subgraph upstreams["Upstream DoH Resolvers"]
        cf["Cloudflare"]
        google["Google"]
        quad9["Quad9"]
    end

    browser -- "HTTPS (POST/GET)" --> doh
    os -- "UDP/TCP :53" --> dns
    doh --> client
    dns --> client
    client -- "HTTPS POST" --> cf
    client -- "HTTPS POST" --> google
    client -- "HTTPS POST" --> quad9
  • 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

flowchart TD
    subgraph tun["TUN Device (captures ALL traffic)"]
        direction TB
        dns["DNS Interception\n→ DoH query via HTTP POST"]
        tcp["intraStreamDialer\n→ DialWithSplitRetry()\n→ TLS hello splitting"]
    end

    dns_packets["DNS packets\n(port 53)"] --> dns
    tcp_packets["TCP packets\n(port 443)"] --> tcp

    tcp --> success{"Success?"}
    success -- "Yes" --> normal["Normal TLS handshake"]
    success -- "No (blocked)" --> retry["Close + retry with\nsplit Client Hello"]
    retry --> normal
  • 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 (664 bytes):

sequenceDiagram
    participant Client
    participant DPI as DPI Firewall
    participant Server

    Note over Client,Server: Normal (BLOCKED)
    Client->>DPI: [TLS Header] [SNI: youtube.com ... full hello]
    DPI->>DPI: Read SNI → youtube.com
    DPI-->>Client: ❌ BLOCKED

    Note over Client,Server: Split (PASSES THROUGH)
    Client->>DPI: Segment 1: [TLS Header] [first N bytes...]
    Client->>DPI: Segment 2: [remaining... SNI: youtube.com ...]
    DPI->>DPI: Can't reassemble fast enough
    DPI-->>Server: ✅ PASSED
    Server-->>Client: Response

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:

// 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:

sequenceDiagram
    participant Browser
    participant OS as OS DNS
    participant DF as doh-forwarder
    participant DPI as ISP DPI
    participant YT as YouTube (142.250.80.46)

    Browser->>OS: youtube.com?
    OS->>DF: DNS query (port 53)
    DF-->>OS: 142.250.80.46 ✅
    OS-->>Browser: 142.250.80.46

    Note over Browser,YT: Steps 47 are outside doh-forwarder's scope
    Browser->>DPI: TCP SYN to 142.250.80.46:443
    Browser->>DPI: TLS ClientHello (SNI: youtube.com)
    DPI->>DPI: Read SNI → block
    DPI-->>Browser: ❌ Connection reset

Step 47 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)

flowchart LR
    browser["Browser"] -- "SOCKS5" --> proxy["doh-forwarder\n(SOCKS5 proxy)"]
    proxy -- "split TCP" --> target["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)

flowchart LR
    all["All Traffic"] -- "TUN" --> forwarder["doh-forwarder"]
    forwarder -- "DNS: DoH" --> dns["DNS Resolver"]
    forwarder -- "TCP: split" --> target["Target Server"]
  • 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