267 lines
9.2 KiB
Markdown
267 lines
9.2 KiB
Markdown
# 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
|