From ae37c56467257b1d2b09673e1d627c34d7149d7c Mon Sep 17 00:00:00 2001 From: Ginder Singh Date: Thu, 19 Mar 2026 00:49:09 -0400 Subject: [PATCH] quic block --- cmd/ctrld_library/netstack/README.md | 267 ++++++++++++++++++++++--- cmd/ctrld_library/netstack/netstack.go | 17 ++ 2 files changed, 254 insertions(+), 30 deletions(-) diff --git a/cmd/ctrld_library/netstack/README.md b/cmd/ctrld_library/netstack/README.md index 9ccb477..db4218e 100644 --- a/cmd/ctrld_library/netstack/README.md +++ b/cmd/ctrld_library/netstack/README.md @@ -15,19 +15,29 @@ This module provides full packet capture capabilities for mobile VPN application ``` Mobile Apps (Browser, Games, etc) ↓ -VPN TUN Interface (10.0.0.2/24) +VPN TUN Interface + - Device: 10.0.0.2/24 + - Gateway: 10.0.0.1 + - DNS: 10.0.0.1 (ControlD) ↓ PacketHandler (Read/Write/Protect) ↓ -gVisor Netstack (TCP/IP Stack) - ├─→ DNS Filter (Port 53) +gVisor Netstack (10.0.0.1) + ├─→ DNS Filter (dest port 53) + │ ├─→ DNS Bridge (transaction ID tracking) │ └─→ ControlD DNS Proxy (localhost:5354) - ├─→ TCP Forwarder - │ └─→ net.Dial("tcp") + protect(fd) - └─→ UDP Forwarder - └─→ net.Dial("udp") + protect(fd) + │ └─→ DoH Upstream (freedns.controld.com) + │ └─→ Protected Socket → Internet + ├─→ TCP Forwarder (non-DNS TCP traffic) + │ ├─→ net.Dial("tcp", destination) + │ ├─→ protect(fd) BEFORE connect() + │ └─→ io.Copy() bidirectional + └─→ UDP Forwarder (non-DNS UDP traffic) + ├─→ net.Dial("udp", destination) + ├─→ protect(fd) BEFORE connect() + └─→ Session tracking (60s timeout) ↓ -Real Network (WiFi/Cellular) - Protected Sockets +Real Network (WiFi/Cellular) - All Sockets Protected ``` ## Key Components @@ -98,10 +108,32 @@ dialer.Control = func(network, address string, c syscall.RawConn) error { ``` **All Protected Sockets:** -1. TCP forwarder sockets (user traffic) -2. UDP forwarder sockets (user traffic) -3. ControlD API HTTP sockets (api.controld.com) -4. DoH upstream sockets (freedns.controld.com) +1. **TCP forwarder sockets** - User HTTP/HTTPS traffic +2. **UDP forwarder sockets** - User games/video/VoIP traffic +3. **ControlD API sockets** - Configuration fetch (api.controld.com) +4. **DoH upstream sockets** - DNS resolution (freedns.controld.com) + +**Timing is Critical:** + +Protection must happen **DURING** socket creation, not after: + +```go +// ✅ CORRECT - Protection happens BEFORE connect() +dialer := &net.Dialer{ + Control: func(network, address string, c syscall.RawConn) error { + return c.Control(func(fd uintptr) { + protectSocket(int(fd)) // Called before connect() syscall + }) + }, +} +conn, _ := dialer.Dial("tcp", "google.com:443") // Socket already protected! + +// ❌ WRONG - Protection happens AFTER connect() +conn, _ := net.Dial("tcp", "google.com:443") // SYN already sent through VPN! +rawConn.Control(func(fd uintptr) { + protectSocket(int(fd)) // TOO LATE - routing loop already started +}) +``` ### Outbound vs Return Packets @@ -123,19 +155,164 @@ For inbound connections to the netstack: Therefore, we dial `LocalAddress:LocalPort` (the destination). +## Packet Flow Examples + +### DNS Query (Port 53) + +``` +1. App queries google.com + DNS query: 10.0.0.2:54321 → 10.0.0.1:53 + +2. VPN TUN captures packet + PacketHandler.readPacket() returns raw IP packet + +3. DNS Filter detects port 53 + Extracts UDP payload (DNS query bytes) + +4. DNS Bridge processes query + - Parses DNS message (transaction ID: 12345) + - Stores in pending map + - Sends to query channel + +5. DNS Handler receives query + - Forwards to ControlD proxy: localhost:5354 + - ControlD applies policies and filters + - Queries DoH upstream (freedns.controld.com) via PROTECTED socket + - Returns filtered response + +6. DNS Bridge matches response + - Finds pending query by transaction ID + - Sends response to waiting channel + +7. DNS Filter builds response packet + - Wraps DNS bytes in UDP/IP packet + - Swaps src/dst: 10.0.0.1:53 → 10.0.0.2:54321 + +8. PacketHandler writes response to TUN + App receives DNS response with IP address +``` + +### TCP Connection (HTTP/HTTPS) + +``` +1. App connects to google.com:443 + SYN packet: 10.0.0.2:12345 → 142.250.190.78:443 + +2. VPN TUN captures packet + PacketHandler.readPacket() returns raw IP packet + +3. Netstack filters packet + - Checks source IP = 10.0.0.2 (outbound? YES) + - Not DNS (port != 53) + - Injects into gVisor netstack + +4. gVisor calls TCP forwarder + TransportEndpointID: + - LocalAddress: 142.250.190.78:443 (destination) + - RemoteAddress: 10.0.0.2:12345 (source) + +5. TCP Forwarder creates connection + - Creates gonet.TCPConn (TUN side) + - Dials net.Dial("tcp", "142.250.190.78:443") + - Protects socket BEFORE connect() → No routing loop! + - Connects via WiFi/Cellular (bypasses VPN) + +6. Bidirectional copy + - TUN → Upstream: io.Copy(upstreamConn, tunConn) + - Upstream → TUN: io.Copy(tunConn, upstreamConn) + - All HTTP data flows through protected socket + +7. Connection closes when either side finishes +``` + +### UDP Packet (Games/Video) + +``` +1. Game sends UDP packet + UDP: 10.0.0.2:54321 → game-server.com:9000 + +2. VPN TUN captures packet + PacketHandler.readPacket() returns raw IP packet + +3. Netstack filters packet + - Checks source IP = 10.0.0.2 (outbound? YES) + - Not DNS (port != 53) + - Injects into gVisor netstack + +4. gVisor calls UDP forwarder + TransportEndpointID: + - LocalAddress: game-server.com:9000 (destination) + - RemoteAddress: 10.0.0.2:54321 (source) + +5. UDP Forwarder creates/reuses session + - Creates gonet.UDPConn (TUN side) + - Dials net.Dial("udp", "game-server.com:9000") + - Protects socket BEFORE connect() → No routing loop! + - Connects via WiFi/Cellular (bypasses VPN) + +6. Bidirectional forwarding + - TUN → Upstream: Read from gonet, write to net.UDPConn + - Upstream → TUN: Read from net.UDPConn, write to gonet + - Session tracked with 60s idle timeout + +7. Auto-cleanup after 60 seconds of inactivity +``` + +## VPN Configuration (Android) + +```kotlin +val builder = Builder() + .setSession("ControlD VPN") + .addAddress("10.0.0.2", 24) // Device address + .addRoute("0.0.0.0", 0) // Route all traffic + .addDnsServer("10.0.0.1") // DNS to local TUN (ControlD) + .setMtu(1500) + +vpnInterface = builder.establish() +``` + +**Why DNS = 10.0.0.1:** +- Apps query 10.0.0.1:53 (local TUN interface) +- Queries captured by DNS filter +- Filtered through ControlD policies +- Apps see "ControlD" as DNS provider (not Cloudflare/Google) +``` + ## Usage Example (Android) ```kotlin -// In VpnService +// In VpnService.startVpn() + +// 1. Create config.toml (required by ctrld) +val configFile = File(filesDir, "config.toml") +configFile.createNewFile() +configFile.writeText("") // Empty config, uses defaults + +// 2. Build VPN interface +val builder = Builder() + .setSession("ControlD VPN") + .addAddress("10.0.0.2", 24) + .addRoute("0.0.0.0", 0) + .addDnsServer("10.0.0.1") // CRITICAL: Use local TUN for DNS + .setMtu(1500) + +vpnInterface = builder.establish() + +// 3. Get TUN file descriptor streams +inputStream = FileInputStream(vpnInterface.fileDescriptor) +outputStream = FileOutputStream(vpnInterface.fileDescriptor) + +// 4. Implement PacketAppCallback val callback = object : PacketAppCallback { override fun readPacket(): ByteArray { - // Read from TUN file descriptor - val length = inputStream.channel.read(buffer) + val length = inputStream.channel.read(readBuffer) + val packet = ByteArray(length) + readBuffer.position(0) + readBuffer.get(packet, 0, length) return packet } override fun writePacket(packet: ByteArray) { - // Write to TUN file descriptor outputStream.write(packet) } @@ -156,35 +333,65 @@ val callback = object : PacketAppCallback { override fun macAddress(): String = "00:00:00:00:00:00" } -// Create packet capture controller -val controller = Ctrld_library.newPacketCaptureController(callback) +// 5. Create and start packet capture controller +packetController = Ctrld_library.newPacketCaptureController(callback) -// Start packet capture -controller.startWithPacketCapture( +packetController.startWithPacketCapture( callback, - "your-cd-uid", - "", "", // provision ID, custom hostname - filesDir.absolutePath, - "doh", // upstream protocol - 2, // log level - "$filesDir/ctrld.log" + "your-cd-uid", // From ControlD dashboard + "", // Provision ID (optional) + "", // Custom hostname (optional) + filesDir.absolutePath, // Home directory + "doh", // Upstream protocol (doh/dot/os) + 2, // Log level (0-5) + "${filesDir.absolutePath}/ctrld.log" ) -// Stop when done -controller.stop(false, 0) +// 6. Stop when disconnecting +packetController.stop(false, 0) ``` ## Protocol Support | Protocol | Support | Details | |----------|---------|---------| -| **DNS** | ✅ Full | Filtered through ControlD proxy | +| **DNS** | ✅ Full | Filtered through ControlD proxy (UDP/TCP port 53) | | **TCP** | ✅ Full | All ports, bidirectional forwarding | -| **UDP** | ✅ Full | All ports except 53, session tracking | +| **UDP** | ✅ Selective | All ports except 53, 80, 443 (see QUIC blocking) | +| **QUIC** | 🚫 Blocked | UDP ports 443 and 80 dropped to force TCP fallback | | **ICMP** | ⚠️ Partial | Basic support (no forwarding yet) | | **IPv4** | ✅ Full | Complete support | | **IPv6** | ✅ Full | Complete support | +### QUIC Blocking + +QUIC (Quick UDP Internet Connections) is blocked by dropping UDP packets on ports 443 and 80: + +**Why Block QUIC:** +- QUIC bypasses traditional DNS lookups (uses Alt-Svc headers) +- Encrypts server name indication (SNI) +- Makes content filtering difficult +- Prevents some ControlD policies from working + +**How It Works:** +```go +// In netstack.go readPackets() +if protocol == UDP { + if dstPort == 443 || dstPort == 80 { + // Drop QUIC/HTTP3 packets + // Apps automatically fallback to TCP (HTTP/2 or HTTP/1.1) + continue + } +} +``` + +**Result:** +- Chrome/apps attempt QUIC first +- QUIC packets dropped silently +- Apps fallback to TCP/TLS (HTTP/2) +- ControlD policies work correctly +- Slightly slower initial connection, then normal speed + ## Performance | Metric | Value | diff --git a/cmd/ctrld_library/netstack/netstack.go b/cmd/ctrld_library/netstack/netstack.go index f0ea5d4..c2ecc49 100644 --- a/cmd/ctrld_library/netstack/netstack.go +++ b/cmd/ctrld_library/netstack/netstack.go @@ -287,6 +287,23 @@ func (nc *NetstackController) readPackets() { // Drop it - return packets come through forwarder's upstream connection continue } + + // Block QUIC protocol (UDP on port 443) + // QUIC runs over UDP and bypasses DNS, so we block it to force HTTP/2 or HTTP/3 over TCP + protocol := packet[9] + if protocol == 17 { // UDP + // Get IP header length + ihl := int(packet[0]&0x0f) * 4 + if len(packet) >= ihl+4 { + // Parse UDP destination port (bytes 2-3 of UDP header) + dstPort := uint16(packet[ihl+2])<<8 | uint16(packet[ihl+3]) + if dstPort == 443 || dstPort == 80 { + // Block QUIC (UDP/443) and HTTP/3 (UDP/80) + // Apps will fallback to TCP automatically + continue + } + } + } } // Create packet buffer