From 0e9a1225fcad6c855d10d09a68a01dbff6584f03 Mon Sep 17 00:00:00 2001 From: Ginder Singh Date: Fri, 20 Mar 2026 01:01:04 -0400 Subject: [PATCH] cleanup. --- cmd/ctrld_library/netstack/README.md | 546 +++++++++++++++---- cmd/ctrld_library/netstack/ip_tracker.go | 43 +- cmd/ctrld_library/netstack/netstack.go | 28 +- cmd/ctrld_library/netstack/packet_handler.go | 32 +- cmd/ctrld_library/netstack/tcp_forwarder.go | 30 +- cmd/ctrld_library/netstack/udp_forwarder.go | 30 +- cmd/ctrld_library/packet_capture.go | 95 ++-- 7 files changed, 516 insertions(+), 288 deletions(-) diff --git a/cmd/ctrld_library/netstack/README.md b/cmd/ctrld_library/netstack/README.md index 7acd934..5ed2ab7 100644 --- a/cmd/ctrld_library/netstack/README.md +++ b/cmd/ctrld_library/netstack/README.md @@ -9,99 +9,408 @@ Provides full packet capture for mobile VPN applications: - **IP whitelisting** - only allows connections to DNS-resolved IPs - **TCP forwarding** for all TCP traffic (with whitelist enforcement) - **UDP forwarding** with session tracking (with whitelist enforcement) -- **Socket protection** to prevent routing loops - **QUIC blocking** for better content filtering -## Architecture +## Master Architecture Diagram ``` -Mobile Apps → VPN TUN Interface → PacketHandler → gVisor Netstack - ↓ -├─→ DNS Filter (Port 53) -│ └─→ ControlD DNS Proxy -├─→ TCP Forwarder -│ └─→ net.Dial("tcp") + protect(fd) -└─→ UDP Forwarder - └─→ net.Dial("udp") + protect(fd) - ↓ -Real Network (Protected Sockets) -``` +┌──────────────────────────────────────────────────────────────────────────────┐ +│ MOBILE APP (Android/iOS) │ +│ │ +│ ┌─────────────────────────────────────────────────────────────────────┐ │ +│ │ VPN Configuration │ │ +│ │ │ │ +│ │ Android: iOS: │ │ +│ │ ┌──────────────────────┐ ┌──────────────────────┐ │ │ +│ │ │ Builder() │ │ NEIPv4Settings │ │ │ +│ │ │ .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 ### 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`) -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`) -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`) -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`) -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`) -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`) -Manages gVisor stack and coordinates all components. - -## Socket Protection - -Critical for preventing routing loops: - -```go -// 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) +- Manages gVisor TCP/IP stack +- Coordinates DNS Filter, IP Tracker, TCP/UDP Forwarders +- Always creates IP Tracker (firewall always on) +- Reads packets from TUN → injects into netstack +- Writes packets from netstack → sends to TUN +- Filters outbound packets (source = 10.0.0.x) +- Blocks QUIC before injection into netstack ## Platform Configuration ### Android ```kotlin +// Base VPN configuration (same for both modes) Builder() .addAddress("10.0.0.2", 24) - .addRoute("0.0.0.0", 0) .addDnsServer("10.0.0.1") .setMtu(1500) + .setBlocking(true) + .addDisallowedApplication(packageName) // Exclude self from VPN! -override fun protectSocket(fd: Long) { - protect(fd.toInt()) // VpnService.protect() +// Firewall mode - route ALL traffic +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 ```swift -NEIPv4Settings(addresses: ["10.0.0.2"], ...) -NEDNSSettings(servers: ["10.0.0.1"]) -includedRoutes = [NEIPv4Route.default()] +// Base configuration (same for both modes) +let ipv4Settings = NEIPv4Settings( + addresses: ["10.0.0.2"], + subnetMasks: ["255.255.255.0"] +) -func protectSocket(_ fd: Int) throws { - // No action needed - iOS Network Extension sockets - // automatically bypass VPN tunnel +// Firewall mode - route ALL traffic +if isFirewallMode { + 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 @@ -117,45 +426,70 @@ func protectSocket(_ fd: Int) throws { ## 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:** -- Better DNS filtering (QUIC bypasses DNS) -- Visible SNI for content filtering -- Consistent ControlD policy enforcement +- QUIC/HTTP3 can use cached IPs, bypassing DNS filtering entirely +- TCP/TLS provides visible SNI for content filtering +- Ensures consistent ControlD policy enforcement +- IP tracker alone isn't enough (apps cache QUIC IPs aggressively) **Result:** -- Apps automatically fallback to TCP/TLS -- No user-visible errors -- Slightly slower initial connection, then normal +- Apps automatically fallback to TCP/TLS (HTTP/2, HTTP/1.1) +- No user-visible errors (fallback is seamless) +- 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) -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:** 1. DNS responses are parsed to extract A and AAAA records -2. Resolved IPs are tracked in memory whitelist for 5 minutes -3. TCP/UDP connections to **non-whitelisted** IPs are **BLOCKED** -4. Only IPs that went through DNS resolution are allowed +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 -**Why:** -- Prevents DNS bypass via hardcoded/cached IPs -- Ensures ALL traffic must go through ControlD DNS first -- Blocks apps that try to skip DNS filtering -- Enforces strict ControlD policy compliance +**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 -**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 -❌ BLOCKED: App connects directly to 1.2.3.4 (not resolved via DNS) +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` - Manages whitelist of DNS-resolved IPs with TTL -- `dns_filter.go` - Extracts IPs from DNS responses for whitelist -- `tcp_forwarder.go` - Allows only whitelisted IPs, blocks others -- `udp_forwarder.go` - Allows only whitelisted IPs, blocks others +- `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) @@ -164,9 +498,6 @@ Enforces whitelist approach: ONLY allows connections to IPs resolved through Con val callback = object : PacketAppCallback { override fun readPacket(): ByteArray { ... } override fun writePacket(packet: ByteArray) { ... } - override fun protectSocket(fd: Long) { - protect(fd.toInt()) - } override fun closePacketIO() { ... } override fun exit(s: String) { ... } override fun hostname(): String = "android-device" @@ -177,44 +508,59 @@ val callback = object : PacketAppCallback { // Create controller val controller = Ctrld_library.newPacketCaptureController(callback) -// Start +// Start with all parameters controller.startWithPacketCapture( - callback, - cdUID, - "", "", - filesDir.absolutePath, - "doh", - 2, - "$filesDir/ctrld.log" + callback, // PacketAppCallback + "10.0.0.1", // TUN address (gateway) + "10.0.0.2", // Device address + 1500, // MTU + "127.0.0.1:5354", // DNS proxy address + "your-cd-uid", // ControlD UID + "", // 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 controller.stop(false, 0) + +// Runtime mode switching (no restart needed) +VpnController.instance?.setFirewallMode(context, isFirewallMode = true) ``` ## Usage (iOS) ```swift -// DNS-only mode +// Start LocalProxy with all parameters let proxy = LocalProxy() +proxy.mode = .firewall // or .dnsOnly + proxy.start( - cUID: cdUID, - deviceName: UIDevice.current.name, - upstreamProto: "doh", - logLevel: 3, - provisionID: "" + tunAddress: "10.0.0.1", // TUN address (gateway) + deviceAddress: "10.0.0.2", // Device address + mtu: 1500, // MTU + dnsProxyAddress: "127.0.0.1:53", // DNS proxy address + 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) -let proxy = LocalProxy() -proxy.startFirewall( - cUID: cdUID, - deviceName: UIDevice.current.name, - upstreamProto: "doh", - logLevel: 3, - provisionID: "", - packetFlow: packetFlow -) +// Stop +proxy.stop() + +// Runtime mode switching (no restart needed) +// Send message from main app to extension: +let message = ["action": "set_firewall_mode", "enabled": "true"] +session.sendProviderMessage(JSONEncoder().encode(message)) { response in } ``` ## Requirements diff --git a/cmd/ctrld_library/netstack/ip_tracker.go b/cmd/ctrld_library/netstack/ip_tracker.go index 09d0d1a..265c9cc 100644 --- a/cmd/ctrld_library/netstack/ip_tracker.go +++ b/cmd/ctrld_library/netstack/ip_tracker.go @@ -16,17 +16,14 @@ type IPTracker struct { // TTL for tracked IPs (how long to remember them) ttl time.Duration - // Enable IP blocking (only in firewall mode) - enabled bool - // Running state running bool stopCh chan struct{} wg sync.WaitGroup } -// NewIPTracker creates a new IP tracker with the specified TTL and enabled flag -func NewIPTracker(ttl time.Duration, enabled bool) *IPTracker { +// 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 } @@ -34,31 +31,10 @@ func NewIPTracker(ttl time.Duration, enabled bool) *IPTracker { return &IPTracker{ resolvedIPs: make(map[string]time.Time), ttl: ttl, - enabled: enabled, 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 func (t *IPTracker) Start() { 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 +// 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 @@ -134,16 +111,10 @@ func (t *IPTracker) IsTracked(ip net.IP) bool { return false } - // Check if expired - if time.Now().After(expiration) { - // Clean up expired entry - t.mu.Lock() - delete(t.resolvedIPs, ipStr) - t.mu.Unlock() - return false - } - - return true + // 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 diff --git a/cmd/ctrld_library/netstack/netstack.go b/cmd/ctrld_library/netstack/netstack.go index 758dd01..53a57d5 100644 --- a/cmd/ctrld_library/netstack/netstack.go +++ b/cmd/ctrld_library/netstack/netstack.go @@ -28,7 +28,7 @@ const ( NICID = 1 // Channel capacity for packet buffers - channelCapacity = 256 + channelCapacity = 512 ) // 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 *net.Interface - - // EnableIPBlocking enables IP whitelisting (firewall mode only) - EnableIPBlocking bool } // NewNetstackController creates a new netstack controller. @@ -104,17 +101,19 @@ func NewNetstackController(handler PacketHandler, cfg *Config) (*NetstackControl // Create link endpoint linkEP := channel.New(channelCapacity, cfg.MTU, "") - // Create IP tracker (5 minute TTL for tracked IPs, enabled based on config) - ipTracker := NewIPTracker(5*time.Minute, cfg.EnableIPBlocking) + // Always create IP tracker (5 minute TTL for tracked IPs) + // 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 dnsFilter := NewDNSFilter(cfg.DNSHandler, ipTracker) // Create TCP forwarder with IP tracker - tcpForwarder := NewTCPForwarder(s, handler.ProtectSocket, ctx, ipTracker) + tcpForwarder := NewTCPForwarder(s, ctx, ipTracker) // Create UDP forwarder with IP tracker - udpForwarder := NewUDPForwarder(s, handler.ProtectSocket, ctx, ipTracker) + udpForwarder := NewUDPForwarder(s, ctx, ipTracker) // Create NIC if err := s.CreateNIC(NICID, linkEP); err != nil { @@ -223,19 +222,6 @@ func (nc *NetstackController) Start() error { 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. func (nc *NetstackController) Stop() error { ctrld.ProxyLogger.Load().Info().Msg("[Netstack] Stop() called - starting shutdown") diff --git a/cmd/ctrld_library/netstack/packet_handler.go b/cmd/ctrld_library/netstack/packet_handler.go index ee86f1e..0b52614 100644 --- a/cmd/ctrld_library/netstack/packet_handler.go +++ b/cmd/ctrld_library/netstack/packet_handler.go @@ -17,20 +17,14 @@ type PacketHandler interface { // Close closes the packet handler and releases resources. 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. // This bridges Go Mobile interface with the netstack implementation. type MobilePacketHandler struct { - readFunc func() ([]byte, error) - writeFunc func([]byte) error - closeFunc func() error - protectFunc func(int) error + readFunc func() ([]byte, error) + writeFunc func([]byte) error + closeFunc func() error mu sync.Mutex closed bool @@ -41,14 +35,12 @@ func NewMobilePacketHandler( readFunc func() ([]byte, error), writeFunc func([]byte) error, closeFunc func() error, - protectFunc func(int) error, ) *MobilePacketHandler { return &MobilePacketHandler{ - readFunc: readFunc, - writeFunc: writeFunc, - closeFunc: closeFunc, - protectFunc: protectFunc, - closed: false, + readFunc: readFunc, + writeFunc: writeFunc, + closeFunc: closeFunc, + closed: false, } } @@ -103,13 +95,3 @@ func (m *MobilePacketHandler) Close() error { 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) -} diff --git a/cmd/ctrld_library/netstack/tcp_forwarder.go b/cmd/ctrld_library/netstack/tcp_forwarder.go index 49af5cb..5efaede 100644 --- a/cmd/ctrld_library/netstack/tcp_forwarder.go +++ b/cmd/ctrld_library/netstack/tcp_forwarder.go @@ -4,7 +4,6 @@ import ( "context" "io" "net" - "syscall" "time" "github.com/Control-D-Inc/ctrld" @@ -16,22 +15,20 @@ import ( // TCPForwarder handles TCP connections from the TUN interface type TCPForwarder struct { - protectSocket func(fd int) error - ctx context.Context - forwarder *tcp.Forwarder - ipTracker *IPTracker + ctx context.Context + forwarder *tcp.Forwarder + ipTracker *IPTracker } // 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{ - protectSocket: protectSocket, - ctx: ctx, - ipTracker: ipTracker, + ctx: ctx, + ipTracker: ipTracker, } // 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) 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) // 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) if !(dstIP[0] == 10 && dstIP[1] == 0 && dstIP[2] == 0) { // 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{ 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()) if err != nil { return diff --git a/cmd/ctrld_library/netstack/udp_forwarder.go b/cmd/ctrld_library/netstack/udp_forwarder.go index 8da1092..e395db0 100644 --- a/cmd/ctrld_library/netstack/udp_forwarder.go +++ b/cmd/ctrld_library/netstack/udp_forwarder.go @@ -5,7 +5,6 @@ import ( "fmt" "net" "sync" - "syscall" "time" "github.com/Control-D-Inc/ctrld" @@ -17,10 +16,9 @@ import ( // UDPForwarder handles UDP packets from the TUN interface type UDPForwarder struct { - protectSocket func(fd int) error - ctx context.Context - forwarder *udp.Forwarder - ipTracker *IPTracker + ctx context.Context + forwarder *udp.Forwarder + ipTracker *IPTracker // Track UDP "connections" (address pairs) connections map[string]*udpConn @@ -34,12 +32,11 @@ type udpConn struct { } // 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{ - protectSocket: protectSocket, - ctx: ctx, - ipTracker: ipTracker, - connections: make(map[string]*udpConn), + ctx: ctx, + ipTracker: ipTracker, + connections: make(map[string]*udpConn), } // 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) // 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) if !(dstIP[0] == 10 && dstIP[1] == 0 && dstIP[2] == 0) { // 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{} - // 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 dialConn, dialErr := dialer.Dial("udp", dstAddr.String()) if dialErr != nil { diff --git a/cmd/ctrld_library/packet_capture.go b/cmd/ctrld_library/packet_capture.go index 28a8fb0..847adc0 100644 --- a/cmd/ctrld_library/packet_capture.go +++ b/cmd/ctrld_library/packet_capture.go @@ -3,7 +3,6 @@ package ctrld_library import ( "fmt" "net/netip" - "runtime" "time" "github.com/Control-D-Inc/ctrld" @@ -26,12 +25,6 @@ type PacketAppCallback interface { // ClosePacketIO closes packet I/O resources. 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 @@ -39,9 +32,10 @@ type PacketCaptureController struct { baseController *Controller // Packet capture mode fields - netstackCtrl *netstack.NetstackController - dnsBridge *netstack.DNSBridge - packetStopCh chan struct{} + netstackCtrl *netstack.NetstackController + dnsBridge *netstack.DNSBridge + packetStopCh chan struct{} + dnsProxyAddress string } // 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. func (pc *PacketCaptureController) StartWithPacketCapture( packetCallback PacketAppCallback, + tunAddress string, + deviceAddress string, + mtu int64, + dnsProxyAddress string, CdUID string, ProvisionID string, CustomHostname string, @@ -69,6 +67,14 @@ func (pc *PacketCaptureController) StartWithPacketCapture( 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 pc.baseController.Config = cli.AppConfig{ CdUID: CdUID, @@ -81,10 +87,6 @@ func (pc *PacketCaptureController) StartWithPacketCapture( } 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 pc.dnsBridge = netstack.NewDNSBridge() pc.dnsBridge.Start() @@ -94,37 +96,29 @@ func (pc *PacketCaptureController) StartWithPacketCapture( packetCallback.ReadPacket, packetCallback.WritePacket, packetCallback.ClosePacketIO, - packetCallback.ProtectSocket, ) // Create DNS handler that uses the bridge dnsHandler := func(query []byte) ([]byte, error) { - // Extract source IP from query context if available - // For now, use a placeholder - return pc.dnsBridge.ProcessQuery(query, "10.0.0.2", 0) + // Use device address as the source of DNS queries + return pc.dnsBridge.ProcessQuery(query, deviceAddress, 0) } - // Auto-detect platform and use appropriate TUN IP - // Android: TUN=10.0.0.1, Device=10.0.0.2 - // 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) + // Parse TUN IP address + tunIPv4, err := netip.ParseAddr(tunAddress) 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{ - MTU: 1500, + MTU: uint32(mtu), TUNIPv4: tunIPv4, DNSHandler: dnsHandler, - UpstreamInterface: nil, // Will use default interface - EnableIPBlocking: true, // Enable IP whitelisting in firewall mode + 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 netstackCtrl, err := netstack.NewNetstackController(packetHandler, netstackCfg) @@ -155,12 +149,10 @@ func (pc *PacketCaptureController) StartWithPacketCapture( cli.RunMobile(&pc.baseController.Config, &appCallback, pc.baseController.stopCh) }() - // Log platform detection for DNS proxy port - dnsPort := "5354" - if runtime.GOOS == "ios" || runtime.GOOS == "darwin" { - dnsPort = "53" - } - ctrld.ProxyLogger.Load().Info().Msgf("[PacketCapture] Platform: %s, DNS proxy port: %s", runtime.GOOS, dnsPort) + // BLOCK here until stopped (critical - Swift expects this to block!) + ctrld.ProxyLogger.Load().Info().Msg("[PacketCapture] Blocking until stop signal...") + <-pc.baseController.stopCh + ctrld.ProxyLogger.Load().Info().Msg("[PacketCapture] Stop signal received, exiting") return nil } @@ -189,22 +181,13 @@ func (pc *PacketCaptureController) handleDNSQuery(query *netstack.DNSQuery) { return } - // Determine DNS proxy port based on platform - // 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 + // Send query to actual DNS proxy using configured address client := &dns.Client{ Net: "udp", Timeout: 3 * time.Second, } - response, _, err := client.Exchange(msg, dnsProxyAddr) + response, _, err := client.Exchange(msg, pc.dnsProxyAddress) if err != nil { // Create SERVFAIL response 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") var errorCode = 0 - // Clear global socket protector - ctrld.ProxyLogger.Load().Info().Msg("[PacketCapture] Stop() - clearing socket protector") - ctrld.SetSocketProtector(nil) - // Stop DNS bridge if pc.dnsBridge != nil { ctrld.ProxyLogger.Load().Info().Msg("[PacketCapture] Stop() - stopping DNS bridge") @@ -296,15 +275,3 @@ func (pc *PacketCaptureController) IsRunning() bool { func (pc *PacketCaptureController) IsPacketMode() bool { 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") - } - } -}