This commit is contained in:
Ginder Singh
2026-03-20 01:01:04 -04:00
parent afe7804a9b
commit 0e9a1225fc
7 changed files with 516 additions and 288 deletions
+446 -100
View File
@@ -9,99 +9,408 @@ Provides full packet capture for mobile VPN applications:
- **IP whitelisting** - only allows connections to DNS-resolved IPs - **IP whitelisting** - only allows connections to DNS-resolved IPs
- **TCP forwarding** for all TCP traffic (with whitelist enforcement) - **TCP forwarding** for all TCP traffic (with whitelist enforcement)
- **UDP forwarding** with session tracking (with whitelist enforcement) - **UDP forwarding** with session tracking (with whitelist enforcement)
- **Socket protection** to prevent routing loops
- **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, routes to ControlD proxy, and extracts resolved IPs. - 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`) ### IP Tracker (`ip_tracker.go`)
Maintains whitelist of DNS-resolved IPs with 5-minute TTL. - **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()`. Blocks non-whitelisted IPs. - 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 30-second read deadline. Blocks non-whitelisted IPs. - 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
@@ -117,45 +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) ## IP Blocking (DNS Bypass Prevention)
Enforces whitelist approach: ONLY allows connections to IPs resolved through ControlD DNS. **Firewall is ALWAYS enabled.** The IP tracker runs in all modes and tracks all DNS-resolved IPs.
**How it works:** **How it works:**
1. DNS responses are parsed to extract A and AAAA records 1. DNS responses are parsed to extract A and AAAA records
2. Resolved IPs are tracked in memory whitelist for 5 minutes 2. Resolved IPs are tracked in memory whitelist for 5 minutes (TTL)
3. TCP/UDP connections to **non-whitelisted** IPs are **BLOCKED** 3. In **firewall mode**: TCP/UDP connections to **non-whitelisted** IPs are **BLOCKED**
4. Only IPs that went through DNS resolution are allowed 4. In **DNS-only mode**: Only DNS traffic reaches the VPN, so IP blocking is inactive
**Why:** **Mode Behavior:**
- Prevents DNS bypass via hardcoded/cached IPs - **Firewall mode** (default routes): OS sends ALL traffic to VPN
- Ensures ALL traffic must go through ControlD DNS first - DNS queries → tracked IPs
- Blocks apps that try to skip DNS filtering - TCP/UDP connections → checked against tracker → blocked if not tracked
- Enforces strict ControlD policy compliance
**Example:** - **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):**
``` ```
✅ ALLOWED: App queries "example.com" → 93.184.216.34 → connects to 93.184.216.34 T=0s: App connects to 1.2.3.4 directly
❌ BLOCKED: App connects directly to 1.2.3.4 (not resolved via DNS) → ❌ 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:** **Components:**
- `ip_tracker.go` - Manages whitelist of DNS-resolved IPs with TTL - `ip_tracker.go` - Always-on whitelist with 5min TTL, 30s cleanup
- `dns_filter.go` - Extracts IPs from DNS responses for whitelist - `dns_filter.go` - Extracts A/AAAA records, tracks IPs automatically
- `tcp_forwarder.go` - Allows only whitelisted IPs, blocks others - `tcp_forwarder.go` - Checks `ipTracker != nil` (always true)
- `udp_forwarder.go` - Allows only whitelisted IPs, blocks others - `udp_forwarder.go` - Checks `ipTracker != nil` (always true)
## Usage (Android) ## Usage (Android)
@@ -164,9 +498,6 @@ Enforces whitelist approach: ONLY allows connections to IPs resolved through Con
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"
@@ -177,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
+7 -36
View File
@@ -16,17 +16,14 @@ type IPTracker struct {
// TTL for tracked IPs (how long to remember them) // TTL for tracked IPs (how long to remember them)
ttl time.Duration ttl time.Duration
// Enable IP blocking (only in firewall mode)
enabled bool
// Running state // Running state
running bool running bool
stopCh chan struct{} stopCh chan struct{}
wg sync.WaitGroup wg sync.WaitGroup
} }
// NewIPTracker creates a new IP tracker with the specified TTL and enabled flag // NewIPTracker creates a new IP tracker with the specified TTL
func NewIPTracker(ttl time.Duration, enabled bool) *IPTracker { func NewIPTracker(ttl time.Duration) *IPTracker {
if ttl == 0 { if ttl == 0 {
ttl = 5 * time.Minute // Default 5 minutes ttl = 5 * time.Minute // Default 5 minutes
} }
@@ -34,31 +31,10 @@ func NewIPTracker(ttl time.Duration, enabled bool) *IPTracker {
return &IPTracker{ return &IPTracker{
resolvedIPs: make(map[string]time.Time), resolvedIPs: make(map[string]time.Time),
ttl: ttl, ttl: ttl,
enabled: enabled,
stopCh: make(chan struct{}), stopCh: make(chan struct{}),
} }
} }
// IsEnabled returns whether IP blocking is enabled
func (t *IPTracker) IsEnabled() bool {
if t == nil {
return false
}
t.mu.RLock()
defer t.mu.RUnlock()
return t.enabled
}
// SetEnabled sets whether IP blocking is enabled
func (t *IPTracker) SetEnabled(enabled bool) {
if t == nil {
return
}
t.mu.Lock()
t.enabled = enabled
t.mu.Unlock()
}
// Start starts the IP tracker cleanup routine // Start starts the IP tracker cleanup routine
func (t *IPTracker) Start() { func (t *IPTracker) Start() {
t.mu.Lock() t.mu.Lock()
@@ -119,6 +95,7 @@ func (t *IPTracker) TrackIP(ip net.IP) {
} }
// IsTracked checks if an IP address is in the tracking list // 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 { func (t *IPTracker) IsTracked(ip net.IP) bool {
if ip == nil { if ip == nil {
return false return false
@@ -134,16 +111,10 @@ func (t *IPTracker) IsTracked(ip net.IP) bool {
return false return false
} }
// Check if expired // Check if expired - but DON'T delete here to avoid write lock
if time.Now().After(expiration) { // Let the cleanup goroutine handle expired entries
// Clean up expired entry // This keeps IsTracked fast with only read locks
t.mu.Lock() return !time.Now().After(expiration)
delete(t.resolvedIPs, ipStr)
t.mu.Unlock()
return false
}
return true
} }
// GetTrackedCount returns the number of currently tracked IPs // GetTrackedCount returns the number of currently tracked IPs
+7 -21
View File
@@ -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.
@@ -65,9 +65,6 @@ type Config struct {
// UpstreamInterface is the real network interface for routing non-DNS traffic // UpstreamInterface is the real network interface for routing non-DNS traffic
UpstreamInterface *net.Interface UpstreamInterface *net.Interface
// EnableIPBlocking enables IP whitelisting (firewall mode only)
EnableIPBlocking bool
} }
// NewNetstackController creates a new netstack controller. // NewNetstackController creates a new netstack controller.
@@ -104,17 +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 IP tracker (5 minute TTL for tracked IPs, enabled based on config) // Always create IP tracker (5 minute TTL for tracked IPs)
ipTracker := NewIPTracker(5*time.Minute, cfg.EnableIPBlocking) // 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 DNS filter with IP tracker // Create DNS filter with IP tracker
dnsFilter := NewDNSFilter(cfg.DNSHandler, ipTracker) dnsFilter := NewDNSFilter(cfg.DNSHandler, ipTracker)
// Create TCP forwarder with IP tracker // Create TCP forwarder with IP tracker
tcpForwarder := NewTCPForwarder(s, handler.ProtectSocket, ctx, ipTracker) tcpForwarder := NewTCPForwarder(s, ctx, ipTracker)
// Create UDP forwarder with IP tracker // Create UDP forwarder with IP tracker
udpForwarder := NewUDPForwarder(s, handler.ProtectSocket, ctx, ipTracker) 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 {
@@ -223,19 +222,6 @@ func (nc *NetstackController) Start() error {
return nil return nil
} }
// SetFirewallMode enables or disables IP whitelisting at runtime
func (nc *NetstackController) SetFirewallMode(enabled bool) {
if nc == nil {
return
}
nc.mu.Lock()
defer nc.mu.Unlock()
if nc.ipTracker != nil {
nc.ipTracker.SetEnabled(enabled)
}
}
// 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") ctrld.ProxyLogger.Load().Info().Msg("[Netstack] Stop() called - starting shutdown")
+7 -25
View File
@@ -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)
}
+9 -21
View File
@@ -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,22 +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
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, ipTracker *IPTracker) *TCPForwarder { func NewTCPForwarder(s *stack.Stack, ctx context.Context, ipTracker *IPTracker) *TCPForwarder {
f := &TCPForwarder{ f := &TCPForwarder{
protectSocket: protectSocket, ctx: ctx,
ctx: ctx, ipTracker: ipTracker,
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
@@ -88,7 +85,7 @@ func (f *TCPForwarder) handleConnection(ep *tcp.Endpoint, wq *waiter.Queue, id s
// Check if IP blocking is enabled (firewall mode only) // Check if IP blocking is enabled (firewall mode only)
// Skip blocking for internal VPN subnet (10.0.0.0/24) // Skip blocking for internal VPN subnet (10.0.0.0/24)
if f.ipTracker != nil && f.ipTracker.IsEnabled() { if f.ipTracker != nil {
// Allow internal VPN traffic (10.0.0.0/24) // Allow internal VPN traffic (10.0.0.0/24)
if !(dstIP[0] == 10 && dstIP[1] == 0 && dstIP[2] == 0) { if !(dstIP[0] == 10 && dstIP[1] == 0 && dstIP[2] == 0) {
// Check if destination IP was resolved through ControlD DNS // Check if destination IP was resolved through ControlD DNS
@@ -102,20 +99,11 @@ func (f *TCPForwarder) handleConnection(ep *tcp.Endpoint, wq *waiter.Queue, id s
} }
} }
// Create outbound connection with socket protection DURING dial // Create outbound connection
dialer := &net.Dialer{ dialer := &net.Dialer{
Timeout: 30 * time.Second, Timeout: 30 * time.Second,
} }
// CRITICAL: Protect socket BEFORE connect() is called
if f.protectSocket != nil {
dialer.Control = func(network, address string, c syscall.RawConn) error {
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())
if err != nil { if err != nil {
return return
+9 -21
View File
@@ -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,10 +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
ipTracker *IPTracker
// Track UDP "connections" (address pairs) // Track UDP "connections" (address pairs)
connections map[string]*udpConn connections map[string]*udpConn
@@ -34,12 +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, ipTracker *IPTracker) *UDPForwarder { func NewUDPForwarder(s *stack.Stack, ctx context.Context, ipTracker *IPTracker) *UDPForwarder {
f := &UDPForwarder{ f := &UDPForwarder{
protectSocket: protectSocket, ctx: ctx,
ctx: ctx, ipTracker: ipTracker,
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
@@ -112,7 +109,7 @@ func (f *UDPForwarder) createConnection(req *udp.ForwarderRequest, connKey strin
// Check if IP blocking is enabled (firewall mode only) // Check if IP blocking is enabled (firewall mode only)
// Skip blocking for internal VPN subnet (10.0.0.0/24) // Skip blocking for internal VPN subnet (10.0.0.0/24)
if f.ipTracker != nil && f.ipTracker.IsEnabled() { if f.ipTracker != nil {
// Allow internal VPN traffic (10.0.0.0/24) // Allow internal VPN traffic (10.0.0.0/24)
if !(dstIP[0] == 10 && dstIP[1] == 0 && dstIP[2] == 0) { if !(dstIP[0] == 10 && dstIP[1] == 0 && dstIP[2] == 0) {
// Check if destination IP was resolved through ControlD DNS // Check if destination IP was resolved through ControlD DNS
@@ -126,18 +123,9 @@ func (f *UDPForwarder) createConnection(req *udp.ForwarderRequest, connKey strin
} }
} }
// Create dialer with socket protection DURING dial // Create dialer
dialer := &net.Dialer{} dialer := &net.Dialer{}
// CRITICAL: Protect socket BEFORE connect() is called
if f.protectSocket != nil {
dialer.Control = func(network, address string, c syscall.RawConn) error {
return c.Control(func(fd uintptr) {
f.protectSocket(int(fd))
})
}
}
// 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 {
+31 -64
View File
@@ -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,37 +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
EnableIPBlocking: true, // Enable IP whitelisting in firewall mode
} }
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)
@@ -155,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
} }
@@ -189,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)
@@ -227,10 +210,6 @@ func (pc *PacketCaptureController) Stop(restart bool, pin int64) int {
ctrld.ProxyLogger.Load().Info().Msg("[PacketCapture] Stop() called - starting shutdown") ctrld.ProxyLogger.Load().Info().Msg("[PacketCapture] Stop() called - starting shutdown")
var errorCode = 0 var errorCode = 0
// Clear global socket protector
ctrld.ProxyLogger.Load().Info().Msg("[PacketCapture] Stop() - clearing 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") ctrld.ProxyLogger.Load().Info().Msg("[PacketCapture] Stop() - stopping DNS bridge")
@@ -296,15 +275,3 @@ func (pc *PacketCaptureController) IsRunning() bool {
func (pc *PacketCaptureController) IsPacketMode() bool { func (pc *PacketCaptureController) IsPacketMode() bool {
return true return true
} }
// SetFirewallMode enables or disables firewall mode (IP whitelisting) at runtime
func (pc *PacketCaptureController) SetFirewallMode(enabled bool) {
if pc.netstackCtrl != nil {
pc.netstackCtrl.SetFirewallMode(enabled)
if enabled {
ctrld.ProxyLogger.Load().Info().Msg("[PacketCapture] Firewall mode ENABLED - IP whitelisting active")
} else {
ctrld.ProxyLogger.Load().Info().Msg("[PacketCapture] Firewall mode DISABLED - all IPs allowed")
}
}
}