mirror of
https://github.com/Control-D-Inc/ctrld.git
synced 2026-03-25 23:30:41 +01:00
Compare commits
22 Commits
release-br
...
ip_blocks
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e9a1225fc | ||
|
|
afe7804a9b | ||
|
|
d7904580ed | ||
|
|
593805bf6f | ||
|
|
ae37c56467 | ||
|
|
41597609c8 | ||
|
|
1f619a669a | ||
|
|
37c3331559 | ||
|
|
f334993f79 | ||
|
|
3ca559e5a4 | ||
|
|
0e3f764299 | ||
|
|
e52402eb0c | ||
|
|
2133f31854 | ||
|
|
a198a5cd65 | ||
|
|
eb2b231bd2 | ||
|
|
7af29cfbc0 | ||
|
|
ce1a165348 | ||
|
|
fd48e6d795 | ||
|
|
d71d1341b6 | ||
|
|
21855df4af | ||
|
|
66e2d3a40a | ||
|
|
26257cf24a |
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -39,6 +39,7 @@ var (
|
||||
skipSelfChecks bool
|
||||
cleanup bool
|
||||
startOnly bool
|
||||
rfc1918 bool
|
||||
|
||||
mainLog atomic.Pointer[zerolog.Logger]
|
||||
consoleWriter zerolog.ConsoleWriter
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
585
cmd/ctrld_library/netstack/README.md
Normal file
585
cmd/ctrld_library/netstack/README.md
Normal 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.
|
||||
228
cmd/ctrld_library/netstack/dns_bridge.go
Normal file
228
cmd/ctrld_library/netstack/dns_bridge.go
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
365
cmd/ctrld_library/netstack/dns_filter.go
Normal file
365
cmd/ctrld_library/netstack/dns_filter.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
150
cmd/ctrld_library/netstack/ip_tracker.go
Normal file
150
cmd/ctrld_library/netstack/ip_tracker.go
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
417
cmd/ctrld_library/netstack/netstack.go
Normal file
417
cmd/ctrld_library/netstack/netstack.go
Normal 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()
|
||||
}
|
||||
}
|
||||
97
cmd/ctrld_library/netstack/packet_handler.go
Normal file
97
cmd/ctrld_library/netstack/packet_handler.go
Normal 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
|
||||
}
|
||||
130
cmd/ctrld_library/netstack/tcp_forwarder.go
Normal file
130
cmd/ctrld_library/netstack/tcp_forwarder.go
Normal 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
|
||||
}
|
||||
238
cmd/ctrld_library/netstack/udp_forwarder.go
Normal file
238
cmd/ctrld_library/netstack/udp_forwarder.go
Normal 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")
|
||||
}
|
||||
277
cmd/ctrld_library/packet_capture.go
Normal file
277
cmd/ctrld_library/packet_capture.go
Normal 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
|
||||
}
|
||||
44
config.go
44
config.go
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
42
docs/known-issues.md
Normal 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
|
||||
@@ -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
35
go.mod
@@ -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
67
go.sum
@@ -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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
35
net_darwin.go
Normal 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
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package cli
|
||||
package ctrld
|
||||
|
||||
import (
|
||||
"maps"
|
||||
15
net_others.go
Normal file
15
net_others.go
Normal 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: {}}
|
||||
}
|
||||
28
resolver.go
28
resolver.go
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user