mirror of
https://github.com/Control-D-Inc/ctrld.git
synced 2026-04-20 00:36:37 +02:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e9a1225fc | ||
|
|
afe7804a9b |
@@ -6,98 +6,411 @@ Complete TCP/UDP/DNS packet capture implementation using gVisor netstack for And
|
|||||||
|
|
||||||
Provides full packet capture for mobile VPN applications:
|
Provides full packet capture for mobile VPN applications:
|
||||||
- **DNS filtering** through ControlD proxy
|
- **DNS filtering** through ControlD proxy
|
||||||
- **TCP forwarding** for all TCP traffic
|
- **IP whitelisting** - only allows connections to DNS-resolved IPs
|
||||||
- **UDP forwarding** with session tracking
|
- **TCP forwarding** for all TCP traffic (with whitelist enforcement)
|
||||||
- **Socket protection** to prevent routing loops
|
- **UDP forwarding** with session tracking (with whitelist enforcement)
|
||||||
- **QUIC blocking** for better content filtering
|
- **QUIC blocking** for better content filtering
|
||||||
|
|
||||||
## Architecture
|
## Master Architecture Diagram
|
||||||
|
|
||||||
```
|
```
|
||||||
Mobile Apps → VPN TUN Interface → PacketHandler → gVisor Netstack
|
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||||
↓
|
│ MOBILE APP (Android/iOS) │
|
||||||
├─→ DNS Filter (Port 53)
|
│ │
|
||||||
│ └─→ ControlD DNS Proxy
|
│ ┌─────────────────────────────────────────────────────────────────────┐ │
|
||||||
├─→ TCP Forwarder
|
│ │ VPN Configuration │ │
|
||||||
│ └─→ net.Dial("tcp") + protect(fd)
|
│ │ │ │
|
||||||
└─→ UDP Forwarder
|
│ │ Android: iOS: │ │
|
||||||
└─→ net.Dial("udp") + protect(fd)
|
│ │ ┌──────────────────────┐ ┌──────────────────────┐ │ │
|
||||||
↓
|
│ │ │ Builder() │ │ NEIPv4Settings │ │ │
|
||||||
Real Network (Protected Sockets)
|
│ │ │ .addAddress( │ │ addresses: [ │ │ │
|
||||||
```
|
│ │ │ "10.0.0.2", 24) │ │ "10.0.0.2"] │ │ │
|
||||||
|
│ │ │ .addDnsServer( │ │ │ │ │
|
||||||
|
│ │ │ "10.0.0.1") │ │ NEDNSSettings │ │ │
|
||||||
|
│ │ │ │ │ servers: [ │ │ │
|
||||||
|
│ │ │ FIREWALL MODE: │ │ "10.0.0.1"] │ │ │
|
||||||
|
│ │ │ .addRoute( │ │ │ │ │
|
||||||
|
│ │ │ "0.0.0.0", 0) │ │ FIREWALL MODE: │ │ │
|
||||||
|
│ │ │ │ │ includedRoutes: │ │ │
|
||||||
|
│ │ │ DNS-ONLY MODE: │ │ [.default()] │ │ │
|
||||||
|
│ │ │ .addRoute( │ │ │ │ │
|
||||||
|
│ │ │ "10.0.0.1", 32) │ │ DNS-ONLY MODE: │ │ │
|
||||||
|
│ │ │ │ │ includedRoutes: │ │ │
|
||||||
|
│ │ │ .addDisallowedApp( │ │ [10.0.0.1/32] │ │ │
|
||||||
|
│ │ │ "com.controld.*") │ │ │ │ │
|
||||||
|
│ │ └──────────────────────┘ └──────────────────────┘ │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ Result: │ │
|
||||||
|
│ │ • Firewall: ALL traffic → VPN │ │
|
||||||
|
│ │ • DNS-only: ONLY DNS (port 53) → VPN │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────────────────┘ │
|
||||||
|
└──────────────────────────┬───────────────────────────────────────────────────┘
|
||||||
|
│ Packets
|
||||||
|
↓
|
||||||
|
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ GOMOBILE LIBRARY (ctrld_library) │
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ PacketCaptureController.StartWithPacketCapture() │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ Parameters: │ │
|
||||||
|
│ │ • tunAddress: "10.0.0.1" (gateway) │ │
|
||||||
|
│ │ • deviceAddress: "10.0.0.2" (device IP) │ │
|
||||||
|
│ │ • dnsProxyAddress: "127.0.0.1:5354" (Android) / ":53" (iOS) │ │
|
||||||
|
│ │ • cdUID, upstreamProto, etc. │ │
|
||||||
|
│ └──────────────────────────┬──────────────────────────────────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ↓ │
|
||||||
|
│ ┌──────────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ NETSTACK CONTROLLER │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ Components: │ │
|
||||||
|
│ │ ┌────────────────┐ ┌─────────────┐ ┌──────────────┐ │ │
|
||||||
|
│ │ │ DNS Filter │ │ IP Tracker │ │ TCP Forwarder│ │ │
|
||||||
|
│ │ │ (port 53) │ │ (5min TTL) │ │ (firewall) │ │ │
|
||||||
|
│ │ └────────────────┘ └─────────────┘ └──────────────┘ │ │
|
||||||
|
│ │ ┌────────────────┐ │ │
|
||||||
|
│ │ │ UDP Forwarder │ │ │
|
||||||
|
│ │ │ (firewall) │ │ │
|
||||||
|
│ │ └────────────────┘ │ │
|
||||||
|
│ └──────────────────────────┬───────────────────────────────────────┘ │
|
||||||
|
└─────────────────────────────┼───────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
↓
|
||||||
|
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ PACKET FLOW DETAILS │
|
||||||
|
│ │
|
||||||
|
│ INCOMING PACKET (from TUN) │
|
||||||
|
│ │ │
|
||||||
|
│ ├──→ Is DNS? (port 53) │
|
||||||
|
│ │ ├─ YES → DNS Filter │
|
||||||
|
│ │ │ ├─→ Forward to ControlD DNS Proxy │
|
||||||
|
│ │ │ │ (127.0.0.1:5354 or 127.0.0.1:53) │
|
||||||
|
│ │ │ ├─→ Get DNS response │
|
||||||
|
│ │ │ ├─→ Extract A/AAAA records │
|
||||||
|
│ │ │ ├─→ TrackIP() for each resolved IP │
|
||||||
|
│ │ │ │ • Store: resolvedIPs["93.184.216.34"] = now+5min │
|
||||||
|
│ │ │ └─→ Return DNS response to app │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ └─ NO → Is TCP/UDP? │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ ├──→ TCP Packet │
|
||||||
|
│ │ │ ├─→ Extract destination IP │
|
||||||
|
│ │ │ ├─→ Check: ipTracker.IsTracked(destIP) │
|
||||||
|
│ │ │ │ ├─ NOT TRACKED → BLOCK │
|
||||||
|
│ │ │ │ │ Log: "BLOCKED hardcoded IP" │
|
||||||
|
│ │ │ │ │ Return (connection reset) │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ │ │ │ └─ TRACKED → ALLOW │
|
||||||
|
│ │ │ │ net.Dial("tcp", destIP) │
|
||||||
|
│ │ │ │ Bidirectional copy (app ↔ internet) │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ │ └──→ UDP Packet │
|
||||||
|
│ │ ├─→ Is QUIC? (port 443/80) │
|
||||||
|
│ │ │ └─ YES → BLOCK (force TCP fallback) │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ ├─→ Extract destination IP │
|
||||||
|
│ │ ├─→ Check: ipTracker.IsTracked(destIP) │
|
||||||
|
│ │ │ ├─ NOT TRACKED → BLOCK │
|
||||||
|
│ │ │ │ Log: "BLOCKED hardcoded IP" │
|
||||||
|
│ │ │ │ Return (drop packet) │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ │ │ └─ TRACKED → ALLOW │
|
||||||
|
│ │ │ net.Dial("udp", destIP) │
|
||||||
|
│ │ │ Forward packets (app ↔ internet) │
|
||||||
|
│ │ │ 30s timeout per session │
|
||||||
|
│ │ │
|
||||||
|
│ IP TRACKER STATE (in-memory map): │
|
||||||
|
│ ┌────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ resolvedIPs map: │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ "93.184.216.34" → expires: 2026-03-20 23:35:00 │ │
|
||||||
|
│ │ "2606:2800:220::1" → expires: 2026-03-20 23:36:15 │ │
|
||||||
|
│ │ "8.8.8.8" → expires: 2026-03-20 23:37:42 │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ Cleanup: Every 30 seconds, remove expired entries │ │
|
||||||
|
│ │ TTL: 5 minutes (configurable) │ │
|
||||||
|
│ └────────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ EXAMPLE SCENARIO: │
|
||||||
|
│ ─────────────────────────────────────────────────────────────────────── │
|
||||||
|
│ │
|
||||||
|
│ T=0s: App tries: connect(1.2.3.4:443) │
|
||||||
|
│ → IsTracked(1.2.3.4)? NO │
|
||||||
|
│ → ❌ BLOCKED │
|
||||||
|
│ │
|
||||||
|
│ T=1s: App queries: DNS "example.com" │
|
||||||
|
│ → Response: A 93.184.216.34 │
|
||||||
|
│ → TrackIP(93.184.216.34) with TTL=5min │
|
||||||
|
│ │
|
||||||
|
│ T=2s: App tries: connect(93.184.216.34:443) │
|
||||||
|
│ → IsTracked(93.184.216.34)? YES (expires T+301s) │
|
||||||
|
│ → ✅ ALLOWED │
|
||||||
|
│ │
|
||||||
|
│ T=302s: App tries: connect(93.184.216.34:443) │
|
||||||
|
│ → IsTracked(93.184.216.34)? NO (expired) │
|
||||||
|
│ → ❌ BLOCKED (must do DNS again) │
|
||||||
|
└──────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ MODE COMPARISON (Firewall vs DNS-only) │
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────────────────────────┬─────────────────────────────────┐ │
|
||||||
|
│ │ FIREWALL MODE │ DNS-ONLY MODE │ │
|
||||||
|
│ │ (Default Routes Configured) │ (Only DNS Route Configured) │ │
|
||||||
|
│ ├─────────────────────────────────┼─────────────────────────────────┤ │
|
||||||
|
│ │ Routes (Android): │ Routes (Android): │ │
|
||||||
|
│ │ • addRoute("0.0.0.0", 0) │ • addRoute("10.0.0.1", 32) │ │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ │ Routes (iOS): │ Routes (iOS): │ │
|
||||||
|
│ │ • includedRoutes: [.default()] │ • includedRoutes: │ │
|
||||||
|
│ │ │ [10.0.0.1/32] │ │
|
||||||
|
│ ├─────────────────────────────────┼─────────────────────────────────┤ │
|
||||||
|
│ │ Traffic Sent to VPN: │ Traffic Sent to VPN: │ │
|
||||||
|
│ │ ✅ DNS (port 53) │ ✅ DNS (port 53) │ │
|
||||||
|
│ │ ✅ TCP (all ports) │ ❌ TCP (bypasses VPN) │ │
|
||||||
|
│ │ ✅ UDP (all ports) │ ❌ UDP (bypasses VPN) │ │
|
||||||
|
│ ├─────────────────────────────────┼─────────────────────────────────┤ │
|
||||||
|
│ │ IP Tracker Behavior: │ IP Tracker Behavior: │ │
|
||||||
|
│ │ • Tracks DNS-resolved IPs │ • Tracks DNS-resolved IPs │ │
|
||||||
|
│ │ • Blocks hardcoded TCP/UDP IPs │ • No TCP/UDP to block │ │
|
||||||
|
│ │ • Enforces DNS-first policy │ • N/A (no non-DNS traffic) │ │
|
||||||
|
│ ├─────────────────────────────────┼─────────────────────────────────┤ │
|
||||||
|
│ │ Use Case: │ Use Case: │ │
|
||||||
|
│ │ • Full content filtering │ • DNS filtering only │ │
|
||||||
|
│ │ • Block DNS bypass attempts │ • Minimal battery impact │ │
|
||||||
|
│ │ • Enforce ControlD policies │ • Fast web browsing │ │
|
||||||
|
│ └─────────────────────────────────┴─────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ MODE SWITCHING: │
|
||||||
|
│ • Android: VpnController.setFirewallMode(enabled) → recreates VPN │
|
||||||
|
│ • iOS: sendProviderMessage("set_firewall_mode") → updates routes │
|
||||||
|
│ • Both: No app restart needed │
|
||||||
|
└──────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ DETAILED PACKET FLOW (Firewall Mode) │
|
||||||
|
│ │
|
||||||
|
│ 1. APP MAKES REQUEST │
|
||||||
|
│ ─────────────────────────────────────────────────────────────────────── │
|
||||||
|
│ App: connect("example.com", 443) │
|
||||||
|
│ ↓ │
|
||||||
|
│ OS: Perform DNS lookup for "example.com" │
|
||||||
|
│ ↓ │
|
||||||
|
│ OS: Send DNS query to VPN DNS server (10.0.0.1) │
|
||||||
|
│ │
|
||||||
|
│ 2. DNS PACKET FLOW │
|
||||||
|
│ ─────────────────────────────────────────────────────────────────────── │
|
||||||
|
│ [DNS Query Packet: 10.0.0.2:12345 → 10.0.0.1:53] │
|
||||||
|
│ ↓ │
|
||||||
|
│ TUN Interface → readPacket() │
|
||||||
|
│ ↓ │
|
||||||
|
│ DNSFilter.ProcessPacket() │
|
||||||
|
│ ├─ Detect port 53 (DNS) │
|
||||||
|
│ ├─ Extract DNS payload │
|
||||||
|
│ ├─ Forward to ControlD DNS proxy (127.0.0.1:5354 or :53) │
|
||||||
|
│ │ ↓ │
|
||||||
|
│ │ ControlD DNS Proxy │
|
||||||
|
│ │ ├─ Apply filtering rules │
|
||||||
|
│ │ ├─ Query upstream DNS (DoH/DoT/DoQ) │
|
||||||
|
│ │ └─ Return response: A 93.184.216.34 │
|
||||||
|
│ │ ↓ │
|
||||||
|
│ ├─ Parse DNS response │
|
||||||
|
│ ├─ extractAndTrackIPs() │
|
||||||
|
│ │ └─ IPTracker.TrackIP(93.184.216.34) │
|
||||||
|
│ │ • Store: resolvedIPs["93.184.216.34"] = now + 5min │
|
||||||
|
│ ├─ Build DNS response packet │
|
||||||
|
│ └─ writePacket() → TUN → App │
|
||||||
|
│ │
|
||||||
|
│ OS receives DNS response → resolves "example.com" to 93.184.216.34 │
|
||||||
|
│ │
|
||||||
|
│ 3. TCP CONNECTION FLOW │
|
||||||
|
│ ─────────────────────────────────────────────────────────────────────── │
|
||||||
|
│ OS: connect(93.184.216.34:443) │
|
||||||
|
│ ↓ │
|
||||||
|
│ [TCP SYN Packet: 10.0.0.2:54321 → 93.184.216.34:443] │
|
||||||
|
│ ↓ │
|
||||||
|
│ TUN Interface → readPacket() │
|
||||||
|
│ ↓ │
|
||||||
|
│ gVisor Netstack → TCPForwarder.handleConnection() │
|
||||||
|
│ ├─ Extract destination IP: 93.184.216.34 │
|
||||||
|
│ ├─ Check internal VPN subnet (10.0.0.0/24)? │
|
||||||
|
│ │ └─ NO (skip check) │
|
||||||
|
│ ├─ ipTracker.IsTracked(93.184.216.34)? │
|
||||||
|
│ │ ├─ Check resolvedIPs map │
|
||||||
|
│ │ ├─ Found: expires at T+300s │
|
||||||
|
│ │ ├─ Not expired yet │
|
||||||
|
│ │ └─ YES ✅ │
|
||||||
|
│ ├─ ALLOWED - create upstream connection │
|
||||||
|
│ ├─ net.Dial("tcp", "93.184.216.34:443") │
|
||||||
|
│ │ ↓ │
|
||||||
|
│ │ [Real Network Connection] │
|
||||||
|
│ │ ↓ │
|
||||||
|
│ └─ Bidirectional copy (TUN ↔ Internet) │
|
||||||
|
│ │
|
||||||
|
│ 4. BLOCKED SCENARIO (Hardcoded IP) │
|
||||||
|
│ ─────────────────────────────────────────────────────────────────────── │
|
||||||
|
│ App: connect(1.2.3.4:443) // Hardcoded IP, no DNS! │
|
||||||
|
│ ↓ │
|
||||||
|
│ [TCP SYN Packet: 10.0.0.2:54322 → 1.2.3.4:443] │
|
||||||
|
│ ↓ │
|
||||||
|
│ TUN Interface → readPacket() │
|
||||||
|
│ ↓ │
|
||||||
|
│ gVisor Netstack → TCPForwarder.handleConnection() │
|
||||||
|
│ ├─ Extract destination IP: 1.2.3.4 │
|
||||||
|
│ ├─ ipTracker.IsTracked(1.2.3.4)? │
|
||||||
|
│ │ └─ Check resolvedIPs map → NOT FOUND │
|
||||||
|
│ │ └─ NO ❌ │
|
||||||
|
│ ├─ BLOCKED │
|
||||||
|
│ ├─ Log: "[TCP] BLOCKED hardcoded IP: 10.0.0.2:54322 → 1.2.3.4:443" │
|
||||||
|
│ └─ Return (send TCP RST to app) │
|
||||||
|
│ │
|
||||||
|
│ App receives connection refused/reset │
|
||||||
|
└──────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ PLATFORM-SPECIFIC DETAILS │
|
||||||
|
│ │
|
||||||
|
│ ANDROID │
|
||||||
|
│ ──────────────────────────────────────────────────────────────────────── │
|
||||||
|
│ • VPN Config: ControlDService.kt │
|
||||||
|
│ • Packet I/O: FileInputStream/FileOutputStream on VPN fd │
|
||||||
|
│ • DNS Proxy: Listens on 0.0.0.0:5354 (connects via 127.0.0.1:5354) │
|
||||||
|
│ • Self-Exclusion: addDisallowedApplication(packageName) │
|
||||||
|
│ • Mode Switch: Recreates VPN interface with new routes │
|
||||||
|
│ • No routing loops: App traffic bypasses VPN │
|
||||||
|
│ │
|
||||||
|
│ IOS │
|
||||||
|
│ ──────────────────────────────────────────────────────────────────────── │
|
||||||
|
│ • VPN Config: PacketTunnelProvider.swift │
|
||||||
|
│ • Packet I/O: NEPacketTunnelFlow (async → blocking via PacketQueue) │
|
||||||
|
│ • DNS Proxy: Listens on 127.0.0.1:53 │
|
||||||
|
│ • Self-Exclusion: Network Extension sockets auto-bypass │
|
||||||
|
│ • Mode Switch: setTunnelNetworkSettings() with new routes │
|
||||||
|
│ • Write Batching: 16 packets per batch, 5ms flush timer │
|
||||||
|
│ • No routing loops: Extension traffic bypasses VPN │
|
||||||
|
└──────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
## Components
|
## Components
|
||||||
|
|
||||||
### DNS Filter (`dns_filter.go`)
|
### DNS Filter (`dns_filter.go`)
|
||||||
Detects DNS packets on port 53 and routes to ControlD proxy.
|
- Detects DNS packets on port 53 (UDP/TCP)
|
||||||
|
- Forwards to ControlD DNS proxy (via DNS bridge)
|
||||||
|
- Parses DNS responses to extract A/AAAA records
|
||||||
|
- Automatically tracks resolved IPs via IP Tracker
|
||||||
|
- Builds DNS response packets and sends back to TUN
|
||||||
|
|
||||||
### DNS Bridge (`dns_bridge.go`)
|
### DNS Bridge (`dns_bridge.go`)
|
||||||
Tracks DNS queries by transaction ID with 5-second timeout.
|
- Bridges between netstack and ControlD DNS proxy
|
||||||
|
- Tracks DNS queries by transaction ID
|
||||||
|
- 5-second timeout per query
|
||||||
|
- Returns responses to DNS filter
|
||||||
|
|
||||||
|
### IP Tracker (`ip_tracker.go`)
|
||||||
|
- **Always enabled** - tracks all DNS-resolved IPs
|
||||||
|
- In-memory whitelist with 5-minute TTL per IP
|
||||||
|
- Background cleanup every 30 seconds (removes expired IPs)
|
||||||
|
- Thread-safe with RWMutex (optimized for read-heavy workload)
|
||||||
|
- Used by TCP/UDP forwarders to enforce DNS-first policy
|
||||||
|
|
||||||
### TCP Forwarder (`tcp_forwarder.go`)
|
### TCP Forwarder (`tcp_forwarder.go`)
|
||||||
Forwards TCP connections using gVisor's `tcp.NewForwarder()`.
|
- Handles TCP connections via gVisor's `tcp.NewForwarder()`
|
||||||
|
- Checks `ipTracker != nil` (always true) for firewall enforcement
|
||||||
|
- Allows internal VPN subnet (10.0.0.0/24) without checks
|
||||||
|
- Blocks connections to non-tracked IPs (logs: "BLOCKED hardcoded IP")
|
||||||
|
- Forwards allowed connections via `net.Dial("tcp")` to real network
|
||||||
|
- Bidirectional copy between TUN and internet
|
||||||
|
|
||||||
### UDP Forwarder (`udp_forwarder.go`)
|
### UDP Forwarder (`udp_forwarder.go`)
|
||||||
Forwards UDP packets with session tracking and 60-second idle timeout.
|
- Handles UDP packets via gVisor's `udp.NewForwarder()`
|
||||||
|
- Session tracking with 30-second read timeout
|
||||||
|
- Checks `ipTracker != nil` (always true) for firewall enforcement
|
||||||
|
- Blocks QUIC (UDP/443, UDP/80) to force TCP fallback
|
||||||
|
- Blocks connections to non-tracked IPs (logs: "BLOCKED hardcoded IP")
|
||||||
|
- Forwards allowed packets via `net.Dial("udp")` to real network
|
||||||
|
|
||||||
### Packet Handler (`packet_handler.go`)
|
### Packet Handler (`packet_handler.go`)
|
||||||
Interface for TUN I/O and socket protection.
|
- Interface for TUN I/O operations (read, write, close)
|
||||||
|
- `MobilePacketHandler` wraps mobile platform callbacks
|
||||||
|
- Bridges gomobile interface with netstack
|
||||||
|
|
||||||
### Netstack Controller (`netstack.go`)
|
### Netstack Controller (`netstack.go`)
|
||||||
Manages gVisor stack and coordinates all components.
|
- Manages gVisor TCP/IP stack
|
||||||
|
- Coordinates DNS Filter, IP Tracker, TCP/UDP Forwarders
|
||||||
## Socket Protection
|
- Always creates IP Tracker (firewall always on)
|
||||||
|
- Reads packets from TUN → injects into netstack
|
||||||
Critical for preventing routing loops:
|
- Writes packets from netstack → sends to TUN
|
||||||
|
- Filters outbound packets (source = 10.0.0.x)
|
||||||
```go
|
- Blocks QUIC before injection into netstack
|
||||||
// Protection happens BEFORE connect()
|
|
||||||
dialer.Control = func(network, address string, c syscall.RawConn) error {
|
|
||||||
return c.Control(func(fd uintptr) {
|
|
||||||
protectSocket(int(fd))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**All protected sockets:**
|
|
||||||
- TCP/UDP forwarder sockets (user traffic)
|
|
||||||
- ControlD API sockets (api.controld.com)
|
|
||||||
- DoH upstream sockets (freedns.controld.com)
|
|
||||||
|
|
||||||
## Platform Configuration
|
## Platform Configuration
|
||||||
|
|
||||||
### Android
|
### Android
|
||||||
|
|
||||||
```kotlin
|
```kotlin
|
||||||
|
// Base VPN configuration (same for both modes)
|
||||||
Builder()
|
Builder()
|
||||||
.addAddress("10.0.0.2", 24)
|
.addAddress("10.0.0.2", 24)
|
||||||
.addRoute("0.0.0.0", 0)
|
|
||||||
.addDnsServer("10.0.0.1")
|
.addDnsServer("10.0.0.1")
|
||||||
.setMtu(1500)
|
.setMtu(1500)
|
||||||
|
.setBlocking(true)
|
||||||
|
.addDisallowedApplication(packageName) // Exclude self from VPN!
|
||||||
|
|
||||||
override fun protectSocket(fd: Long) {
|
// Firewall mode - route ALL traffic
|
||||||
protect(fd.toInt()) // VpnService.protect()
|
if (isFirewallMode) {
|
||||||
|
vpnBuilder.addRoute("0.0.0.0", 0)
|
||||||
|
}
|
||||||
|
// DNS-only mode - route ONLY DNS server IP
|
||||||
|
else {
|
||||||
|
vpnBuilder.addRoute("10.0.0.1", 32)
|
||||||
}
|
}
|
||||||
|
|
||||||
// DNS Proxy: 0.0.0.0:5354
|
vpnInterface = vpnBuilder.establish()
|
||||||
|
|
||||||
|
// DNS Proxy listens on: 0.0.0.0:5354
|
||||||
|
// Library connects to: 127.0.0.1:5354
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Important:**
|
||||||
|
- App MUST exclude itself using `addDisallowedApplication()` to prevent routing loops
|
||||||
|
- Mode switching: Call `setFirewallMode(enabled)` to recreate VPN interface with new routes
|
||||||
|
|
||||||
### iOS
|
### iOS
|
||||||
|
|
||||||
```swift
|
```swift
|
||||||
NEIPv4Settings(addresses: ["10.0.0.2"], ...)
|
// Base configuration (same for both modes)
|
||||||
NEDNSSettings(servers: ["10.0.0.1"])
|
let ipv4Settings = NEIPv4Settings(
|
||||||
includedRoutes = [NEIPv4Route.default()]
|
addresses: ["10.0.0.2"],
|
||||||
|
subnetMasks: ["255.255.255.0"]
|
||||||
|
)
|
||||||
|
|
||||||
func protectSocket(_ fd: Int) throws {
|
// Firewall mode - route ALL traffic
|
||||||
// No action needed - iOS Network Extension sockets
|
if isFirewallMode {
|
||||||
// automatically bypass VPN tunnel
|
ipv4Settings.includedRoutes = [NEIPv4Route.default()]
|
||||||
|
}
|
||||||
|
// DNS-only mode - route ONLY DNS server IP
|
||||||
|
else {
|
||||||
|
ipv4Settings.includedRoutes = [
|
||||||
|
NEIPv4Route(destinationAddress: "10.0.0.1", subnetMask: "255.255.255.255")
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
// DNS Proxy: 127.0.0.1:53
|
let dnsSettings = NEDNSSettings(servers: ["10.0.0.1"])
|
||||||
|
dnsSettings.matchDomains = [""]
|
||||||
|
|
||||||
|
networkSettings.ipv4Settings = ipv4Settings
|
||||||
|
networkSettings.dnsSettings = dnsSettings
|
||||||
|
networkSettings.mtu = 1500
|
||||||
|
|
||||||
|
setTunnelNetworkSettings(networkSettings)
|
||||||
|
|
||||||
|
// DNS Proxy listens on: 127.0.0.1:53
|
||||||
|
// Library connects to: 127.0.0.1:53
|
||||||
```
|
```
|
||||||
|
|
||||||
**Note:** iOS Network Extensions run in separate process - sockets automatically bypass VPN.
|
**Note:**
|
||||||
|
- Network Extension sockets automatically bypass VPN - no routing loops
|
||||||
|
- Mode switching: Send message `{"action": "set_firewall_mode", "enabled": "true"}` to extension
|
||||||
|
|
||||||
## Protocol Support
|
## Protocol Support
|
||||||
|
|
||||||
@@ -113,17 +426,70 @@ func protectSocket(_ fd: Int) throws {
|
|||||||
|
|
||||||
## QUIC Blocking
|
## QUIC Blocking
|
||||||
|
|
||||||
Drops UDP packets on ports 443 and 80 to force TCP fallback:
|
Blocks UDP packets on ports 443 and 80 to force TCP fallback.
|
||||||
|
|
||||||
|
**Where it's blocked:**
|
||||||
|
- `netstack.go:354-369` - Blocks QUIC **before** injection into gVisor stack
|
||||||
|
- Early blocking (pre-netstack) for efficiency
|
||||||
|
- Checks destination port (UDP/443, UDP/80) in raw packet
|
||||||
|
|
||||||
**Why:**
|
**Why:**
|
||||||
- Better DNS filtering (QUIC bypasses DNS)
|
- QUIC/HTTP3 can use cached IPs, bypassing DNS filtering entirely
|
||||||
- Visible SNI for content filtering
|
- TCP/TLS provides visible SNI for content filtering
|
||||||
- Consistent ControlD policy enforcement
|
- Ensures consistent ControlD policy enforcement
|
||||||
|
- IP tracker alone isn't enough (apps cache QUIC IPs aggressively)
|
||||||
|
|
||||||
**Result:**
|
**Result:**
|
||||||
- Apps automatically fallback to TCP/TLS
|
- Apps automatically fallback to TCP/TLS (HTTP/2, HTTP/1.1)
|
||||||
- No user-visible errors
|
- No user-visible errors (fallback is seamless)
|
||||||
- Slightly slower initial connection, then normal
|
- Slightly slower initial connection, then normal performance
|
||||||
|
|
||||||
|
**Note:** IP tracker ALSO blocks hardcoded IPs, but QUIC blocking provides additional layer of protection since QUIC apps often cache IPs longer than 5 minutes.
|
||||||
|
|
||||||
|
## IP Blocking (DNS Bypass Prevention)
|
||||||
|
|
||||||
|
**Firewall is ALWAYS enabled.** The IP tracker runs in all modes and tracks all DNS-resolved IPs.
|
||||||
|
|
||||||
|
**How it works:**
|
||||||
|
1. DNS responses are parsed to extract A and AAAA records
|
||||||
|
2. Resolved IPs are tracked in memory whitelist for 5 minutes (TTL)
|
||||||
|
3. In **firewall mode**: TCP/UDP connections to **non-whitelisted** IPs are **BLOCKED**
|
||||||
|
4. In **DNS-only mode**: Only DNS traffic reaches the VPN, so IP blocking is inactive
|
||||||
|
|
||||||
|
**Mode Behavior:**
|
||||||
|
- **Firewall mode** (default routes): OS sends ALL traffic to VPN
|
||||||
|
- DNS queries → tracked IPs
|
||||||
|
- TCP/UDP connections → checked against tracker → blocked if not tracked
|
||||||
|
|
||||||
|
- **DNS-only mode** (DNS route only): OS sends ONLY DNS to VPN
|
||||||
|
- DNS queries → tracked IPs
|
||||||
|
- TCP/UDP connections → bypass VPN entirely (never reach tracker)
|
||||||
|
|
||||||
|
**Why IP tracker is always on:**
|
||||||
|
- Simplifies implementation (no enable/disable logic)
|
||||||
|
- Ready for mode switching at runtime
|
||||||
|
- In DNS-only mode, tracker tracks IPs but never blocks (no TCP/UDP traffic)
|
||||||
|
|
||||||
|
**Example (Firewall Mode):**
|
||||||
|
```
|
||||||
|
T=0s: App connects to 1.2.3.4 directly
|
||||||
|
→ ❌ BLOCKED (not in tracker)
|
||||||
|
|
||||||
|
T=1s: App queries "example.com" → DNS returns 93.184.216.34
|
||||||
|
→ Tracker stores: 93.184.216.34 (expires in 5min)
|
||||||
|
|
||||||
|
T=2s: App connects to 93.184.216.34
|
||||||
|
→ ✅ ALLOWED (found in tracker, not expired)
|
||||||
|
|
||||||
|
T=302s: App connects to 93.184.216.34
|
||||||
|
→ ❌ BLOCKED (expired, must query DNS again)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Components:**
|
||||||
|
- `ip_tracker.go` - Always-on whitelist with 5min TTL, 30s cleanup
|
||||||
|
- `dns_filter.go` - Extracts A/AAAA records, tracks IPs automatically
|
||||||
|
- `tcp_forwarder.go` - Checks `ipTracker != nil` (always true)
|
||||||
|
- `udp_forwarder.go` - Checks `ipTracker != nil` (always true)
|
||||||
|
|
||||||
## Usage (Android)
|
## Usage (Android)
|
||||||
|
|
||||||
@@ -132,9 +498,6 @@ Drops UDP packets on ports 443 and 80 to force TCP fallback:
|
|||||||
val callback = object : PacketAppCallback {
|
val callback = object : PacketAppCallback {
|
||||||
override fun readPacket(): ByteArray { ... }
|
override fun readPacket(): ByteArray { ... }
|
||||||
override fun writePacket(packet: ByteArray) { ... }
|
override fun writePacket(packet: ByteArray) { ... }
|
||||||
override fun protectSocket(fd: Long) {
|
|
||||||
protect(fd.toInt())
|
|
||||||
}
|
|
||||||
override fun closePacketIO() { ... }
|
override fun closePacketIO() { ... }
|
||||||
override fun exit(s: String) { ... }
|
override fun exit(s: String) { ... }
|
||||||
override fun hostname(): String = "android-device"
|
override fun hostname(): String = "android-device"
|
||||||
@@ -145,44 +508,59 @@ val callback = object : PacketAppCallback {
|
|||||||
// Create controller
|
// Create controller
|
||||||
val controller = Ctrld_library.newPacketCaptureController(callback)
|
val controller = Ctrld_library.newPacketCaptureController(callback)
|
||||||
|
|
||||||
// Start
|
// Start with all parameters
|
||||||
controller.startWithPacketCapture(
|
controller.startWithPacketCapture(
|
||||||
callback,
|
callback, // PacketAppCallback
|
||||||
cdUID,
|
"10.0.0.1", // TUN address (gateway)
|
||||||
"", "",
|
"10.0.0.2", // Device address
|
||||||
filesDir.absolutePath,
|
1500, // MTU
|
||||||
"doh",
|
"127.0.0.1:5354", // DNS proxy address
|
||||||
2,
|
"your-cd-uid", // ControlD UID
|
||||||
"$filesDir/ctrld.log"
|
"", // Provision ID (optional)
|
||||||
|
"", // Custom hostname (optional)
|
||||||
|
filesDir.absolutePath, // Home directory
|
||||||
|
"doh", // Upstream protocol (doh/dot/doq)
|
||||||
|
2, // Log level (0-3)
|
||||||
|
"$filesDir/ctrld.log" // Log path
|
||||||
)
|
)
|
||||||
|
|
||||||
// Stop
|
// Stop
|
||||||
controller.stop(false, 0)
|
controller.stop(false, 0)
|
||||||
|
|
||||||
|
// Runtime mode switching (no restart needed)
|
||||||
|
VpnController.instance?.setFirewallMode(context, isFirewallMode = true)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Usage (iOS)
|
## Usage (iOS)
|
||||||
|
|
||||||
```swift
|
```swift
|
||||||
// DNS-only mode
|
// Start LocalProxy with all parameters
|
||||||
let proxy = LocalProxy()
|
let proxy = LocalProxy()
|
||||||
|
proxy.mode = .firewall // or .dnsOnly
|
||||||
|
|
||||||
proxy.start(
|
proxy.start(
|
||||||
cUID: cdUID,
|
tunAddress: "10.0.0.1", // TUN address (gateway)
|
||||||
deviceName: UIDevice.current.name,
|
deviceAddress: "10.0.0.2", // Device address
|
||||||
upstreamProto: "doh",
|
mtu: 1500, // MTU
|
||||||
logLevel: 3,
|
dnsProxyAddress: "127.0.0.1:53", // DNS proxy address
|
||||||
provisionID: ""
|
cUID: cdUID, // ControlD UID
|
||||||
|
provisionID: "", // Provision ID (optional)
|
||||||
|
customHostname: "", // Custom hostname (optional)
|
||||||
|
homeDir: FileManager().temporaryDirectory.path, // Home directory
|
||||||
|
upstreamProto: "doh", // Upstream protocol
|
||||||
|
logLevel: 2, // Log level (0-3)
|
||||||
|
logPath: FileManager().temporaryDirectory.appendingPathComponent("ctrld.log").path,
|
||||||
|
deviceName: UIDevice.current.name, // Device name
|
||||||
|
packetFlow: packetFlow // NEPacketTunnelFlow
|
||||||
)
|
)
|
||||||
|
|
||||||
// Firewall mode (full capture)
|
// Stop
|
||||||
let proxy = LocalProxy()
|
proxy.stop()
|
||||||
proxy.startFirewall(
|
|
||||||
cUID: cdUID,
|
// Runtime mode switching (no restart needed)
|
||||||
deviceName: UIDevice.current.name,
|
// Send message from main app to extension:
|
||||||
upstreamProto: "doh",
|
let message = ["action": "set_firewall_mode", "enabled": "true"]
|
||||||
logLevel: 3,
|
session.sendProviderMessage(JSONEncoder().encode(message)) { response in }
|
||||||
provisionID: "",
|
|
||||||
packetFlow: packetFlow
|
|
||||||
)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
@@ -196,10 +574,11 @@ proxy.startFirewall(
|
|||||||
|
|
||||||
- `packet_handler.go` - TUN I/O interface
|
- `packet_handler.go` - TUN I/O interface
|
||||||
- `netstack.go` - gVisor controller
|
- `netstack.go` - gVisor controller
|
||||||
- `dns_filter.go` - DNS packet detection
|
- `dns_filter.go` - DNS packet detection and IP extraction
|
||||||
- `dns_bridge.go` - Transaction tracking
|
- `dns_bridge.go` - Transaction tracking
|
||||||
- `tcp_forwarder.go` - TCP forwarding
|
- `ip_tracker.go` - DNS-resolved IP whitelist with TTL
|
||||||
- `udp_forwarder.go` - UDP forwarding
|
- `tcp_forwarder.go` - TCP forwarding with whitelist enforcement
|
||||||
|
- `udp_forwarder.go` - UDP forwarding with whitelist enforcement
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
|
|
||||||
|
"github.com/miekg/dns"
|
||||||
"gvisor.dev/gvisor/pkg/tcpip"
|
"gvisor.dev/gvisor/pkg/tcpip"
|
||||||
"gvisor.dev/gvisor/pkg/tcpip/header"
|
"gvisor.dev/gvisor/pkg/tcpip/header"
|
||||||
)
|
)
|
||||||
@@ -12,12 +13,14 @@ import (
|
|||||||
// DNSFilter intercepts and processes DNS packets.
|
// DNSFilter intercepts and processes DNS packets.
|
||||||
type DNSFilter struct {
|
type DNSFilter struct {
|
||||||
dnsHandler func([]byte) ([]byte, error)
|
dnsHandler func([]byte) ([]byte, error)
|
||||||
|
ipTracker *IPTracker
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewDNSFilter creates a new DNS filter with the given handler.
|
// NewDNSFilter creates a new DNS filter with the given handler.
|
||||||
func NewDNSFilter(handler func([]byte) ([]byte, error)) *DNSFilter {
|
func NewDNSFilter(handler func([]byte) ([]byte, error), ipTracker *IPTracker) *DNSFilter {
|
||||||
return &DNSFilter{
|
return &DNSFilter{
|
||||||
dnsHandler: handler,
|
dnsHandler: handler,
|
||||||
|
ipTracker: ipTracker,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,6 +104,11 @@ func (df *DNSFilter) processIPv4(packet []byte) (bool, []byte, error) {
|
|||||||
return true, nil, fmt.Errorf("DNS handler error: %v", err)
|
return true, nil, fmt.Errorf("DNS handler error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Track IPs from DNS response
|
||||||
|
if df.ipTracker != nil {
|
||||||
|
df.extractAndTrackIPs(dnsResponse)
|
||||||
|
}
|
||||||
|
|
||||||
// Build response packet
|
// Build response packet
|
||||||
responsePacket := df.buildIPv4UDPPacket(
|
responsePacket := df.buildIPv4UDPPacket(
|
||||||
dstIP.As4(), // Swap src/dst
|
dstIP.As4(), // Swap src/dst
|
||||||
@@ -166,6 +174,11 @@ func (df *DNSFilter) processIPv6(packet []byte) (bool, []byte, error) {
|
|||||||
return true, nil, fmt.Errorf("DNS handler error: %v", err)
|
return true, nil, fmt.Errorf("DNS handler error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Track IPs from DNS response
|
||||||
|
if df.ipTracker != nil {
|
||||||
|
df.extractAndTrackIPs(dnsResponse)
|
||||||
|
}
|
||||||
|
|
||||||
// Build response packet
|
// Build response packet
|
||||||
srcIP := ipHdr.SourceAddress()
|
srcIP := ipHdr.SourceAddress()
|
||||||
dstIP := ipHdr.DestinationAddress()
|
dstIP := ipHdr.DestinationAddress()
|
||||||
@@ -322,3 +335,31 @@ func parseUDP(udpHeader []byte) (srcPort, dstPort uint16, ok bool) {
|
|||||||
ok = true
|
ok = true
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// extractAndTrackIPs parses DNS response and tracks resolved IP addresses
|
||||||
|
func (df *DNSFilter) extractAndTrackIPs(dnsResponse []byte) {
|
||||||
|
if len(dnsResponse) < 12 {
|
||||||
|
return // Invalid DNS response
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := new(dns.Msg)
|
||||||
|
if err := msg.Unpack(dnsResponse); err != nil {
|
||||||
|
return // Failed to parse DNS response
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract IPs from answer section
|
||||||
|
for _, answer := range msg.Answer {
|
||||||
|
switch rr := answer.(type) {
|
||||||
|
case *dns.A:
|
||||||
|
// IPv4 address
|
||||||
|
if rr.A != nil {
|
||||||
|
df.ipTracker.TrackIP(rr.A)
|
||||||
|
}
|
||||||
|
case *dns.AAAA:
|
||||||
|
// IPv6 address
|
||||||
|
if rr.AAAA != nil {
|
||||||
|
df.ipTracker.TrackIP(rr.AAAA)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
150
cmd/ctrld_library/netstack/ip_tracker.go
Normal file
150
cmd/ctrld_library/netstack/ip_tracker.go
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
package netstack
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// IPTracker tracks IP addresses that have been resolved through DNS.
|
||||||
|
// This allows blocking direct IP connections that bypass DNS filtering.
|
||||||
|
type IPTracker struct {
|
||||||
|
// Map of IP address string -> expiration time
|
||||||
|
resolvedIPs map[string]time.Time
|
||||||
|
mu sync.RWMutex
|
||||||
|
|
||||||
|
// TTL for tracked IPs (how long to remember them)
|
||||||
|
ttl time.Duration
|
||||||
|
|
||||||
|
// Running state
|
||||||
|
running bool
|
||||||
|
stopCh chan struct{}
|
||||||
|
wg sync.WaitGroup
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewIPTracker creates a new IP tracker with the specified TTL
|
||||||
|
func NewIPTracker(ttl time.Duration) *IPTracker {
|
||||||
|
if ttl == 0 {
|
||||||
|
ttl = 5 * time.Minute // Default 5 minutes
|
||||||
|
}
|
||||||
|
|
||||||
|
return &IPTracker{
|
||||||
|
resolvedIPs: make(map[string]time.Time),
|
||||||
|
ttl: ttl,
|
||||||
|
stopCh: make(chan struct{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start starts the IP tracker cleanup routine
|
||||||
|
func (t *IPTracker) Start() {
|
||||||
|
t.mu.Lock()
|
||||||
|
if t.running {
|
||||||
|
t.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.running = true
|
||||||
|
t.mu.Unlock()
|
||||||
|
|
||||||
|
// Start cleanup goroutine to remove expired IPs
|
||||||
|
t.wg.Add(1)
|
||||||
|
go t.cleanupExpiredIPs()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop stops the IP tracker
|
||||||
|
func (t *IPTracker) Stop() {
|
||||||
|
if t == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
t.mu.Lock()
|
||||||
|
if !t.running {
|
||||||
|
t.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.running = false
|
||||||
|
t.mu.Unlock()
|
||||||
|
|
||||||
|
// Close stop channel (protected against double close)
|
||||||
|
select {
|
||||||
|
case <-t.stopCh:
|
||||||
|
// Already closed
|
||||||
|
default:
|
||||||
|
close(t.stopCh)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.wg.Wait()
|
||||||
|
|
||||||
|
// Clear all tracked IPs
|
||||||
|
t.mu.Lock()
|
||||||
|
t.resolvedIPs = make(map[string]time.Time)
|
||||||
|
t.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TrackIP adds an IP address to the tracking list
|
||||||
|
func (t *IPTracker) TrackIP(ip net.IP) {
|
||||||
|
if ip == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize to string format
|
||||||
|
ipStr := ip.String()
|
||||||
|
|
||||||
|
t.mu.Lock()
|
||||||
|
t.resolvedIPs[ipStr] = time.Now().Add(t.ttl)
|
||||||
|
t.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsTracked checks if an IP address is in the tracking list
|
||||||
|
// Optimized to minimize lock contention by avoiding write locks in the hot path
|
||||||
|
func (t *IPTracker) IsTracked(ip net.IP) bool {
|
||||||
|
if ip == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
ipStr := ip.String()
|
||||||
|
|
||||||
|
t.mu.RLock()
|
||||||
|
expiration, exists := t.resolvedIPs[ipStr]
|
||||||
|
t.mu.RUnlock()
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if expired - but DON'T delete here to avoid write lock
|
||||||
|
// Let the cleanup goroutine handle expired entries
|
||||||
|
// This keeps IsTracked fast with only read locks
|
||||||
|
return !time.Now().After(expiration)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTrackedCount returns the number of currently tracked IPs
|
||||||
|
func (t *IPTracker) GetTrackedCount() int {
|
||||||
|
t.mu.RLock()
|
||||||
|
defer t.mu.RUnlock()
|
||||||
|
return len(t.resolvedIPs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanupExpiredIPs periodically removes expired IP entries
|
||||||
|
func (t *IPTracker) cleanupExpiredIPs() {
|
||||||
|
defer t.wg.Done()
|
||||||
|
|
||||||
|
ticker := time.NewTicker(30 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-t.stopCh:
|
||||||
|
return
|
||||||
|
|
||||||
|
case <-ticker.C:
|
||||||
|
now := time.Now()
|
||||||
|
t.mu.Lock()
|
||||||
|
for ip, expiration := range t.resolvedIPs {
|
||||||
|
if now.After(expiration) {
|
||||||
|
delete(t.resolvedIPs, ip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,7 +28,7 @@ const (
|
|||||||
NICID = 1
|
NICID = 1
|
||||||
|
|
||||||
// Channel capacity for packet buffers
|
// Channel capacity for packet buffers
|
||||||
channelCapacity = 256
|
channelCapacity = 512
|
||||||
)
|
)
|
||||||
|
|
||||||
// NetstackController manages the gVisor netstack integration for mobile packet capture.
|
// NetstackController manages the gVisor netstack integration for mobile packet capture.
|
||||||
@@ -37,6 +37,7 @@ type NetstackController struct {
|
|||||||
linkEP *channel.Endpoint
|
linkEP *channel.Endpoint
|
||||||
packetHandler PacketHandler
|
packetHandler PacketHandler
|
||||||
dnsFilter *DNSFilter
|
dnsFilter *DNSFilter
|
||||||
|
ipTracker *IPTracker
|
||||||
tcpForwarder *TCPForwarder
|
tcpForwarder *TCPForwarder
|
||||||
udpForwarder *UDPForwarder
|
udpForwarder *UDPForwarder
|
||||||
|
|
||||||
@@ -100,14 +101,19 @@ func NewNetstackController(handler PacketHandler, cfg *Config) (*NetstackControl
|
|||||||
// Create link endpoint
|
// Create link endpoint
|
||||||
linkEP := channel.New(channelCapacity, cfg.MTU, "")
|
linkEP := channel.New(channelCapacity, cfg.MTU, "")
|
||||||
|
|
||||||
// Create DNS filter
|
// Always create IP tracker (5 minute TTL for tracked IPs)
|
||||||
dnsFilter := NewDNSFilter(cfg.DNSHandler)
|
// In firewall mode (default routes): blocks direct IP connections
|
||||||
|
// In DNS-only mode: no non-DNS traffic to block
|
||||||
|
ipTracker := NewIPTracker(5 * time.Minute)
|
||||||
|
|
||||||
// Create TCP forwarder
|
// Create DNS filter with IP tracker
|
||||||
tcpForwarder := NewTCPForwarder(s, handler.ProtectSocket, ctx)
|
dnsFilter := NewDNSFilter(cfg.DNSHandler, ipTracker)
|
||||||
|
|
||||||
// Create UDP forwarder
|
// Create TCP forwarder with IP tracker
|
||||||
udpForwarder := NewUDPForwarder(s, handler.ProtectSocket, ctx)
|
tcpForwarder := NewTCPForwarder(s, ctx, ipTracker)
|
||||||
|
|
||||||
|
// Create UDP forwarder with IP tracker
|
||||||
|
udpForwarder := NewUDPForwarder(s, ctx, ipTracker)
|
||||||
|
|
||||||
// Create NIC
|
// Create NIC
|
||||||
if err := s.CreateNIC(NICID, linkEP); err != nil {
|
if err := s.CreateNIC(NICID, linkEP); err != nil {
|
||||||
@@ -176,6 +182,7 @@ func NewNetstackController(handler PacketHandler, cfg *Config) (*NetstackControl
|
|||||||
linkEP: linkEP,
|
linkEP: linkEP,
|
||||||
packetHandler: handler,
|
packetHandler: handler,
|
||||||
dnsFilter: dnsFilter,
|
dnsFilter: dnsFilter,
|
||||||
|
ipTracker: ipTracker,
|
||||||
tcpForwarder: tcpForwarder,
|
tcpForwarder: tcpForwarder,
|
||||||
udpForwarder: udpForwarder,
|
udpForwarder: udpForwarder,
|
||||||
ctx: ctx,
|
ctx: ctx,
|
||||||
@@ -199,6 +206,9 @@ func (nc *NetstackController) Start() error {
|
|||||||
|
|
||||||
nc.started = true
|
nc.started = true
|
||||||
|
|
||||||
|
// Start IP tracker
|
||||||
|
nc.ipTracker.Start()
|
||||||
|
|
||||||
// Start packet reader goroutine (TUN -> netstack)
|
// Start packet reader goroutine (TUN -> netstack)
|
||||||
nc.wg.Add(1)
|
nc.wg.Add(1)
|
||||||
go nc.readPackets()
|
go nc.readPackets()
|
||||||
@@ -207,36 +217,69 @@ func (nc *NetstackController) Start() error {
|
|||||||
nc.wg.Add(1)
|
nc.wg.Add(1)
|
||||||
go nc.writePackets()
|
go nc.writePackets()
|
||||||
|
|
||||||
ctrld.ProxyLogger.Load().Info().Msg("[Netstack] Packet processing started (read/write goroutines)")
|
ctrld.ProxyLogger.Load().Info().Msg("[Netstack] Packet processing started (read/write goroutines + IP tracker)")
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop stops the netstack controller and waits for all goroutines to finish.
|
// Stop stops the netstack controller and waits for all goroutines to finish.
|
||||||
func (nc *NetstackController) Stop() error {
|
func (nc *NetstackController) Stop() error {
|
||||||
|
ctrld.ProxyLogger.Load().Info().Msg("[Netstack] Stop() called - starting shutdown")
|
||||||
|
|
||||||
nc.mu.Lock()
|
nc.mu.Lock()
|
||||||
if !nc.started {
|
if !nc.started {
|
||||||
nc.mu.Unlock()
|
nc.mu.Unlock()
|
||||||
|
ctrld.ProxyLogger.Load().Info().Msg("[Netstack] Stop() - already stopped, returning")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
nc.mu.Unlock()
|
nc.mu.Unlock()
|
||||||
|
|
||||||
|
ctrld.ProxyLogger.Load().Info().Msg("[Netstack] Stop() - canceling context")
|
||||||
nc.cancel()
|
nc.cancel()
|
||||||
nc.wg.Wait()
|
|
||||||
|
// Close packet handler FIRST to unblock all pending reads
|
||||||
|
ctrld.ProxyLogger.Load().Info().Msg("[Netstack] Stop() - closing packet handler to unblock goroutines")
|
||||||
|
if err := nc.packetHandler.Close(); err != nil {
|
||||||
|
ctrld.ProxyLogger.Load().Error().Msgf("[Netstack] Stop() - failed to close packet handler: %v", err)
|
||||||
|
// Continue shutdown even if close fails
|
||||||
|
}
|
||||||
|
ctrld.ProxyLogger.Load().Info().Msg("[Netstack] Stop() - packet handler closed")
|
||||||
|
|
||||||
|
ctrld.ProxyLogger.Load().Info().Msg("[Netstack] Stop() - waiting for goroutines (max 2 seconds)")
|
||||||
|
|
||||||
|
// Wait for goroutines with timeout
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
nc.wg.Wait()
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
ctrld.ProxyLogger.Load().Info().Msg("[Netstack] Stop() - all goroutines finished")
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
ctrld.ProxyLogger.Load().Warn().Msg("[Netstack] Stop() - timeout waiting for goroutines, proceeding anyway")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop IP tracker
|
||||||
|
if nc.ipTracker != nil {
|
||||||
|
ctrld.ProxyLogger.Load().Info().Msg("[Netstack] Stop() - stopping IP tracker")
|
||||||
|
nc.ipTracker.Stop()
|
||||||
|
ctrld.ProxyLogger.Load().Info().Msg("[Netstack] Stop() - IP tracker stopped")
|
||||||
|
}
|
||||||
|
|
||||||
// Close UDP forwarder
|
// Close UDP forwarder
|
||||||
if nc.udpForwarder != nil {
|
if nc.udpForwarder != nil {
|
||||||
|
ctrld.ProxyLogger.Load().Info().Msg("[Netstack] Stop() - closing UDP forwarder")
|
||||||
nc.udpForwarder.Close()
|
nc.udpForwarder.Close()
|
||||||
}
|
ctrld.ProxyLogger.Load().Info().Msg("[Netstack] Stop() - UDP forwarder closed")
|
||||||
|
|
||||||
if err := nc.packetHandler.Close(); err != nil {
|
|
||||||
return fmt.Errorf("failed to close packet handler: %v", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
nc.mu.Lock()
|
nc.mu.Lock()
|
||||||
nc.started = false
|
nc.started = false
|
||||||
nc.mu.Unlock()
|
nc.mu.Unlock()
|
||||||
|
|
||||||
|
ctrld.ProxyLogger.Load().Info().Msg("[Netstack] Stop() - shutdown complete")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,20 +17,14 @@ type PacketHandler interface {
|
|||||||
|
|
||||||
// Close closes the packet handler and releases resources.
|
// Close closes the packet handler and releases resources.
|
||||||
Close() error
|
Close() error
|
||||||
|
|
||||||
// ProtectSocket protects a socket file descriptor from being routed through the VPN.
|
|
||||||
// This is required on Android/iOS to prevent routing loops.
|
|
||||||
// Returns nil if successful, error otherwise.
|
|
||||||
ProtectSocket(fd int) error
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MobilePacketHandler implements PacketHandler using callbacks from mobile platforms.
|
// MobilePacketHandler implements PacketHandler using callbacks from mobile platforms.
|
||||||
// This bridges Go Mobile interface with the netstack implementation.
|
// This bridges Go Mobile interface with the netstack implementation.
|
||||||
type MobilePacketHandler struct {
|
type MobilePacketHandler struct {
|
||||||
readFunc func() ([]byte, error)
|
readFunc func() ([]byte, error)
|
||||||
writeFunc func([]byte) error
|
writeFunc func([]byte) error
|
||||||
closeFunc func() error
|
closeFunc func() error
|
||||||
protectFunc func(int) error
|
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
closed bool
|
closed bool
|
||||||
@@ -41,14 +35,12 @@ func NewMobilePacketHandler(
|
|||||||
readFunc func() ([]byte, error),
|
readFunc func() ([]byte, error),
|
||||||
writeFunc func([]byte) error,
|
writeFunc func([]byte) error,
|
||||||
closeFunc func() error,
|
closeFunc func() error,
|
||||||
protectFunc func(int) error,
|
|
||||||
) *MobilePacketHandler {
|
) *MobilePacketHandler {
|
||||||
return &MobilePacketHandler{
|
return &MobilePacketHandler{
|
||||||
readFunc: readFunc,
|
readFunc: readFunc,
|
||||||
writeFunc: writeFunc,
|
writeFunc: writeFunc,
|
||||||
closeFunc: closeFunc,
|
closeFunc: closeFunc,
|
||||||
protectFunc: protectFunc,
|
closed: false,
|
||||||
closed: false,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,13 +95,3 @@ func (m *MobilePacketHandler) Close() error {
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProtectSocket protects a socket file descriptor from VPN routing.
|
|
||||||
func (m *MobilePacketHandler) ProtectSocket(fd int) error {
|
|
||||||
if m.protectFunc == nil {
|
|
||||||
// No protect function provided - this is okay for non-VPN scenarios
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return m.protectFunc(fd)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"syscall"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/Control-D-Inc/ctrld"
|
"github.com/Control-D-Inc/ctrld"
|
||||||
@@ -16,20 +15,20 @@ import (
|
|||||||
|
|
||||||
// TCPForwarder handles TCP connections from the TUN interface
|
// TCPForwarder handles TCP connections from the TUN interface
|
||||||
type TCPForwarder struct {
|
type TCPForwarder struct {
|
||||||
protectSocket func(fd int) error
|
ctx context.Context
|
||||||
ctx context.Context
|
forwarder *tcp.Forwarder
|
||||||
forwarder *tcp.Forwarder
|
ipTracker *IPTracker
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewTCPForwarder creates a new TCP forwarder
|
// NewTCPForwarder creates a new TCP forwarder
|
||||||
func NewTCPForwarder(s *stack.Stack, protectSocket func(fd int) error, ctx context.Context) *TCPForwarder {
|
func NewTCPForwarder(s *stack.Stack, ctx context.Context, ipTracker *IPTracker) *TCPForwarder {
|
||||||
f := &TCPForwarder{
|
f := &TCPForwarder{
|
||||||
protectSocket: protectSocket,
|
ctx: ctx,
|
||||||
ctx: ctx,
|
ipTracker: ipTracker,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create gVisor TCP forwarder with handler callback
|
// Create gVisor TCP forwarder with handler callback
|
||||||
// rcvWnd=0 (default), maxInFlight=1024
|
// rcvWnd=0 (use default), maxInFlight=1024
|
||||||
f.forwarder = tcp.NewForwarder(s, 0, 1024, f.handleRequest)
|
f.forwarder = tcp.NewForwarder(s, 0, 1024, f.handleRequest)
|
||||||
|
|
||||||
return f
|
return f
|
||||||
@@ -78,23 +77,31 @@ func (f *TCPForwarder) handleConnection(ep *tcp.Endpoint, wq *waiter.Queue, id s
|
|||||||
// - LocalAddress/LocalPort = the destination (where packet is going TO)
|
// - LocalAddress/LocalPort = the destination (where packet is going TO)
|
||||||
// - RemoteAddress/RemotePort = the source (where packet is coming FROM)
|
// - RemoteAddress/RemotePort = the source (where packet is coming FROM)
|
||||||
// We want to dial the DESTINATION (LocalAddress/LocalPort)
|
// We want to dial the DESTINATION (LocalAddress/LocalPort)
|
||||||
|
dstIP := net.IP(id.LocalAddress.AsSlice())
|
||||||
dstAddr := net.TCPAddr{
|
dstAddr := net.TCPAddr{
|
||||||
IP: net.IP(id.LocalAddress.AsSlice()),
|
IP: dstIP,
|
||||||
Port: int(id.LocalPort),
|
Port: int(id.LocalPort),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create outbound connection with socket protection DURING dial
|
// Check if IP blocking is enabled (firewall mode only)
|
||||||
dialer := &net.Dialer{
|
// Skip blocking for internal VPN subnet (10.0.0.0/24)
|
||||||
Timeout: 30 * time.Second,
|
if f.ipTracker != nil {
|
||||||
|
// Allow internal VPN traffic (10.0.0.0/24)
|
||||||
|
if !(dstIP[0] == 10 && dstIP[1] == 0 && dstIP[2] == 0) {
|
||||||
|
// Check if destination IP was resolved through ControlD DNS
|
||||||
|
// ONLY allow connections to IPs that went through DNS (whitelist approach)
|
||||||
|
if !f.ipTracker.IsTracked(dstIP) {
|
||||||
|
srcAddr := net.IP(id.RemoteAddress.AsSlice())
|
||||||
|
ctrld.ProxyLogger.Load().Info().Msgf("[TCP] BLOCKED hardcoded IP: %s:%d -> %s:%d (not resolved via DNS)",
|
||||||
|
srcAddr, id.RemotePort, dstIP, id.LocalPort)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// CRITICAL: Protect socket BEFORE connect() is called
|
// Create outbound connection
|
||||||
if f.protectSocket != nil {
|
dialer := &net.Dialer{
|
||||||
dialer.Control = func(network, address string, c syscall.RawConn) error {
|
Timeout: 30 * time.Second,
|
||||||
return c.Control(func(fd uintptr) {
|
|
||||||
f.protectSocket(int(fd))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
upstreamConn, err := dialer.DialContext(f.ctx, "tcp", dstAddr.String())
|
upstreamConn, err := dialer.DialContext(f.ctx, "tcp", dstAddr.String())
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"sync"
|
"sync"
|
||||||
"syscall"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/Control-D-Inc/ctrld"
|
"github.com/Control-D-Inc/ctrld"
|
||||||
@@ -17,9 +16,9 @@ import (
|
|||||||
|
|
||||||
// UDPForwarder handles UDP packets from the TUN interface
|
// UDPForwarder handles UDP packets from the TUN interface
|
||||||
type UDPForwarder struct {
|
type UDPForwarder struct {
|
||||||
protectSocket func(fd int) error
|
ctx context.Context
|
||||||
ctx context.Context
|
forwarder *udp.Forwarder
|
||||||
forwarder *udp.Forwarder
|
ipTracker *IPTracker
|
||||||
|
|
||||||
// Track UDP "connections" (address pairs)
|
// Track UDP "connections" (address pairs)
|
||||||
connections map[string]*udpConn
|
connections map[string]*udpConn
|
||||||
@@ -33,11 +32,11 @@ type udpConn struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewUDPForwarder creates a new UDP forwarder
|
// NewUDPForwarder creates a new UDP forwarder
|
||||||
func NewUDPForwarder(s *stack.Stack, protectSocket func(fd int) error, ctx context.Context) *UDPForwarder {
|
func NewUDPForwarder(s *stack.Stack, ctx context.Context, ipTracker *IPTracker) *UDPForwarder {
|
||||||
f := &UDPForwarder{
|
f := &UDPForwarder{
|
||||||
protectSocket: protectSocket,
|
ctx: ctx,
|
||||||
ctx: ctx,
|
ipTracker: ipTracker,
|
||||||
connections: make(map[string]*udpConn),
|
connections: make(map[string]*udpConn),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create gVisor UDP forwarder with handler callback
|
// Create gVisor UDP forwarder with handler callback
|
||||||
@@ -102,23 +101,31 @@ func (f *UDPForwarder) createConnection(req *udp.ForwarderRequest, connKey strin
|
|||||||
// Extract destination address
|
// Extract destination address
|
||||||
// LocalAddress/LocalPort = destination (where packet is going TO)
|
// LocalAddress/LocalPort = destination (where packet is going TO)
|
||||||
// RemoteAddress/RemotePort = source (where packet is coming FROM)
|
// RemoteAddress/RemotePort = source (where packet is coming FROM)
|
||||||
|
dstIP := net.IP(id.LocalAddress.AsSlice())
|
||||||
dstAddr := &net.UDPAddr{
|
dstAddr := &net.UDPAddr{
|
||||||
IP: net.IP(id.LocalAddress.AsSlice()),
|
IP: dstIP,
|
||||||
Port: int(id.LocalPort),
|
Port: int(id.LocalPort),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create dialer with socket protection DURING dial
|
// Check if IP blocking is enabled (firewall mode only)
|
||||||
dialer := &net.Dialer{}
|
// Skip blocking for internal VPN subnet (10.0.0.0/24)
|
||||||
|
if f.ipTracker != nil {
|
||||||
// CRITICAL: Protect socket BEFORE connect() is called
|
// Allow internal VPN traffic (10.0.0.0/24)
|
||||||
if f.protectSocket != nil {
|
if !(dstIP[0] == 10 && dstIP[1] == 0 && dstIP[2] == 0) {
|
||||||
dialer.Control = func(network, address string, c syscall.RawConn) error {
|
// Check if destination IP was resolved through ControlD DNS
|
||||||
return c.Control(func(fd uintptr) {
|
// ONLY allow connections to IPs that went through DNS (whitelist approach)
|
||||||
f.protectSocket(int(fd))
|
if !f.ipTracker.IsTracked(dstIP) {
|
||||||
})
|
srcAddr := net.IP(id.RemoteAddress.AsSlice())
|
||||||
|
ctrld.ProxyLogger.Load().Info().Msgf("[UDP] BLOCKED hardcoded IP: %s:%d -> %s:%d (not resolved via DNS)",
|
||||||
|
srcAddr, id.RemotePort, dstIP, id.LocalPort)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create dialer
|
||||||
|
dialer := &net.Dialer{}
|
||||||
|
|
||||||
// Create outbound UDP connection
|
// Create outbound UDP connection
|
||||||
dialConn, dialErr := dialer.Dial("udp", dstAddr.String())
|
dialConn, dialErr := dialer.Dial("udp", dstAddr.String())
|
||||||
if dialErr != nil {
|
if dialErr != nil {
|
||||||
@@ -214,13 +221,18 @@ func (f *UDPForwarder) forwardUpstreamToTun(conn *udpConn, ctx context.Context,
|
|||||||
|
|
||||||
// Close closes all UDP connections
|
// Close closes all UDP connections
|
||||||
func (f *UDPForwarder) Close() {
|
func (f *UDPForwarder) Close() {
|
||||||
|
ctrld.ProxyLogger.Load().Info().Msg("[UDP] Close() called - closing all connections")
|
||||||
|
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
defer f.mu.Unlock()
|
defer f.mu.Unlock()
|
||||||
|
|
||||||
for _, conn := range f.connections {
|
ctrld.ProxyLogger.Load().Info().Msgf("[UDP] Close() - closing %d connections", len(f.connections))
|
||||||
|
for key, conn := range f.connections {
|
||||||
|
ctrld.ProxyLogger.Load().Debug().Msgf("[UDP] Close() - closing connection: %s", key)
|
||||||
conn.cancel()
|
conn.cancel()
|
||||||
conn.tunEP.Close()
|
conn.tunEP.Close()
|
||||||
conn.upstreamConn.Close()
|
conn.upstreamConn.Close()
|
||||||
}
|
}
|
||||||
f.connections = make(map[string]*udpConn)
|
f.connections = make(map[string]*udpConn)
|
||||||
|
ctrld.ProxyLogger.Load().Info().Msg("[UDP] Close() - all connections closed")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package ctrld_library
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"runtime"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/Control-D-Inc/ctrld"
|
"github.com/Control-D-Inc/ctrld"
|
||||||
@@ -26,12 +25,6 @@ type PacketAppCallback interface {
|
|||||||
|
|
||||||
// ClosePacketIO closes packet I/O resources.
|
// ClosePacketIO closes packet I/O resources.
|
||||||
ClosePacketIO() error
|
ClosePacketIO() error
|
||||||
|
|
||||||
// ProtectSocket protects a socket file descriptor from being routed through the VPN.
|
|
||||||
// On Android, this calls VpnService.protect() to prevent routing loops.
|
|
||||||
// On iOS, this marks the socket to bypass the VPN.
|
|
||||||
// Returns nil on success, error on failure.
|
|
||||||
ProtectSocket(fd int) error
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// PacketCaptureController holds state for packet capture mode
|
// PacketCaptureController holds state for packet capture mode
|
||||||
@@ -39,9 +32,10 @@ type PacketCaptureController struct {
|
|||||||
baseController *Controller
|
baseController *Controller
|
||||||
|
|
||||||
// Packet capture mode fields
|
// Packet capture mode fields
|
||||||
netstackCtrl *netstack.NetstackController
|
netstackCtrl *netstack.NetstackController
|
||||||
dnsBridge *netstack.DNSBridge
|
dnsBridge *netstack.DNSBridge
|
||||||
packetStopCh chan struct{}
|
packetStopCh chan struct{}
|
||||||
|
dnsProxyAddress string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewPacketCaptureController creates a new packet capture controller
|
// NewPacketCaptureController creates a new packet capture controller
|
||||||
@@ -57,6 +51,10 @@ func NewPacketCaptureController(appCallback PacketAppCallback) *PacketCaptureCon
|
|||||||
// It requires a PacketAppCallback that provides packet read/write capabilities.
|
// It requires a PacketAppCallback that provides packet read/write capabilities.
|
||||||
func (pc *PacketCaptureController) StartWithPacketCapture(
|
func (pc *PacketCaptureController) StartWithPacketCapture(
|
||||||
packetCallback PacketAppCallback,
|
packetCallback PacketAppCallback,
|
||||||
|
tunAddress string,
|
||||||
|
deviceAddress string,
|
||||||
|
mtu int64,
|
||||||
|
dnsProxyAddress string,
|
||||||
CdUID string,
|
CdUID string,
|
||||||
ProvisionID string,
|
ProvisionID string,
|
||||||
CustomHostname string,
|
CustomHostname string,
|
||||||
@@ -69,6 +67,14 @@ func (pc *PacketCaptureController) StartWithPacketCapture(
|
|||||||
return fmt.Errorf("controller already running")
|
return fmt.Errorf("controller already running")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Store DNS proxy address for handleDNSQuery
|
||||||
|
pc.dnsProxyAddress = dnsProxyAddress
|
||||||
|
|
||||||
|
// Set defaults
|
||||||
|
if mtu == 0 {
|
||||||
|
mtu = 1500
|
||||||
|
}
|
||||||
|
|
||||||
// Set up configuration
|
// Set up configuration
|
||||||
pc.baseController.Config = cli.AppConfig{
|
pc.baseController.Config = cli.AppConfig{
|
||||||
CdUID: CdUID,
|
CdUID: CdUID,
|
||||||
@@ -81,10 +87,6 @@ func (pc *PacketCaptureController) StartWithPacketCapture(
|
|||||||
}
|
}
|
||||||
pc.baseController.AppCallback = packetCallback
|
pc.baseController.AppCallback = packetCallback
|
||||||
|
|
||||||
// Set global socket protector for HTTP client sockets (API calls, etc)
|
|
||||||
// This prevents routing loops when ctrld makes HTTP requests to api.controld.com
|
|
||||||
ctrld.SetSocketProtector(packetCallback.ProtectSocket)
|
|
||||||
|
|
||||||
// Create DNS bridge for communication between netstack and DNS proxy
|
// Create DNS bridge for communication between netstack and DNS proxy
|
||||||
pc.dnsBridge = netstack.NewDNSBridge()
|
pc.dnsBridge = netstack.NewDNSBridge()
|
||||||
pc.dnsBridge.Start()
|
pc.dnsBridge.Start()
|
||||||
@@ -94,36 +96,29 @@ func (pc *PacketCaptureController) StartWithPacketCapture(
|
|||||||
packetCallback.ReadPacket,
|
packetCallback.ReadPacket,
|
||||||
packetCallback.WritePacket,
|
packetCallback.WritePacket,
|
||||||
packetCallback.ClosePacketIO,
|
packetCallback.ClosePacketIO,
|
||||||
packetCallback.ProtectSocket,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Create DNS handler that uses the bridge
|
// Create DNS handler that uses the bridge
|
||||||
dnsHandler := func(query []byte) ([]byte, error) {
|
dnsHandler := func(query []byte) ([]byte, error) {
|
||||||
// Extract source IP from query context if available
|
// Use device address as the source of DNS queries
|
||||||
// For now, use a placeholder
|
return pc.dnsBridge.ProcessQuery(query, deviceAddress, 0)
|
||||||
return pc.dnsBridge.ProcessQuery(query, "10.0.0.2", 0)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-detect platform and use appropriate TUN IP
|
// Parse TUN IP address
|
||||||
// Android: TUN=10.0.0.1, Device=10.0.0.2
|
tunIPv4, err := netip.ParseAddr(tunAddress)
|
||||||
// iOS: TUN=10.0.0.2, Device=10.0.0.1
|
|
||||||
tunIP := "10.0.0.1" // Default for Android
|
|
||||||
// Check if running on iOS (no reliable way, so we'll make it configurable)
|
|
||||||
// For now, use Android config. iOS should update their VPN settings to match.
|
|
||||||
|
|
||||||
tunIPv4, err := netip.ParseAddr(tunIP)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to parse TUN IPv4: %v", err)
|
return fmt.Errorf("failed to parse TUN IPv4 address '%s': %v", tunAddress, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
netstackCfg := &netstack.Config{
|
netstackCfg := &netstack.Config{
|
||||||
MTU: 1500,
|
MTU: uint32(mtu),
|
||||||
TUNIPv4: tunIPv4,
|
TUNIPv4: tunIPv4,
|
||||||
DNSHandler: dnsHandler,
|
DNSHandler: dnsHandler,
|
||||||
UpstreamInterface: nil, // Will use default interface
|
UpstreamInterface: nil, // Will use default interface
|
||||||
}
|
}
|
||||||
|
|
||||||
ctrld.ProxyLogger.Load().Info().Msgf("[PacketCapture] Netstack TUN IP: %s", tunIP)
|
ctrld.ProxyLogger.Load().Info().Msgf("[PacketCapture] Network config - TUN: %s, Device: %s, MTU: %d, DNS Proxy: %s",
|
||||||
|
tunAddress, deviceAddress, mtu, dnsProxyAddress)
|
||||||
|
|
||||||
// Create netstack controller
|
// Create netstack controller
|
||||||
netstackCtrl, err := netstack.NewNetstackController(packetHandler, netstackCfg)
|
netstackCtrl, err := netstack.NewNetstackController(packetHandler, netstackCfg)
|
||||||
@@ -154,12 +149,10 @@ func (pc *PacketCaptureController) StartWithPacketCapture(
|
|||||||
cli.RunMobile(&pc.baseController.Config, &appCallback, pc.baseController.stopCh)
|
cli.RunMobile(&pc.baseController.Config, &appCallback, pc.baseController.stopCh)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Log platform detection for DNS proxy port
|
// BLOCK here until stopped (critical - Swift expects this to block!)
|
||||||
dnsPort := "5354"
|
ctrld.ProxyLogger.Load().Info().Msg("[PacketCapture] Blocking until stop signal...")
|
||||||
if runtime.GOOS == "ios" || runtime.GOOS == "darwin" {
|
<-pc.baseController.stopCh
|
||||||
dnsPort = "53"
|
ctrld.ProxyLogger.Load().Info().Msg("[PacketCapture] Stop signal received, exiting")
|
||||||
}
|
|
||||||
ctrld.ProxyLogger.Load().Info().Msgf("[PacketCapture] Platform: %s, DNS proxy port: %s", runtime.GOOS, dnsPort)
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -188,22 +181,13 @@ func (pc *PacketCaptureController) handleDNSQuery(query *netstack.DNSQuery) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine DNS proxy port based on platform
|
// Send query to actual DNS proxy using configured address
|
||||||
// Android: 0.0.0.0:5354
|
|
||||||
// iOS: 127.0.0.1:53
|
|
||||||
dnsProxyAddr := "127.0.0.1:5354" // Default for Android
|
|
||||||
if runtime.GOOS == "ios" || runtime.GOOS == "darwin" {
|
|
||||||
// iOS uses port 53
|
|
||||||
dnsProxyAddr = "127.0.0.1:53"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send query to actual DNS proxy
|
|
||||||
client := &dns.Client{
|
client := &dns.Client{
|
||||||
Net: "udp",
|
Net: "udp",
|
||||||
Timeout: 3 * time.Second,
|
Timeout: 3 * time.Second,
|
||||||
}
|
}
|
||||||
|
|
||||||
response, _, err := client.Exchange(msg, dnsProxyAddr)
|
response, _, err := client.Exchange(msg, pc.dnsProxyAddress)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Create SERVFAIL response
|
// Create SERVFAIL response
|
||||||
response = new(dns.Msg)
|
response = new(dns.Msg)
|
||||||
@@ -223,41 +207,62 @@ func (pc *PacketCaptureController) handleDNSQuery(query *netstack.DNSQuery) {
|
|||||||
|
|
||||||
// Stop stops the packet capture controller
|
// Stop stops the packet capture controller
|
||||||
func (pc *PacketCaptureController) Stop(restart bool, pin int64) int {
|
func (pc *PacketCaptureController) Stop(restart bool, pin int64) int {
|
||||||
|
ctrld.ProxyLogger.Load().Info().Msg("[PacketCapture] Stop() called - starting shutdown")
|
||||||
var errorCode = 0
|
var errorCode = 0
|
||||||
|
|
||||||
// Clear global socket protector
|
|
||||||
ctrld.SetSocketProtector(nil)
|
|
||||||
|
|
||||||
// Stop DNS bridge
|
// Stop DNS bridge
|
||||||
if pc.dnsBridge != nil {
|
if pc.dnsBridge != nil {
|
||||||
|
ctrld.ProxyLogger.Load().Info().Msg("[PacketCapture] Stop() - stopping DNS bridge")
|
||||||
pc.dnsBridge.Stop()
|
pc.dnsBridge.Stop()
|
||||||
pc.dnsBridge = nil
|
pc.dnsBridge = nil
|
||||||
|
ctrld.ProxyLogger.Load().Info().Msg("[PacketCapture] Stop() - DNS bridge stopped")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop netstack
|
// Stop netstack
|
||||||
if pc.netstackCtrl != nil {
|
if pc.netstackCtrl != nil {
|
||||||
|
ctrld.ProxyLogger.Load().Info().Msg("[PacketCapture] Stop() - stopping netstack controller")
|
||||||
if err := pc.netstackCtrl.Stop(); err != nil {
|
if err := pc.netstackCtrl.Stop(); err != nil {
|
||||||
// Log error but continue shutdown
|
// Log error but continue shutdown
|
||||||
fmt.Printf("Error stopping netstack: %v\n", err)
|
ctrld.ProxyLogger.Load().Error().Msgf("[PacketCapture] Stop() - error stopping netstack: %v", err)
|
||||||
}
|
}
|
||||||
pc.netstackCtrl = nil
|
pc.netstackCtrl = nil
|
||||||
|
ctrld.ProxyLogger.Load().Info().Msg("[PacketCapture] Stop() - netstack controller stopped")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close packet stop channel
|
// Close packet stop channel
|
||||||
if pc.packetStopCh != nil {
|
if pc.packetStopCh != nil {
|
||||||
close(pc.packetStopCh)
|
ctrld.ProxyLogger.Load().Info().Msg("[PacketCapture] Stop() - closing packet stop channel")
|
||||||
|
select {
|
||||||
|
case <-pc.packetStopCh:
|
||||||
|
// Already closed
|
||||||
|
ctrld.ProxyLogger.Load().Info().Msg("[PacketCapture] Stop() - packet stop channel already closed")
|
||||||
|
default:
|
||||||
|
close(pc.packetStopCh)
|
||||||
|
ctrld.ProxyLogger.Load().Info().Msg("[PacketCapture] Stop() - packet stop channel closed")
|
||||||
|
}
|
||||||
pc.packetStopCh = make(chan struct{})
|
pc.packetStopCh = make(chan struct{})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop base controller
|
// Stop base controller
|
||||||
|
ctrld.ProxyLogger.Load().Info().Msgf("[PacketCapture] Stop() - stopping base controller (restart=%v, pin=%d)", restart, pin)
|
||||||
if !restart {
|
if !restart {
|
||||||
errorCode = cli.CheckDeactivationPin(pin, pc.baseController.stopCh)
|
errorCode = cli.CheckDeactivationPin(pin, pc.baseController.stopCh)
|
||||||
|
ctrld.ProxyLogger.Load().Info().Msgf("[PacketCapture] Stop() - deactivation pin check returned: %d", errorCode)
|
||||||
}
|
}
|
||||||
if errorCode == 0 && pc.baseController.stopCh != nil {
|
if errorCode == 0 && pc.baseController.stopCh != nil {
|
||||||
close(pc.baseController.stopCh)
|
ctrld.ProxyLogger.Load().Info().Msg("[PacketCapture] Stop() - closing base controller stop channel")
|
||||||
|
select {
|
||||||
|
case <-pc.baseController.stopCh:
|
||||||
|
// Already closed
|
||||||
|
ctrld.ProxyLogger.Load().Info().Msg("[PacketCapture] Stop() - base controller stop channel already closed")
|
||||||
|
default:
|
||||||
|
close(pc.baseController.stopCh)
|
||||||
|
ctrld.ProxyLogger.Load().Info().Msg("[PacketCapture] Stop() - base controller stop channel closed")
|
||||||
|
}
|
||||||
pc.baseController.stopCh = nil
|
pc.baseController.stopCh = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ctrld.ProxyLogger.Load().Info().Msgf("[PacketCapture] Stop() - shutdown complete, errorCode=%d", errorCode)
|
||||||
return errorCode
|
return errorCode
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user