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

287 lines
8.8 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.
```mermaid
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
```mermaid
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
```mermaid
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):
```mermaid
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:
```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:
```mermaid
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)
```mermaid
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)
```mermaid
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
- [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