Compare commits

...

42 Commits

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

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

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

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

This change improves separation of concerns by isolating network monitoring
from DNS serving logic, and prevents potential issues with multiple
monitoring goroutines if starting multiple listeners.
2025-08-12 16:46:57 +07:00
Cuong Manh Le
26257cf24a Merge pull request #250 from Control-D-Inc/release-branch-v1.4.5
Release branch v1.4.5
2025-07-25 04:06:24 +07:00
Cuong Manh Le
36a7423634 refactor: extract empty string filtering to reusable function
- Add filterEmptyStrings utility function for consistent string filtering
- Replace inline slices.DeleteFunc calls with filterEmptyStrings
- Apply filtering to osArgs in addition to command args
- Improves code readability and reduces duplication
- Uses slices.DeleteFunc internally for efficient filtering
2025-07-15 23:09:54 +07:00
Cuong Manh Le
e616091249 cmd/cli: ignore empty positional argument for start command
The validation was added during v1.4.0 release, but causing one-liner
install failed unexpectedly.
2025-07-15 21:57:36 +07:00
Cuong Manh Le
0948161529 Avoiding Windows runners file locking issue 2025-07-15 20:59:57 +07:00
Cuong Manh Le
ce29b5d217 refactor: split selfUpgradeCheck into version check and upgrade execution
- Move version checking logic to shouldUpgrade for testability
- Move upgrade command execution to performUpgrade
- selfUpgradeCheck now composes these two for clarity
- Update and expand tests: focus on logic, not side effects
- Improves maintainability, testability, and separation of concerns
2025-07-15 19:12:23 +07:00
Cuong Manh Le
de24fa293e internal/router: support Ubios 4.3+
This change improves compatibility with newer UniFi OS versions while
maintaining backward compatibility with UniFi OS 4.2 and earlier.
The refactoring also reduces code duplication and improves maintainability
by centralizing dnsmasq configuration path logic.
2025-07-15 19:11:13 +07:00
Cuong Manh Le
6663925c4d internal/router: support Merlin Guest Network Pro VLAN
By looking for any additional dnsmasq configuration files under
/tmp/etc, and handling them like default one.
2025-07-15 19:10:10 +07:00
Cuong Manh Le
b9ece6d7b9 Merge pull request #239 from Control-D-Inc/release-branch-v1.4.4
Release branch v1.4.4
2025-06-16 16:45:11 +07:00
Cuong Manh Le
c4efa1ab97 Initializing default os resolver during upstream bootstrap
Since calling defaultNameservers may block the whole bootstrap process
if there's no valid DNS servers available.
2025-06-12 16:22:52 +07:00
Cuong Manh Le
7cea5305e1 all: fix a regression causing invalid reloading timeout
In v1.4.3, ControlD bootstrap DNS is used again for bootstrapping
process. When this happened, the default system nameservers will be
retrieved first, then ControlD DNS will be used if none available.

However, getting default system nameservers process may take longer than
reloading command timeout, causing invalid error message printed.

To fix this, ensuring default system nameservers is retrieved once.
2025-06-10 19:42:26 +07:00
Cuong Manh Le
a20fbf95de all: enhanced TLS certificate verification error messages
Added more descriptive error messages for TLS certificate verification
failures across DoH, DoT, DoQ, and DoH3 protocols. The error messages
now include:

- Certificate subject information
- Issuer organization details
- Common name of the certificate

This helps users and developers better understand certificate validation
failures by providing specific details about the untrusted certificate,
rather than just a generic "unknown authority" message.

Example error message change:
Before: "certificate signed by unknown authority"
After: "certificate signed by unknown authority: TestCA, TestOrg, TestIssuerOrg"
2025-06-10 19:42:00 +07:00
Cuong Manh Le
628c4302aa cmd/cli: preserve search domains when reverting resolv.conf
Fixes search domains not being preserved when the resolv.conf file is
reverted to its previous state. This ensures that important domain
search configuration is maintained during DNS configuration changes.

The search domains handling was missing in setResolvConf function,
which is responsible for restoring DNS settings.
2025-06-04 18:36:51 +07:00
Cuong Manh Le
8dc34f8bf5 internal/net: improve IPv6 support detection with multiple common ports
Changed the IPv6 support detection to try multiple common ports (HTTP/HTTPS) instead of
just testing against a DNS port. The function now returns both the IPv6 support status
and the successful port that confirmed the connectivity. This makes the IPv6 detection
more reliable by not depending solely on DNS port availability.

Previously, the function only tested connectivity to a DNS port (53) over IPv6.
Now it tries to connect to commonly available ports like HTTP (80) and HTTPS (443)
until it finds a working one, making the detection more robust in environments where
certain ports might be blocked.
2025-06-04 16:29:28 +07:00
Cuong Manh Le
b4faf82f76 all: set edns0 cookie for shared message
For cached or singleflight messages, the edns0 cookie is currently
shared among all of them, causing mismatch cookie warning from clients.
The ctrld proxy should re-set client cookies for each request
separately, even though they use the same shared answer.
2025-05-27 18:09:16 +07:00
Cuong Manh Le
a983dfaee2 all: optimizing multiple queries to upstreams
To guard ctrld from possible DoS to remote upstreams, this commit
implements following things:

 - Optimizing multiple queries with the same domain and qtype to use
   singleflight group, so there's only 1 query to remote upstreams at
   any time.
 - Adding a hot cache with 1 second TTL, so repeated queries will re-use
   the result from cache if existed, preventing unnecessary requests to
   remote upstreams.
2025-05-23 21:09:15 +07:00
Cuong Manh Le
62f73bcaa2 all: preserve search domains settings
So bare hostname will be resolved as expected when ctrld is running.
2025-05-15 17:00:59 +07:00
Cuong Manh Le
00e9d2bdd3 all: do not listen on 0.0.0.0 on desktop clients
Since this may create security vulnerabilities such as DNS amplification
or abusing because the listener was exposed to the entire local network.
2025-05-15 16:59:24 +07:00
Cuong Manh Le
ace3b1e66e Merge pull request #233 from Control-D-Inc/release-branch-v1.4.3
[WIP] Release branch v1.4.3
2025-04-28 17:08:34 +07:00
Cuong Manh Le
d1ea1ba08c Disable parallel test for TestUpstreamConfig_SetupBootstrapIP
There's a bug in wmi library which causes race condition when getting
wmi instance manager concurrently. The new tests for setup bootstrap ip
concurrently thus failed unexpectedly.

There's going to be a fix sent to the upstream, in the meantime, disable
the parallel test temporary.

See: https://github.com/microsoft/wmi/issues/165
2025-04-18 00:36:58 +07:00
Cuong Manh Le
c06c8aa859 Unifying DNS from /etc/resolv.conf function
As part of v1.4.0 release, reading DNS from /etc/resolv.conf file is
only available for Macos. However, there's no reason to prevent this
function from working on other *nix systems.

This commit unify the function to *nix, so it could be added as DNS
source for Linux and Freebsd.
2025-04-17 17:19:47 +07:00
Cuong Manh Le
0c2cc00c4f Using ControlD bootstrap DNS again
So on system where there's no available DNS, non-ControlD upstreams
could be bootstrapped like before.

While at it, also improving lookupIP to not initializing OS resolver
anymore, removing the un-necessary contention for accquiring/releasing
OS resolver mutex.
2025-04-17 17:15:15 +07:00
Cuong Manh Le
8d6ea91f35 Allowing bootstrap IPs for ControlD sub-domains
So protocol which uses sub-domain like doq/dot could be bootstrap in
case of no DNS available.
2025-04-17 17:13:10 +07:00
Cuong Manh Le
7dfb77228f cmd/cli: handle ipc warning message more precisely
If the socket file does not exist, it means that "ctrld start" was never
run. In this case, the warning message should not be printed to avoid
needless confusion.
2025-04-17 17:12:06 +07:00
66 changed files with 3975 additions and 316 deletions

View File

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

View File

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

View File

@@ -178,7 +178,15 @@ func RunMobile(appConfig *AppConfig, appCallback *AppCallback, stopCh chan struc
noConfigStart = false
homedir = appConfig.HomeDir
verbose = appConfig.Verbose
cdUID = appConfig.CdUID
if appConfig.ProvisionID != "" {
cdOrg = appConfig.ProvisionID
}
if appConfig.CustomHostname != "" {
customHostname = appConfig.CustomHostname
}
if appConfig.CdUID != "" {
cdUID = appConfig.CdUID
}
cdUpstreamProto = appConfig.UpstreamProto
logPath = appConfig.LogPath
run(appCallback, stopCh)
@@ -227,7 +235,9 @@ func run(appCallback *AppCallback, stopCh chan struct{}) {
consoleWriter.Out = io.MultiWriter(os.Stdout, lc)
p.logConn = lc
} else {
mainLog.Load().Warn().Err(err).Msgf("unable to create log ipc connection")
if !errors.Is(err, os.ErrNotExist) {
mainLog.Load().Warn().Err(err).Msg("unable to create log ipc connection")
}
}
} else {
mainLog.Load().Warn().Err(err).Msgf("unable to resolve socket address: %s", sockPath)
@@ -1214,13 +1224,18 @@ func tryUpdateListenerConfig(cfg *ctrld.Config, infoLogger *zerolog.Logger, noti
// For Windows server with local Dns server running, we can only try on random local IP.
hasLocalDnsServer := hasLocalDnsServerRunning()
notRouter := router.Name() == ""
isDesktop := ctrld.IsDesktopPlatform()
for n, listener := range cfg.Listener {
lcc[n] = &listenerConfigCheck{}
if listener.IP == "" {
listener.IP = "0.0.0.0"
if hasLocalDnsServer {
// Windows Server lies to us that we could listen on 0.0.0.0:53
// even there's a process already done that, stick to local IP only.
// Windows Server lies to us that we could listen on 0.0.0.0:53
// even there's a process already done that, stick to local IP only.
//
// For desktop clients, also stick the listener to the local IP only.
// Listening on 0.0.0.0 would expose it to the entire local network, potentially
// creating security vulnerabilities (such as DNS amplification or abusing).
if hasLocalDnsServer || isDesktop {
listener.IP = "127.0.0.1"
}
lcc[n].IP = true

View File

@@ -13,6 +13,7 @@ import (
"os/exec"
"path/filepath"
"runtime"
"slices"
"sort"
"strconv"
"strings"
@@ -188,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)
@@ -206,6 +208,7 @@ func initStartCmd() *cobra.Command {
NOTE: running "ctrld start" without any arguments will start already installed ctrld service.`,
Args: func(cmd *cobra.Command, args []string) error {
args = filterEmptyStrings(args)
if len(args) > 0 {
return fmt.Errorf("'ctrld start' doesn't accept positional arguments\n" +
"Use flags instead (e.g. --cd, --iface) or see 'ctrld start --help' for all options")
@@ -219,6 +222,7 @@ NOTE: running "ctrld start" without any arguments will start already installed c
sc := &service.Config{}
*sc = *svcConfig
osArgs := os.Args[2:]
osArgs = filterEmptyStrings(osArgs)
if os.Args[1] == "service" {
osArgs = os.Args[3:]
}
@@ -528,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",
@@ -566,6 +571,7 @@ NOTE: running "ctrld start" without any arguments will start already installed c
NOTE: running "ctrld start" without any arguments will start already installed ctrld service.`,
Args: func(cmd *cobra.Command, args []string) error {
args = filterEmptyStrings(args)
if len(args) > 0 {
return fmt.Errorf("'ctrld start' doesn't accept positional arguments\n" +
"Use flags instead (e.g. --cd, --iface) or see 'ctrld start --help' for all options")
@@ -1381,3 +1387,11 @@ func initServicesCmd(commands ...*cobra.Command) *cobra.Command {
return serviceCmd
}
// filterEmptyStrings removes empty strings from a slice of strings.
// It returns a new slice containing only non-empty strings.
func filterEmptyStrings(slice []string) []string {
return slices.DeleteFunc(slice, func(s string) bool {
return s == ""
})
}

View File

@@ -84,13 +84,7 @@ type upstreamForResult struct {
srcAddr string
}
func (p *prog) serveDNS(mainCtx context.Context, listenerNum string) error {
// Start network monitoring
if err := p.monitorNetworkChanges(mainCtx); err != nil {
mainLog.Load().Error().Err(err).Msg("Failed to start network monitoring")
// Don't return here as we still want DNS service to run
}
func (p *prog) serveDNS(listenerNum string) error {
listenerConfig := p.cfg.Listener[listenerNum]
// make sure ip is allocated
if allocErr := p.allocateIP(listenerConfig.IP); allocErr != nil {
@@ -213,8 +207,8 @@ func (p *prog) serveDNS(mainCtx context.Context, listenerNum string) error {
return nil
})
}
// When we spawn a listener on 127.0.0.1, also spawn listeners on the RFC1918
// addresses of the machine. So ctrld could receive queries from LAN clients.
// When we spawn a listener on 127.0.0.1, also spawn listeners on the RFC1918 addresses of the machine
// if explicitly set via setting rfc1918 flag, so ctrld could receive queries from LAN clients.
if needRFC1918Listeners(listenerConfig) {
g.Go(func() error {
for _, addr := range ctrld.Rfc1918Addresses() {
@@ -500,7 +494,7 @@ func (p *prog) proxy(ctx context.Context, req *proxyRequest) *proxyResponse {
continue
}
answer := cachedValue.Msg.Copy()
answer.SetRcode(req.msg, answer.Rcode)
ctrld.SetCacheReply(answer, req.msg, answer.Rcode)
now := time.Now()
if cachedValue.Expire.After(now) {
ctrld.Log(ctx, mainLog.Load().Debug(), "hit cached response")
@@ -1042,8 +1036,10 @@ func (p *prog) queryFromSelf(ip string) bool {
return false
}
// 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
return rfc1918 && lc.IP == "127.0.0.1" && lc.Port == 53
}
// ipFromARPA parses a FQDN arpa domain and return the IP address if valid.
@@ -1185,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...)
@@ -1404,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)
@@ -1420,6 +1413,7 @@ func (p *prog) checkUpstreamOnce(upstream string, uc *ctrld.UpstreamConfig) erro
mainLog.Load().Debug().Msgf("Rebootstrapping resolver for upstream: %s", upstream)
start := time.Now()
msg := uc.VerifyMsg()
_, err = resolver.Resolve(ctx, msg)
duration := time.Since(start)

View File

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

View File

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

View File

@@ -47,6 +47,9 @@ func setDnsIgnoreUnusableInterface(iface *net.Interface, nameservers []string) e
// networksetup -setdnsservers Wi-Fi 8.8.8.8 1.1.1.1
// TODO(cuonglm): use system API
func setDNS(iface *net.Interface, nameservers []string) error {
// Note that networksetup won't modify search domains settings,
// This assignment is just a placeholder to silent linter.
_ = searchDomains
cmd := "networksetup"
args := []string{"-setdnsservers", iface.Name}
args = append(args, nameservers...)
@@ -88,7 +91,7 @@ func restoreDNS(iface *net.Interface) (err error) {
}
func currentDNS(_ *net.Interface) []string {
return resolvconffile.NameServers("")
return resolvconffile.NameServers()
}
// currentStaticDNS returns the current static DNS settings of given interface.

View File

@@ -7,6 +7,7 @@ import (
"tailscale.com/control/controlknobs"
"tailscale.com/health"
"tailscale.com/util/dnsname"
"github.com/Control-D-Inc/ctrld/internal/dns"
"github.com/Control-D-Inc/ctrld/internal/resolvconffile"
@@ -50,7 +51,17 @@ func setDNS(iface *net.Interface, nameservers []string) error {
ns = append(ns, netip.MustParseAddr(nameserver))
}
if err := r.SetDNS(dns.OSConfig{Nameservers: ns}); err != nil {
osConfig := dns.OSConfig{
Nameservers: ns,
SearchDomains: []dnsname.FQDN{},
}
if sds, err := searchDomains(); err == nil {
osConfig.SearchDomains = sds
} else {
mainLog.Load().Debug().Err(err).Msg("failed to get search domains list")
}
if err := r.SetDNS(osConfig); err != nil {
mainLog.Load().Error().Err(err).Msg("failed to set DNS")
return err
}
@@ -83,7 +94,7 @@ func restoreDNS(iface *net.Interface) (err error) {
}
func currentDNS(_ *net.Interface) []string {
return resolvconffile.NameServers("")
return resolvconffile.NameServers()
}
// currentStaticDNS returns the current static DNS settings of given interface.

View File

@@ -71,6 +71,11 @@ func setDNS(iface *net.Interface, nameservers []string) error {
Nameservers: ns,
SearchDomains: []dnsname.FQDN{},
}
if sds, err := searchDomains(); err == nil {
osConfig.SearchDomains = sds
} else {
mainLog.Load().Debug().Err(err).Msg("failed to get search domains list")
}
trySystemdResolve := false
if err := r.SetDNS(osConfig); err != nil {
if strings.Contains(err.Error(), "Rejected send message") &&
@@ -196,7 +201,8 @@ func restoreDNS(iface *net.Interface) (err error) {
}
func currentDNS(iface *net.Interface) []string {
for _, fn := range []getDNS{getDNSByResolvectl, getDNSBySystemdResolved, getDNSByNmcli, resolvconffile.NameServers} {
resolvconfFunc := func(_ string) []string { return resolvconffile.NameServers() }
for _, fn := range []getDNS{getDNSByResolvectl, getDNSBySystemdResolved, getDNSByNmcli, resolvconfFunc} {
if ns := fn(iface.Name); len(ns) > 0 {
return ns
}

View File

@@ -100,6 +100,10 @@ func setDNS(iface *net.Interface, nameservers []string) error {
}
}
// Note that Windows won't modify the current search domains if passing nil to luid.SetDNS function.
// searchDomains is still implemented for Windows just in case Windows API changes in future versions.
_ = searchDomains
if len(serversV4) == 0 && len(serversV6) == 0 {
return errors.New("invalid DNS nameservers")
}

View File

@@ -35,6 +35,7 @@ import (
"github.com/Control-D-Inc/ctrld/internal/controld"
"github.com/Control-D-Inc/ctrld/internal/dnscache"
"github.com/Control-D-Inc/ctrld/internal/router"
"github.com/Control-D-Inc/ctrld/internal/router/dnsmasq"
)
const (
@@ -70,10 +71,17 @@ func ControlSocketName() string {
}
}
// logf is a function variable used for logging formatted debug messages with optional arguments.
// This is used only when creating a new DNS OS configurator.
var logf = func(format string, args ...any) {
mainLog.Load().Debug().Msgf(format, args...)
}
// noopLogf is like logf but discards formatted log messages and arguments without any processing.
//
//lint:ignore U1000 use in newLoopbackOSConfigurator
var noopLogf = func(format string, args ...any) {}
var svcConfig = &service.Config{
Name: ctrldServiceName,
DisplayName: "Control-D Helper Service",
@@ -321,7 +329,7 @@ func (p *prog) apiConfigReload() {
// Performing self-upgrade check for production version.
if isStable {
selfUpgradeCheck(resolverConfig.Ctrld.VersionTarget, curVer, &logger)
_ = selfUpgradeCheck(resolverConfig.Ctrld.VersionTarget, curVer, &logger)
}
if resolverConfig.DeactivationPin != nil {
@@ -522,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 {
@@ -533,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)
@@ -600,6 +617,12 @@ func (p *prog) setupClientInfoDiscover(selfIP string) {
format := ctrld.LeaseFileFormat(p.cfg.Service.DHCPLeaseFileFormat)
p.ciTable.AddLeaseFile(leaseFile, format)
}
if leaseFiles := dnsmasq.AdditionalLeaseFiles(); len(leaseFiles) > 0 {
mainLog.Load().Debug().Msgf("watching additional lease files: %v", leaseFiles)
for _, leaseFile := range leaseFiles {
p.ciTable.AddLeaseFile(leaseFile, ctrld.Dnsmasq)
}
}
}
// runClientInfoDiscover runs the client info discover.
@@ -1460,14 +1483,15 @@ func selfUninstallCheck(uninstallErr error, p *prog, logger zerolog.Logger) {
}
}
// selfUpgradeCheck checks if the version target vt is greater
// than the current one cv, perform self-upgrade then.
// shouldUpgrade checks if the version target vt is greater than the current one cv.
// Major version upgrades are not allowed to prevent breaking changes.
//
// The callers must ensure curVer and logger are non-nil.
func selfUpgradeCheck(vt string, cv *semver.Version, logger *zerolog.Logger) {
// Returns true if upgrade is allowed, false otherwise.
func shouldUpgrade(vt string, cv *semver.Version, logger *zerolog.Logger) bool {
if vt == "" {
logger.Debug().Msg("no version target set, skipped checking self-upgrade")
return
return false
}
vts := vt
if !strings.HasPrefix(vts, "v") {
@@ -1476,28 +1500,58 @@ func selfUpgradeCheck(vt string, cv *semver.Version, logger *zerolog.Logger) {
targetVer, err := semver.NewVersion(vts)
if err != nil {
logger.Warn().Err(err).Msgf("invalid target version, skipped self-upgrade: %s", vt)
return
return false
}
// Prevent major version upgrades to avoid breaking changes
if targetVer.Major() != cv.Major() {
logger.Warn().
Str("target", vt).
Str("current", cv.String()).
Msgf("major version upgrade not allowed (target: %d, current: %d), skipped self-upgrade", targetVer.Major(), cv.Major())
return false
}
if !targetVer.GreaterThan(cv) {
logger.Debug().
Str("target", vt).
Str("current", cv.String()).
Msgf("target version is not greater than current one, skipped self-upgrade")
return
return false
}
return true
}
// performUpgrade executes the self-upgrade command.
// Returns true if upgrade was initiated successfully, false otherwise.
func performUpgrade(vt string) bool {
exe, err := os.Executable()
if err != nil {
mainLog.Load().Error().Err(err).Msg("failed to get executable path, skipped self-upgrade")
return
return false
}
cmd := exec.Command(exe, "upgrade", "prod", "-vv")
cmd.SysProcAttr = sysProcAttrForDetachedChildProcess()
if err := cmd.Start(); err != nil {
mainLog.Load().Error().Err(err).Msg("failed to start self-upgrade")
return
return false
}
mainLog.Load().Debug().Msgf("self-upgrade triggered, version target: %s", vts)
mainLog.Load().Debug().Msgf("self-upgrade triggered, version target: %s", vt)
return true
}
// selfUpgradeCheck checks if the version target vt is greater
// than the current one cv, perform self-upgrade then.
// Major version upgrades are not allowed to prevent breaking changes.
//
// The callers must ensure curVer and logger are non-nil.
// Returns true if upgrade is allowed and should proceed, false otherwise.
func selfUpgradeCheck(vt string, cv *semver.Version, logger *zerolog.Logger) bool {
if shouldUpgrade(vt, cv, logger) {
return performUpgrade(vt)
}
return false
}
// leakOnUpstreamFailure reports whether ctrld should initiate a recovery flow

View File

@@ -9,15 +9,12 @@ import (
"strings"
"github.com/kardianos/service"
"tailscale.com/control/controlknobs"
"tailscale.com/health"
"github.com/Control-D-Inc/ctrld/internal/dns"
"github.com/Control-D-Inc/ctrld/internal/router"
)
func init() {
if r, err := dns.NewOSConfigurator(func(format string, args ...any) {}, &health.Tracker{}, &controlknobs.Knobs{}, "lo"); err == nil {
if r, err := newLoopbackOSConfigurator(); err == nil {
useSystemdResolved = r.Mode() == "systemd-resolved"
}
// Disable quic-go's ECN support by default, see https://github.com/quic-go/quic-go/issues/3911

View File

@@ -1,11 +1,15 @@
package cli
import (
"runtime"
"testing"
"time"
"github.com/Control-D-Inc/ctrld"
"github.com/Masterminds/semver/v3"
"github.com/rs/zerolog"
"github.com/stretchr/testify/assert"
"github.com/Control-D-Inc/ctrld"
)
func Test_prog_dnsWatchdogEnabled(t *testing.T) {
@@ -55,3 +59,215 @@ func Test_prog_dnsWatchdogInterval(t *testing.T) {
})
}
}
func Test_shouldUpgrade(t *testing.T) {
// Helper function to create a version
makeVersion := func(v string) *semver.Version {
ver, err := semver.NewVersion(v)
if err != nil {
t.Fatalf("failed to create version %s: %v", v, err)
}
return ver
}
tests := []struct {
name string
versionTarget string
currentVersion *semver.Version
shouldUpgrade bool
description string
}{
{
name: "empty version target",
versionTarget: "",
currentVersion: makeVersion("v1.0.0"),
shouldUpgrade: false,
description: "should skip upgrade when version target is empty",
},
{
name: "invalid version target",
versionTarget: "invalid-version",
currentVersion: makeVersion("v1.0.0"),
shouldUpgrade: false,
description: "should skip upgrade when version target is invalid",
},
{
name: "same version",
versionTarget: "v1.0.0",
currentVersion: makeVersion("v1.0.0"),
shouldUpgrade: false,
description: "should skip upgrade when target version equals current version",
},
{
name: "older version",
versionTarget: "v1.0.0",
currentVersion: makeVersion("v1.1.0"),
shouldUpgrade: false,
description: "should skip upgrade when target version is older than current version",
},
{
name: "patch upgrade allowed",
versionTarget: "v1.0.1",
currentVersion: makeVersion("v1.0.0"),
shouldUpgrade: true,
description: "should allow patch version upgrade within same major version",
},
{
name: "minor upgrade allowed",
versionTarget: "v1.1.0",
currentVersion: makeVersion("v1.0.0"),
shouldUpgrade: true,
description: "should allow minor version upgrade within same major version",
},
{
name: "major upgrade blocked",
versionTarget: "v2.0.0",
currentVersion: makeVersion("v1.0.0"),
shouldUpgrade: false,
description: "should block major version upgrade",
},
{
name: "major downgrade blocked",
versionTarget: "v1.0.0",
currentVersion: makeVersion("v2.0.0"),
shouldUpgrade: false,
description: "should block major version downgrade",
},
{
name: "version without v prefix",
versionTarget: "1.0.1",
currentVersion: makeVersion("v1.0.0"),
shouldUpgrade: true,
description: "should handle version target without v prefix",
},
{
name: "complex version upgrade allowed",
versionTarget: "v1.5.3",
currentVersion: makeVersion("v1.4.2"),
shouldUpgrade: true,
description: "should allow complex version upgrade within same major version",
},
{
name: "complex major upgrade blocked",
versionTarget: "v3.1.0",
currentVersion: makeVersion("v2.5.3"),
shouldUpgrade: false,
description: "should block complex major version upgrade",
},
{
name: "pre-release version upgrade allowed",
versionTarget: "v1.0.1-beta.1",
currentVersion: makeVersion("v1.0.0"),
shouldUpgrade: true,
description: "should allow pre-release version upgrade within same major version",
},
{
name: "pre-release major upgrade blocked",
versionTarget: "v2.0.0-alpha.1",
currentVersion: makeVersion("v1.0.0"),
shouldUpgrade: false,
description: "should block pre-release major version upgrade",
},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
// Create test logger
testLogger := zerolog.New(zerolog.NewTestWriter(t)).With().Logger()
// Call the function and capture the result
result := shouldUpgrade(tc.versionTarget, tc.currentVersion, &testLogger)
// Assert the expected result
assert.Equal(t, tc.shouldUpgrade, result, tc.description)
})
}
}
func Test_selfUpgradeCheck(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("skipped due to Windows file locking issue on Github Action runners")
}
// Helper function to create a version
makeVersion := func(v string) *semver.Version {
ver, err := semver.NewVersion(v)
if err != nil {
t.Fatalf("failed to create version %s: %v", v, err)
}
return ver
}
tests := []struct {
name string
versionTarget string
currentVersion *semver.Version
shouldUpgrade bool
description string
}{
{
name: "upgrade allowed",
versionTarget: "v1.0.1",
currentVersion: makeVersion("v1.0.0"),
shouldUpgrade: true,
description: "should allow upgrade and attempt to perform it",
},
{
name: "upgrade blocked",
versionTarget: "v2.0.0",
currentVersion: makeVersion("v1.0.0"),
shouldUpgrade: false,
description: "should block upgrade and not attempt to perform it",
},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
// Create test logger
testLogger := zerolog.New(zerolog.NewTestWriter(t)).With().Logger()
// Call the function and capture the result
result := selfUpgradeCheck(tc.versionTarget, tc.currentVersion, &testLogger)
// Assert the expected result
assert.Equal(t, tc.shouldUpgrade, result, tc.description)
})
}
}
func Test_performUpgrade(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("skipped due to Windows file locking issue on Github Action runners")
}
tests := []struct {
name string
versionTarget string
expectedResult bool
description string
}{
{
name: "valid version target",
versionTarget: "v1.0.1",
expectedResult: true,
description: "should attempt to perform upgrade with valid version target",
},
{
name: "empty version target",
versionTarget: "",
expectedResult: true,
description: "should attempt to perform upgrade even with empty version target",
},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
// Call the function and capture the result
result := performUpgrade(tc.versionTarget)
assert.Equal(t, tc.expectedResult, result, tc.description)
})
}
}

View File

@@ -13,9 +13,9 @@ import (
"github.com/Control-D-Inc/ctrld/internal/dns"
)
// setResolvConf sets the content of resolv.conf file using the given nameservers list.
// setResolvConf sets the content of the resolv.conf file using the given nameservers list.
func setResolvConf(iface *net.Interface, ns []netip.Addr) error {
r, err := dns.NewOSConfigurator(func(format string, args ...any) {}, &health.Tracker{}, &controlknobs.Knobs{}, "lo") // interface name does not matter.
r, err := newLoopbackOSConfigurator()
if err != nil {
return err
}
@@ -24,12 +24,17 @@ func setResolvConf(iface *net.Interface, ns []netip.Addr) error {
Nameservers: ns,
SearchDomains: []dnsname.FQDN{},
}
if sds, err := searchDomains(); err == nil {
oc.SearchDomains = sds
} else {
mainLog.Load().Debug().Err(err).Msg("failed to get search domains list when reverting resolv.conf file")
}
return r.SetDNS(oc)
}
// shouldWatchResolvconf reports whether ctrld should watch changes to resolv.conf file with given OS configurator.
func shouldWatchResolvconf() bool {
r, err := dns.NewOSConfigurator(func(format string, args ...any) {}, &health.Tracker{}, &controlknobs.Knobs{}, "lo") // interface name does not matter.
r, err := newLoopbackOSConfigurator()
if err != nil {
return false
}
@@ -40,3 +45,8 @@ func shouldWatchResolvconf() bool {
return false
}
}
// newLoopbackOSConfigurator creates an OSConfigurator for DNS management using the "lo" interface.
func newLoopbackOSConfigurator() (dns.OSConfigurator, error) {
return dns.NewOSConfigurator(noopLogf, &health.Tracker{}, &controlknobs.Knobs{}, "lo")
}

View File

@@ -0,0 +1,14 @@
//go:build unix
package cli
import (
"tailscale.com/util/dnsname"
"github.com/Control-D-Inc/ctrld/internal/resolvconffile"
)
// searchDomains returns the current search domains config.
func searchDomains() ([]dnsname.FQDN, error) {
return resolvconffile.SearchDomains()
}

View File

@@ -0,0 +1,43 @@
package cli
import (
"fmt"
"syscall"
"golang.zx2c4.com/wireguard/windows/tunnel/winipcfg"
"tailscale.com/util/dnsname"
)
// searchDomains returns the current search domains config.
func searchDomains() ([]dnsname.FQDN, error) {
flags := winipcfg.GAAFlagIncludeGateways |
winipcfg.GAAFlagIncludePrefix
aas, err := winipcfg.GetAdaptersAddresses(syscall.AF_UNSPEC, flags)
if err != nil {
return nil, fmt.Errorf("winipcfg.GetAdaptersAddresses: %w", err)
}
var sds []dnsname.FQDN
for _, aa := range aas {
if aa.OperStatus != winipcfg.IfOperStatusUp {
continue
}
// Skip if software loopback or other non-physical types
// This is to avoid the "Loopback Pseudo-Interface 1" issue we see on windows
if aa.IfType == winipcfg.IfTypeSoftwareLoopback {
continue
}
for a := aa.FirstDNSSuffix; a != nil; a = a.Next {
d, err := dnsname.ToFQDN(a.String())
if err != nil {
mainLog.Load().Debug().Err(err).Msgf("failed to parse domain: %s", a.String())
continue
}
sds = append(sds, d)
}
}
return sds, nil
}

View File

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

View File

@@ -0,0 +1,206 @@
# 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
- **TCP forwarding** for all TCP traffic
- **UDP forwarding** with session tracking
- **Socket protection** to prevent routing loops
- **QUIC blocking** for better content filtering
## Architecture
```
Mobile Apps → VPN TUN Interface → PacketHandler → gVisor Netstack
├─→ DNS Filter (Port 53)
│ └─→ ControlD DNS Proxy
├─→ TCP Forwarder
│ └─→ net.Dial("tcp") + protect(fd)
└─→ UDP Forwarder
└─→ net.Dial("udp") + protect(fd)
Real Network (Protected Sockets)
```
## Components
### DNS Filter (`dns_filter.go`)
Detects DNS packets on port 53 and routes to ControlD proxy.
### DNS Bridge (`dns_bridge.go`)
Tracks DNS queries by transaction ID with 5-second timeout.
### TCP Forwarder (`tcp_forwarder.go`)
Forwards TCP connections using gVisor's `tcp.NewForwarder()`.
### UDP Forwarder (`udp_forwarder.go`)
Forwards UDP packets with session tracking and 60-second idle timeout.
### Packet Handler (`packet_handler.go`)
Interface for TUN I/O and socket protection.
### Netstack Controller (`netstack.go`)
Manages gVisor stack and coordinates all components.
## Socket Protection
Critical for preventing routing loops:
```go
// Protection happens BEFORE connect()
dialer.Control = func(network, address string, c syscall.RawConn) error {
return c.Control(func(fd uintptr) {
protectSocket(int(fd))
})
}
```
**All protected sockets:**
- TCP/UDP forwarder sockets (user traffic)
- ControlD API sockets (api.controld.com)
- DoH upstream sockets (freedns.controld.com)
## Platform Configuration
### Android
```kotlin
Builder()
.addAddress("10.0.0.2", 24)
.addRoute("0.0.0.0", 0)
.addDnsServer("10.0.0.1")
.setMtu(1500)
override fun protectSocket(fd: Long) {
protect(fd.toInt()) // VpnService.protect()
}
// DNS Proxy: 0.0.0.0:5354
```
### iOS
```swift
NEIPv4Settings(addresses: ["10.0.0.2"], ...)
NEDNSSettings(servers: ["10.0.0.1"])
includedRoutes = [NEIPv4Route.default()]
func protectSocket(_ fd: Int) throws {
// No action needed - iOS Network Extension sockets
// automatically bypass VPN tunnel
}
// DNS Proxy: 127.0.0.1:53
```
**Note:** iOS Network Extensions run in separate process - sockets automatically bypass VPN.
## 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
Drops UDP packets on ports 443 and 80 to force TCP fallback:
**Why:**
- Better DNS filtering (QUIC bypasses DNS)
- Visible SNI for content filtering
- Consistent ControlD policy enforcement
**Result:**
- Apps automatically fallback to TCP/TLS
- No user-visible errors
- Slightly slower initial connection, then normal
## Usage (Android)
```kotlin
// Create callback
val callback = object : PacketAppCallback {
override fun readPacket(): ByteArray { ... }
override fun writePacket(packet: ByteArray) { ... }
override fun protectSocket(fd: Long) {
protect(fd.toInt())
}
override fun closePacketIO() { ... }
override fun exit(s: String) { ... }
override fun hostname(): String = "android-device"
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
controller.startWithPacketCapture(
callback,
cdUID,
"", "",
filesDir.absolutePath,
"doh",
2,
"$filesDir/ctrld.log"
)
// Stop
controller.stop(false, 0)
```
## Usage (iOS)
```swift
// DNS-only mode
let proxy = LocalProxy()
proxy.start(
cUID: cdUID,
deviceName: UIDevice.current.name,
upstreamProto: "doh",
logLevel: 3,
provisionID: ""
)
// Firewall mode (full capture)
let proxy = LocalProxy()
proxy.startFirewall(
cUID: cdUID,
deviceName: UIDevice.current.name,
upstreamProto: "doh",
logLevel: 3,
provisionID: "",
packetFlow: packetFlow
)
```
## 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
- `dns_bridge.go` - Transaction tracking
- `tcp_forwarder.go` - TCP forwarding
- `udp_forwarder.go` - UDP forwarding
## License
Same as parent ctrld project.

View File

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

View File

@@ -0,0 +1,324 @@
package netstack
import (
"encoding/binary"
"fmt"
"net"
"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)
}
// NewDNSFilter creates a new DNS filter with the given handler.
func NewDNSFilter(handler func([]byte) ([]byte, error)) *DNSFilter {
return &DNSFilter{
dnsHandler: handler,
}
}
// 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)
}
// 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)
}
// 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
}

View File

@@ -0,0 +1,374 @@
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 = 256
)
// NetstackController manages the gVisor netstack integration for mobile packet capture.
type NetstackController struct {
stack *stack.Stack
linkEP *channel.Endpoint
packetHandler PacketHandler
dnsFilter *DNSFilter
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, "")
// Create DNS filter
dnsFilter := NewDNSFilter(cfg.DNSHandler)
// Create TCP forwarder
tcpForwarder := NewTCPForwarder(s, handler.ProtectSocket, ctx)
// Create UDP forwarder
udpForwarder := NewUDPForwarder(s, handler.ProtectSocket, ctx)
// 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,
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 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)")
return nil
}
// Stop stops the netstack controller and waits for all goroutines to finish.
func (nc *NetstackController) Stop() error {
nc.mu.Lock()
if !nc.started {
nc.mu.Unlock()
return nil
}
nc.mu.Unlock()
nc.cancel()
nc.wg.Wait()
// Close UDP forwarder
if nc.udpForwarder != nil {
nc.udpForwarder.Close()
}
if err := nc.packetHandler.Close(); err != nil {
return fmt.Errorf("failed to close packet handler: %v", err)
}
nc.mu.Lock()
nc.started = false
nc.mu.Unlock()
return nil
}
// readPackets reads packets from the TUN interface and injects them into the netstack.
func (nc *NetstackController) readPackets() {
defer nc.wg.Done()
for {
select {
case <-nc.ctx.Done():
return
default:
}
// Read packet from TUN
packet, err := nc.packetHandler.ReadPacket()
if err != nil {
if nc.ctx.Err() != nil {
return
}
time.Sleep(10 * time.Millisecond)
continue
}
if len(packet) == 0 {
continue
}
// Check if this is a DNS packet
isDNS, response, err := nc.dnsFilter.ProcessPacket(packet)
if err != nil {
continue
}
if isDNS && response != nil {
// DNS packet was handled, send response back to TUN
nc.packetHandler.WritePacket(response)
ctrld.ProxyLogger.Load().Debug().Msgf("[Netstack] DNS response sent (%d bytes)", len(response))
continue
}
if isDNS {
continue
}
// Not a DNS packet - check if it's an OUTBOUND packet (source = 10.0.0.x)
// We should ONLY inject outbound packets, not return packets
if len(packet) >= 20 {
// Check if source is in our VPN subnet (10.0.0.x)
isOutbound := packet[12] == 10 && packet[13] == 0 && packet[14] == 0
if !isOutbound {
// This is a return packet (server -> mobile)
// Drop it - return packets come through forwarder's upstream connection
continue
}
// Block QUIC protocol (UDP on port 443)
// QUIC runs over UDP and bypasses DNS, so we block it to force HTTP/2 or HTTP/3 over TCP
protocol := packet[9]
if protocol == 17 { // UDP
// Get IP header length
ihl := int(packet[0]&0x0f) * 4
if len(packet) >= ihl+4 {
// Parse UDP destination port (bytes 2-3 of UDP header)
dstPort := uint16(packet[ihl+2])<<8 | uint16(packet[ihl+3])
if dstPort == 443 || dstPort == 80 {
// Block QUIC (UDP/443) and HTTP/3 (UDP/80)
// Apps will fallback to TCP automatically
dstIP := net.IPv4(packet[16], packet[17], packet[18], packet[19])
ctrld.ProxyLogger.Load().Debug().Msgf("[Netstack] Blocked QUIC packet to %s:%d", dstIP, dstPort)
continue
}
}
}
}
// Create packet buffer
pkt := stack.NewPacketBuffer(stack.PacketBufferOptions{
Payload: buffer.MakeWithData(packet),
})
// Determine protocol number
var proto tcpip.NetworkProtocolNumber
if len(packet) > 0 {
version := packet[0] >> 4
switch version {
case 4:
proto = header.IPv4ProtocolNumber
case 6:
proto = header.IPv6ProtocolNumber
default:
pkt.DecRef()
continue
}
} else {
pkt.DecRef()
continue
}
// Inject into netstack - TCP/UDP forwarders will handle it
nc.linkEP.InjectInbound(proto, pkt)
}
}
// writePackets reads packets from netstack and writes them to the TUN interface.
func (nc *NetstackController) writePackets() {
defer nc.wg.Done()
for {
select {
case <-nc.ctx.Done():
return
default:
}
// Read packet from netstack
pkt := nc.linkEP.ReadContext(nc.ctx)
if pkt == nil {
continue
}
// Convert packet to bytes
vv := pkt.ToView()
packet := vv.AsSlice()
// Write to TUN
if err := nc.packetHandler.WritePacket(packet); err != nil {
// Log error
continue
}
pkt.DecRef()
}
}

View File

@@ -0,0 +1,115 @@
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
// ProtectSocket protects a socket file descriptor from being routed through the VPN.
// This is required on Android/iOS to prevent routing loops.
// Returns nil if successful, error otherwise.
ProtectSocket(fd int) error
}
// MobilePacketHandler implements PacketHandler using callbacks from mobile platforms.
// This bridges Go Mobile interface with the netstack implementation.
type MobilePacketHandler struct {
readFunc func() ([]byte, error)
writeFunc func([]byte) error
closeFunc func() error
protectFunc func(int) error
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,
protectFunc func(int) error,
) *MobilePacketHandler {
return &MobilePacketHandler{
readFunc: readFunc,
writeFunc: writeFunc,
closeFunc: closeFunc,
protectFunc: protectFunc,
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
}
// ProtectSocket protects a socket file descriptor from VPN routing.
func (m *MobilePacketHandler) ProtectSocket(fd int) error {
if m.protectFunc == nil {
// No protect function provided - this is okay for non-VPN scenarios
return nil
}
return m.protectFunc(fd)
}

View File

@@ -0,0 +1,123 @@
package netstack
import (
"context"
"io"
"net"
"syscall"
"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 {
protectSocket func(fd int) error
ctx context.Context
forwarder *tcp.Forwarder
}
// NewTCPForwarder creates a new TCP forwarder
func NewTCPForwarder(s *stack.Stack, protectSocket func(fd int) error, ctx context.Context) *TCPForwarder {
f := &TCPForwarder{
protectSocket: protectSocket,
ctx: ctx,
}
// Create gVisor TCP forwarder with handler callback
// rcvWnd=0 (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)
dstAddr := net.TCPAddr{
IP: net.IP(id.LocalAddress.AsSlice()),
Port: int(id.LocalPort),
}
// Create outbound connection with socket protection DURING dial
dialer := &net.Dialer{
Timeout: 30 * time.Second,
}
// CRITICAL: Protect socket BEFORE connect() is called
if f.protectSocket != nil {
dialer.Control = func(network, address string, c syscall.RawConn) error {
return c.Control(func(fd uintptr) {
f.protectSocket(int(fd))
})
}
}
upstreamConn, err := dialer.DialContext(f.ctx, "tcp", dstAddr.String())
if err != nil {
return
}
defer upstreamConn.Close()
// Log successful TCP connection
srcAddr := net.IP(id.RemoteAddress.AsSlice())
ctrld.ProxyLogger.Load().Debug().Msgf("[TCP] %s:%d -> %s:%d", srcAddr, id.RemotePort, dstAddr.IP, dstAddr.Port)
// Bidirectional copy
done := make(chan struct{}, 2)
go func() {
io.Copy(upstreamConn, tunConn)
done <- struct{}{}
}()
go func() {
io.Copy(tunConn, upstreamConn)
done <- struct{}{}
}()
// Wait for one direction to finish
<-done
}

View File

@@ -0,0 +1,226 @@
package netstack
import (
"context"
"fmt"
"net"
"sync"
"syscall"
"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 {
protectSocket func(fd int) error
ctx context.Context
forwarder *udp.Forwarder
// 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, protectSocket func(fd int) error, ctx context.Context) *UDPForwarder {
f := &UDPForwarder{
protectSocket: protectSocket,
ctx: ctx,
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)
dstAddr := &net.UDPAddr{
IP: net.IP(id.LocalAddress.AsSlice()),
Port: int(id.LocalPort),
}
// Create dialer with socket protection DURING dial
dialer := &net.Dialer{}
// CRITICAL: Protect socket BEFORE connect() is called
if f.protectSocket != nil {
dialer.Control = func(network, address string, c syscall.RawConn) error {
return c.Control(func(fd uintptr) {
f.protectSocket(int(fd))
})
}
}
// Create outbound UDP connection
dialConn, dialErr := dialer.Dial("udp", dstAddr.String())
if dialErr != nil {
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() {
f.mu.Lock()
defer f.mu.Unlock()
for _, conn := range f.connections {
conn.cancel()
conn.tunEP.Close()
conn.upstreamConn.Close()
}
f.connections = make(map[string]*udpConn)
}

View File

@@ -0,0 +1,272 @@
package ctrld_library
import (
"fmt"
"net/netip"
"runtime"
"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
// ProtectSocket protects a socket file descriptor from being routed through the VPN.
// On Android, this calls VpnService.protect() to prevent routing loops.
// On iOS, this marks the socket to bypass the VPN.
// Returns nil on success, error on failure.
ProtectSocket(fd int) error
}
// PacketCaptureController holds state for packet capture mode
type PacketCaptureController struct {
baseController *Controller
// Packet capture mode fields
netstackCtrl *netstack.NetstackController
dnsBridge *netstack.DNSBridge
packetStopCh chan struct{}
}
// 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,
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")
}
// 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
// Set global socket protector for HTTP client sockets (API calls, etc)
// This prevents routing loops when ctrld makes HTTP requests to api.controld.com
ctrld.SetSocketProtector(packetCallback.ProtectSocket)
// Create DNS bridge for communication between netstack and DNS proxy
pc.dnsBridge = netstack.NewDNSBridge()
pc.dnsBridge.Start()
// Create packet handler that wraps the mobile callbacks
packetHandler := netstack.NewMobilePacketHandler(
packetCallback.ReadPacket,
packetCallback.WritePacket,
packetCallback.ClosePacketIO,
packetCallback.ProtectSocket,
)
// Create DNS handler that uses the bridge
dnsHandler := func(query []byte) ([]byte, error) {
// Extract source IP from query context if available
// For now, use a placeholder
return pc.dnsBridge.ProcessQuery(query, "10.0.0.2", 0)
}
// Auto-detect platform and use appropriate TUN IP
// Android: TUN=10.0.0.1, Device=10.0.0.2
// iOS: TUN=10.0.0.2, Device=10.0.0.1
tunIP := "10.0.0.1" // Default for Android
// Check if running on iOS (no reliable way, so we'll make it configurable)
// For now, use Android config. iOS should update their VPN settings to match.
tunIPv4, err := netip.ParseAddr(tunIP)
if err != nil {
return fmt.Errorf("failed to parse TUN IPv4: %v", err)
}
netstackCfg := &netstack.Config{
MTU: 1500,
TUNIPv4: tunIPv4,
DNSHandler: dnsHandler,
UpstreamInterface: nil, // Will use default interface
}
ctrld.ProxyLogger.Load().Info().Msgf("[PacketCapture] Netstack TUN IP: %s", tunIP)
// 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)
}()
// Log platform detection for DNS proxy port
dnsPort := "5354"
if runtime.GOOS == "ios" || runtime.GOOS == "darwin" {
dnsPort = "53"
}
ctrld.ProxyLogger.Load().Info().Msgf("[PacketCapture] Platform: %s, DNS proxy port: %s", runtime.GOOS, dnsPort)
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
}
// Determine DNS proxy port based on platform
// Android: 0.0.0.0:5354
// iOS: 127.0.0.1:53
dnsProxyAddr := "127.0.0.1:5354" // Default for Android
if runtime.GOOS == "ios" || runtime.GOOS == "darwin" {
// iOS uses port 53
dnsProxyAddr = "127.0.0.1:53"
}
// Send query to actual DNS proxy
client := &dns.Client{
Net: "udp",
Timeout: 3 * time.Second,
}
response, _, err := client.Exchange(msg, dnsProxyAddr)
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 {
var errorCode = 0
// Clear global socket protector
ctrld.SetSocketProtector(nil)
// Stop DNS bridge
if pc.dnsBridge != nil {
pc.dnsBridge.Stop()
pc.dnsBridge = nil
}
// Stop netstack
if pc.netstackCtrl != nil {
if err := pc.netstackCtrl.Stop(); err != nil {
// Log error but continue shutdown
fmt.Printf("Error stopping netstack: %v\n", err)
}
pc.netstackCtrl = nil
}
// Close packet stop channel
if pc.packetStopCh != nil {
close(pc.packetStopCh)
pc.packetStopCh = make(chan struct{})
}
// Stop base controller
if !restart {
errorCode = cli.CheckDeactivationPin(pin, pc.baseController.stopCh)
}
if errorCode == 0 && pc.baseController.stopCh != nil {
close(pc.baseController.stopCh)
pc.baseController.stopCh = nil
}
return errorCode
}
// IsRunning returns true if the controller is running
func (pc *PacketCaptureController) IsRunning() bool {
return pc.baseController.stopCh != nil
}
// IsPacketMode returns true (always in packet mode for this controller)
func (pc *PacketCaptureController) IsPacketMode() bool {
return true
}

View File

@@ -21,6 +21,7 @@ import (
"strings"
"sync"
"sync/atomic"
"syscall"
"time"
"github.com/ameshkov/dnsstamps"
@@ -358,6 +359,15 @@ func (uc *UpstreamConfig) Init() {
}
}
// VerifyMsg creates and returns a new DNS message could be used for testing upstream health.
func (uc *UpstreamConfig) VerifyMsg() *dns.Msg {
msg := new(dns.Msg)
msg.RecursionDesired = true
msg.SetQuestion(".", dns.TypeNS)
msg.SetEdns0(4096, false) // ensure handling of large DNS response
return msg
}
// VerifyDomain returns the domain name that could be resolved by the upstream endpoint.
// It returns empty for non-ControlD upstream endpoint.
func (uc *UpstreamConfig) VerifyDomain() string {
@@ -427,11 +437,19 @@ func (uc *UpstreamConfig) UID() string {
// SetupBootstrapIP manually find all available IPs of the upstream.
// The first usable IP will be used as bootstrap IP of the upstream.
// The upstream domain will be looked up using following orders:
//
// - Current system DNS settings.
// - Direct IPs table for ControlD upstreams.
// - ControlD Bootstrap DNS 76.76.2.22
//
// The setup process will block until there's usable IPs found.
func (uc *UpstreamConfig) SetupBootstrapIP() {
b := backoff.NewBackoff("setupBootstrapIP", func(format string, args ...any) {}, 10*time.Second)
isControlD := uc.IsControlD()
nss := initDefaultOsResolver()
for {
uc.bootstrapIPs = lookupIP(uc.Domain, uc.Timeout)
uc.bootstrapIPs = lookupIP(uc.Domain, uc.Timeout, nss)
// For ControlD upstream, the bootstrap IPs could not be RFC 1918 addresses,
// filtering them out here to prevent weird behavior.
if isControlD {
@@ -446,9 +464,14 @@ func (uc *UpstreamConfig) SetupBootstrapIP() {
uc.bootstrapIPs = uc.bootstrapIPs[:n]
if len(uc.bootstrapIPs) == 0 {
uc.bootstrapIPs = bootstrapIPsFromControlDDomain(uc.Domain)
ProxyLogger.Load().Warn().Msgf("no bootstrap IPs found for %q, fallback to direct IPs", uc.Domain)
ProxyLogger.Load().Warn().Msgf("no record found for %q, lookup from direct IP table", uc.Domain)
}
}
if len(uc.bootstrapIPs) == 0 {
ProxyLogger.Load().Warn().Msgf("no record found for %q, using bootstrap server: %s", uc.Domain, PremiumDNSBoostrapIP)
uc.bootstrapIPs = lookupIP(uc.Domain, uc.Timeout, []string{net.JoinHostPort(PremiumDNSBoostrapIP, "53")})
}
if len(uc.bootstrapIPs) > 0 {
break
}
@@ -533,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)
@@ -549,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
}
@@ -951,14 +1006,14 @@ func (uc *UpstreamConfig) String() string {
// bootstrapIPsFromControlDDomain returns bootstrap IPs for ControlD domain.
func bootstrapIPsFromControlDDomain(domain string) []string {
switch domain {
case PremiumDnsDomain:
switch {
case dns.IsSubDomain(PremiumDnsDomain, domain):
return []string{PremiumDNSBoostrapIP, PremiumDNSBoostrapIPv6}
case FreeDnsDomain:
case dns.IsSubDomain(FreeDnsDomain, domain):
return []string{FreeDNSBoostrapIP, FreeDNSBoostrapIPv6}
case premiumDnsDomainDev:
case dns.IsSubDomain(premiumDnsDomainDev, domain):
return []string{premiumDNSBoostrapIP, premiumDNSBoostrapIPv6}
case freeDnsDomainDev:
case dns.IsSubDomain(freeDnsDomainDev, domain):
return []string{freeDNSBoostrapIP, freeDNSBoostrapIPv6}
}
return nil

View File

@@ -8,19 +8,43 @@ import (
)
func TestUpstreamConfig_SetupBootstrapIP(t *testing.T) {
uc := &UpstreamConfig{
Name: "test",
Type: ResolverTypeDOH,
Endpoint: "https://freedns.controld.com/p2",
Timeout: 5000,
tests := []struct {
name string
uc *UpstreamConfig
}{
{
name: "doh/doh3",
uc: &UpstreamConfig{
Name: "doh",
Type: ResolverTypeDOH,
Endpoint: "https://freedns.controld.com/p2",
Timeout: 5000,
},
},
{
name: "doq/dot",
uc: &UpstreamConfig{
Name: "dot",
Type: ResolverTypeDOT,
Endpoint: "p2.freedns.controld.com",
Timeout: 5000,
},
},
}
uc.Init()
uc.SetupBootstrapIP()
if len(uc.bootstrapIPs) == 0 {
t.Log(defaultNameservers())
t.Fatal("could not bootstrap ip without bootstrap DNS")
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
// Enable parallel tests once https://github.com/microsoft/wmi/issues/165 fixed.
// t.Parallel()
tc.uc.Init()
tc.uc.SetupBootstrapIP()
if len(tc.uc.bootstrapIPs) == 0 {
t.Log(defaultNameservers())
t.Fatalf("could not bootstrap ip: %s", tc.uc.String())
}
})
}
t.Log(uc)
}
func TestUpstreamConfig_Init(t *testing.T) {

View File

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

7
desktop_darwin.go Normal file
View File

@@ -0,0 +1,7 @@
package ctrld
// IsDesktopPlatform indicates if ctrld is running on a desktop platform,
// currently defined as macOS or Windows workstation.
func IsDesktopPlatform() bool {
return true
}

9
desktop_others.go Normal file
View File

@@ -0,0 +1,9 @@
//go:build !windows && !darwin
package ctrld
// IsDesktopPlatform indicates if ctrld is running on a desktop platform,
// currently defined as macOS or Windows workstation.
func IsDesktopPlatform() bool {
return false
}

7
desktop_windows.go Normal file
View File

@@ -0,0 +1,7 @@
package ctrld
// IsDesktopPlatform indicates if ctrld is running on a desktop platform,
// currently defined as macOS or Windows workstation.
func IsDesktopPlatform() bool {
return isWindowsWorkStation()
}

30
dns.go Normal file
View File

@@ -0,0 +1,30 @@
package ctrld
import (
"github.com/miekg/dns"
)
// SetCacheReply extracts and stores the necessary data from the message for a cached answer.
func SetCacheReply(answer, msg *dns.Msg, code int) {
answer.SetRcode(msg, code)
cCookie := getEdns0Cookie(msg.IsEdns0())
sCookie := getEdns0Cookie(answer.IsEdns0())
if cCookie != nil && sCookie != nil {
// Client cookie is fixed size 8 bytes, Server cookie is variable size 8 -> 32 bytes.
// See https://datatracker.ietf.org/doc/html/rfc7873#section-4
sCookie.Cookie = cCookie.Cookie[:16] + sCookie.Cookie[16:]
}
}
// getEdns0Cookie returns Edns0 cookie from *dns.OPT if present.
func getEdns0Cookie(opt *dns.OPT) *dns.EDNS0_COOKIE {
if opt == nil {
return nil
}
for _, o := range opt.Option {
if e, ok := o.(*dns.EDNS0_COOKIE); ok {
return e
}
}
return nil
}

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

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

51
doh.go
View File

@@ -2,6 +2,7 @@ package ctrld
import (
"context"
"crypto/tls"
"encoding/base64"
"errors"
"fmt"
@@ -120,6 +121,7 @@ func (r *dohResolver) Resolve(ctx context.Context, msg *dns.Msg) (*dns.Msg, erro
resp, err = c.Do(req.Clone(retryCtx))
}
if err != nil {
err = wrapUrlError(err)
if r.isDoH3 {
if closer, ok := c.Transport.(io.Closer); ok {
closer.Close()
@@ -208,3 +210,52 @@ func newNextDNSHeaders(ci *ClientInfo) http.Header {
}
return header
}
// wrapCertificateVerificationError wraps a certificate verification error with additional context about the certificate issuer.
// It extracts information like the issuer, organization, and subject from the certificate for a more descriptive error output.
// If no certificate-related information is available, it simply returns the original error unmodified.
func wrapCertificateVerificationError(err error) error {
var tlsErr *tls.CertificateVerificationError
if errors.As(err, &tlsErr) {
if len(tlsErr.UnverifiedCertificates) > 0 {
cert := tlsErr.UnverifiedCertificates[0]
// Extract a more user-friendly issuer name
var issuer string
var organization string
if len(cert.Issuer.Organization) > 0 {
organization = cert.Issuer.Organization[0]
issuer = organization
} else if cert.Issuer.CommonName != "" {
issuer = cert.Issuer.CommonName
} else {
issuer = cert.Issuer.String()
}
// Get the organization from the subject field as well
if len(cert.Subject.Organization) > 0 {
organization = cert.Subject.Organization[0]
}
// Extract the subject information
subjectCN := cert.Subject.CommonName
if subjectCN == "" && len(cert.Subject.Organization) > 0 {
subjectCN = cert.Subject.Organization[0]
}
return fmt.Errorf("%w: %s, %s, %s", tlsErr, subjectCN, organization, issuer)
}
}
return err
}
// wrapUrlError inspects and wraps a URL error, focusing on certificate verification errors for detailed context.
func wrapUrlError(err error) error {
var urlErr *url.Error
if errors.As(err, &urlErr) {
var tlsErr *tls.CertificateVerificationError
if errors.As(urlErr.Err, &tlsErr) {
urlErr.Err = wrapCertificateVerificationError(tlsErr)
return urlErr
}
}
return err
}

View File

@@ -1,8 +1,22 @@
package ctrld
import (
"context"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"errors"
"net"
"net/http"
"net/http/httptest"
"net/url"
"runtime"
"strings"
"testing"
"time"
"github.com/miekg/dns"
"github.com/quic-go/quic-go/http3"
)
func Test_dohOsHeaderValue(t *testing.T) {
@@ -21,3 +35,232 @@ func Test_dohOsHeaderValue(t *testing.T) {
t.Fatalf("missing decoding value for: %q", runtime.GOOS)
}
}
func Test_wrapUrlError(t *testing.T) {
tests := []struct {
name string
err error
wantErr string
}{
{
name: "No wrapping for non-URL errors",
err: errors.New("plain error"),
wantErr: "plain error",
},
{
name: "URL error without TLS error",
err: &url.Error{
Op: "Get",
URL: "https://example.com",
Err: errors.New("underlying error"),
},
wantErr: "Get \"https://example.com\": underlying error",
},
{
name: "TLS error with missing unverified certificate data",
err: &url.Error{
Op: "Get",
URL: "https://example.com",
Err: &tls.CertificateVerificationError{
UnverifiedCertificates: nil,
Err: &x509.UnknownAuthorityError{},
},
},
wantErr: `Get "https://example.com": tls: failed to verify certificate: x509: certificate signed by unknown authority`,
},
{
name: "TLS error with valid certificate data",
err: &url.Error{
Op: "Get",
URL: "https://example.com",
Err: &tls.CertificateVerificationError{
UnverifiedCertificates: []*x509.Certificate{
{
Subject: pkix.Name{
CommonName: "BadSubjectCN",
Organization: []string{"BadSubjectOrg"},
},
Issuer: pkix.Name{
CommonName: "BadIssuerCN",
Organization: []string{"BadIssuerOrg"},
},
},
},
Err: &x509.UnknownAuthorityError{},
},
},
wantErr: `Get "https://example.com": tls: failed to verify certificate: x509: certificate signed by unknown authority: BadSubjectCN, BadSubjectOrg, BadIssuerOrg`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotErr := wrapUrlError(tt.err)
if gotErr.Error() != tt.wantErr {
t.Errorf("wrapCertificateVerificationError() error = %v, want %v", gotErr, tt.wantErr)
}
})
}
}
func Test_ClientCertificateVerificationError(t *testing.T) {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/dns-message")
})
tlsServer, cert := testTLSServer(t, handler)
tlsServerUrl, err := url.Parse(tlsServer.URL)
if err != nil {
t.Fatal(err)
}
quicServer := newTestQUICServer(t)
http3Server := newTestHTTP3Server(t, handler)
tests := []struct {
name string
uc *UpstreamConfig
}{
{
"doh",
&UpstreamConfig{
Name: "doh",
Type: ResolverTypeDOH,
Endpoint: tlsServer.URL,
Timeout: 1000,
},
},
{
"doh3",
&UpstreamConfig{
Name: "doh3",
Type: ResolverTypeDOH3,
Endpoint: http3Server.addr,
Timeout: 5000,
},
},
{
"doq",
&UpstreamConfig{
Name: "doq",
Type: ResolverTypeDOQ,
Endpoint: quicServer.addr,
Timeout: 5000,
},
},
{
"dot",
&UpstreamConfig{
Name: "dot",
Type: ResolverTypeDOT,
Endpoint: net.JoinHostPort(tlsServerUrl.Hostname(), tlsServerUrl.Port()),
Timeout: 1000,
},
},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
tc.uc.Init()
tc.uc.SetupBootstrapIP()
r, err := NewResolver(tc.uc)
if err != nil {
t.Fatal(err)
}
msg := new(dns.Msg)
msg.SetQuestion("verify.controld.com.", dns.TypeA)
msg.RecursionDesired = true
_, err = r.Resolve(context.Background(), msg)
// Verify the error contains the expected certificate information
if err == nil {
t.Fatal("expected certificate verification error, got nil")
}
// You can check the error contains information about the test certificate
if !strings.Contains(err.Error(), cert.Issuer.CommonName) {
t.Fatalf("error should contain issuer information %q, got: %v", cert.Issuer.CommonName, err)
}
})
}
}
// testTLSServer creates an HTTPS test server with a self-signed certificate
// returns the server and its certificate for verification testing
// testTLSServer creates an HTTPS test server with a self-signed certificate
func testTLSServer(t *testing.T, handler http.Handler) (*httptest.Server, *x509.Certificate) {
t.Helper()
testCert := generateTestCertificate(t)
// Create a test server
server := httptest.NewUnstartedServer(handler)
server.TLS = &tls.Config{
Certificates: []tls.Certificate{testCert.tlsCert},
}
server.StartTLS()
// Add cleanup
t.Cleanup(server.Close)
return server, testCert.cert
}
// testHTTP3Server represents a structure for an HTTP/3 test server with its server instance, TLS certificate, and address.
type testHTTP3Server struct {
server *http3.Server
cert *x509.Certificate
addr string
}
// newTestHTTP3Server creates and starts a test HTTP/3 server with a given handler and returns the server instance.
func newTestHTTP3Server(t *testing.T, handler http.Handler) *testHTTP3Server {
t.Helper()
testCert := generateTestCertificate(t)
// First create a listener to get the actual port
udpAddr := &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: 0}
udpConn, err := net.ListenUDP("udp", udpAddr)
if err != nil {
t.Fatalf("failed to create UDP listener: %v", err)
}
// Get the actual address
actualAddr := udpConn.LocalAddr().String()
// Create TLS config
tlsConfig := &tls.Config{
Certificates: []tls.Certificate{testCert.tlsCert},
NextProtos: []string{"h3"}, // HTTP/3 protocol identifier
}
// Create HTTP/3 server
server := &http3.Server{
Handler: handler,
TLSConfig: tlsConfig,
}
// Start the server with the existing UDP connection
go func() {
if err := server.Serve(udpConn); err != nil && !errors.Is(err, http.ErrServerClosed) {
t.Logf("HTTP/3 server error: %v", err)
}
}()
h3Server := &testHTTP3Server{
server: server,
cert: testCert.cert,
addr: actualAddr,
}
// Add cleanup
t.Cleanup(func() {
server.Close()
udpConn.Close()
})
// Wait a bit for the server to be ready
time.Sleep(100 * time.Millisecond)
return h3Server
}

2
doq.go
View File

@@ -43,7 +43,7 @@ func resolve(ctx context.Context, msg *dns.Msg, endpoint string, tlsConfig *tls.
continue
}
if err != nil {
return nil, err
return nil, wrapCertificateVerificationError(err)
}
return answer, nil
}

223
doq_test.go Normal file
View File

@@ -0,0 +1,223 @@
// test_helpers.go
package ctrld
import (
"context"
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"math/big"
"net"
"strings"
"testing"
"time"
"github.com/miekg/dns"
"github.com/quic-go/quic-go"
)
// testCertificate represents a test certificate with its components
type testCertificate struct {
cert *x509.Certificate
tlsCert tls.Certificate
template *x509.Certificate
}
// generateTestCertificate creates a self-signed certificate for testing
func generateTestCertificate(t *testing.T) *testCertificate {
t.Helper()
// Generate private key
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("failed to generate private key: %v", err)
}
// Create certificate template
template := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{
Organization: []string{"Test Org"},
CommonName: "Test CA",
},
Issuer: pkix.Name{
Organization: []string{"Test Issuer Org"},
CommonName: "Test Issuer CA",
},
NotBefore: time.Now(),
NotAfter: time.Now().Add(time.Hour),
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
DNSNames: []string{"localhost"},
}
// Create certificate
derBytes, err := x509.CreateCertificate(rand.Reader, template, template, &privateKey.PublicKey, privateKey)
if err != nil {
t.Fatalf("failed to create certificate: %v", err)
}
cert, err := x509.ParseCertificate(derBytes)
if err != nil {
t.Fatalf("failed to parse certificate: %v", err)
}
// Create TLS certificate
tlsCert := tls.Certificate{
Certificate: [][]byte{derBytes},
PrivateKey: privateKey,
}
return &testCertificate{
cert: cert,
tlsCert: tlsCert,
template: template,
}
}
// testQUICServer is a structure representing a test QUIC server for handling connections and streams.
// listener is the QUIC listener used to accept incoming connections.
// cert is the x509 certificate used by the server for authentication.
// addr is the address on which the test server is running.
type testQUICServer struct {
listener *quic.Listener
cert *x509.Certificate
addr string
}
// newTestQUICServer creates and initializes a test QUIC server with TLS configuration and starts accepting connections.
func newTestQUICServer(t *testing.T) *testQUICServer {
t.Helper()
testCert := generateTestCertificate(t)
// Create TLS config
tlsConfig := &tls.Config{
Certificates: []tls.Certificate{testCert.tlsCert},
NextProtos: []string{"doq"},
}
// Create QUIC listener
listener, err := quic.ListenAddr("127.0.0.1:0", tlsConfig, nil)
if err != nil {
t.Fatalf("failed to create QUIC listener: %v", err)
}
server := &testQUICServer{
listener: listener,
cert: testCert.cert,
addr: listener.Addr().String(),
}
// Start handling connections
go server.serve(t)
// Add cleanup
t.Cleanup(func() {
listener.Close()
})
return server
}
// serve handles incoming connections on the QUIC listener and delegates them to connection handlers in separate goroutines.
func (s *testQUICServer) serve(t *testing.T) {
for {
conn, err := s.listener.Accept(context.Background())
if err != nil {
// Check if the error is due to the listener being closed
if strings.Contains(err.Error(), "server closed") {
return
}
t.Logf("failed to accept connection: %v", err)
continue
}
go s.handleConnection(t, conn)
}
}
// handleConnection manages an individual QUIC connection by accepting and handling incoming streams in separate goroutines.
func (s *testQUICServer) handleConnection(t *testing.T, conn *quic.Conn) {
for {
stream, err := conn.AcceptStream(context.Background())
if err != nil {
return
}
go s.handleStream(t, stream)
}
}
// 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) {
defer stream.Close()
// Read length (2 bytes)
lenBuf := make([]byte, 2)
_, err := stream.Read(lenBuf)
if err != nil {
t.Logf("failed to read message length: %v", err)
return
}
msgLen := uint16(lenBuf[0])<<8 | uint16(lenBuf[1])
// Read message
msgBuf := make([]byte, msgLen)
_, err = stream.Read(msgBuf)
if err != nil {
t.Logf("failed to read message: %v", err)
return
}
// Parse DNS message
msg := new(dns.Msg)
if err := msg.Unpack(msgBuf); err != nil {
t.Logf("failed to unpack DNS message: %v", err)
return
}
// Create response
response := new(dns.Msg)
response.SetReply(msg)
response.Authoritative = true
// Add a test answer
if len(msg.Question) > 0 && msg.Question[0].Qtype == dns.TypeA {
response.Answer = append(response.Answer, &dns.A{
Hdr: dns.RR_Header{
Name: msg.Question[0].Name,
Rrtype: dns.TypeA,
Class: dns.ClassINET,
Ttl: 300,
},
A: net.ParseIP("192.0.2.1"), // TEST-NET-1 address
})
}
// Pack response
respBytes, err := response.Pack()
if err != nil {
t.Logf("failed to pack response: %v", err)
return
}
// Write length
respLen := uint16(len(respBytes))
_, err = stream.Write([]byte{byte(respLen >> 8), byte(respLen & 0xFF)})
if err != nil {
t.Logf("failed to write response length: %v", err)
return
}
// Write response
_, err = stream.Write(respBytes)
if err != nil {
t.Logf("failed to write response: %v", err)
return
}
}

3
dot.go
View File

@@ -23,7 +23,6 @@ func (r *dotResolver) Resolve(ctx context.Context, msg *dns.Msg) (*dns.Msg, erro
if msg != nil && len(msg.Question) > 0 {
dnsTyp = msg.Question[0].Qtype
}
tcpNet, _ := r.uc.netForDNSType(dnsTyp)
dnsClient := &dns.Client{
Net: tcpNet,
@@ -39,5 +38,5 @@ func (r *dotResolver) Resolve(ctx context.Context, msg *dns.Msg) (*dns.Msg, erro
}
answer, _, err := dnsClient.ExchangeContext(ctx, msg, endpoint)
return answer, err
return answer, wrapCertificateVerificationError(err)
}

35
go.mod
View File

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

67
go.sum
View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -17,10 +17,17 @@ import (
)
const (
v4BootstrapDNS = "76.76.2.22:53"
v6BootstrapDNS = "[2606:1a40::22]:53"
v4BootstrapDNS = "76.76.2.22:53"
v6BootstrapDNS = "[2606:1a40::22]:53"
v6BootstrapIP = "2606:1a40::22"
defaultHTTPSPort = "443"
defaultHTTPPort = "80"
defaultDNSPort = "53"
probeStackTimeout = 2 * time.Second
)
var commonIPv6Ports = []string{defaultHTTPSPort, defaultHTTPPort, defaultDNSPort}
var Dialer = &net.Dialer{
Resolver: &net.Resolver{
PreferGo: true,
@@ -33,8 +40,6 @@ var Dialer = &net.Dialer{
},
}
const probeStackTimeout = 2 * time.Second
var probeStackDialer = &net.Dialer{
Resolver: Dialer.Resolver,
Timeout: probeStackTimeout,
@@ -50,12 +55,28 @@ func init() {
stackOnce.Store(new(sync.Once))
}
func supportIPv6(ctx context.Context) bool {
c, err := probeStackDialer.DialContext(ctx, "tcp6", v6BootstrapDNS)
// supportIPv6 checks for IPv6 connectivity by attempting to connect to predefined ports
// on a specific IPv6 address.
// Returns a boolean indicating if IPv6 is supported and the port on which the connection succeeded.
// If no connection is successful, returns false and an empty string.
func supportIPv6(ctx context.Context) (supported bool, successPort string) {
for _, port := range commonIPv6Ports {
if canConnectToIPv6Port(ctx, port) {
return true, string(port)
}
}
return false, ""
}
// canConnectToIPv6Port attempts to establish a TCP connection to the specified port
// using IPv6. Returns true if the connection was successful.
func canConnectToIPv6Port(ctx context.Context, port string) bool {
address := net.JoinHostPort(v6BootstrapIP, port)
conn, err := probeStackDialer.DialContext(ctx, "tcp6", address)
if err != nil {
return false
}
c.Close()
_ = conn.Close()
return true
}
@@ -110,7 +131,8 @@ func SupportsIPv6ListenLocal() bool {
// IPv6Available is like SupportsIPv6, but always do the check without caching.
func IPv6Available(ctx context.Context) bool {
return supportIPv6(ctx)
hasV6, _ := supportIPv6(ctx)
return hasV6
}
// IsIPv6 checks if the provided IP is v6.

View File

@@ -12,7 +12,12 @@ func TestProbeStackTimeout(t *testing.T) {
go func() {
defer close(done)
close(started)
supportIPv6(context.Background())
hasV6, port := supportIPv6(context.Background())
if hasV6 {
t.Logf("connect to port %s using ipv6: %v", port, hasV6)
} else {
t.Log("ipv6 is not available")
}
}()
<-started

View File

@@ -6,6 +6,7 @@ import (
"net"
"tailscale.com/net/dns/resolvconffile"
"tailscale.com/util/dnsname"
)
const resolvconfPath = "/etc/resolv.conf"
@@ -22,7 +23,7 @@ func NameServersWithPort() []string {
return ns
}
func NameServers(_ string) []string {
func NameServers() []string {
c, err := resolvconffile.ParseFile(resolvconfPath)
if err != nil {
return nil
@@ -33,3 +34,12 @@ func NameServers(_ string) []string {
}
return ns
}
// SearchDomains returns the current search domains config in /etc/resolv.conf file.
func SearchDomains() ([]dnsname.FQDN, error) {
c, err := resolvconffile.ParseFile(resolvconfPath)
if err != nil {
return nil, err
}
return c.SearchDomains, nil
}

View File

@@ -9,7 +9,7 @@ import (
)
func TestNameServers(t *testing.T) {
ns := NameServers("")
ns := NameServers()
require.NotNil(t, ns)
t.Log(ns)
}

View File

@@ -6,6 +6,7 @@ import (
"errors"
"io"
"os"
"path/filepath"
"strings"
)
@@ -28,3 +29,62 @@ func interfaceNameFromReader(r io.Reader) (string, error) {
}
return "", errors.New("not found")
}
// AdditionalConfigFiles returns a list of Dnsmasq configuration files found in the "/tmp/etc" directory.
func AdditionalConfigFiles() []string {
if paths, err := filepath.Glob("/tmp/etc/dnsmasq-*.conf"); err == nil {
return paths
}
return nil
}
// AdditionalLeaseFiles returns a list of lease file paths corresponding to the Dnsmasq configuration files.
func AdditionalLeaseFiles() []string {
cfgFiles := AdditionalConfigFiles()
if len(cfgFiles) == 0 {
return nil
}
leaseFiles := make([]string, 0, len(cfgFiles))
for _, cfgFile := range cfgFiles {
if leaseFile := leaseFileFromConfigFileName(cfgFile); leaseFile != "" {
leaseFiles = append(leaseFiles, leaseFile)
} else {
leaseFiles = append(leaseFiles, defaultLeaseFileFromConfigPath(cfgFile))
}
}
return leaseFiles
}
// leaseFileFromConfigFileName retrieves the DHCP lease file path by reading and parsing the provided configuration file.
func leaseFileFromConfigFileName(cfgFile string) string {
if f, err := os.Open(cfgFile); err == nil {
return leaseFileFromReader(f)
}
return ""
}
// leaseFileFromReader parses the given io.Reader for the "dhcp-leasefile" configuration and returns its value as a string.
func leaseFileFromReader(r io.Reader) string {
scanner := bufio.NewScanner(r)
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, "#") {
continue
}
before, after, found := strings.Cut(line, "=")
if !found {
continue
}
if before == "dhcp-leasefile" {
return after
}
}
return ""
}
// defaultLeaseFileFromConfigPath generates the default lease file path based on the provided configuration file path.
func defaultLeaseFileFromConfigPath(path string) string {
name := filepath.Base(path)
return filepath.Join("/var/lib/misc", strings.TrimSuffix(name, ".conf")+".leases")
}

View File

@@ -1,6 +1,7 @@
package dnsmasq
import (
"io"
"strings"
"testing"
)
@@ -44,3 +45,49 @@ interface=eth0
})
}
}
func Test_leaseFileFromReader(t *testing.T) {
tests := []struct {
name string
in io.Reader
expected string
}{
{
"default",
strings.NewReader(`
dhcp-script=/sbin/dhcpc_lease
dhcp-leasefile=/var/lib/misc/dnsmasq-1.leases
script-arp
`),
"/var/lib/misc/dnsmasq-1.leases",
},
{
"non-default",
strings.NewReader(`
dhcp-script=/sbin/dhcpc_lease
dhcp-leasefile=/tmp/var/lib/misc/dnsmasq-1.leases
script-arp
`),
"/tmp/var/lib/misc/dnsmasq-1.leases",
},
{
"missing",
strings.NewReader(`
dhcp-script=/sbin/dhcpc_lease
script-arp
`),
"",
},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := leaseFileFromReader(tc.in); got != tc.expected {
t.Errorf("leaseFileFromReader() = %v, want %v", got, tc.expected)
}
})
}
}

View File

@@ -4,6 +4,7 @@ import (
"errors"
"html/template"
"net"
"os"
"path/filepath"
"strings"
@@ -26,9 +27,13 @@ max-cache-ttl=0
{{- end}}
`
const MerlinConfPath = "/tmp/etc/dnsmasq.conf"
const MerlinJffsConfPath = "/jffs/configs/dnsmasq.conf"
const MerlinPostConfPath = "/jffs/scripts/dnsmasq.postconf"
const (
MerlinConfPath = "/tmp/etc/dnsmasq.conf"
MerlinJffsConfDir = "/jffs/configs"
MerlinJffsConfPath = "/jffs/configs/dnsmasq.conf"
MerlinPostConfPath = "/jffs/scripts/dnsmasq.postconf"
)
const MerlinPostConfMarker = `# GENERATED BY ctrld - EOF`
const MerlinPostConfTmpl = `# GENERATED BY ctrld - DO NOT MODIFY
@@ -159,3 +164,27 @@ func FirewallaSelfInterfaces() []*net.Interface {
}
return ifaces
}
const (
ubios43ConfPath = "/run/dnsmasq.dhcp.conf.d"
ubios42ConfPath = "/run/dnsmasq.conf.d"
ubios43PidFile = "/run/dnsmasq-main.pid"
ubios42PidFile = "/run/dnsmasq.pid"
UbiosConfName = "zzzctrld.conf"
)
// UbiosConfPath returns the appropriate configuration path based on the system's directory structure.
func UbiosConfPath() string {
if st, _ := os.Stat(ubios43ConfPath); st != nil && st.IsDir() {
return ubios43ConfPath
}
return ubios42ConfPath
}
// UbiosPidFile returns the appropriate dnsmasq pid file based on the system's directory structure.
func UbiosPidFile() string {
if st, _ := os.Stat(ubios43PidFile); st != nil && !st.IsDir() {
return ubios43PidFile
}
return ubios42PidFile
}

View File

@@ -6,6 +6,7 @@ import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/kardianos/service"
@@ -181,7 +182,7 @@ func ContentFilteringEnabled() bool {
// DnsShieldEnabled reports whether DNS Shield is enabled.
// See: https://community.ui.com/releases/UniFi-OS-Dream-Machines-3-2-7/251dfc1e-f4dd-4264-a080-3be9d8b9e02b
func DnsShieldEnabled() bool {
buf, err := os.ReadFile("/var/run/dnsmasq.conf.d/dns.conf")
buf, err := os.ReadFile(filepath.Join(dnsmasq.UbiosConfPath(), "dns.conf"))
if err != nil {
return false
}

View File

@@ -6,6 +6,7 @@ import (
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"unicode"
@@ -20,10 +21,18 @@ import (
const Name = "merlin"
// nvramKvMap is a map of NVRAM key-value pairs used to configure and manage Merlin-specific settings.
var nvramKvMap = map[string]string{
"dnspriv_enable": "0", // Ensure Merlin native DoT disabled.
}
// dnsmasqConfig represents configuration paths for dnsmasq operations in Merlin firmware.
type dnsmasqConfig struct {
confPath string
jffsConfPath string
}
// Merlin represents a configuration handler for setting up and managing ctrld on Merlin routers.
type Merlin struct {
cfg *ctrld.Config
}
@@ -33,18 +42,22 @@ func New(cfg *ctrld.Config) *Merlin {
return &Merlin{cfg: cfg}
}
// ConfigureService configures the service based on the provided configuration. It returns an error if the configuration fails.
func (m *Merlin) ConfigureService(config *service.Config) error {
return nil
}
// Install sets up the necessary configurations and services required for the Merlin instance to function properly.
func (m *Merlin) Install(_ *service.Config) error {
return nil
}
// Uninstall removes the ctrld-related configurations and services from the Merlin router and reverts to the original state.
func (m *Merlin) Uninstall(_ *service.Config) error {
return nil
}
// PreRun prepares the Merlin instance for operation by waiting for essential services and directories to become available.
func (m *Merlin) PreRun() error {
// Wait NTP ready.
_ = m.Cleanup()
@@ -66,6 +79,7 @@ func (m *Merlin) PreRun() error {
return nil
}
// Setup initializes and configures the Merlin instance for use, including setting up dnsmasq and necessary nvram settings.
func (m *Merlin) Setup() error {
if m.cfg.FirstListener().IsDirectDnsListener() {
return nil
@@ -79,35 +93,10 @@ func (m *Merlin) Setup() error {
return err
}
// Copy current dnsmasq config to /jffs/configs/dnsmasq.conf,
// Then we will run postconf script on this file.
//
// Normally, adding postconf script is enough. However, we see
// reports on some Merlin devices that postconf scripts does not
// work, but manipulating the config directly via /jffs/configs does.
src, err := os.Open(dnsmasq.MerlinConfPath)
if err != nil {
return fmt.Errorf("failed to open dnsmasq config: %w", err)
}
defer src.Close()
dst, err := os.Create(dnsmasq.MerlinJffsConfPath)
if err != nil {
return fmt.Errorf("failed to create %s: %w", dnsmasq.MerlinJffsConfPath, err)
}
defer dst.Close()
if _, err := io.Copy(dst, src); err != nil {
return fmt.Errorf("failed to copy current dnsmasq config: %w", err)
}
if err := dst.Close(); err != nil {
return fmt.Errorf("failed to save %s: %w", dnsmasq.MerlinJffsConfPath, err)
}
// Run postconf script on /jffs/configs/dnsmasq.conf directly.
cmd := exec.Command("/bin/sh", dnsmasq.MerlinPostConfPath, dnsmasq.MerlinJffsConfPath)
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("failed to run post conf: %s: %w", string(out), err)
for _, cfg := range getDnsmasqConfigs() {
if err := m.setupDnsmasq(cfg); err != nil {
return fmt.Errorf("failed to setup dnsmasq: config: %s, error: %w", cfg.confPath, err)
}
}
// Restart dnsmasq service.
@@ -122,6 +111,7 @@ func (m *Merlin) Setup() error {
return nil
}
// Cleanup restores the original dnsmasq and nvram configurations and restarts dnsmasq if necessary.
func (m *Merlin) Cleanup() error {
if m.cfg.FirstListener().IsDirectDnsListener() {
return nil
@@ -143,9 +133,11 @@ func (m *Merlin) Cleanup() error {
if err := os.WriteFile(dnsmasq.MerlinPostConfPath, merlinParsePostConf(buf), 0750); err != nil {
return err
}
// Remove /jffs/configs/dnsmasq.conf file.
if err := os.Remove(dnsmasq.MerlinJffsConfPath); err != nil && !os.IsNotExist(err) {
return err
for _, cfg := range getDnsmasqConfigs() {
if err := m.cleanupDnsmasqJffs(cfg); err != nil {
return fmt.Errorf("failed to cleanup jffs dnsmasq: config: %s, error: %w", cfg.confPath, err)
}
}
// Restart dnsmasq service.
if err := restartDNSMasq(); err != nil {
@@ -154,6 +146,54 @@ func (m *Merlin) Cleanup() error {
return nil
}
// setupDnsmasq sets up dnsmasq configuration by writing postconf, copying configuration, and running a postconf script.
func (m *Merlin) setupDnsmasq(cfg *dnsmasqConfig) error {
src, err := os.Open(cfg.confPath)
if os.IsNotExist(err) {
return nil // nothing to do if conf file does not exist.
}
if err != nil {
return fmt.Errorf("failed to open dnsmasq config: %w", err)
}
defer src.Close()
// Copy current dnsmasq config to cfg.jffsConfPath,
// Then we will run postconf script on this file.
//
// Normally, adding postconf script is enough. However, we see
// reports on some Merlin devices that postconf scripts does not
// work, but manipulating the config directly via /jffs/configs does.
dst, err := os.Create(cfg.jffsConfPath)
if err != nil {
return fmt.Errorf("failed to create %s: %w", cfg.jffsConfPath, err)
}
defer dst.Close()
if _, err := io.Copy(dst, src); err != nil {
return fmt.Errorf("failed to copy current dnsmasq config: %w", err)
}
if err := dst.Close(); err != nil {
return fmt.Errorf("failed to save %s: %w", cfg.jffsConfPath, err)
}
// Run postconf script on cfg.jffsConfPath directly.
cmd := exec.Command("/bin/sh", dnsmasq.MerlinPostConfPath, cfg.jffsConfPath)
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("failed to run post conf: %s: %w", string(out), err)
}
return nil
}
// cleanupDnsmasqJffs removes the JFFS configuration file specified in the given dnsmasqConfig, if it exists.
func (m *Merlin) cleanupDnsmasqJffs(cfg *dnsmasqConfig) error {
// Remove cfg.jffsConfPath file.
if err := os.Remove(cfg.jffsConfPath); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
// writeDnsmasqPostconf writes the requireddnsmasqConfigs post-configuration for dnsmasq to enable custom DNS settings with ctrld.
func (m *Merlin) writeDnsmasqPostconf() error {
buf, err := os.ReadFile(dnsmasq.MerlinPostConfPath)
// Already setup.
@@ -179,6 +219,8 @@ func (m *Merlin) writeDnsmasqPostconf() error {
return os.WriteFile(dnsmasq.MerlinPostConfPath, []byte(data), 0750)
}
// restartDNSMasq restarts the dnsmasq service by executing the appropriate system command using "service".
// Returns an error if the command fails or if there is an issue processing the command output.
func restartDNSMasq() error {
if out, err := exec.Command("service", "restart_dnsmasq").CombinedOutput(); err != nil {
return fmt.Errorf("restart_dnsmasq: %s, %w", string(out), err)
@@ -186,6 +228,22 @@ func restartDNSMasq() error {
return nil
}
// getDnsmasqConfigs retrieves a list of dnsmasqConfig containing configuration and JFFS paths for dnsmasq operations.
func getDnsmasqConfigs() []*dnsmasqConfig {
cfgs := []*dnsmasqConfig{
{dnsmasq.MerlinConfPath, dnsmasq.MerlinJffsConfPath},
}
for _, path := range dnsmasq.AdditionalConfigFiles() {
jffsConfPath := filepath.Join(dnsmasq.MerlinJffsConfDir, filepath.Base(path))
cfgs = append(cfgs, &dnsmasqConfig{path, jffsConfPath})
}
return cfgs
}
// merlinParsePostConf parses the dnsmasq post configuration by removing content after the MerlinPostConfMarker, if present.
// If no marker is found, the original buffer is returned unmodified.
// Returns nil if the input buffer is empty.
func merlinParsePostConf(buf []byte) []byte {
if len(buf) == 0 {
return nil
@@ -197,6 +255,7 @@ func merlinParsePostConf(buf []byte) []byte {
return buf
}
// waitDirExists waits until the specified directory exists, polling its existence every second.
func waitDirExists(dir string) {
for {
if _, err := os.Stat(dir); !os.IsNotExist(err) {

View File

@@ -13,14 +13,13 @@ import (
"time"
"github.com/kardianos/service"
"github.com/Control-D-Inc/ctrld/internal/router/dnsmasq"
)
// This is a copy of https://github.com/kardianos/service/blob/v1.2.1/service_sysv_linux.go,
// with modification for supporting ubios v1 init system.
// Keep in sync with ubios.ubiosDNSMasqConfigPath
const ubiosDNSMasqConfigPath = "/run/dnsmasq.conf.d/zzzctrld.conf"
type ubiosSvc struct {
i service.Interface
platform string
@@ -86,7 +85,7 @@ func (s *ubiosSvc) Install() error {
}{
s.Config,
path,
ubiosDNSMasqConfigPath,
filepath.Join(dnsmasq.UbiosConfPath(), dnsmasq.UbiosConfName),
}
if err := s.template().Execute(f, to); err != nil {

View File

@@ -3,6 +3,7 @@ package ubios
import (
"bytes"
"os"
"path/filepath"
"strconv"
"github.com/kardianos/service"
@@ -12,19 +13,19 @@ import (
"github.com/Control-D-Inc/ctrld/internal/router/edgeos"
)
const (
Name = "ubios"
ubiosDNSMasqConfigPath = "/run/dnsmasq.conf.d/zzzctrld.conf"
ubiosDNSMasqDnsConfigPath = "/run/dnsmasq.conf.d/dns.conf"
)
const Name = "ubios"
type Ubios struct {
cfg *ctrld.Config
cfg *ctrld.Config
dnsmasqConfPath string
}
// New returns a router.Router for configuring/setup/run ctrld on Ubios routers.
func New(cfg *ctrld.Config) *Ubios {
return &Ubios{cfg: cfg}
return &Ubios{
cfg: cfg,
dnsmasqConfPath: filepath.Join(dnsmasq.UbiosConfPath(), dnsmasq.UbiosConfName),
}
}
func (u *Ubios) ConfigureService(config *service.Config) error {
@@ -59,7 +60,7 @@ func (u *Ubios) Setup() error {
if err != nil {
return err
}
if err := os.WriteFile(ubiosDNSMasqConfigPath, []byte(data), 0600); err != nil {
if err := os.WriteFile(u.dnsmasqConfPath, []byte(data), 0600); err != nil {
return err
}
// Restart dnsmasq service.
@@ -74,7 +75,7 @@ func (u *Ubios) Cleanup() error {
return nil
}
// Remove the custom dnsmasq config
if err := os.Remove(ubiosDNSMasqConfigPath); err != nil {
if err := os.Remove(u.dnsmasqConfPath); err != nil {
return err
}
// Restart dnsmasq service.
@@ -85,7 +86,7 @@ func (u *Ubios) Cleanup() error {
}
func restartDNSMasq() error {
buf, err := os.ReadFile("/run/dnsmasq.pid")
buf, err := os.ReadFile(dnsmasq.UbiosPidFile())
if err != nil {
return err
}

View File

@@ -10,7 +10,7 @@ import (
)
func dnsFns() []dnsFn {
return []dnsFn{dnsFromRIB}
return []dnsFn{dnsFromResolvConf, dnsFromRIB}
}
func dnsFromRIB() []string {

View File

@@ -16,58 +16,12 @@ import (
"time"
"tailscale.com/net/netmon"
"github.com/Control-D-Inc/ctrld/internal/resolvconffile"
)
func dnsFns() []dnsFn {
return []dnsFn{dnsFromResolvConf, getDNSFromScutil, getAllDHCPNameservers}
}
// dnsFromResolvConf reads nameservers from /etc/resolv.conf
func dnsFromResolvConf() []string {
const (
maxRetries = 10
retryInterval = 100 * time.Millisecond
)
regularIPs, loopbackIPs, _ := netmon.LocalAddresses()
var dns []string
for attempt := 0; attempt < maxRetries; attempt++ {
if attempt > 0 {
time.Sleep(retryInterval)
}
nss := resolvconffile.NameServers("")
var localDNS []string
seen := make(map[string]bool)
for _, ns := range nss {
if ip := net.ParseIP(ns); ip != nil {
// skip loopback IPs
for _, v := range slices.Concat(regularIPs, loopbackIPs) {
ipStr := v.String()
if ip.String() == ipStr {
continue
}
}
if !seen[ip.String()] {
seen[ip.String()] = true
localDNS = append(localDNS, ip.String())
}
}
}
// If we successfully read the file and found nameservers, return them
if len(localDNS) > 0 {
return localDNS
}
}
return dns
}
func getDNSFromScutil() []string {
logger := *ProxyLogger.Load()

View File

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

View File

@@ -2,8 +2,63 @@
package ctrld
import "github.com/Control-D-Inc/ctrld/internal/resolvconffile"
import (
"net"
"slices"
"time"
func nameserversFromResolvconf() []string {
return resolvconffile.NameServers("")
"tailscale.com/net/netmon"
"github.com/Control-D-Inc/ctrld/internal/resolvconffile"
)
// currentNameserversFromResolvconf returns the current nameservers set from /etc/resolv.conf file.
func currentNameserversFromResolvconf() []string {
return resolvconffile.NameServers()
}
// dnsFromResolvConf reads usable nameservers from /etc/resolv.conf file.
// A nameserver is usable if it's not one of current machine's IP addresses
// and loopback IP addresses.
func dnsFromResolvConf() []string {
const (
maxRetries = 10
retryInterval = 100 * time.Millisecond
)
regularIPs, loopbackIPs, _ := netmon.LocalAddresses()
var dns []string
for attempt := 0; attempt < maxRetries; attempt++ {
if attempt > 0 {
time.Sleep(retryInterval)
}
nss := resolvconffile.NameServers()
var localDNS []string
seen := make(map[string]bool)
for _, ns := range nss {
if ip := net.ParseIP(ns); ip != nil {
// skip loopback IPs
for _, v := range slices.Concat(regularIPs, loopbackIPs) {
ipStr := v.String()
if ip.String() == ipStr {
continue
}
}
if !seen[ip.String()] {
seen[ip.String()] = true
localDNS = append(localDNS, ip.String())
}
}
}
// If we successfully read the file and found nameservers, return them
if len(localDNS) > 0 {
return localDNS
}
}
return dns
}

View File

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

35
net_darwin.go Normal file
View File

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

View File

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

15
net_others.go Normal file
View File

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

View File

@@ -9,12 +9,14 @@ import (
"net/netip"
"runtime"
"slices"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/miekg/dns"
"github.com/rs/zerolog"
"golang.org/x/sync/singleflight"
"tailscale.com/net/netmon"
"tailscale.com/net/tsaddr"
)
@@ -60,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() {
@@ -216,6 +239,8 @@ func NewResolver(uc *UpstreamConfig) (Resolver, error) {
type osResolver struct {
lanServers atomic.Pointer[[]string]
publicServers atomic.Pointer[[]string]
group *singleflight.Group
cache *sync.Map
}
type osResolverResult struct {
@@ -273,10 +298,75 @@ func customDNSExchange(ctx context.Context, msg *dns.Msg, server string, desired
return dnsClient.ExchangeContext(ctx, msg, server)
}
const hotCacheTTL = time.Second
// Resolve resolves DNS queries using pre-configured nameservers.
// Query is sent to all nameservers concurrently, and the first
// The Query is sent to all nameservers concurrently, and the first
// success response will be returned.
//
// To guard against unexpected DoS to upstreams, multiple queries of
// the same Qtype to a domain will be shared, so there's only 1 qps
// for each upstream at any time.
//
// Further, a hot cache will be used, so repeated queries will be cached
// for a short period (currently 1 second), reducing unnecessary traffics
// sent to upstreams.
func (o *osResolver) Resolve(ctx context.Context, msg *dns.Msg) (*dns.Msg, error) {
if len(msg.Question) == 0 {
return nil, errors.New("no question found")
}
domain := strings.TrimSuffix(msg.Question[0].Name, ".")
qtype := msg.Question[0].Qtype
// Unique key for the singleflight group.
key := fmt.Sprintf("%s:%d:", domain, qtype)
// Checking the cache first.
if val, ok := o.cache.Load(key); ok {
if val, ok := val.(*dns.Msg); ok {
Log(ctx, ProxyLogger.Load().Debug(), "hit hot cached result: %s - %s", domain, dns.TypeToString[qtype])
res := val.Copy()
SetCacheReply(res, msg, val.Rcode)
return res, nil
}
}
// Ensure only one DNS query is in flight for the key.
v, err, shared := o.group.Do(key, func() (interface{}, error) {
msg, err := o.resolve(ctx, msg)
if err != nil {
return nil, err
}
// If we got an answer, storing it to the hot cache for hotCacheTTL
// This prevents possible DoS to upstream, ensuring there's only 1 QPS.
o.cache.Store(key, msg)
// Depends on go runtime scheduling, the result may end up in hot cache longer
// than hotCacheTTL duration. However, this is fine since we only want to guard
// against DoS attack. The result will be cleaned from the cache eventually.
time.AfterFunc(hotCacheTTL, func() {
o.removeCache(key)
})
return msg, nil
})
if err != nil {
return nil, err
}
sharedMsg, ok := v.(*dns.Msg)
if !ok {
return nil, fmt.Errorf("invalid answer for key: %s", key)
}
res := sharedMsg.Copy()
SetCacheReply(res, msg, sharedMsg.Rcode)
if shared {
Log(ctx, ProxyLogger.Load().Debug(), "shared result: %s - %s", domain, dns.TypeToString[qtype])
}
return res, nil
}
// resolve sends the query to current nameservers.
func (o *osResolver) resolve(ctx context.Context, msg *dns.Msg) (*dns.Msg, error) {
publicServers := *o.publicServers.Load()
var nss []string
if p := o.lanServers.Load(); p != nil {
@@ -431,6 +521,10 @@ func (o *osResolver) Resolve(ctx context.Context, msg *dns.Msg) (*dns.Msg, error
return nil, errors.Join(errs...)
}
func (o *osResolver) removeCache(key string) {
o.cache.Delete(key)
}
type legacyResolver struct {
uc *UpstreamConfig
}
@@ -466,27 +560,41 @@ func (d dummyResolver) Resolve(ctx context.Context, msg *dns.Msg) (*dns.Msg, err
return ans, nil
}
// LookupIP looks up host using OS resolver.
// LookupIP looks up domain using current system nameservers settings.
// It returns a slice of that host's IPv4 and IPv6 addresses.
func LookupIP(domain string) []string {
return lookupIP(domain, -1)
nss := initDefaultOsResolver()
return lookupIP(domain, -1, nss)
}
func lookupIP(domain string, timeout int) (ips []string) {
if net.ParseIP(domain) != nil {
return []string{domain}
}
// initDefaultOsResolver initializes the default OS resolver with system's default nameservers if it hasn't been initialized yet.
// It returns the combined list of LAN and public nameservers currently held by the resolver.
func initDefaultOsResolver() []string {
resolverMutex.Lock()
defer resolverMutex.Unlock()
if or == nil {
ProxyLogger.Load().Debug().Msgf("Initialize OS resolver in lookupIP")
ProxyLogger.Load().Debug().Msgf("Initialize new OS resolver with default nameservers")
or = newResolverWithNameserver(defaultNameservers())
}
nss := *or.lanServers.Load()
nss = append(nss, *or.publicServers.Load()...)
resolverMutex.Unlock()
return nss
}
resolver := newResolverWithNameserver(nss)
ProxyLogger.Load().Debug().Msgf("resolving %q using bootstrap DNS %q", domain, nss)
// lookupIP looks up domain with given timeout and bootstrapDNS.
// If the timeout is negative, default timeout 2000 ms will be used.
// It returns nil if bootstrapDNS is nil or empty.
func lookupIP(domain string, timeout int, bootstrapDNS []string) (ips []string) {
if net.ParseIP(domain) != nil {
return []string{domain}
}
if bootstrapDNS == nil {
ProxyLogger.Load().Debug().Msgf("empty bootstrap DNS")
return nil
}
resolver := newResolverWithNameserver(bootstrapDNS)
ProxyLogger.Load().Debug().Msgf("resolving %q using bootstrap DNS %q", domain, bootstrapDNS)
timeoutMs := 2000
if timeout > 0 && timeout < timeoutMs {
timeoutMs = timeout
@@ -578,14 +686,8 @@ func NewBootstrapResolver(servers ...string) Resolver {
//
// This is useful for doing PTR lookup in LAN network.
func NewPrivateResolver() Resolver {
resolverMutex.Lock()
if or == nil {
ProxyLogger.Load().Debug().Msgf("Initialize new OS resolver in NewPrivateResolver")
or = newResolverWithNameserver(defaultNameservers())
}
nss := *or.lanServers.Load()
resolverMutex.Unlock()
resolveConfNss := nameserversFromResolvconf()
nss := initDefaultOsResolver()
resolveConfNss := currentNameserversFromResolvconf()
localRfc1918Addrs := Rfc1918Addresses()
n := 0
for _, ns := range nss {
@@ -628,10 +730,10 @@ func NewResolverWithNameserver(nameservers []string) Resolver {
// newResolverWithNameserver returns an OS resolver from given nameservers list.
// The caller must ensure each server in list is formed "ip:53".
func newResolverWithNameserver(nameservers []string) *osResolver {
logger := *ProxyLogger.Load()
Log(context.Background(), logger.Debug(), "newResolverWithNameserver called with nameservers: %v", nameservers)
r := &osResolver{}
r := &osResolver{
group: &singleflight.Group{},
cache: &sync.Map{},
}
var publicNss []string
var lanNss []string
for _, ns := range slices.Sorted(slices.Values(nameservers)) {
@@ -648,10 +750,15 @@ func newResolverWithNameserver(nameservers []string) *osResolver {
return r
}
// Rfc1918Addresses returns the list of local interfaces private IP addresses
// Rfc1918Addresses returns the list of local physical interfaces private IP addresses
func Rfc1918Addresses() []string {
vis := validInterfaces()
var res []string
netmon.ForeachInterface(func(i netmon.Interface, prefixes []netip.Prefix) {
// Skip virtual interfaces.
if _, existed := vis[i.Name]; !existed {
return
}
addrs, _ := i.Addrs()
for _, addr := range addrs {
ipNet, ok := addr.(*net.IPNet)

View File

@@ -2,8 +2,11 @@ package ctrld
import (
"context"
"crypto/rand"
"encoding/hex"
"net"
"sync"
"sync/atomic"
"testing"
"time"
@@ -16,8 +19,7 @@ func Test_osResolver_Resolve(t *testing.T) {
go func() {
defer cancel()
resolver := &osResolver{}
resolver.publicServers.Store(&[]string{"127.0.0.127:5353"})
resolver := newResolverWithNameserver([]string{"127.0.0.127:5353"})
m := new(dns.Msg)
m.SetQuestion("controld.com.", dns.TypeA)
m.RecursionDesired = true
@@ -50,8 +52,7 @@ func Test_osResolver_ResolveLanHostname(t *testing.T) {
t.Error("not a LAN query")
return
}
resolver := &osResolver{}
resolver.publicServers.Store(&[]string{"76.76.2.0:53"})
resolver := newResolverWithNameserver([]string{"76.76.2.0:53"})
m := new(dns.Msg)
m.SetQuestion("controld.com.", dns.TypeA)
m.RecursionDesired = true
@@ -107,11 +108,9 @@ func Test_osResolver_ResolveWithNonSuccessAnswer(t *testing.T) {
}()
// We now create an osResolver which has both a LAN and public nameserver.
resolver := &osResolver{}
// Explicitly store the LAN nameserver.
resolver.lanServers.Store(&[]string{lanAddr})
// And store the public nameservers.
resolver.publicServers.Store(&publicNS)
nss := []string{lanAddr}
nss = append(nss, publicNS...)
resolver := newResolverWithNameserver(nss)
msg := new(dns.Msg)
msg.SetQuestion(".", dns.TypeNS)
@@ -139,6 +138,179 @@ func Test_osResolver_InitializationRace(t *testing.T) {
wg.Wait()
}
func Test_osResolver_Singleflight(t *testing.T) {
lanPC, err := net.ListenPacket("udp", "127.0.0.1:0")
if err != nil {
t.Fatalf("failed to listen on LAN address: %v", err)
}
call := &atomic.Int64{}
lanServer, lanAddr, err := runLocalPacketConnTestServer(t, lanPC, countHandler(call))
if err != nil {
t.Fatalf("failed to run LAN test server: %v", err)
}
defer lanServer.Shutdown()
or := newResolverWithNameserver([]string{lanAddr})
domain := "controld.com"
n := 10
var wg sync.WaitGroup
wg.Add(n)
for i := 0; i < n; i++ {
go func() {
defer wg.Done()
m := new(dns.Msg)
m.SetQuestion(dns.Fqdn(domain), dns.TypeA)
m.RecursionDesired = true
_, err := or.Resolve(context.Background(), m)
if err != nil {
t.Error(err)
}
}()
}
wg.Wait()
// All above queries should only make 1 call to server.
if call.Load() != 1 {
t.Fatalf("expected 1 result from singleflight lookup, got %d", call)
}
}
func Test_osResolver_HotCache(t *testing.T) {
lanPC, err := net.ListenPacket("udp", "127.0.0.1:0")
if err != nil {
t.Fatalf("failed to listen on LAN address: %v", err)
}
call := &atomic.Int64{}
lanServer, lanAddr, err := runLocalPacketConnTestServer(t, lanPC, countHandler(call))
if err != nil {
t.Fatalf("failed to run LAN test server: %v", err)
}
defer lanServer.Shutdown()
or := newResolverWithNameserver([]string{lanAddr})
domain := "controld.com"
m := new(dns.Msg)
m.SetQuestion(dns.Fqdn(domain), dns.TypeA)
m.RecursionDesired = true
// Make 2 repeated queries to server, should hit hot cache.
for i := 0; i < 2; i++ {
if _, err := or.Resolve(context.Background(), m.Copy()); err != nil {
t.Fatal(err)
}
}
if call.Load() != 1 {
t.Fatalf("cache not hit, server was called: %d", call.Load())
}
timeoutChan := make(chan struct{})
time.AfterFunc(5*time.Second, func() {
close(timeoutChan)
})
for {
select {
case <-timeoutChan:
t.Fatal("timed out waiting for cache cleaned")
default:
count := 0
or.cache.Range(func(key, value interface{}) bool {
count++
return true
})
if count != 0 {
t.Logf("hot cache is not empty: %d elements", count)
continue
}
}
break
}
if _, err := or.Resolve(context.Background(), m.Copy()); err != nil {
t.Fatal(err)
}
if call.Load() != 2 {
t.Fatal("cache hit unexpectedly")
}
}
func Test_Edns0_CacheReply(t *testing.T) {
lanPC, err := net.ListenPacket("udp", "127.0.0.1:0")
if err != nil {
t.Fatalf("failed to listen on LAN address: %v", err)
}
call := &atomic.Int64{}
lanServer, lanAddr, err := runLocalPacketConnTestServer(t, lanPC, countHandler(call))
if err != nil {
t.Fatalf("failed to run LAN test server: %v", err)
}
defer lanServer.Shutdown()
or := newResolverWithNameserver([]string{lanAddr})
domain := "controld.com"
m := new(dns.Msg)
m.SetQuestion(dns.Fqdn(domain), dns.TypeA)
m.RecursionDesired = true
do := func() *dns.Msg {
msg := m.Copy()
msg.SetEdns0(4096, true)
cookieOption := new(dns.EDNS0_COOKIE)
cookieOption.Code = dns.EDNS0COOKIE
cookieOption.Cookie = generateEdns0ClientCookie()
msg.IsEdns0().Option = append(msg.IsEdns0().Option, cookieOption)
answer, err := or.Resolve(context.Background(), msg)
if err != nil {
t.Fatal(err)
}
return answer
}
answer1 := do()
answer2 := do()
// Ensure the cache was hit, so we can check that edns0 cookie must be modified.
if call.Load() != 1 {
t.Fatalf("cache not hit, server was called: %d", call.Load())
}
cookie1 := getEdns0Cookie(answer1.IsEdns0())
cookie2 := getEdns0Cookie(answer2.IsEdns0())
if cookie1 == nil || cookie2 == nil {
t.Fatalf("unexpected nil cookie value (cookie1: %v, cookie2: %v)", cookie1, cookie2)
}
if cookie1.Cookie == cookie2.Cookie {
t.Fatalf("edns0 cookie is not modified: %v", cookie1)
}
}
// 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
@@ -208,3 +380,99 @@ func nonSuccessHandlerWithRcode(rcode int) dns.HandlerFunc {
w.WriteMsg(m)
}
}
func countHandler(call *atomic.Int64) dns.HandlerFunc {
return func(w dns.ResponseWriter, msg *dns.Msg) {
m := new(dns.Msg)
m.SetRcode(msg, dns.RcodeSuccess)
if cookie := getEdns0Cookie(msg.IsEdns0()); cookie != nil {
if m.IsEdns0() == nil {
m.SetEdns0(4096, false)
}
cookieOption := new(dns.EDNS0_COOKIE)
cookieOption.Code = dns.EDNS0COOKIE
cookieOption.Cookie = generateEdns0ServerCookie(cookie.Cookie)
m.IsEdns0().Option = append(m.IsEdns0().Option, cookieOption)
}
w.WriteMsg(m)
call.Add(1)
}
}
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 {
panic(err)
}
return hex.EncodeToString(cookie)
}
func generateEdns0ServerCookie(clientCookie string) string {
cookie := make([]byte, 32)
if _, err := rand.Read(cookie); err != nil {
panic(err)
}
return clientCookie + hex.EncodeToString(cookie)
}