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 (10.0.0.2/24)
↓
PacketHandler (Read/Write/Protect)
↓
gVisor Netstack (TCP/IP Stack)
├─→ DNS Filter (Port 53)
│ └─→ ControlD DNS Proxy (localhost:5354)
├─→ TCP Forwarder
│ └─→ net.Dial("tcp") + protect(fd)
└─→ UDP Forwarder
└─→ net.Dial("udp") + protect(fd)
↓
Real Network (WiFi/Cellular) - Protected Sockets
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 traffic)
- UDP forwarder sockets (user traffic)
- ControlD API HTTP sockets (api.controld.com)
- DoH upstream sockets (freedns.controld.com)
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).
Usage Example (Android)
// In VpnService
val callback = object : PacketAppCallback {
override fun readPacket(): ByteArray {
// Read from TUN file descriptor
val length = inputStream.channel.read(buffer)
return packet
}
override fun writePacket(packet: ByteArray) {
// Write to TUN file descriptor
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"
}
// Create packet capture controller
val controller = Ctrld_library.newPacketCaptureController(callback)
// Start packet capture
controller.startWithPacketCapture(
callback,
"your-cd-uid",
"", "", // provision ID, custom hostname
filesDir.absolutePath,
"doh", // upstream protocol
2, // log level
"$filesDir/ctrld.log"
)
// Stop when done
controller.stop(false, 0)
Protocol Support
| Protocol | Support | Details |
|---|---|---|
| DNS | ✅ Full | Filtered through ControlD proxy |
| TCP | ✅ Full | All ports, bidirectional forwarding |
| UDP | ✅ Full | All ports except 53, session tracking |
| ICMP | ⚠️ Partial | Basic support (no forwarding yet) |
| IPv4 | ✅ Full | Complete support |
| IPv6 | ✅ Full | Complete support |
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.