Netstack - Full Packet Capture for Mobile VPN
Complete TCP/UDP/DNS packet capture implementation using gVisor netstack for Android and iOS VPN apps.
Overview
This module provides full packet capture capabilities for mobile VPN applications, handling:
- DNS filtering through ControlD proxy
- TCP forwarding for HTTP/HTTPS and all TCP traffic
- UDP forwarding for games, video streaming, VoIP, etc.
- Socket protection to prevent routing loops on Android/iOS
Architecture
Mobile Apps (Browser, Games, etc)
↓
VPN TUN Interface
- Device: 10.0.0.2/24
- Gateway: 10.0.0.1
- DNS: 10.0.0.1 (ControlD)
↓
PacketHandler (Read/Write/Protect)
↓
gVisor Netstack (10.0.0.1)
├─→ DNS Filter (dest port 53)
│ ├─→ DNS Bridge (transaction ID tracking)
│ └─→ ControlD DNS Proxy (localhost:5354)
│ └─→ DoH Upstream (freedns.controld.com)
│ └─→ Protected Socket → Internet
├─→ TCP Forwarder (non-DNS TCP traffic)
│ ├─→ net.Dial("tcp", destination)
│ ├─→ protect(fd) BEFORE connect()
│ └─→ io.Copy() bidirectional
└─→ UDP Forwarder (non-DNS UDP traffic)
├─→ net.Dial("udp", destination)
├─→ protect(fd) BEFORE connect()
└─→ Session tracking (60s timeout)
↓
Real Network (WiFi/Cellular) - All Sockets Protected
Key Components
1. DNS Filter (dns_filter.go)
- Detects DNS packets (UDP port 53)
- Extracts DNS query payload
- Sends to DNS bridge
- Builds DNS response packets
2. DNS Bridge (dns_bridge.go)
- Transaction ID tracking
- Query/response matching
- 5-second timeout per query
- Channel-based communication
3. TCP Forwarder (tcp_forwarder.go)
- Uses gVisor's
tcp.NewForwarder() - Converts gVisor endpoints to Go
net.Conn - Dials regular TCP sockets (no root required)
- Protects sockets using
VpnService.protect()callback - Bidirectional
io.Copy()for data forwarding
4. UDP Forwarder (udp_forwarder.go)
- Uses gVisor's
udp.NewForwarder() - Per-session connection tracking
- Dials regular UDP sockets (no root required)
- Protected sockets prevent routing loops
- 60-second idle timeout with automatic cleanup
5. Packet Handler (packet_handler.go)
- Interface for reading/writing raw IP packets
- Mobile platforms implement:
ReadPacket()- Read from TUN file descriptorWritePacket()- Write to TUN file descriptorProtectSocket(fd)- Protect socket from VPN routingClose()- Clean up resources
6. Netstack Controller (netstack.go)
- Manages gVisor stack lifecycle
- Coordinates DNS filter and TCP/UDP forwarders
- Filters outbound packets (source=10.0.0.x)
- Drops return packets (handled by forwarders)
Critical Design Decisions
Socket Protection
Why It's Critical: Without socket protection, outbound connections would route back through the VPN, creating infinite loops:
Bad (without protect):
App → VPN → TCP Forwarder → net.Dial() → VPN → TCP Forwarder → LOOP!
Good (with protect):
App → VPN → TCP Forwarder → net.Dial() → [PROTECTED] → WiFi → Internet ✅
Implementation:
// Protect socket BEFORE connect() is called
dialer.Control = func(network, address string, c syscall.RawConn) error {
return c.Control(func(fd uintptr) {
protectSocket(int(fd)) // Android: VpnService.protect()
})
}
All Protected Sockets:
- TCP forwarder sockets - User HTTP/HTTPS traffic
- UDP forwarder sockets - User games/video/VoIP traffic
- ControlD API sockets - Configuration fetch (api.controld.com)
- DoH upstream sockets - DNS resolution (freedns.controld.com)
Timing is Critical:
Protection must happen DURING socket creation, not after:
// ✅ CORRECT - Protection happens BEFORE connect()
dialer := &net.Dialer{
Control: func(network, address string, c syscall.RawConn) error {
return c.Control(func(fd uintptr) {
protectSocket(int(fd)) // Called before connect() syscall
})
},
}
conn, _ := dialer.Dial("tcp", "google.com:443") // Socket already protected!
// ❌ WRONG - Protection happens AFTER connect()
conn, _ := net.Dial("tcp", "google.com:443") // SYN already sent through VPN!
rawConn.Control(func(fd uintptr) {
protectSocket(int(fd)) // TOO LATE - routing loop already started
})
Outbound vs Return Packets
Outbound packets (10.0.0.x → Internet):
- Source IP: 10.0.0.x
- Injected into gVisor netstack
- Handled by TCP/UDP forwarders
Return packets (Internet → 10.0.0.x):
- Source IP: NOT 10.0.0.x
- Dropped by readPackets()
- Return through forwarder's upstream connection automatically
Address Mapping in gVisor
For inbound connections to the netstack:
id.LocalAddress/LocalPort= Destination (where packet is going TO)id.RemoteAddress/RemotePort= Source (where packet is coming FROM)
Therefore, we dial LocalAddress:LocalPort (the destination).
Packet Flow Examples
DNS Query (Port 53)
1. App queries google.com
DNS query: 10.0.0.2:54321 → 10.0.0.1:53
2. VPN TUN captures packet
PacketHandler.readPacket() returns raw IP packet
3. DNS Filter detects port 53
Extracts UDP payload (DNS query bytes)
4. DNS Bridge processes query
- Parses DNS message (transaction ID: 12345)
- Stores in pending map
- Sends to query channel
5. DNS Handler receives query
- Forwards to ControlD proxy: localhost:5354
- ControlD applies policies and filters
- Queries DoH upstream (freedns.controld.com) via PROTECTED socket
- Returns filtered response
6. DNS Bridge matches response
- Finds pending query by transaction ID
- Sends response to waiting channel
7. DNS Filter builds response packet
- Wraps DNS bytes in UDP/IP packet
- Swaps src/dst: 10.0.0.1:53 → 10.0.0.2:54321
8. PacketHandler writes response to TUN
App receives DNS response with IP address
TCP Connection (HTTP/HTTPS)
1. App connects to google.com:443
SYN packet: 10.0.0.2:12345 → 142.250.190.78:443
2. VPN TUN captures packet
PacketHandler.readPacket() returns raw IP packet
3. Netstack filters packet
- Checks source IP = 10.0.0.2 (outbound? YES)
- Not DNS (port != 53)
- Injects into gVisor netstack
4. gVisor calls TCP forwarder
TransportEndpointID:
- LocalAddress: 142.250.190.78:443 (destination)
- RemoteAddress: 10.0.0.2:12345 (source)
5. TCP Forwarder creates connection
- Creates gonet.TCPConn (TUN side)
- Dials net.Dial("tcp", "142.250.190.78:443")
- Protects socket BEFORE connect() → No routing loop!
- Connects via WiFi/Cellular (bypasses VPN)
6. Bidirectional copy
- TUN → Upstream: io.Copy(upstreamConn, tunConn)
- Upstream → TUN: io.Copy(tunConn, upstreamConn)
- All HTTP data flows through protected socket
7. Connection closes when either side finishes
UDP Packet (Games/Video)
1. Game sends UDP packet
UDP: 10.0.0.2:54321 → game-server.com:9000
2. VPN TUN captures packet
PacketHandler.readPacket() returns raw IP packet
3. Netstack filters packet
- Checks source IP = 10.0.0.2 (outbound? YES)
- Not DNS (port != 53)
- Injects into gVisor netstack
4. gVisor calls UDP forwarder
TransportEndpointID:
- LocalAddress: game-server.com:9000 (destination)
- RemoteAddress: 10.0.0.2:54321 (source)
5. UDP Forwarder creates/reuses session
- Creates gonet.UDPConn (TUN side)
- Dials net.Dial("udp", "game-server.com:9000")
- Protects socket BEFORE connect() → No routing loop!
- Connects via WiFi/Cellular (bypasses VPN)
6. Bidirectional forwarding
- TUN → Upstream: Read from gonet, write to net.UDPConn
- Upstream → TUN: Read from net.UDPConn, write to gonet
- Session tracked with 60s idle timeout
7. Auto-cleanup after 60 seconds of inactivity
VPN Configuration (Android)
val builder = Builder()
.setSession("ControlD VPN")
.addAddress("10.0.0.2", 24) // Device address
.addRoute("0.0.0.0", 0) // Route all traffic
.addDnsServer("10.0.0.1") // DNS to local TUN (ControlD)
.setMtu(1500)
vpnInterface = builder.establish()
Why DNS = 10.0.0.1:
- Apps query 10.0.0.1:53 (local TUN interface)
- Queries captured by DNS filter
- Filtered through ControlD policies
- Apps see "ControlD" as DNS provider (not Cloudflare/Google)
## Usage Example (Android)
```kotlin
// In VpnService.startVpn()
// 1. Create config.toml (required by ctrld)
val configFile = File(filesDir, "config.toml")
configFile.createNewFile()
configFile.writeText("") // Empty config, uses defaults
// 2. Build VPN interface
val builder = Builder()
.setSession("ControlD VPN")
.addAddress("10.0.0.2", 24)
.addRoute("0.0.0.0", 0)
.addDnsServer("10.0.0.1") // CRITICAL: Use local TUN for DNS
.setMtu(1500)
vpnInterface = builder.establish()
// 3. Get TUN file descriptor streams
inputStream = FileInputStream(vpnInterface.fileDescriptor)
outputStream = FileOutputStream(vpnInterface.fileDescriptor)
// 4. Implement PacketAppCallback
val callback = object : PacketAppCallback {
override fun readPacket(): ByteArray {
val length = inputStream.channel.read(readBuffer)
val packet = ByteArray(length)
readBuffer.position(0)
readBuffer.get(packet, 0, length)
return packet
}
override fun writePacket(packet: ByteArray) {
outputStream.write(packet)
}
override fun protectSocket(fd: Long) {
// CRITICAL: Protect socket from VPN routing
val success = protect(fd.toInt()) // VpnService.protect()
if (!success) throw Exception("Failed to protect socket")
}
override fun closePacketIO() {
inputStream?.close()
outputStream?.close()
}
override fun exit(s: String) { }
override fun hostname(): String = "android-device"
override fun lanIp(): String = "10.0.0.2"
override fun macAddress(): String = "00:00:00:00:00:00"
}
// 5. Create and start packet capture controller
packetController = Ctrld_library.newPacketCaptureController(callback)
packetController.startWithPacketCapture(
callback,
"your-cd-uid", // From ControlD dashboard
"", // Provision ID (optional)
"", // Custom hostname (optional)
filesDir.absolutePath, // Home directory
"doh", // Upstream protocol (doh/dot/os)
2, // Log level (0-5)
"${filesDir.absolutePath}/ctrld.log"
)
// 6. Stop when disconnecting
packetController.stop(false, 0)
Protocol Support
| Protocol | Support | Details |
|---|---|---|
| DNS | ✅ Full | Filtered through ControlD proxy (UDP/TCP port 53) |
| TCP | ✅ Full | All ports, bidirectional forwarding |
| UDP | ✅ Selective | All ports except 53, 80, 443 (see QUIC blocking) |
| QUIC | 🚫 Blocked | UDP ports 443 and 80 dropped to force TCP fallback |
| ICMP | ⚠️ Partial | Basic support (no forwarding yet) |
| IPv4 | ✅ Full | Complete support |
| IPv6 | ✅ Full | Complete support |
QUIC Blocking
QUIC (Quick UDP Internet Connections) is blocked by dropping UDP packets on ports 443 and 80:
Why Block QUIC:
- QUIC bypasses traditional DNS lookups (uses Alt-Svc headers)
- Encrypts server name indication (SNI)
- Makes content filtering difficult
- Prevents some ControlD policies from working
How It Works:
// In netstack.go readPackets()
if protocol == UDP {
if dstPort == 443 || dstPort == 80 {
// Drop QUIC/HTTP3 packets
// Apps automatically fallback to TCP (HTTP/2 or HTTP/1.1)
continue
}
}
Result:
- Chrome/apps attempt QUIC first
- QUIC packets dropped silently
- Apps fallback to TCP/TLS (HTTP/2)
- ControlD policies work correctly
- Slightly slower initial connection, then normal speed
Performance
| Metric | Value |
|---|---|
| DNS Timeout | 5 seconds |
| TCP Dial Timeout | 30 seconds |
| UDP Idle Timeout | 60 seconds |
| UDP Cleanup Interval | 30 seconds |
| MTU | 1500 bytes |
| Overhead per TCP connection | ~2KB |
| Overhead per UDP session | ~1KB |
Requirements
- Go 1.23+
- gVisor netstack v0.0.0-20240722211153-64c016c92987
- For Android: API 24+ (Android 7.0+)
- For iOS: iOS 12+
No Root Required
This implementation uses regular TCP/UDP sockets instead of raw sockets, making it compatible with non-rooted Android/iOS devices. Socket protection via VpnService.protect() (Android) or NEPacketTunnelFlow (iOS) prevents routing loops.
Files
packet_handler.go- Interface for TUN I/O and socket protectionnetstack.go- Main controller managing gVisor stackdns_filter.go- DNS packet detection and response buildingdns_bridge.go- DNS query/response bridgingtcp_forwarder.go- TCP connection forwardingudp_forwarder.go- UDP packet forwarding with session tracking
License
Same as parent ctrld project.