Compare commits

...

22 Commits

Author SHA1 Message Date
Ginder Singh
0e9a1225fc cleanup. 2026-03-20 01:01:04 -04:00
Ginder Singh
afe7804a9b blocks direct Ip. 2026-03-19 16:53:34 -04:00
Ginder Singh
d7904580ed remove unused code. 2026-03-19 15:16:44 -04:00
Ginder Singh
593805bf6f ios support. 2026-03-19 03:55:25 -04:00
Ginder Singh
ae37c56467 quic block 2026-03-19 00:49:09 -04:00
Ginder Singh
41597609c8 tcp/ip stack + firewall mode. 2026-03-19 00:24:35 -04:00
Ginder Singh
1f619a669a tcp/ip stack + firewall mode. 2026-03-19 00:24:07 -04:00
Cuong Manh Le
37c3331559 Merge pull request #285 from Control-D-Inc/cuonglm-patch-1 2026-03-06 22:16:47 +07:00
Cuong Manh Le
f334993f79 Fix typo in README usage section 2026-01-22 22:15:02 +07:00
Cuong Manh Le
3ca559e5a4 Merge pull request #264 from Control-D-Inc/release-branch-v1.4.7
Release branch v1.4.7
2025-10-07 01:02:39 +07:00
Cuong Manh Le
0e3f764299 feat: add --rfc1918 flag for explicit LAN client support
Make RFC1918 listener spawning opt-in via --rfc1918 flag instead of automatic behavior.
This allows users to explicitly control when ctrld listens on private network addresses
to receive DNS queries from LAN clients, improving security and configurability.

Refactor network interface detection to better distinguish between physical and virtual
interfaces, ensuring only real hardware interfaces are used for RFC1918 address binding.
2025-09-25 16:45:56 +07:00
Cuong Manh Le
e52402eb0c Upgrade quic-go to v0.54.0 2025-09-25 16:45:05 +07:00
Cuong Manh Le
2133f31854 docs: add known issues documentation for Darwin 15.5 upgrade issue
Documents the self-upgrade issue on macOS Darwin 15.5 affecting
ctrld v1.4.2+ and provides workarounds for affected users.
2025-09-25 16:44:54 +07:00
Ginder Singh
a198a5cd65 start mobile library with provision id and custom hostname. 2025-09-25 16:44:39 +07:00
Cuong Manh Le
eb2b231bd2 Merge pull request #254 from Control-D-Inc/release-branch-v1.4.6
Release branch v1.4.6
2025-08-22 04:08:56 +07:00
Jared Quick
7af29cfbc0 Add OPNsense new lease file
Signed-off-by: Jared Quick <jared.quick@salesforce.com>
2025-08-20 18:19:35 +07:00
Cuong Manh Le
ce1a165348 .github/workflows: bump go version to 1.24.x 2025-08-15 23:33:23 +07:00
Cuong Manh Le
fd48e6d795 fix: ensure upstream health checks can handle large DNS responses
- Add UpstreamConfig.VerifyMsg() method with proper EDNS0 support
- Replace hardcoded DNS messages in health checks with standardized verification method
- Set EDNS0 buffer size to 4096 bytes to handle large DNS responses
- Add test case for legacy resolver with extensive extra sections
2025-08-15 22:55:47 +07:00
Cuong Manh Le
d71d1341b6 refactor(prog): move network monitoring outside listener loop
Move the network monitoring goroutine initialization outside the listener
loop to prevent it from being started multiple times. Previously, the
network monitoring was started once per listener during first run, which
was unnecessary and could lead to multiple monitoring instances.

The change ensures network monitoring is started only once per program
execution cycle, improving efficiency and preventing potential resource
waste from duplicate monitoring goroutines.

- Extract network monitoring goroutine from listener loop
- Start network monitoring once per run cycle instead of per listener
- Maintain same functionality while improving resource usage
2025-08-12 16:49:05 +07:00
Cuong Manh Le
21855df4af fix: correct Windows API constants to fix domain join detection
The function was incorrectly identifying domain-joined status due to wrong
constant values, potentially causing false negatives for domain-joined machines.
2025-08-12 16:48:10 +07:00
Cuong Manh Le
66e2d3a40a refactor: move network monitoring to separate goroutine
- Move network monitoring initialization out of serveDNS() function
- Start network monitoring in a separate goroutine during program startup
- Remove context parameter from monitorNetworkChanges() as it's not used
- Simplify serveDNS() function signature by removing unused context parameter
- Ensure network monitoring starts only once during initial run, not on reload

This change improves separation of concerns by isolating network monitoring
from DNS serving logic, and prevents potential issues with multiple
monitoring goroutines if starting multiple listeners.
2025-08-12 16:46:57 +07:00
Cuong Manh Le
26257cf24a Merge pull request #250 from Control-D-Inc/release-branch-v1.4.5
Release branch v1.4.5
2025-07-25 04:06:24 +07:00
35 changed files with 2927 additions and 126 deletions

View File

@@ -9,7 +9,7 @@ jobs:
fail-fast: false
matrix:
os: ["windows-latest", "ubuntu-latest", "macOS-latest"]
go: ["1.23.x"]
go: ["1.24.x"]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v3
@@ -21,6 +21,6 @@ jobs:
- run: "go test -race ./..."
- uses: dominikh/staticcheck-action@v1.3.1
with:
version: "2024.1.1"
version: "2025.1"
install-go: false
cache-key: ${{ matrix.go }}

View File

@@ -100,7 +100,7 @@ docker build -t controldns/ctrld . -f docker/Dockerfile
# Usage
The cli is self documenting, so free free to run `--help` on any sub-command to get specific usages.
The cli is self documenting, so feel free to run `--help` on any sub-command to get specific usages.
## Arguments
```

View File

@@ -178,7 +178,15 @@ func RunMobile(appConfig *AppConfig, appCallback *AppCallback, stopCh chan struc
noConfigStart = false
homedir = appConfig.HomeDir
verbose = appConfig.Verbose
cdUID = appConfig.CdUID
if appConfig.ProvisionID != "" {
cdOrg = appConfig.ProvisionID
}
if appConfig.CustomHostname != "" {
customHostname = appConfig.CustomHostname
}
if appConfig.CdUID != "" {
cdUID = appConfig.CdUID
}
cdUpstreamProto = appConfig.UpstreamProto
logPath = appConfig.LogPath
run(appCallback, stopCh)

View File

@@ -189,6 +189,7 @@ func initRunCmd() *cobra.Command {
runCmd.Flags().StringVarP(&iface, "iface", "", "", `Update DNS setting for iface, "auto" means the default interface gateway`)
_ = runCmd.Flags().MarkHidden("iface")
runCmd.Flags().StringVarP(&cdUpstreamProto, "proto", "", ctrld.ResolverTypeDOH, `Control D upstream type, either "doh" or "doh3"`)
runCmd.Flags().BoolVarP(&rfc1918, "rfc1918", "", false, "Listen on RFC1918 addresses when 127.0.0.1 is the only listener")
runCmd.FParseErrWhitelist = cobra.FParseErrWhitelist{UnknownFlags: true}
rootCmd.AddCommand(runCmd)
@@ -531,6 +532,7 @@ NOTE: running "ctrld start" without any arguments will start already installed c
startCmd.Flags().BoolVarP(&skipSelfChecks, "skip_self_checks", "", false, `Skip self checks after installing ctrld service`)
startCmd.Flags().BoolVarP(&startOnly, "start_only", "", false, "Do not install new service")
_ = startCmd.Flags().MarkHidden("start_only")
startCmd.Flags().BoolVarP(&rfc1918, "rfc1918", "", false, "Listen on RFC1918 addresses when 127.0.0.1 is the only listener")
routerCmd := &cobra.Command{
Use: "setup",

View File

@@ -84,13 +84,7 @@ type upstreamForResult struct {
srcAddr string
}
func (p *prog) serveDNS(mainCtx context.Context, listenerNum string) error {
// Start network monitoring
if err := p.monitorNetworkChanges(mainCtx); err != nil {
mainLog.Load().Error().Err(err).Msg("Failed to start network monitoring")
// Don't return here as we still want DNS service to run
}
func (p *prog) serveDNS(listenerNum string) error {
listenerConfig := p.cfg.Listener[listenerNum]
// make sure ip is allocated
if allocErr := p.allocateIP(listenerConfig.IP); allocErr != nil {
@@ -213,8 +207,8 @@ func (p *prog) serveDNS(mainCtx context.Context, listenerNum string) error {
return nil
})
}
// When we spawn a listener on 127.0.0.1, also spawn listeners on the RFC1918
// addresses of the machine. So ctrld could receive queries from LAN clients.
// When we spawn a listener on 127.0.0.1, also spawn listeners on the RFC1918 addresses of the machine
// if explicitly set via setting rfc1918 flag, so ctrld could receive queries from LAN clients.
if needRFC1918Listeners(listenerConfig) {
g.Go(func() error {
for _, addr := range ctrld.Rfc1918Addresses() {
@@ -1045,7 +1039,7 @@ func (p *prog) queryFromSelf(ip string) bool {
// needRFC1918Listeners reports whether ctrld need to spawn listener for RFC 1918 addresses.
// This is helpful for non-desktop platforms to receive queries from LAN clients.
func needRFC1918Listeners(lc *ctrld.ListenerConfig) bool {
return lc.IP == "127.0.0.1" && lc.Port == 53 && !ctrld.IsDesktopPlatform()
return rfc1918 && lc.IP == "127.0.0.1" && lc.Port == 53
}
// ipFromARPA parses a FQDN arpa domain and return the IP address if valid.
@@ -1187,7 +1181,7 @@ func FlushDNSCache() error {
}
// monitorNetworkChanges starts monitoring for network interface changes
func (p *prog) monitorNetworkChanges(ctx context.Context) error {
func (p *prog) monitorNetworkChanges() error {
mon, err := netmon.New(func(format string, args ...any) {
// Always fetch the latest logger (and inject the prefix)
mainLog.Load().Printf("netmon: "+format, args...)
@@ -1406,9 +1400,6 @@ func (p *prog) checkUpstreamOnce(upstream string, uc *ctrld.UpstreamConfig) erro
return err
}
msg := new(dns.Msg)
msg.SetQuestion(".", dns.TypeNS)
timeout := 1000 * time.Millisecond
if uc.Timeout > 0 {
timeout = time.Millisecond * time.Duration(uc.Timeout)
@@ -1422,6 +1413,7 @@ func (p *prog) checkUpstreamOnce(upstream string, uc *ctrld.UpstreamConfig) erro
mainLog.Load().Debug().Msgf("Rebootstrapping resolver for upstream: %s", upstream)
start := time.Now()
msg := uc.VerifyMsg()
_, err = resolver.Resolve(ctx, msg)
duration := time.Since(start)

View File

@@ -18,11 +18,13 @@ type AppCallback struct {
// AppConfig allows overwriting ctrld cli flags from mobile platforms.
type AppConfig struct {
CdUID string
HomeDir string
UpstreamProto string
Verbose int
LogPath string
CdUID string
ProvisionID string
CustomHostname string
HomeDir string
UpstreamProto string
Verbose int
LogPath string
}
const (

View File

@@ -39,6 +39,7 @@ var (
skipSelfChecks bool
cleanup bool
startOnly bool
rfc1918 bool
mainLog atomic.Pointer[zerolog.Logger]
consoleWriter zerolog.ConsoleWriter

View File

@@ -530,6 +530,15 @@ func (p *prog) run(reload bool, reloadCh chan struct{}) {
go p.watchLinkState(ctx)
}
if !reload {
go func() {
// Start network monitoring
if err := p.monitorNetworkChanges(); err != nil {
mainLog.Load().Error().Err(err).Msg("Failed to start network monitoring")
}
}()
}
for listenerNum := range p.cfg.Listener {
p.cfg.Listener[listenerNum].Init()
if !reload {
@@ -541,7 +550,7 @@ func (p *prog) run(reload bool, reloadCh chan struct{}) {
}
addr := net.JoinHostPort(listenerConfig.IP, strconv.Itoa(listenerConfig.Port))
mainLog.Load().Info().Msgf("starting DNS server on listener.%s: %s", listenerNum, addr)
if err := p.serveDNS(ctx, listenerNum); err != nil {
if err := p.serveDNS(listenerNum); err != nil {
mainLog.Load().Fatal().Err(err).Msgf("unable to start dns proxy on listener.%s", listenerNum)
}
mainLog.Load().Debug().Msgf("end of serveDNS listener.%s: %s", listenerNum, addr)

View File

@@ -28,15 +28,17 @@ type AppCallback interface {
// Start configures utility with config.toml from provided directory.
// This function will block until Stop is called
// Check port availability prior to calling it.
func (c *Controller) Start(CdUID string, HomeDir string, UpstreamProto string, logLevel int, logPath string) {
func (c *Controller) Start(CdUID string, ProvisionID string, CustomHostname string, HomeDir string, UpstreamProto string, logLevel int, logPath string) {
if c.stopCh == nil {
c.stopCh = make(chan struct{})
c.Config = cli.AppConfig{
CdUID: CdUID,
HomeDir: HomeDir,
UpstreamProto: UpstreamProto,
Verbose: logLevel,
LogPath: logPath,
CdUID: CdUID,
ProvisionID: ProvisionID,
CustomHostname: CustomHostname,
HomeDir: HomeDir,
UpstreamProto: UpstreamProto,
Verbose: logLevel,
LogPath: logPath,
}
appCallback := mapCallback(c.AppCallback)
cli.RunMobile(&c.Config, &appCallback, c.stopCh)

View File

@@ -0,0 +1,585 @@
# Netstack - Full Packet Capture for Mobile VPN
Complete TCP/UDP/DNS packet capture implementation using gVisor netstack for Android and iOS.
## Overview
Provides full packet capture for mobile VPN applications:
- **DNS filtering** through ControlD proxy
- **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)
- **QUIC blocking** for better content filtering
## Master Architecture Diagram
```
┌──────────────────────────────────────────────────────────────────────────────┐
│ 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 (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`)
- Bridges between netstack and ControlD DNS proxy
- Tracks DNS queries by transaction ID
- 5-second timeout per query
- Returns responses to DNS filter
### IP Tracker (`ip_tracker.go`)
- **Always enabled** - tracks all DNS-resolved IPs
- In-memory whitelist with 5-minute TTL per IP
- Background cleanup every 30 seconds (removes expired IPs)
- Thread-safe with RWMutex (optimized for read-heavy workload)
- Used by TCP/UDP forwarders to enforce DNS-first policy
### TCP Forwarder (`tcp_forwarder.go`)
- 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`)
- 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 operations (read, write, close)
- `MobilePacketHandler` wraps mobile platform callbacks
- Bridges gomobile interface with netstack
### Netstack Controller (`netstack.go`)
- 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)
.addDnsServer("10.0.0.1")
.setMtu(1500)
.setBlocking(true)
.addDisallowedApplication(packageName) // Exclude self from VPN!
// 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)
}
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
// Base configuration (same for both modes)
let ipv4Settings = NEIPv4Settings(
addresses: ["10.0.0.2"],
subnetMasks: ["255.255.255.0"]
)
// 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")
]
}
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:**
- 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 |
|----------|---------|
| DNS (UDP/TCP port 53) | ✅ Full |
| TCP (all ports) | ✅ Full |
| UDP (except 53, 80, 443) | ✅ Full |
| QUIC (UDP/443, UDP/80) | 🚫 Blocked |
| ICMP | ⚠️ Partial |
| IPv4 | ✅ Full |
| IPv6 | ✅ Full |
## QUIC Blocking
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:**
- 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 (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)
**Firewall is ALWAYS enabled.** The IP tracker runs in all modes and tracks all DNS-resolved IPs.
**How it works:**
1. DNS responses are parsed to extract A and AAAA records
2. Resolved IPs are tracked in memory whitelist for 5 minutes (TTL)
3. In **firewall mode**: TCP/UDP connections to **non-whitelisted** IPs are **BLOCKED**
4. In **DNS-only mode**: Only DNS traffic reaches the VPN, so IP blocking is inactive
**Mode Behavior:**
- **Firewall mode** (default routes): OS sends ALL traffic to VPN
- DNS queries → tracked IPs
- TCP/UDP connections → checked against tracker → blocked if not tracked
- **DNS-only mode** (DNS route only): OS sends ONLY DNS to VPN
- DNS queries → tracked IPs
- TCP/UDP connections → bypass VPN entirely (never reach tracker)
**Why IP tracker is always on:**
- Simplifies implementation (no enable/disable logic)
- Ready for mode switching at runtime
- In DNS-only mode, tracker tracks IPs but never blocks (no TCP/UDP traffic)
**Example (Firewall Mode):**
```
T=0s: App connects to 1.2.3.4 directly
→ ❌ BLOCKED (not in tracker)
T=1s: App queries "example.com" → DNS returns 93.184.216.34
→ Tracker stores: 93.184.216.34 (expires in 5min)
T=2s: App connects to 93.184.216.34
→ ✅ ALLOWED (found in tracker, not expired)
T=302s: App connects to 93.184.216.34
→ ❌ BLOCKED (expired, must query DNS again)
```
**Components:**
- `ip_tracker.go` - Always-on whitelist with 5min TTL, 30s cleanup
- `dns_filter.go` - Extracts A/AAAA records, tracks IPs automatically
- `tcp_forwarder.go` - Checks `ipTracker != nil` (always true)
- `udp_forwarder.go` - Checks `ipTracker != nil` (always true)
## Usage (Android)
```kotlin
// Create callback
val callback = object : PacketAppCallback {
override fun readPacket(): ByteArray { ... }
override fun writePacket(packet: ByteArray) { ... }
override fun closePacketIO() { ... }
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 controller
val controller = Ctrld_library.newPacketCaptureController(callback)
// Start with all parameters
controller.startWithPacketCapture(
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
// Start LocalProxy with all parameters
let proxy = LocalProxy()
proxy.mode = .firewall // or .dnsOnly
proxy.start(
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
)
// 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
- **Android**: API 24+ (Android 7.0+)
- **iOS**: iOS 12.0+
- **Go**: 1.23+
- **gVisor**: v0.0.0-20240722211153-64c016c92987
## Files
- `packet_handler.go` - TUN I/O interface
- `netstack.go` - gVisor controller
- `dns_filter.go` - DNS packet detection and IP extraction
- `dns_bridge.go` - Transaction tracking
- `ip_tracker.go` - DNS-resolved IP whitelist with TTL
- `tcp_forwarder.go` - TCP forwarding with whitelist enforcement
- `udp_forwarder.go` - UDP forwarding with whitelist enforcement
## License
Same as parent ctrld project.

View File

@@ -0,0 +1,228 @@
package netstack
import (
"fmt"
"sync"
"time"
"github.com/miekg/dns"
)
// DNSBridge provides a bridge between the netstack DNS filter and the existing ctrld DNS proxy.
// It allows DNS queries captured from packets to be processed by the same logic as traditional DNS queries.
type DNSBridge struct {
// Channel for sending DNS queries
queryCh chan *DNSQuery
// Channel for receiving DNS responses
responseCh chan *DNSResponse
// Map to track pending queries by transaction ID
pendingQueries map[uint16]*PendingQuery
mu sync.RWMutex
// Timeout for DNS queries
queryTimeout time.Duration
// Running state
running bool
stopCh chan struct{}
wg sync.WaitGroup
}
// DNSQuery represents a DNS query to be processed
type DNSQuery struct {
ID uint16 // Transaction ID for matching response
Query []byte // Raw DNS query bytes
RespCh chan []byte // Response channel
SrcIP string // Source IP for logging
SrcPort uint16 // Source port
}
// DNSResponse represents a DNS response
type DNSResponse struct {
ID uint16
Response []byte
}
// PendingQuery tracks a query waiting for response
type PendingQuery struct {
Query *DNSQuery
Timestamp time.Time
}
// NewDNSBridge creates a new DNS bridge
func NewDNSBridge() *DNSBridge {
return &DNSBridge{
queryCh: make(chan *DNSQuery, 100),
responseCh: make(chan *DNSResponse, 100),
pendingQueries: make(map[uint16]*PendingQuery),
queryTimeout: 5 * time.Second,
stopCh: make(chan struct{}),
}
}
// Start starts the DNS bridge
func (b *DNSBridge) Start() {
b.mu.Lock()
if b.running {
b.mu.Unlock()
return
}
b.running = true
b.mu.Unlock()
// Start response handler
b.wg.Add(1)
go b.handleResponses()
// Start timeout checker
b.wg.Add(1)
go b.checkTimeouts()
}
// Stop stops the DNS bridge
func (b *DNSBridge) Stop() {
b.mu.Lock()
if !b.running {
b.mu.Unlock()
return
}
b.running = false
b.mu.Unlock()
close(b.stopCh)
b.wg.Wait()
// Clean up pending queries
b.mu.Lock()
for _, pending := range b.pendingQueries {
close(pending.Query.RespCh)
}
b.pendingQueries = make(map[uint16]*PendingQuery)
b.mu.Unlock()
}
// ProcessQuery processes a DNS query and waits for response
func (b *DNSBridge) ProcessQuery(query []byte, srcIP string, srcPort uint16) ([]byte, error) {
if len(query) < 12 {
return nil, fmt.Errorf("invalid DNS query: too short")
}
// Parse DNS message to get transaction ID
msg := new(dns.Msg)
if err := msg.Unpack(query); err != nil {
return nil, fmt.Errorf("failed to parse DNS query: %v", err)
}
// Create response channel
respCh := make(chan []byte, 1)
// Create query
dnsQuery := &DNSQuery{
ID: msg.Id,
Query: query,
RespCh: respCh,
SrcIP: srcIP,
SrcPort: srcPort,
}
// Store as pending
b.mu.Lock()
b.pendingQueries[msg.Id] = &PendingQuery{
Query: dnsQuery,
Timestamp: time.Now(),
}
b.mu.Unlock()
// Send query
select {
case b.queryCh <- dnsQuery:
case <-time.After(time.Second):
b.mu.Lock()
delete(b.pendingQueries, msg.Id)
b.mu.Unlock()
return nil, fmt.Errorf("query channel full")
}
// Wait for response with timeout
select {
case response := <-respCh:
b.mu.Lock()
delete(b.pendingQueries, msg.Id)
b.mu.Unlock()
return response, nil
case <-time.After(b.queryTimeout):
b.mu.Lock()
delete(b.pendingQueries, msg.Id)
b.mu.Unlock()
return nil, fmt.Errorf("DNS query timeout")
}
}
// GetQueryChannel returns the channel for receiving DNS queries
func (b *DNSBridge) GetQueryChannel() <-chan *DNSQuery {
return b.queryCh
}
// SendResponse sends a DNS response back to the waiting query
func (b *DNSBridge) SendResponse(id uint16, response []byte) error {
b.mu.RLock()
pending, exists := b.pendingQueries[id]
b.mu.RUnlock()
if !exists {
return fmt.Errorf("no pending query for ID %d", id)
}
select {
case pending.Query.RespCh <- response:
return nil
case <-time.After(time.Second):
return fmt.Errorf("failed to send response: channel blocked")
}
}
// handleResponses handles incoming responses
func (b *DNSBridge) handleResponses() {
defer b.wg.Done()
for {
select {
case <-b.stopCh:
return
case resp := <-b.responseCh:
if err := b.SendResponse(resp.ID, resp.Response); err != nil {
// Log error but continue
}
}
}
}
// checkTimeouts periodically checks for and removes timed out queries
func (b *DNSBridge) checkTimeouts() {
defer b.wg.Done()
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for {
select {
case <-b.stopCh:
return
case <-ticker.C:
now := time.Now()
b.mu.Lock()
for id, pending := range b.pendingQueries {
if now.Sub(pending.Timestamp) > b.queryTimeout {
close(pending.Query.RespCh)
delete(b.pendingQueries, id)
}
}
b.mu.Unlock()
}
}
}

View File

@@ -0,0 +1,365 @@
package netstack
import (
"encoding/binary"
"fmt"
"net"
"github.com/miekg/dns"
"gvisor.dev/gvisor/pkg/tcpip"
"gvisor.dev/gvisor/pkg/tcpip/header"
)
// DNSFilter intercepts and processes DNS packets.
type DNSFilter struct {
dnsHandler func([]byte) ([]byte, error)
ipTracker *IPTracker
}
// NewDNSFilter creates a new DNS filter with the given handler.
func NewDNSFilter(handler func([]byte) ([]byte, error), ipTracker *IPTracker) *DNSFilter {
return &DNSFilter{
dnsHandler: handler,
ipTracker: ipTracker,
}
}
// ProcessPacket checks if a packet is a DNS query and processes it.
// Returns:
// - isDNS: true if this is a DNS packet
// - response: DNS response packet (if handled), nil otherwise
// - error: any error that occurred
func (df *DNSFilter) ProcessPacket(packet []byte) (isDNS bool, response []byte, err error) {
if len(packet) < header.IPv4MinimumSize {
return false, nil, nil
}
// Parse IP version
ipVersion := packet[0] >> 4
switch ipVersion {
case 4:
return df.processIPv4(packet)
case 6:
return df.processIPv6(packet)
default:
return false, nil, nil
}
}
// processIPv4 processes an IPv4 packet and checks if it's DNS.
func (df *DNSFilter) processIPv4(packet []byte) (bool, []byte, error) {
if len(packet) < header.IPv4MinimumSize {
return false, nil, nil
}
// Parse IPv4 header
ipHdr := header.IPv4(packet)
if !ipHdr.IsValid(len(packet)) {
return false, nil, nil
}
// Check if it's UDP
if ipHdr.TransportProtocol() != header.UDPProtocolNumber {
return false, nil, nil
}
// Get IP header length
ihl := int(ipHdr.HeaderLength())
if len(packet) < ihl+header.UDPMinimumSize {
return false, nil, nil
}
// Parse UDP header
udpHdr := header.UDP(packet[ihl:])
srcPort := udpHdr.SourcePort()
dstPort := udpHdr.DestinationPort()
// Check if destination port is 53 (DNS)
if dstPort != 53 {
return false, nil, nil
}
srcIP := ipHdr.SourceAddress()
dstIP := ipHdr.DestinationAddress()
// Extract DNS payload
udpPayloadOffset := ihl + header.UDPMinimumSize
if len(packet) <= udpPayloadOffset {
return true, nil, fmt.Errorf("invalid UDP packet length")
}
dnsQuery := packet[udpPayloadOffset:]
if len(dnsQuery) == 0 {
return true, nil, fmt.Errorf("empty DNS query")
}
// Process DNS query
if df.dnsHandler == nil {
return true, nil, fmt.Errorf("no DNS handler configured")
}
dnsResponse, err := df.dnsHandler(dnsQuery)
if err != nil {
return true, nil, fmt.Errorf("DNS handler error: %v", err)
}
// Track IPs from DNS response
if df.ipTracker != nil {
df.extractAndTrackIPs(dnsResponse)
}
// Build response packet
responsePacket := df.buildIPv4UDPPacket(
dstIP.As4(), // Swap src/dst
srcIP.As4(),
dstPort, // Swap ports
srcPort,
dnsResponse,
)
return true, responsePacket, nil
}
// processIPv6 processes an IPv6 packet and checks if it's DNS.
func (df *DNSFilter) processIPv6(packet []byte) (bool, []byte, error) {
if len(packet) < header.IPv6MinimumSize {
return false, nil, nil
}
// Parse IPv6 header
ipHdr := header.IPv6(packet)
if !ipHdr.IsValid(len(packet)) {
return false, nil, nil
}
// Check if it's UDP
if ipHdr.TransportProtocol() != header.UDPProtocolNumber {
return false, nil, nil
}
// IPv6 header is fixed size
if len(packet) < header.IPv6MinimumSize+header.UDPMinimumSize {
return false, nil, nil
}
// Parse UDP header
udpHdr := header.UDP(packet[header.IPv6MinimumSize:])
srcPort := udpHdr.SourcePort()
dstPort := udpHdr.DestinationPort()
// Check if destination port is 53 (DNS)
if dstPort != 53 {
return false, nil, nil
}
// Extract DNS payload
udpPayloadOffset := header.IPv6MinimumSize + header.UDPMinimumSize
if len(packet) <= udpPayloadOffset {
return true, nil, fmt.Errorf("invalid UDP packet length")
}
dnsQuery := packet[udpPayloadOffset:]
if len(dnsQuery) == 0 {
return true, nil, fmt.Errorf("empty DNS query")
}
// Process DNS query
if df.dnsHandler == nil {
return true, nil, fmt.Errorf("no DNS handler configured")
}
dnsResponse, err := df.dnsHandler(dnsQuery)
if err != nil {
return true, nil, fmt.Errorf("DNS handler error: %v", err)
}
// Track IPs from DNS response
if df.ipTracker != nil {
df.extractAndTrackIPs(dnsResponse)
}
// Build response packet
srcIP := ipHdr.SourceAddress()
dstIP := ipHdr.DestinationAddress()
responsePacket := df.buildIPv6UDPPacket(
dstIP.As16(), // Swap src/dst
srcIP.As16(),
dstPort, // Swap ports
srcPort,
dnsResponse,
)
return true, responsePacket, nil
}
// buildIPv4UDPPacket builds a complete IPv4/UDP packet with the given payload.
func (df *DNSFilter) buildIPv4UDPPacket(srcIP, dstIP [4]byte, srcPort, dstPort uint16, payload []byte) []byte {
// Calculate lengths
udpLen := header.UDPMinimumSize + len(payload)
ipLen := header.IPv4MinimumSize + udpLen
packet := make([]byte, ipLen)
// Build IPv4 header
ipHdr := header.IPv4(packet)
ipHdr.Encode(&header.IPv4Fields{
TotalLength: uint16(ipLen),
TTL: 64,
Protocol: uint8(header.UDPProtocolNumber),
SrcAddr: tcpip.AddrFrom4(srcIP),
DstAddr: tcpip.AddrFrom4(dstIP),
})
ipHdr.SetChecksum(^ipHdr.CalculateChecksum())
// Build UDP header
udpHdr := header.UDP(packet[header.IPv4MinimumSize:])
udpHdr.Encode(&header.UDPFields{
SrcPort: srcPort,
DstPort: dstPort,
Length: uint16(udpLen),
})
// Copy payload
copy(packet[header.IPv4MinimumSize+header.UDPMinimumSize:], payload)
// Calculate UDP checksum
xsum := header.PseudoHeaderChecksum(
header.UDPProtocolNumber,
tcpip.AddrFrom4(srcIP),
tcpip.AddrFrom4(dstIP),
uint16(udpLen),
)
xsum = checksum(payload, xsum)
udpHdr.SetChecksum(^udpHdr.CalculateChecksum(xsum))
return packet
}
// buildIPv6UDPPacket builds a complete IPv6/UDP packet with the given payload.
func (df *DNSFilter) buildIPv6UDPPacket(srcIP, dstIP [16]byte, srcPort, dstPort uint16, payload []byte) []byte {
// Calculate lengths
udpLen := header.UDPMinimumSize + len(payload)
ipLen := header.IPv6MinimumSize + udpLen
packet := make([]byte, ipLen)
// Build IPv6 header
ipHdr := header.IPv6(packet)
ipHdr.Encode(&header.IPv6Fields{
PayloadLength: uint16(udpLen),
TransportProtocol: header.UDPProtocolNumber,
HopLimit: 64,
SrcAddr: tcpip.AddrFrom16(srcIP),
DstAddr: tcpip.AddrFrom16(dstIP),
})
// Build UDP header
udpHdr := header.UDP(packet[header.IPv6MinimumSize:])
udpHdr.Encode(&header.UDPFields{
SrcPort: srcPort,
DstPort: dstPort,
Length: uint16(udpLen),
})
// Copy payload
copy(packet[header.IPv6MinimumSize+header.UDPMinimumSize:], payload)
// Calculate UDP checksum
xsum := header.PseudoHeaderChecksum(
header.UDPProtocolNumber,
tcpip.AddrFrom16(srcIP),
tcpip.AddrFrom16(dstIP),
uint16(udpLen),
)
xsum = checksum(payload, xsum)
udpHdr.SetChecksum(^udpHdr.CalculateChecksum(xsum))
return packet
}
// checksum calculates the checksum for the given data.
func checksum(buf []byte, initial uint16) uint16 {
v := uint32(initial)
l := len(buf)
if l&1 != 0 {
l--
v += uint32(buf[l]) << 8
}
for i := 0; i < l; i += 2 {
v += (uint32(buf[i]) << 8) + uint32(buf[i+1])
}
return reduceChecksum(v)
}
// reduceChecksum reduces a 32-bit checksum to 16 bits.
func reduceChecksum(v uint32) uint16 {
v = (v >> 16) + (v & 0xffff)
v = (v >> 16) + (v & 0xffff)
return uint16(v)
}
// IPv4Address is a helper to create an IPv4 address from a byte array.
func IPv4Address(b [4]byte) net.IP {
return net.IPv4(b[0], b[1], b[2], b[3])
}
// IPv6Address is a helper to create an IPv6 address from a byte array.
func IPv6Address(b [16]byte) net.IP {
return net.IP(b[:])
}
// parseIPv4 extracts source and destination IPs from an IPv4 packet.
func parseIPv4(packet []byte) (srcIP, dstIP [4]byte, ok bool) {
if len(packet) < header.IPv4MinimumSize {
return
}
ipHdr := header.IPv4(packet)
if !ipHdr.IsValid(len(packet)) {
return
}
srcAddr := ipHdr.SourceAddress().As4()
dstAddr := ipHdr.DestinationAddress().As4()
copy(srcIP[:], srcAddr[:])
copy(dstIP[:], dstAddr[:])
ok = true
return
}
// parseUDP extracts UDP header information.
func parseUDP(udpHeader []byte) (srcPort, dstPort uint16, ok bool) {
if len(udpHeader) < header.UDPMinimumSize {
return
}
srcPort = binary.BigEndian.Uint16(udpHeader[0:2])
dstPort = binary.BigEndian.Uint16(udpHeader[2:4])
ok = true
return
}
// extractAndTrackIPs parses DNS response and tracks resolved IP addresses
func (df *DNSFilter) extractAndTrackIPs(dnsResponse []byte) {
if len(dnsResponse) < 12 {
return // Invalid DNS response
}
msg := new(dns.Msg)
if err := msg.Unpack(dnsResponse); err != nil {
return // Failed to parse DNS response
}
// Extract IPs from answer section
for _, answer := range msg.Answer {
switch rr := answer.(type) {
case *dns.A:
// IPv4 address
if rr.A != nil {
df.ipTracker.TrackIP(rr.A)
}
case *dns.AAAA:
// IPv6 address
if rr.AAAA != nil {
df.ipTracker.TrackIP(rr.AAAA)
}
}
}
}

View File

@@ -0,0 +1,150 @@
package netstack
import (
"net"
"sync"
"time"
)
// IPTracker tracks IP addresses that have been resolved through DNS.
// This allows blocking direct IP connections that bypass DNS filtering.
type IPTracker struct {
// Map of IP address string -> expiration time
resolvedIPs map[string]time.Time
mu sync.RWMutex
// TTL for tracked IPs (how long to remember them)
ttl time.Duration
// Running state
running bool
stopCh chan struct{}
wg sync.WaitGroup
}
// NewIPTracker creates a new IP tracker with the specified TTL
func NewIPTracker(ttl time.Duration) *IPTracker {
if ttl == 0 {
ttl = 5 * time.Minute // Default 5 minutes
}
return &IPTracker{
resolvedIPs: make(map[string]time.Time),
ttl: ttl,
stopCh: make(chan struct{}),
}
}
// Start starts the IP tracker cleanup routine
func (t *IPTracker) Start() {
t.mu.Lock()
if t.running {
t.mu.Unlock()
return
}
t.running = true
t.mu.Unlock()
// Start cleanup goroutine to remove expired IPs
t.wg.Add(1)
go t.cleanupExpiredIPs()
}
// Stop stops the IP tracker
func (t *IPTracker) Stop() {
if t == nil {
return
}
t.mu.Lock()
if !t.running {
t.mu.Unlock()
return
}
t.running = false
t.mu.Unlock()
// Close stop channel (protected against double close)
select {
case <-t.stopCh:
// Already closed
default:
close(t.stopCh)
}
t.wg.Wait()
// Clear all tracked IPs
t.mu.Lock()
t.resolvedIPs = make(map[string]time.Time)
t.mu.Unlock()
}
// TrackIP adds an IP address to the tracking list
func (t *IPTracker) TrackIP(ip net.IP) {
if ip == nil {
return
}
// Normalize to string format
ipStr := ip.String()
t.mu.Lock()
t.resolvedIPs[ipStr] = time.Now().Add(t.ttl)
t.mu.Unlock()
}
// IsTracked checks if an IP address is in the tracking list
// Optimized to minimize lock contention by avoiding write locks in the hot path
func (t *IPTracker) IsTracked(ip net.IP) bool {
if ip == nil {
return false
}
ipStr := ip.String()
t.mu.RLock()
expiration, exists := t.resolvedIPs[ipStr]
t.mu.RUnlock()
if !exists {
return false
}
// Check if expired - but DON'T delete here to avoid write lock
// Let the cleanup goroutine handle expired entries
// This keeps IsTracked fast with only read locks
return !time.Now().After(expiration)
}
// GetTrackedCount returns the number of currently tracked IPs
func (t *IPTracker) GetTrackedCount() int {
t.mu.RLock()
defer t.mu.RUnlock()
return len(t.resolvedIPs)
}
// cleanupExpiredIPs periodically removes expired IP entries
func (t *IPTracker) cleanupExpiredIPs() {
defer t.wg.Done()
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
select {
case <-t.stopCh:
return
case <-ticker.C:
now := time.Now()
t.mu.Lock()
for ip, expiration := range t.resolvedIPs {
if now.After(expiration) {
delete(t.resolvedIPs, ip)
}
}
t.mu.Unlock()
}
}
}

View File

@@ -0,0 +1,417 @@
package netstack
import (
"context"
"fmt"
"net"
"net/netip"
"sync"
"time"
"github.com/Control-D-Inc/ctrld"
"gvisor.dev/gvisor/pkg/buffer"
"gvisor.dev/gvisor/pkg/tcpip"
"gvisor.dev/gvisor/pkg/tcpip/header"
"gvisor.dev/gvisor/pkg/tcpip/link/channel"
"gvisor.dev/gvisor/pkg/tcpip/network/ipv4"
"gvisor.dev/gvisor/pkg/tcpip/network/ipv6"
"gvisor.dev/gvisor/pkg/tcpip/stack"
"gvisor.dev/gvisor/pkg/tcpip/transport/tcp"
"gvisor.dev/gvisor/pkg/tcpip/transport/udp"
)
const (
// Default MTU for the TUN interface
defaultMTU = 1500
// NICID is the ID of the network interface
NICID = 1
// Channel capacity for packet buffers
channelCapacity = 512
)
// NetstackController manages the gVisor netstack integration for mobile packet capture.
type NetstackController struct {
stack *stack.Stack
linkEP *channel.Endpoint
packetHandler PacketHandler
dnsFilter *DNSFilter
ipTracker *IPTracker
tcpForwarder *TCPForwarder
udpForwarder *UDPForwarder
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
started bool
mu sync.Mutex
}
// Config holds configuration for NetstackController.
type Config struct {
// MTU is the maximum transmission unit
MTU uint32
// TUNIPv4 is the IPv4 address assigned to the TUN interface
TUNIPv4 netip.Addr
// TUNIPv6 is the IPv6 address assigned to the TUN interface (optional)
TUNIPv6 netip.Addr
// DNSHandler is the function to process DNS queries
DNSHandler func([]byte) ([]byte, error)
// UpstreamInterface is the real network interface for routing non-DNS traffic
UpstreamInterface *net.Interface
}
// NewNetstackController creates a new netstack controller.
func NewNetstackController(handler PacketHandler, cfg *Config) (*NetstackController, error) {
if handler == nil {
return nil, fmt.Errorf("packet handler cannot be nil")
}
if cfg == nil {
cfg = &Config{
MTU: defaultMTU,
TUNIPv4: netip.MustParseAddr("10.0.0.1"),
}
}
if cfg.MTU == 0 {
cfg.MTU = defaultMTU
}
ctx, cancel := context.WithCancel(context.Background())
// Create gVisor stack
s := stack.New(stack.Options{
NetworkProtocols: []stack.NetworkProtocolFactory{
ipv4.NewProtocol,
ipv6.NewProtocol,
},
TransportProtocols: []stack.TransportProtocolFactory{
tcp.NewProtocol,
udp.NewProtocol,
},
})
// Create link endpoint
linkEP := channel.New(channelCapacity, cfg.MTU, "")
// 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, ctx, ipTracker)
// Create UDP forwarder with IP tracker
udpForwarder := NewUDPForwarder(s, ctx, ipTracker)
// Create NIC
if err := s.CreateNIC(NICID, linkEP); err != nil {
cancel()
return nil, fmt.Errorf("failed to create NIC: %v", err)
}
// Enable spoofing to allow packets with any source IP
if err := s.SetSpoofing(NICID, true); err != nil {
cancel()
return nil, fmt.Errorf("failed to enable spoofing: %v", err)
}
// Enable promiscuous mode to accept all packets
if err := s.SetPromiscuousMode(NICID, true); err != nil {
cancel()
return nil, fmt.Errorf("failed to enable promiscuous mode: %v", err)
}
// Add IPv4 address
protocolAddr := tcpip.ProtocolAddress{
Protocol: ipv4.ProtocolNumber,
AddressWithPrefix: tcpip.AddressWithPrefix{
Address: tcpip.AddrFromSlice(cfg.TUNIPv4.AsSlice()),
PrefixLen: 24,
},
}
if err := s.AddProtocolAddress(NICID, protocolAddr, stack.AddressProperties{}); err != nil {
cancel()
return nil, fmt.Errorf("failed to add IPv4 address: %v", err)
}
// Add IPv6 address if provided
if cfg.TUNIPv6.IsValid() {
protocolAddr6 := tcpip.ProtocolAddress{
Protocol: ipv6.ProtocolNumber,
AddressWithPrefix: tcpip.AddressWithPrefix{
Address: tcpip.AddrFromSlice(cfg.TUNIPv6.AsSlice()),
PrefixLen: 64,
},
}
if err := s.AddProtocolAddress(NICID, protocolAddr6, stack.AddressProperties{}); err != nil {
cancel()
return nil, fmt.Errorf("failed to add IPv6 address: %v", err)
}
}
// Add default routes
s.SetRouteTable([]tcpip.Route{
{
Destination: header.IPv4EmptySubnet,
NIC: NICID,
},
{
Destination: header.IPv6EmptySubnet,
NIC: NICID,
},
})
// Register forwarders with the stack
s.SetTransportProtocolHandler(tcp.ProtocolNumber, tcpForwarder.forwarder.HandlePacket)
s.SetTransportProtocolHandler(udp.ProtocolNumber, udpForwarder.forwarder.HandlePacket)
nc := &NetstackController{
stack: s,
linkEP: linkEP,
packetHandler: handler,
dnsFilter: dnsFilter,
ipTracker: ipTracker,
tcpForwarder: tcpForwarder,
udpForwarder: udpForwarder,
ctx: ctx,
cancel: cancel,
started: false,
}
ctrld.ProxyLogger.Load().Info().Msg("[Netstack] Controller created with TCP/UDP forwarders")
return nc, nil
}
// Start starts the netstack controller and begins processing packets.
func (nc *NetstackController) Start() error {
nc.mu.Lock()
defer nc.mu.Unlock()
if nc.started {
return fmt.Errorf("netstack controller already started")
}
nc.started = true
// Start IP tracker
nc.ipTracker.Start()
// Start packet reader goroutine (TUN -> netstack)
nc.wg.Add(1)
go nc.readPackets()
// Start packet writer goroutine (netstack -> TUN)
nc.wg.Add(1)
go nc.writePackets()
ctrld.ProxyLogger.Load().Info().Msg("[Netstack] Packet processing started (read/write goroutines + IP tracker)")
return nil
}
// 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")
nc.mu.Lock()
if !nc.started {
nc.mu.Unlock()
ctrld.ProxyLogger.Load().Info().Msg("[Netstack] Stop() - already stopped, returning")
return nil
}
nc.mu.Unlock()
ctrld.ProxyLogger.Load().Info().Msg("[Netstack] Stop() - canceling context")
nc.cancel()
// Close packet handler FIRST to unblock all pending reads
ctrld.ProxyLogger.Load().Info().Msg("[Netstack] Stop() - closing packet handler to unblock goroutines")
if err := nc.packetHandler.Close(); err != nil {
ctrld.ProxyLogger.Load().Error().Msgf("[Netstack] Stop() - failed to close packet handler: %v", err)
// Continue shutdown even if close fails
}
ctrld.ProxyLogger.Load().Info().Msg("[Netstack] Stop() - packet handler closed")
ctrld.ProxyLogger.Load().Info().Msg("[Netstack] Stop() - waiting for goroutines (max 2 seconds)")
// Wait for goroutines with timeout
done := make(chan struct{})
go func() {
nc.wg.Wait()
close(done)
}()
select {
case <-done:
ctrld.ProxyLogger.Load().Info().Msg("[Netstack] Stop() - all goroutines finished")
case <-time.After(2 * time.Second):
ctrld.ProxyLogger.Load().Warn().Msg("[Netstack] Stop() - timeout waiting for goroutines, proceeding anyway")
}
// Stop IP tracker
if nc.ipTracker != nil {
ctrld.ProxyLogger.Load().Info().Msg("[Netstack] Stop() - stopping IP tracker")
nc.ipTracker.Stop()
ctrld.ProxyLogger.Load().Info().Msg("[Netstack] Stop() - IP tracker stopped")
}
// Close UDP forwarder
if nc.udpForwarder != nil {
ctrld.ProxyLogger.Load().Info().Msg("[Netstack] Stop() - closing UDP forwarder")
nc.udpForwarder.Close()
ctrld.ProxyLogger.Load().Info().Msg("[Netstack] Stop() - UDP forwarder closed")
}
nc.mu.Lock()
nc.started = false
nc.mu.Unlock()
ctrld.ProxyLogger.Load().Info().Msg("[Netstack] Stop() - shutdown complete")
return nil
}
// readPackets reads packets from the TUN interface and injects them into the netstack.
func (nc *NetstackController) readPackets() {
defer nc.wg.Done()
for {
select {
case <-nc.ctx.Done():
return
default:
}
// Read packet from TUN
packet, err := nc.packetHandler.ReadPacket()
if err != nil {
if nc.ctx.Err() != nil {
return
}
time.Sleep(10 * time.Millisecond)
continue
}
if len(packet) == 0 {
continue
}
// Check if this is a DNS packet
isDNS, response, err := nc.dnsFilter.ProcessPacket(packet)
if err != nil {
continue
}
if isDNS && response != nil {
// DNS packet was handled, send response back to TUN
nc.packetHandler.WritePacket(response)
ctrld.ProxyLogger.Load().Debug().Msgf("[Netstack] DNS response sent (%d bytes)", len(response))
continue
}
if isDNS {
continue
}
// Not a DNS packet - check if it's an OUTBOUND packet (source = 10.0.0.x)
// We should ONLY inject outbound packets, not return packets
if len(packet) >= 20 {
// Check if source is in our VPN subnet (10.0.0.x)
isOutbound := packet[12] == 10 && packet[13] == 0 && packet[14] == 0
if !isOutbound {
// This is a return packet (server -> mobile)
// Drop it - return packets come through forwarder's upstream connection
continue
}
// Block QUIC protocol (UDP on port 443)
// QUIC runs over UDP and bypasses DNS, so we block it to force HTTP/2 or HTTP/3 over TCP
protocol := packet[9]
if protocol == 17 { // UDP
// Get IP header length
ihl := int(packet[0]&0x0f) * 4
if len(packet) >= ihl+4 {
// Parse UDP destination port (bytes 2-3 of UDP header)
dstPort := uint16(packet[ihl+2])<<8 | uint16(packet[ihl+3])
if dstPort == 443 || dstPort == 80 {
// Block QUIC (UDP/443) and HTTP/3 (UDP/80)
// Apps will fallback to TCP automatically
dstIP := net.IPv4(packet[16], packet[17], packet[18], packet[19])
ctrld.ProxyLogger.Load().Debug().Msgf("[Netstack] Blocked QUIC packet to %s:%d", dstIP, dstPort)
continue
}
}
}
}
// Create packet buffer
pkt := stack.NewPacketBuffer(stack.PacketBufferOptions{
Payload: buffer.MakeWithData(packet),
})
// Determine protocol number
var proto tcpip.NetworkProtocolNumber
if len(packet) > 0 {
version := packet[0] >> 4
switch version {
case 4:
proto = header.IPv4ProtocolNumber
case 6:
proto = header.IPv6ProtocolNumber
default:
pkt.DecRef()
continue
}
} else {
pkt.DecRef()
continue
}
// Inject into netstack - TCP/UDP forwarders will handle it
nc.linkEP.InjectInbound(proto, pkt)
}
}
// writePackets reads packets from netstack and writes them to the TUN interface.
func (nc *NetstackController) writePackets() {
defer nc.wg.Done()
for {
select {
case <-nc.ctx.Done():
return
default:
}
// Read packet from netstack
pkt := nc.linkEP.ReadContext(nc.ctx)
if pkt == nil {
continue
}
// Convert packet to bytes
vv := pkt.ToView()
packet := vv.AsSlice()
// Write to TUN
if err := nc.packetHandler.WritePacket(packet); err != nil {
// Log error
continue
}
pkt.DecRef()
}
}

View File

@@ -0,0 +1,97 @@
package netstack
import (
"fmt"
"sync"
)
// PacketHandler defines the interface for reading and writing raw IP packets
// from/to the mobile TUN interface.
type PacketHandler interface {
// ReadPacket reads a raw IP packet from the TUN interface.
// This should be a blocking call.
ReadPacket() ([]byte, error)
// WritePacket writes a raw IP packet back to the TUN interface.
WritePacket(packet []byte) error
// Close closes the packet handler and releases resources.
Close() 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
mu sync.Mutex
closed bool
}
// NewMobilePacketHandler creates a new packet handler with mobile callbacks.
func NewMobilePacketHandler(
readFunc func() ([]byte, error),
writeFunc func([]byte) error,
closeFunc func() error,
) *MobilePacketHandler {
return &MobilePacketHandler{
readFunc: readFunc,
writeFunc: writeFunc,
closeFunc: closeFunc,
closed: false,
}
}
// ReadPacket reads a packet from mobile TUN interface.
func (m *MobilePacketHandler) ReadPacket() ([]byte, error) {
m.mu.Lock()
closed := m.closed
m.mu.Unlock()
if closed {
return nil, fmt.Errorf("packet handler is closed")
}
if m.readFunc == nil {
return nil, fmt.Errorf("read function not set")
}
return m.readFunc()
}
// WritePacket writes a packet back to mobile TUN interface.
func (m *MobilePacketHandler) WritePacket(packet []byte) error {
m.mu.Lock()
closed := m.closed
m.mu.Unlock()
if closed {
return fmt.Errorf("packet handler is closed")
}
if m.writeFunc == nil {
return fmt.Errorf("write function not set")
}
return m.writeFunc(packet)
}
// Close closes the packet handler.
func (m *MobilePacketHandler) Close() error {
m.mu.Lock()
defer m.mu.Unlock()
if m.closed {
return nil
}
m.closed = true
if m.closeFunc != nil {
return m.closeFunc()
}
return nil
}

View File

@@ -0,0 +1,130 @@
package netstack
import (
"context"
"io"
"net"
"time"
"github.com/Control-D-Inc/ctrld"
"gvisor.dev/gvisor/pkg/tcpip/adapters/gonet"
"gvisor.dev/gvisor/pkg/tcpip/stack"
"gvisor.dev/gvisor/pkg/tcpip/transport/tcp"
"gvisor.dev/gvisor/pkg/waiter"
)
// TCPForwarder handles TCP connections from the TUN interface
type TCPForwarder struct {
ctx context.Context
forwarder *tcp.Forwarder
ipTracker *IPTracker
}
// NewTCPForwarder creates a new TCP forwarder
func NewTCPForwarder(s *stack.Stack, ctx context.Context, ipTracker *IPTracker) *TCPForwarder {
f := &TCPForwarder{
ctx: ctx,
ipTracker: ipTracker,
}
// Create gVisor TCP forwarder with handler callback
// rcvWnd=0 (use default), maxInFlight=1024
f.forwarder = tcp.NewForwarder(s, 0, 1024, f.handleRequest)
return f
}
// GetForwarder returns the underlying gVisor forwarder
func (f *TCPForwarder) GetForwarder() *tcp.Forwarder {
return f.forwarder
}
// handleRequest handles an incoming TCP connection request
func (f *TCPForwarder) handleRequest(req *tcp.ForwarderRequest) {
// Get the endpoint ID
id := req.ID()
// Create waiter queue
var wq waiter.Queue
// Create endpoint from request
ep, err := req.CreateEndpoint(&wq)
if err != nil {
req.Complete(true) // Send RST
return
}
// Accept the connection
req.Complete(false)
// Cast to TCP endpoint
tcpEP, ok := ep.(*tcp.Endpoint)
if !ok {
ep.Close()
return
}
// Handle in goroutine
go f.handleConnection(tcpEP, &wq, id)
}
func (f *TCPForwarder) handleConnection(ep *tcp.Endpoint, wq *waiter.Queue, id stack.TransportEndpointID) {
// Convert endpoint to Go net.Conn
tunConn := gonet.NewTCPConn(wq, ep)
defer tunConn.Close()
// In gVisor's TransportEndpointID for an inbound connection:
// - LocalAddress/LocalPort = the destination (where packet is going TO)
// - RemoteAddress/RemotePort = the source (where packet is coming FROM)
// We want to dial the DESTINATION (LocalAddress/LocalPort)
dstIP := net.IP(id.LocalAddress.AsSlice())
dstAddr := net.TCPAddr{
IP: dstIP,
Port: int(id.LocalPort),
}
// Check if IP blocking is enabled (firewall mode only)
// Skip blocking for internal VPN subnet (10.0.0.0/24)
if f.ipTracker != nil {
// Allow internal VPN traffic (10.0.0.0/24)
if !(dstIP[0] == 10 && dstIP[1] == 0 && dstIP[2] == 0) {
// Check if destination IP was resolved through ControlD DNS
// ONLY allow connections to IPs that went through DNS (whitelist approach)
if !f.ipTracker.IsTracked(dstIP) {
srcAddr := net.IP(id.RemoteAddress.AsSlice())
ctrld.ProxyLogger.Load().Info().Msgf("[TCP] BLOCKED hardcoded IP: %s:%d -> %s:%d (not resolved via DNS)",
srcAddr, id.RemotePort, dstIP, id.LocalPort)
return
}
}
}
// Create outbound connection
dialer := &net.Dialer{
Timeout: 30 * time.Second,
}
upstreamConn, err := dialer.DialContext(f.ctx, "tcp", dstAddr.String())
if err != nil {
return
}
defer upstreamConn.Close()
// Log successful TCP connection
srcAddr := net.IP(id.RemoteAddress.AsSlice())
ctrld.ProxyLogger.Load().Debug().Msgf("[TCP] %s:%d -> %s:%d", srcAddr, id.RemotePort, dstAddr.IP, dstAddr.Port)
// Bidirectional copy
done := make(chan struct{}, 2)
go func() {
io.Copy(upstreamConn, tunConn)
done <- struct{}{}
}()
go func() {
io.Copy(tunConn, upstreamConn)
done <- struct{}{}
}()
// Wait for one direction to finish
<-done
}

View File

@@ -0,0 +1,238 @@
package netstack
import (
"context"
"fmt"
"net"
"sync"
"time"
"github.com/Control-D-Inc/ctrld"
"gvisor.dev/gvisor/pkg/tcpip/adapters/gonet"
"gvisor.dev/gvisor/pkg/tcpip/stack"
"gvisor.dev/gvisor/pkg/tcpip/transport/udp"
"gvisor.dev/gvisor/pkg/waiter"
)
// UDPForwarder handles UDP packets from the TUN interface
type UDPForwarder struct {
ctx context.Context
forwarder *udp.Forwarder
ipTracker *IPTracker
// Track UDP "connections" (address pairs)
connections map[string]*udpConn
mu sync.Mutex
}
type udpConn struct {
tunEP *gonet.UDPConn
upstreamConn *net.UDPConn
cancel context.CancelFunc
}
// NewUDPForwarder creates a new UDP forwarder
func NewUDPForwarder(s *stack.Stack, ctx context.Context, ipTracker *IPTracker) *UDPForwarder {
f := &UDPForwarder{
ctx: ctx,
ipTracker: ipTracker,
connections: make(map[string]*udpConn),
}
// Create gVisor UDP forwarder with handler callback
f.forwarder = udp.NewForwarder(s, f.handlePacket)
return f
}
// GetForwarder returns the underlying gVisor forwarder
func (f *UDPForwarder) GetForwarder() *udp.Forwarder {
return f.forwarder
}
// handlePacket handles an incoming UDP packet
func (f *UDPForwarder) handlePacket(req *udp.ForwarderRequest) {
// Get the endpoint ID
id := req.ID()
// Create connection key (source -> destination)
connKey := fmt.Sprintf("%s:%d->%s:%d",
net.IP(id.RemoteAddress.AsSlice()),
id.RemotePort,
net.IP(id.LocalAddress.AsSlice()),
id.LocalPort,
)
f.mu.Lock()
conn, exists := f.connections[connKey]
if !exists {
// Create new connection
conn = f.createConnection(req, connKey)
if conn == nil {
f.mu.Unlock()
return
}
f.connections[connKey] = conn
// Log new UDP session
srcAddr := net.IP(id.RemoteAddress.AsSlice())
dstAddr := net.IP(id.LocalAddress.AsSlice())
ctrld.ProxyLogger.Load().Debug().Msgf("[UDP] New session: %s:%d -> %s:%d (total: %d)",
srcAddr, id.RemotePort, dstAddr, id.LocalPort, len(f.connections))
}
f.mu.Unlock()
}
func (f *UDPForwarder) createConnection(req *udp.ForwarderRequest, connKey string) *udpConn {
id := req.ID()
// Create waiter queue
var wq waiter.Queue
// Create endpoint from request
ep, err := req.CreateEndpoint(&wq)
if err != nil {
return nil
}
// Convert to Go UDP conn
tunConn := gonet.NewUDPConn(&wq, ep)
// Extract destination address
// LocalAddress/LocalPort = destination (where packet is going TO)
// RemoteAddress/RemotePort = source (where packet is coming FROM)
dstIP := net.IP(id.LocalAddress.AsSlice())
dstAddr := &net.UDPAddr{
IP: dstIP,
Port: int(id.LocalPort),
}
// Check if IP blocking is enabled (firewall mode only)
// Skip blocking for internal VPN subnet (10.0.0.0/24)
if f.ipTracker != nil {
// Allow internal VPN traffic (10.0.0.0/24)
if !(dstIP[0] == 10 && dstIP[1] == 0 && dstIP[2] == 0) {
// Check if destination IP was resolved through ControlD DNS
// ONLY allow connections to IPs that went through DNS (whitelist approach)
if !f.ipTracker.IsTracked(dstIP) {
srcAddr := net.IP(id.RemoteAddress.AsSlice())
ctrld.ProxyLogger.Load().Info().Msgf("[UDP] BLOCKED hardcoded IP: %s:%d -> %s:%d (not resolved via DNS)",
srcAddr, id.RemotePort, dstIP, id.LocalPort)
return nil
}
}
}
// Create dialer
dialer := &net.Dialer{}
// Create outbound UDP connection
dialConn, dialErr := dialer.Dial("udp", dstAddr.String())
if dialErr != nil {
tunConn.Close()
return nil
}
upstreamConn, ok := dialConn.(*net.UDPConn)
if !ok {
dialConn.Close()
tunConn.Close()
return nil
}
// Create connection context
ctx, cancel := context.WithCancel(f.ctx)
udpConnection := &udpConn{
tunEP: tunConn,
upstreamConn: upstreamConn,
cancel: cancel,
}
// Start forwarding goroutines
go f.forwardTunToUpstream(udpConnection, ctx)
go f.forwardUpstreamToTun(udpConnection, ctx, connKey)
return udpConnection
}
func (f *UDPForwarder) forwardTunToUpstream(conn *udpConn, ctx context.Context) {
buffer := make([]byte, 65535)
for {
select {
case <-ctx.Done():
return
default:
}
// Read from TUN
n, err := conn.tunEP.Read(buffer)
if err != nil {
return
}
// Write to upstream
_, err = conn.upstreamConn.Write(buffer[:n])
if err != nil {
return
}
}
}
func (f *UDPForwarder) forwardUpstreamToTun(conn *udpConn, ctx context.Context, connKey string) {
defer func() {
conn.tunEP.Close()
conn.upstreamConn.Close()
f.mu.Lock()
delete(f.connections, connKey)
f.mu.Unlock()
}()
buffer := make([]byte, 65535)
// Set read timeout
conn.upstreamConn.SetReadDeadline(time.Now().Add(30 * time.Second))
for {
select {
case <-ctx.Done():
return
default:
}
// Read from upstream
n, err := conn.upstreamConn.Read(buffer)
if err != nil {
return
}
// Reset read deadline
conn.upstreamConn.SetReadDeadline(time.Now().Add(30 * time.Second))
// Write to TUN
_, err = conn.tunEP.Write(buffer[:n])
if err != nil {
return
}
}
}
// Close closes all UDP connections
func (f *UDPForwarder) Close() {
ctrld.ProxyLogger.Load().Info().Msg("[UDP] Close() called - closing all connections")
f.mu.Lock()
defer f.mu.Unlock()
ctrld.ProxyLogger.Load().Info().Msgf("[UDP] Close() - closing %d connections", len(f.connections))
for key, conn := range f.connections {
ctrld.ProxyLogger.Load().Debug().Msgf("[UDP] Close() - closing connection: %s", key)
conn.cancel()
conn.tunEP.Close()
conn.upstreamConn.Close()
}
f.connections = make(map[string]*udpConn)
ctrld.ProxyLogger.Load().Info().Msg("[UDP] Close() - all connections closed")
}

View File

@@ -0,0 +1,277 @@
package ctrld_library
import (
"fmt"
"net/netip"
"time"
"github.com/Control-D-Inc/ctrld"
"github.com/Control-D-Inc/ctrld/cmd/cli"
"github.com/Control-D-Inc/ctrld/cmd/ctrld_library/netstack"
"github.com/miekg/dns"
)
// PacketAppCallback extends AppCallback with packet read/write capabilities.
// Mobile platforms implementing full packet capture should use this interface.
type PacketAppCallback interface {
AppCallback
// ReadPacket reads a raw IP packet from the TUN interface.
// This should be a blocking call that returns when a packet is available.
ReadPacket() ([]byte, error)
// WritePacket writes a raw IP packet back to the TUN interface.
WritePacket(packet []byte) error
// ClosePacketIO closes packet I/O resources.
ClosePacketIO() error
}
// PacketCaptureController holds state for packet capture mode
type PacketCaptureController struct {
baseController *Controller
// Packet capture mode fields
netstackCtrl *netstack.NetstackController
dnsBridge *netstack.DNSBridge
packetStopCh chan struct{}
dnsProxyAddress string
}
// NewPacketCaptureController creates a new packet capture controller
func NewPacketCaptureController(appCallback PacketAppCallback) *PacketCaptureController {
return &PacketCaptureController{
baseController: &Controller{AppCallback: appCallback},
packetStopCh: make(chan struct{}),
}
}
// StartWithPacketCapture starts ctrld in full packet capture mode for mobile.
// This method enables full IP packet processing with DNS filtering and upstream routing.
// 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,
HomeDir string,
UpstreamProto string,
logLevel int,
logPath string,
) error {
if pc.baseController.stopCh != nil {
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,
ProvisionID: ProvisionID,
CustomHostname: CustomHostname,
HomeDir: HomeDir,
UpstreamProto: UpstreamProto,
Verbose: logLevel,
LogPath: logPath,
}
pc.baseController.AppCallback = packetCallback
// Create DNS bridge for communication between netstack and DNS proxy
pc.dnsBridge = netstack.NewDNSBridge()
pc.dnsBridge.Start()
// Create packet handler that wraps the mobile callbacks
packetHandler := netstack.NewMobilePacketHandler(
packetCallback.ReadPacket,
packetCallback.WritePacket,
packetCallback.ClosePacketIO,
)
// Create DNS handler that uses the bridge
dnsHandler := func(query []byte) ([]byte, error) {
// Use device address as the source of DNS queries
return pc.dnsBridge.ProcessQuery(query, deviceAddress, 0)
}
// Parse TUN IP address
tunIPv4, err := netip.ParseAddr(tunAddress)
if err != nil {
return fmt.Errorf("failed to parse TUN IPv4 address '%s': %v", tunAddress, err)
}
netstackCfg := &netstack.Config{
MTU: uint32(mtu),
TUNIPv4: tunIPv4,
DNSHandler: dnsHandler,
UpstreamInterface: nil, // Will use default interface
}
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)
if err != nil {
pc.dnsBridge.Stop()
return fmt.Errorf("failed to create netstack controller: %v", err)
}
pc.netstackCtrl = netstackCtrl
// Start netstack processing
if err := pc.netstackCtrl.Start(); err != nil {
pc.dnsBridge.Stop()
return fmt.Errorf("failed to start netstack: %v", err)
}
// Start regular ctrld DNS processing in background
// This allows us to use existing DNS filtering logic
pc.baseController.stopCh = make(chan struct{})
// Start DNS query processor that receives queries from the bridge
// and sends them to the ctrld DNS proxy
go pc.processDNSQueries()
// Start the main ctrld mobile runner
go func() {
appCallback := mapCallback(pc.baseController.AppCallback)
cli.RunMobile(&pc.baseController.Config, &appCallback, pc.baseController.stopCh)
}()
// 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
}
// processDNSQueries processes DNS queries from the bridge using the ctrld DNS proxy
func (pc *PacketCaptureController) processDNSQueries() {
queryCh := pc.dnsBridge.GetQueryChannel()
for {
select {
case <-pc.packetStopCh:
return
case <-pc.baseController.stopCh:
return
case query := <-queryCh:
go pc.handleDNSQuery(query)
}
}
}
// handleDNSQuery handles a single DNS query
func (pc *PacketCaptureController) handleDNSQuery(query *netstack.DNSQuery) {
// Parse DNS message
msg := new(dns.Msg)
if err := msg.Unpack(query.Query); err != nil {
return
}
// Send query to actual DNS proxy using configured address
client := &dns.Client{
Net: "udp",
Timeout: 3 * time.Second,
}
response, _, err := client.Exchange(msg, pc.dnsProxyAddress)
if err != nil {
// Create SERVFAIL response
response = new(dns.Msg)
response.SetReply(msg)
response.Rcode = dns.RcodeServerFailure
}
// Pack response
responseBytes, err := response.Pack()
if err != nil {
return
}
// Send response back through bridge
pc.dnsBridge.SendResponse(query.ID, responseBytes)
}
// Stop stops the packet capture controller
func (pc *PacketCaptureController) Stop(restart bool, pin int64) int {
ctrld.ProxyLogger.Load().Info().Msg("[PacketCapture] Stop() called - starting shutdown")
var errorCode = 0
// Stop DNS bridge
if pc.dnsBridge != nil {
ctrld.ProxyLogger.Load().Info().Msg("[PacketCapture] Stop() - stopping DNS bridge")
pc.dnsBridge.Stop()
pc.dnsBridge = nil
ctrld.ProxyLogger.Load().Info().Msg("[PacketCapture] Stop() - DNS bridge stopped")
}
// Stop netstack
if pc.netstackCtrl != nil {
ctrld.ProxyLogger.Load().Info().Msg("[PacketCapture] Stop() - stopping netstack controller")
if err := pc.netstackCtrl.Stop(); err != nil {
// Log error but continue shutdown
ctrld.ProxyLogger.Load().Error().Msgf("[PacketCapture] Stop() - error stopping netstack: %v", err)
}
pc.netstackCtrl = nil
ctrld.ProxyLogger.Load().Info().Msg("[PacketCapture] Stop() - netstack controller stopped")
}
// Close packet stop channel
if pc.packetStopCh != nil {
ctrld.ProxyLogger.Load().Info().Msg("[PacketCapture] Stop() - closing packet stop channel")
select {
case <-pc.packetStopCh:
// Already closed
ctrld.ProxyLogger.Load().Info().Msg("[PacketCapture] Stop() - packet stop channel already closed")
default:
close(pc.packetStopCh)
ctrld.ProxyLogger.Load().Info().Msg("[PacketCapture] Stop() - packet stop channel closed")
}
pc.packetStopCh = make(chan struct{})
}
// Stop base controller
ctrld.ProxyLogger.Load().Info().Msgf("[PacketCapture] Stop() - stopping base controller (restart=%v, pin=%d)", restart, pin)
if !restart {
errorCode = cli.CheckDeactivationPin(pin, pc.baseController.stopCh)
ctrld.ProxyLogger.Load().Info().Msgf("[PacketCapture] Stop() - deactivation pin check returned: %d", errorCode)
}
if errorCode == 0 && pc.baseController.stopCh != nil {
ctrld.ProxyLogger.Load().Info().Msg("[PacketCapture] Stop() - closing base controller stop channel")
select {
case <-pc.baseController.stopCh:
// Already closed
ctrld.ProxyLogger.Load().Info().Msg("[PacketCapture] Stop() - base controller stop channel already closed")
default:
close(pc.baseController.stopCh)
ctrld.ProxyLogger.Load().Info().Msg("[PacketCapture] Stop() - base controller stop channel closed")
}
pc.baseController.stopCh = nil
}
ctrld.ProxyLogger.Load().Info().Msgf("[PacketCapture] Stop() - shutdown complete, errorCode=%d", errorCode)
return errorCode
}
// IsRunning returns true if the controller is running
func (pc *PacketCaptureController) IsRunning() bool {
return pc.baseController.stopCh != nil
}
// IsPacketMode returns true (always in packet mode for this controller)
func (pc *PacketCaptureController) IsPacketMode() bool {
return true
}

View File

@@ -21,6 +21,7 @@ import (
"strings"
"sync"
"sync/atomic"
"syscall"
"time"
"github.com/ameshkov/dnsstamps"
@@ -358,6 +359,15 @@ func (uc *UpstreamConfig) Init() {
}
}
// VerifyMsg creates and returns a new DNS message could be used for testing upstream health.
func (uc *UpstreamConfig) VerifyMsg() *dns.Msg {
msg := new(dns.Msg)
msg.RecursionDesired = true
msg.SetQuestion(".", dns.TypeNS)
msg.SetEdns0(4096, false) // ensure handling of large DNS response
return msg
}
// VerifyDomain returns the domain name that could be resolved by the upstream endpoint.
// It returns empty for non-ControlD upstream endpoint.
func (uc *UpstreamConfig) VerifyDomain() string {
@@ -546,7 +556,24 @@ func (uc *UpstreamConfig) newDOHTransport(addrs []string) *http.Transport {
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
_, port, _ := net.SplitHostPort(addr)
if uc.BootstrapIP != "" {
dialer := net.Dialer{Timeout: dialerTimeout, KeepAlive: dialerTimeout}
// Create custom dialer with socket protection - matches working example pattern
dialer := &net.Dialer{
Timeout: dialerTimeout,
KeepAlive: dialerTimeout,
}
// Access underlying socket fd before connecting to it
dialer.Control = func(network, address string, c syscall.RawConn) error {
return c.Control(func(fd uintptr) {
Log(ctx, ProxyLogger.Load().Debug(), "Received DoH socket fd %d for %s", fd, address)
i := int(fd)
// Protect socket from VPN routing
if err := ProtectSocket(i); err != nil {
Log(ctx, ProxyLogger.Load().Warn(), "Failed to protect DoH socket fd=%d: %v", i, err)
} else {
Log(ctx, ProxyLogger.Load().Debug(), "Protected DoH socket fd=%d", i)
}
})
}
addr := net.JoinHostPort(uc.BootstrapIP, port)
Log(ctx, ProxyLogger.Load().Debug(), "sending doh request to: %s", addr)
return dialer.DialContext(ctx, network, addr)
@@ -562,6 +589,21 @@ func (uc *UpstreamConfig) newDOHTransport(addrs []string) *http.Transport {
if err != nil {
return nil, err
}
// Protect DoH socket from VPN routing
if tcpConn, ok := conn.(*net.TCPConn); ok {
if rawConn, err := tcpConn.SyscallConn(); err == nil {
rawConn.Control(func(fd uintptr) {
i := int(fd)
if err := ProtectSocket(i); err != nil {
Log(ctx, ProxyLogger.Load().Warn(), "Failed to protect DoH socket fd=%d: %v", i, err)
} else {
Log(ctx, ProxyLogger.Load().Debug(), "Protected DoH socket fd=%d", i)
}
})
}
}
Log(ctx, ProxyLogger.Load().Debug(), "sending doh request to: %s", conn.RemoteAddr())
return conn, nil
}

View File

@@ -36,7 +36,7 @@ func (uc *UpstreamConfig) setupDOH3Transport() {
func (uc *UpstreamConfig) newDOH3Transport(addrs []string) http.RoundTripper {
rt := &http3.Transport{}
rt.TLSClientConfig = &tls.Config{RootCAs: uc.certPool}
rt.Dial = func(ctx context.Context, addr string, tlsCfg *tls.Config, cfg *quic.Config) (quic.EarlyConnection, error) {
rt.Dial = func(ctx context.Context, addr string, tlsCfg *tls.Config, cfg *quic.Config) (*quic.Conn, error) {
_, port, _ := net.SplitHostPort(addr)
// if we have a bootstrap ip set, use it to avoid DNS lookup
if uc.BootstrapIP != "" {
@@ -96,14 +96,14 @@ func (uc *UpstreamConfig) doh3Transport(dnsType uint16) http.RoundTripper {
// - quic dialer is different with net.Dialer
// - simplification for quic free version
type parallelDialerResult struct {
conn quic.EarlyConnection
conn *quic.Conn
err error
}
type quicParallelDialer struct{}
// Dial performs parallel dialing to the given address list.
func (d *quicParallelDialer) Dial(ctx context.Context, addrs []string, tlsCfg *tls.Config, cfg *quic.Config) (quic.EarlyConnection, error) {
func (d *quicParallelDialer) Dial(ctx context.Context, addrs []string, tlsCfg *tls.Config, cfg *quic.Config) (*quic.Conn, error) {
if len(addrs) == 0 {
return nil, errors.New("empty addresses")
}

42
docs/known-issues.md Normal file
View File

@@ -0,0 +1,42 @@
# Known Issues
This document outlines known issues with ctrld and their current status, workarounds, and recommendations.
## macOS (Darwin) Issues
### Self-Upgrade Issue on Darwin 15.5
**Issue**: ctrld self-upgrading functionality may not work on macOS Darwin 15.5.
**Status**: Under investigation
**Description**: Users on macOS Darwin 15.5 may experience issues when ctrld attempts to perform automatic self-upgrades. The upgrade process would be triggered, but ctrld won't be upgraded.
**Workarounds**:
1. **Recommended**: Upgrade your macOS system to Darwin 15.6 or later, which has been tested and verified to work correctly with ctrld self-upgrade functionality.
2. **Alternative**: Run `ctrld upgrade prod` directly to manually upgrade ctrld to the latest version on Darwin 15.5.
**Affected Versions**: ctrld v1.4.2 and later on macOS Darwin 15.5
**Last Updated**: 05/09/2025
---
## Contributing to Known Issues
If you encounter an issue not listed here, please:
1. Check the [GitHub Issues](https://github.com/Control-D-Inc/ctrld/issues) to see if it's already reported
2. If not reported, create a new issue with:
- Detailed description of the problem
- Steps to reproduce
- Expected vs actual behavior
- System information (OS, version, architecture)
- ctrld version
## Issue Status Legend
- **Under investigation**: Issue is confirmed and being analyzed
- **Workaround available**: Temporary solution exists while permanent fix is developed
- **Fixed**: Issue has been resolved in a specific version
- **Won't fix**: Issue is acknowledged but will not be addressed due to technical limitations or design decisions

View File

@@ -142,7 +142,7 @@ func (s *testQUICServer) serve(t *testing.T) {
}
// handleConnection manages an individual QUIC connection by accepting and handling incoming streams in separate goroutines.
func (s *testQUICServer) handleConnection(t *testing.T, conn quic.Connection) {
func (s *testQUICServer) handleConnection(t *testing.T, conn *quic.Conn) {
for {
stream, err := conn.AcceptStream(context.Background())
if err != nil {
@@ -154,7 +154,7 @@ func (s *testQUICServer) handleConnection(t *testing.T, conn quic.Connection) {
}
// handleStream processes a single QUIC stream, reads DNS messages, generates a response, and sends it back to the client.
func (s *testQUICServer) handleStream(t *testing.T, stream quic.Stream) {
func (s *testQUICServer) handleStream(t *testing.T, stream *quic.Stream) {
defer stream.Close()
// Read length (2 bytes)

35
go.mod
View File

@@ -1,13 +1,11 @@
module github.com/Control-D-Inc/ctrld
go 1.23.0
toolchain go1.23.7
go 1.25.5
require (
github.com/Masterminds/semver/v3 v3.2.1
github.com/ameshkov/dnsstamps v1.0.3
github.com/coreos/go-systemd/v22 v22.5.0
github.com/coreos/go-systemd/v22 v22.6.0
github.com/cuonglm/osinfo v0.0.0-20230921071424-e0e1b1e0bbbf
github.com/docker/go-units v0.5.0
github.com/frankban/quicktest v1.14.6
@@ -29,17 +27,18 @@ require (
github.com/prometheus/client_golang v1.19.1
github.com/prometheus/client_model v0.5.0
github.com/prometheus/prom2json v1.3.3
github.com/quic-go/quic-go v0.48.2
github.com/quic-go/quic-go v0.54.0
github.com/rs/zerolog v1.28.0
github.com/spf13/cobra v1.8.1
github.com/spf13/pflag v1.0.5
github.com/spf13/viper v1.16.0
github.com/stretchr/testify v1.9.0
github.com/vishvananda/netlink v1.2.1-beta.2
golang.org/x/net v0.38.0
golang.org/x/sync v0.12.0
golang.org/x/sys v0.31.0
golang.org/x/net v0.52.0
golang.org/x/sync v0.20.0
golang.org/x/sys v0.42.0
golang.zx2c4.com/wireguard/windows v0.5.3
gvisor.dev/gvisor v0.0.0-20240722211153-64c016c92987
tailscale.com v1.74.0
)
@@ -54,10 +53,9 @@ require (
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-playground/locales v0.14.0 // indirect
github.com/go-playground/universal-translator v0.18.0 // indirect
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/google/go-cmp v0.6.0 // indirect
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd // indirect
github.com/google/btree v1.1.2 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
@@ -74,7 +72,6 @@ require (
github.com/mdlayher/packet v1.1.2 // indirect
github.com/mdlayher/socket v0.5.0 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/onsi/ginkgo/v2 v2.9.5 // indirect
github.com/pierrec/lz4/v4 v4.1.21 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
@@ -89,15 +86,17 @@ require (
github.com/subosito/gotenv v1.4.2 // indirect
github.com/u-root/uio v0.0.0-20240118234441-a3c409a6018e // indirect
github.com/vishvananda/netns v0.0.4 // indirect
go.uber.org/mock v0.4.0 // indirect
go.uber.org/mock v0.5.0 // indirect
go4.org/mem v0.0.0-20220726221520-4f986261bf13 // indirect
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
golang.org/x/crypto v0.36.0 // indirect
golang.org/x/crypto v0.49.0 // indirect
golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 // indirect
golang.org/x/mod v0.19.0 // indirect
golang.org/x/text v0.23.0 // indirect
golang.org/x/tools v0.23.0 // indirect
google.golang.org/protobuf v1.33.0 // indirect
golang.org/x/mobile v0.0.0-20260312152759-81488f6aeb60 // indirect
golang.org/x/mod v0.34.0 // indirect
golang.org/x/text v0.35.0 // indirect
golang.org/x/time v0.12.0 // indirect
golang.org/x/tools v0.43.0 // indirect
google.golang.org/protobuf v1.36.8 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

67
go.sum
View File

@@ -62,8 +62,9 @@ github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDk
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/coreos/go-systemd/v22 v22.6.0 h1:aGVa/v8B7hpb0TKl0MWoAavPDmHvobFe5R5zn0bCJWo=
github.com/coreos/go-systemd/v22 v22.6.0/go.mod h1:iG+pp635Fo7ZmV/j14KUcmEyWF+0X7Lua8rrTWzYgWU=
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/cuonglm/osinfo v0.0.0-20230921071424-e0e1b1e0bbbf h1:40DHYsri+d1bnroFDU2FQAeq68f3kAlOzlQ93kCf26Q=
@@ -91,8 +92,6 @@ github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-json-experiment/json v0.0.0-20231102232822-2e55bd4e08b0 h1:ymLjT4f35nQbASLnvxEde4XOBL+Sn7rFuV+FOJqkljg=
github.com/go-json-experiment/json v0.0.0-20231102232822-2e55bd4e08b0/go.mod h1:6daplAwHHGbUGib4990V3Il26O0OC4aRyvewaaAihaA=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/go-playground/assert/v2 v2.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A=
@@ -103,8 +102,6 @@ github.com/go-playground/universal-translator v0.18.0 h1:82dyy6p4OuJq4/CByFNOn/j
github.com/go-playground/universal-translator v0.18.0/go.mod h1:UvRDBj+xPUEGrFYl+lu/H90nyDXpg0fqeB/AQUGNTVA=
github.com/go-playground/validator/v10 v10.11.1 h1:prmOlTVv+YjZjmRmNSF3VmspqJIxJWXmqUsHwfTRRkQ=
github.com/go-playground/validator/v10 v10.11.1/go.mod h1:i+3WkQ1FvaUjjxh1kSvIA4dMGDBiPU55YFDl0WbKdWU=
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI=
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 h1:sQspH8M4niEijh3PFscJRLDnkL547IeP7kpPe3uUhEg=
github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466/go.mod h1:ZiQxhyQ+bbbfxUKVvjfO498oPYvtYhZzycal3G/NHmU=
@@ -137,6 +134,8 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v1.1.2 h1:xf4v41cLI2Z6FxbKm+8Bu+m8ifhj15JuZ9sa0jZCMUU=
github.com/google/btree v1.1.2/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
@@ -147,8 +146,8 @@ github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
@@ -162,8 +161,6 @@ github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hf
github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20201218002935-b9804c9f04c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -242,10 +239,6 @@ github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyua
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
github.com/onsi/ginkgo/v2 v2.9.5 h1:+6Hr4uxzP4XIUyAkg61dWBw8lb/gc4/X5luuxN/EC+Q=
github.com/onsi/ginkgo/v2 v2.9.5/go.mod h1:tvAoo1QUJwNEU2ITftXTpR7R1RbCzoZUOs3RonqW57k=
github.com/onsi/gomega v1.27.6 h1:ENqfyGeS5AX/rlXDd/ETokDz93u0YufY1Pgxuy/PvWE=
github.com/onsi/gomega v1.27.6/go.mod h1:PIQNjfQwkP3aQAH7lf7j87O/5FiNr+ZR8+ipb+qQlhg=
github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
github.com/pierrec/lz4/v4 v4.1.14/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
@@ -271,8 +264,8 @@ github.com/prometheus/prom2json v1.3.3 h1:IYfSMiZ7sSOfliBoo89PcufjWO4eAR0gznGcET
github.com/prometheus/prom2json v1.3.3/go.mod h1:Pv4yIPktEkK7btWsrUTWDDDrnpUrAELaOCj+oFwlgmc=
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
github.com/quic-go/quic-go v0.48.2 h1:wsKXZPeGWpMpCGSWqOcqpW2wZYic/8T3aqiOID0/KWE=
github.com/quic-go/quic-go v0.48.2/go.mod h1:yBgs3rWBOADpga7F+jJsb6Ybg1LSYiQvwWlLX+/6HMs=
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
@@ -330,8 +323,8 @@ go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU=
go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc=
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
go4.org/mem v0.0.0-20220726221520-4f986261bf13 h1:CbZeCBZ0aZj8EfVgnqQcYZgf0lpZ3H9rmp5nkDTAst8=
go4.org/mem v0.0.0-20220726221520-4f986261bf13/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g=
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M=
@@ -346,8 +339,8 @@ golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm
golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
@@ -375,6 +368,8 @@ golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPI
golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
golang.org/x/mobile v0.0.0-20260312152759-81488f6aeb60 h1:MOzyaj0wu2xneBkzkg9LHNYjDBB4W5vP043A2SYQRPA=
golang.org/x/mobile v0.0.0-20260312152759-81488f6aeb60/go.mod h1:th6VJvzjMbrYF8SduQY5rpD0HG0GleGxjadkqSxFs3k=
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
@@ -383,8 +378,8 @@ golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.19.0 h1:fEdghXQSo20giMthA7cd28ZC+jts4amQ3YMXiP5oMQ8=
golang.org/x/mod v0.19.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -417,8 +412,8 @@ golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v
golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@@ -438,8 +433,8 @@ golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -488,8 +483,8 @@ golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.4.1-0.20230131160137-e7d7f63158de/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -500,13 +495,13 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
@@ -554,8 +549,8 @@ golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4f
golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20210108195828-e2f9c7f1fc8e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
golang.org/x/tools v0.23.0 h1:SGsXPZ+2l4JsgaCKkx+FQ9YZ5XEtA1GZYuoDjenLjvg=
golang.org/x/tools v0.23.0/go.mod h1:pnu6ufv6vQkll6szChhK3C3L/ruaIv5eBeztNG8wtsI=
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -650,8 +645,8 @@ google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4=
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
@@ -664,6 +659,8 @@ gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gvisor.dev/gvisor v0.0.0-20240722211153-64c016c92987 h1:TU8z2Lh3Bbq77w0t1eG8yRlLcNHzZu3x6mhoH2Mk0c8=
gvisor.dev/gvisor v0.0.0-20240722211153-64c016c92987/go.mod h1:sxc3Uvk/vHcd3tj7/DHVBoR5wvWT/MmRq2pj7HRJnwU=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=

View File

@@ -16,4 +16,5 @@ var clientInfoFiles = map[string]ctrld.LeaseFileFormat{
"/var/dhcpd/var/db/dhcpd.leases": ctrld.IscDhcpd, // Pfsense
"/home/pi/.router/run/dhcp/dnsmasq.leases": ctrld.Dnsmasq, // Firewalla
"/var/lib/kea/dhcp4.leases": ctrld.KeaDHCP4, // Pfsense
"/var/db/dnsmasq.leases": ctrld.Dnsmasq, // OPNsense
}

View File

@@ -74,7 +74,6 @@ func (m *mdns) lookupIPByHostname(name string, v6 bool) string {
if value == name {
if addr, err := netip.ParseAddr(key.(string)); err == nil && addr.Is6() == v6 {
ip = addr.String()
//lint:ignore S1008 This is used for readable.
if addr.IsLoopback() { // Continue searching if this is loopback address.
return true
}

View File

@@ -104,7 +104,6 @@ func (p *ptrDiscover) lookupIPByHostname(name string, v6 bool) string {
if value == name {
if addr, err := netip.ParseAddr(key.(string)); err == nil && addr.Is6() == v6 {
ip = addr.String()
//lint:ignore S1008 This is used for readable.
if addr.IsLoopback() { // Continue searching if this is loopback address.
return true
}
@@ -120,8 +119,7 @@ func (p *ptrDiscover) lookupIPByHostname(name string, v6 bool) string {
// is reachable, set p.serverDown to false, so p.lookupHostname can continue working.
func (p *ptrDiscover) checkServer() {
bo := backoff.NewBackoff("ptrDiscover", func(format string, args ...any) {}, time.Minute*5)
m := new(dns.Msg)
m.SetQuestion(".", dns.TypeNS)
m := (&ctrld.UpstreamConfig{}).VerifyMsg()
ping := func() error {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()

View File

@@ -13,11 +13,11 @@ import (
"os"
"runtime"
"strings"
"syscall"
"time"
"github.com/Control-D-Inc/ctrld"
"github.com/Control-D-Inc/ctrld/internal/certs"
ctrldnet "github.com/Control-D-Inc/ctrld/internal/net"
"github.com/Control-D-Inc/ctrld/internal/router"
"github.com/Control-D-Inc/ctrld/internal/router/ddwrt"
)
@@ -244,8 +244,38 @@ func apiTransport(cdDev bool) *http.Transport {
}
dial := func(ctx context.Context, network string, addrs []string) (net.Conn, error) {
d := &ctrldnet.ParallelDialer{}
return d.DialContext(ctx, network, addrs, ctrld.ProxyLogger.Load())
// Create custom dialer with socket protection - matches working example pattern
baseDialer := &net.Dialer{
Timeout: 10 * time.Second,
KeepAlive: 30 * time.Second,
}
// Access underlying socket fd before connecting to it
baseDialer.Control = func(network, address string, c syscall.RawConn) error {
return c.Control(func(fd uintptr) {
ctrld.ProxyLogger.Load().Debug().Msgf("Received API socket fd %d for %s", fd, address)
i := int(fd)
// Protect socket from VPN routing
if err := ctrld.ProtectSocket(i); err != nil {
ctrld.ProxyLogger.Load().Warn().Err(err).Msgf("Failed to protect API socket fd=%d", i)
} else {
ctrld.ProxyLogger.Load().Debug().Msgf("Protected API socket fd=%d", i)
}
})
}
// Try each address with the protected dialer
var lastErr error
for _, addr := range addrs {
ctrld.ProxyLogger.Load().Debug().Msgf("dialing to %s", addr)
conn, err := baseDialer.DialContext(ctx, network, addr)
if err == nil {
return conn, nil
}
lastErr = err
ctrld.ProxyLogger.Load().Debug().Err(err).Msgf("failed to dial %s", addr)
}
return nil, lastErr
}
_, port, _ := net.SplitHostPort(addr)

View File

@@ -5,9 +5,12 @@ import (
"bytes"
"encoding/hex"
"net"
"net/netip"
"os"
"strings"
"tailscale.com/net/netmon"
"github.com/Control-D-Inc/ctrld/internal/dns/resolvconffile"
)
@@ -128,3 +131,25 @@ func virtualInterfaces() set {
}
return s
}
// validInterfacesMap returns a set containing non virtual interfaces.
// TODO: deduplicated with cmd/cli/net_linux.go in v2.
func validInterfaces() set {
m := make(map[string]struct{})
vis := virtualInterfaces()
netmon.ForeachInterface(func(i netmon.Interface, prefixes []netip.Prefix) {
if _, existed := vis[i.Name]; existed {
return
}
m[i.Name] = struct{}{}
})
// Fallback to default route interface if found nothing.
if len(m) == 0 {
defaultRoute, err := netmon.DefaultRoute()
if err != nil {
return m
}
m[defaultRoute.InterfaceName] = struct{}{}
}
return m
}

View File

@@ -23,20 +23,17 @@ import (
)
const (
maxDNSAdapterRetries = 5
retryDelayDNSAdapter = 1 * time.Second
defaultDNSAdapterTimeout = 10 * time.Second
minDNSServers = 1 // Minimum number of DNS servers we want to find
NetSetupUnknown uint32 = 0
NetSetupWorkgroup uint32 = 1
NetSetupDomain uint32 = 2
NetSetupCloudDomain uint32 = 3
DS_FORCE_REDISCOVERY = 0x00000001
DS_DIRECTORY_SERVICE_REQUIRED = 0x00000010
DS_BACKGROUND_ONLY = 0x00000100
DS_IP_REQUIRED = 0x00000200
DS_IS_DNS_NAME = 0x00020000
DS_RETURN_DNS_NAME = 0x40000000
maxDNSAdapterRetries = 5
retryDelayDNSAdapter = 1 * time.Second
defaultDNSAdapterTimeout = 10 * time.Second
minDNSServers = 1 // Minimum number of DNS servers we want to find
DS_FORCE_REDISCOVERY = 0x00000001
DS_DIRECTORY_SERVICE_REQUIRED = 0x00000010
DS_BACKGROUND_ONLY = 0x00000100
DS_IP_REQUIRED = 0x00000200
DS_IS_DNS_NAME = 0x00020000
DS_RETURN_DNS_NAME = 0x40000000
)
type DomainControllerInfo struct {
@@ -158,7 +155,7 @@ func getDNSServers(ctx context.Context) ([]string, error) {
0, // DomainGuid - not needed
0, // SiteName - not needed
uintptr(flags), // Flags
uintptr(unsafe.Pointer(&info))) // DomainControllerInfo - output
uintptr(unsafe.Pointer(&info))) // DomainControllerInfo - output
if ret != 0 {
switch ret {
@@ -343,27 +340,28 @@ func checkDomainJoined() bool {
var domain *uint16
var status uint32
err := windows.NetGetJoinInformation(nil, &domain, &status)
if err != nil {
Log(context.Background(), logger.Debug(),
"Failed to get domain join status: %v", err)
if err := windows.NetGetJoinInformation(nil, &domain, &status); err != nil {
Log(context.Background(), logger.Debug(), "Failed to get domain join status: %v", err)
return false
}
defer windows.NetApiBufferFree((*byte)(unsafe.Pointer(domain)))
// NETSETUP_JOIN_STATUS constants from Microsoft Windows API
// See: https://learn.microsoft.com/en-us/windows/win32/api/lmjoin/ne-lmjoin-netsetup_join_status
//
// NetSetupUnknownStatus uint32 = 0 // The status is unknown
// NetSetupUnjoined uint32 = 1 // The computer is not joined to a domain or workgroup
// NetSetupWorkgroupName uint32 = 2 // The computer is joined to a workgroup
// NetSetupDomainName uint32 = 3 // The computer is joined to a domain
//
// We only care about NetSetupDomainName.
domainName := windows.UTF16PtrToString(domain)
Log(context.Background(), logger.Debug(),
"Domain join status: domain=%s status=%d (Unknown=0, Workgroup=1, Domain=2, CloudDomain=3)",
"Domain join status: domain=%s status=%d (UnknownStatus=0, Unjoined=1, WorkgroupName=2, DomainName=3)",
domainName, status)
// Consider domain or cloud domain as domain-joined
isDomain := status == NetSetupDomain || status == NetSetupCloudDomain
Log(context.Background(), logger.Debug(),
"Is domain joined? status=%d, traditional=%v, cloud=%v, result=%v",
status,
status == NetSetupDomain,
status == NetSetupCloudDomain,
isDomain)
isDomain := status == syscall.NetSetupDomainName
Log(context.Background(), logger.Debug(), "Is domain joined? status=%d, result=%v", status, isDomain)
return isDomain
}

35
net_darwin.go Normal file
View File

@@ -0,0 +1,35 @@
package ctrld
import (
"bufio"
"bytes"
"io"
"os/exec"
"strings"
)
// validInterfaces returns a set of all valid hardware ports.
// TODO: deduplicated with cmd/cli/net_darwin.go in v2.
func validInterfaces() map[string]struct{} {
b, err := exec.Command("networksetup", "-listallhardwareports").Output()
if err != nil {
return nil
}
return parseListAllHardwarePorts(bytes.NewReader(b))
}
// parseListAllHardwarePorts parses output of "networksetup -listallhardwareports"
// and returns map presents all hardware ports.
func parseListAllHardwarePorts(r io.Reader) map[string]struct{} {
m := make(map[string]struct{})
scanner := bufio.NewScanner(r)
for scanner.Scan() {
line := scanner.Text()
after, ok := strings.CutPrefix(line, "Device: ")
if !ok {
continue
}
m[after] = struct{}{}
}
return m
}

View File

@@ -1,4 +1,4 @@
package cli
package ctrld
import (
"maps"

15
net_others.go Normal file
View File

@@ -0,0 +1,15 @@
//go:build !darwin && !windows && !linux
package ctrld
import "tailscale.com/net/netmon"
// validInterfaces returns a set containing only default route interfaces.
// TODO: deuplicated with cmd/cli/net_others.go in v2.
func validInterfaces() map[string]struct{} {
defaultRoute, err := netmon.DefaultRoute()
if err != nil {
return nil
}
return map[string]struct{}{defaultRoute.InterfaceName: {}}
}

View File

@@ -62,8 +62,29 @@ var (
or *osResolver
defaultLocalIPv4 atomic.Value // holds net.IP (IPv4)
defaultLocalIPv6 atomic.Value // holds net.IP (IPv6)
// socketProtector is a global function that can be set by mobile apps to protect
// sockets from being routed through the VPN. This prevents routing loops.
socketProtector atomic.Value // holds func(int) error
)
// SetSocketProtector sets the global socket protection function.
// This should be called by mobile VPN apps to prevent routing loops.
func SetSocketProtector(protectFunc func(int) error) {
socketProtector.Store(protectFunc)
}
// ProtectSocket protects a socket using the globally set protector.
// Returns nil if no protector is set.
func ProtectSocket(fd int) error {
if v := socketProtector.Load(); v != nil {
if protectFunc, ok := v.(func(int) error); ok {
return protectFunc(fd)
}
}
return nil
}
func newLocalResolver() Resolver {
var nss []string
for _, addr := range Rfc1918Addresses() {
@@ -729,10 +750,15 @@ func newResolverWithNameserver(nameservers []string) *osResolver {
return r
}
// Rfc1918Addresses returns the list of local interfaces private IP addresses
// Rfc1918Addresses returns the list of local physical interfaces private IP addresses
func Rfc1918Addresses() []string {
vis := validInterfaces()
var res []string
netmon.ForeachInterface(func(i netmon.Interface, prefixes []netip.Prefix) {
// Skip virtual interfaces.
if _, existed := vis[i.Name]; !existed {
return
}
addrs, _ := i.Addrs()
for _, addr := range addrs {
ipNet, ok := addr.(*net.IPNet)

View File

@@ -282,6 +282,35 @@ func Test_Edns0_CacheReply(t *testing.T) {
}
}
// https://github.com/Control-D-Inc/ctrld/issues/255
func Test_legacyResolverWithBigExtraSection(t *testing.T) {
lanPC, err := net.ListenPacket("udp", "127.0.0.1:0") // 127.0.0.1 is considered LAN (loopback)
if err != nil {
t.Fatalf("failed to listen on LAN address: %v", err)
}
lanServer, lanAddr, err := runLocalPacketConnTestServer(t, lanPC, bigExtraSectionHandler())
if err != nil {
t.Fatalf("failed to run LAN test server: %v", err)
}
defer lanServer.Shutdown()
uc := &UpstreamConfig{
Name: "Legacy",
Type: ResolverTypeLegacy,
Endpoint: lanAddr,
}
uc.Init()
r, err := NewResolver(uc)
if err != nil {
t.Fatal(err)
}
_, err = r.Resolve(context.Background(), uc.VerifyMsg())
if err != nil {
t.Fatal(err)
}
}
func Test_upstreamTypeFromEndpoint(t *testing.T) {
tests := []struct {
name string
@@ -370,6 +399,68 @@ func countHandler(call *atomic.Int64) dns.HandlerFunc {
}
}
func mustRR(s string) dns.RR {
r, err := dns.NewRR(s)
if err != nil {
panic(err)
}
return r
}
func bigExtraSectionHandler() dns.HandlerFunc {
return func(w dns.ResponseWriter, msg *dns.Msg) {
m := &dns.Msg{
Answer: []dns.RR{
mustRR(". 7149 IN NS m.root-servers.net."),
mustRR(". 7149 IN NS c.root-servers.net."),
mustRR(". 7149 IN NS e.root-servers.net."),
mustRR(". 7149 IN NS j.root-servers.net."),
mustRR(". 7149 IN NS g.root-servers.net."),
mustRR(". 7149 IN NS k.root-servers.net."),
mustRR(". 7149 IN NS l.root-servers.net."),
mustRR(". 7149 IN NS d.root-servers.net."),
mustRR(". 7149 IN NS h.root-servers.net."),
mustRR(". 7149 IN NS b.root-servers.net."),
mustRR(". 7149 IN NS a.root-servers.net."),
mustRR(". 7149 IN NS f.root-servers.net."),
mustRR(". 7149 IN NS i.root-servers.net."),
},
Extra: []dns.RR{
mustRR("m.root-servers.net. 656 IN A 202.12.27.33"),
mustRR("m.root-servers.net. 656 IN AAAA 2001:dc3::35"),
mustRR("c.root-servers.net. 656 IN A 192.33.4.12"),
mustRR("c.root-servers.net. 656 IN AAAA 2001:500:2::c"),
mustRR("e.root-servers.net. 656 IN A 192.203.230.10"),
mustRR("e.root-servers.net. 656 IN AAAA 2001:500:a8::e"),
mustRR("j.root-servers.net. 656 IN A 192.58.128.30"),
mustRR("j.root-servers.net. 656 IN AAAA 2001:503:c27::2:30"),
mustRR("g.root-servers.net. 656 IN A 192.112.36.4"),
mustRR("g.root-servers.net. 656 IN AAAA 2001:500:12::d0d"),
mustRR("k.root-servers.net. 656 IN A 193.0.14.129"),
mustRR("k.root-servers.net. 656 IN AAAA 2001:7fd::1"),
mustRR("l.root-servers.net. 656 IN A 199.7.83.42"),
mustRR("l.root-servers.net. 656 IN AAAA 2001:500:9f::42"),
mustRR("d.root-servers.net. 656 IN A 199.7.91.13"),
mustRR("d.root-servers.net. 656 IN AAAA 2001:500:2d::d"),
mustRR("h.root-servers.net. 656 IN A 198.97.190.53"),
mustRR("h.root-servers.net. 656 IN AAAA 2001:500:1::53"),
mustRR("b.root-servers.net. 656 IN A 170.247.170.2"),
mustRR("b.root-servers.net. 656 IN AAAA 2801:1b8:10::b"),
mustRR("a.root-servers.net. 656 IN A 198.41.0.4"),
mustRR("a.root-servers.net. 656 IN AAAA 2001:503:ba3e::2:30"),
mustRR("f.root-servers.net. 656 IN A 192.5.5.241"),
mustRR("f.root-servers.net. 656 IN AAAA 2001:500:2f::f"),
mustRR("i.root-servers.net. 656 IN A 192.36.148.17"),
mustRR("i.root-servers.net. 656 IN AAAA 2001:7fe::53"),
},
}
m.Compress = true
m.SetReply(msg)
w.WriteMsg(m)
}
}
func generateEdns0ClientCookie() string {
cookie := make([]byte, 8)
if _, err := rand.Read(cookie); err != nil {