diff --git a/README.md b/README.md index 66e70c3..5b048ca 100644 --- a/README.md +++ b/README.md @@ -4,12 +4,12 @@ [![Go Reference](https://pkg.go.dev/badge/github.com/Control-D-Inc/ctrld.svg)](https://pkg.go.dev/github.com/Control-D-Inc/ctrld) [![Go Report Card](https://goreportcard.com/badge/github.com/Control-D-Inc/ctrld)](https://goreportcard.com/report/github.com/Control-D-Inc/ctrld) -![ctrld spash image](/docs/ctrldsplash.png) +![ctrld splash image](/docs/ctrldsplash.png) A highly configurable DNS forwarding proxy with support for: - Multiple listeners for incoming queries - Multiple upstreams with fallbacks -- Multiple network policy driven DNS query steering +- Multiple network policy driven DNS query steering (via network cidr, MAC address or FQDN) - Policy driven domain based "split horizon" DNS with wildcard support - Integrations with common router vendors and firmware - LAN client discovery via DHCP, mDNS, ARP, NDP, hosts file parsing @@ -35,13 +35,29 @@ All DNS protocols are supported, including: ## OS Support - Windows (386, amd64, arm) -- Mac (amd64, arm64) +- Windows Server (386, amd64) +- MacOS (amd64, arm64) - Linux (386, amd64, arm, mips) -- FreeBSD -- Common routers (See Router Mode below) +- FreeBSD (386, amd64, arm) +- Common routers (See below) + + +### Supported Routers +You can run `ctrld` on any supported router. The list of supported routers and firmware includes: +- Asus Merlin +- DD-WRT +- Firewalla +- FreshTomato +- GL.iNet +- OpenWRT +- pfSense / OPNsense +- Synology +- Ubiquiti (UniFi, EdgeOS) + +`ctrld` will attempt to interface with dnsmasq (or Windows Server) whenever possible and set itself as the upstream, while running on port 5354. On FreeBSD based OSes, `ctrld` will terminate dnsmasq and unbound in order to be able to listen on port 53 directly. # Install -There are several ways to download and install `ctrld. +There are several ways to download and install `ctrld`. ## Quick Install The simplest way to download and install `ctrld` is to use the following installer command on any UNIX-like platform: @@ -50,14 +66,14 @@ The simplest way to download and install `ctrld` is to use the following install sh -c 'sh -c "$(curl -sL https://api.controld.com/dl)"' ``` -Windows user and prefer Powershell (who doesn't)? No problem, execute this command instead in administrative cmd: +Windows user and prefer Powershell (who doesn't)? No problem, execute this command instead in administrative PowerShell: ```shell -powershell -Command "(Invoke-WebRequest -Uri 'https://api.controld.com/dl' -UseBasicParsing).Content | Set-Content 'ctrld_install.bat'" && ctrld_install.bat +(Invoke-WebRequest -Uri 'https://api.controld.com/dl/ps1' -UseBasicParsing).Content | Set-Content "$env:TEMPctrld_install.ps1"; Invoke-Expression "& '$env:TEMPctrld_install.ps1'" ``` Or you can pull and run a Docker container from [Docker Hub](https://hub.docker.com/r/controldns/ctrld) -``` -$ docker pull controldns/ctrld +```shell +docker run -d --name=ctrld -p 127.0.0.1:53:53/tcp -p 127.0.0.1:53:53/udp controldns/ctrld:latest ``` ## Download Manually @@ -67,20 +83,19 @@ Alternatively, if you know what you're doing you can download pre-compiled binar Lastly, you can build `ctrld` from source which requires `go1.21+`: ```shell -$ go build ./cmd/ctrld +go build ./cmd/ctrld ``` or ```shell -$ go install github.com/Control-D-Inc/ctrld/cmd/ctrld@latest +go install github.com/Control-D-Inc/ctrld/cmd/ctrld@latest ``` or -``` -$ docker build -t controldns/ctrld . -f docker/Dockerfile -$ docker run -d --name=ctrld -p 53:53/tcp -p 53:53/udp controldns/ctrld --cd=RESOLVER_ID_GOES_HERE -vv +```shell +docker build -t controldns/ctrld . -f docker/Dockerfile ``` @@ -101,15 +116,16 @@ Usage: Available Commands: run Run the DNS proxy server - service Manage ctrld service start Quick start service and configure DNS on interface stop Quick stop service and remove DNS from interface restart Restart the ctrld service reload Reload the ctrld service status Show status of the ctrld service uninstall Stop and uninstall the ctrld service + service Manage ctrld service clients Manage clients upgrade Upgrading ctrld to latest version + log Manage runtime debug logs Flags: -h, --help help for ctrld @@ -121,81 +137,99 @@ Use "ctrld [command] --help" for more information about a command. ``` ## Basic Run Mode -To start the server with default configuration, simply run: `./ctrld run`. This will create a generic `ctrld.toml` file in the **working directory** and start the application in foreground. -1. Start the server - ``` - $ sudo ./ctrld run +This is the most basic way to run `ctrld`, in foreground mode. Unless you already have a config file, a default one will be generated. + +### Command + +Windows (Admin Shell) + ```shell + ctrld.exe run ``` -2. Run a test query using a DNS client, for example, `dig`: +Linux or Macos + ```shell + sudo ctrld run + ``` + +You can then run a test query using a DNS client, for example, `dig`: ``` $ dig verify.controld.com @127.0.0.1 +short api.controld.com. 147.185.34.1 ``` -If `verify.controld.com` resolves, you're successfully using the default Control D upstream. From here, you can start editing the config file and go nuts with it. To enforce a new config, restart the server. +If `verify.controld.com` resolves, you're successfully using the default Control D upstream. From here, you can start editing the config file that was generated. To enforce a new config, restart the server. ## Service Mode -To run the application in service mode on any Windows, MacOS, Linux distibution or supported router, simply run: `./ctrld start` as system/root user. This will create a generic `ctrld.toml` file in the **user home** directory (on Windows) or `/etc/controld/` (almost everywhere else), start the system service, and configure the listener on the default network interface. Service will start on OS boot. +This mode will run the application as a background system service on any Windows, MacOS, Linux, FreeBSD distribution or supported router. This will create a generic `ctrld.toml` file in the **C:\ControlD** directory (on Windows) or `/etc/controld/` (almost everywhere else), start the system service, and **configure the listener on all physical network interface**. Service will start on OS boot. -When Control D upstreams are used, `ctrld` willl [relay your network topology](https://docs.controld.com/docs/device-clients) to Control D (LAN IPs, MAC addresses, and hostnames), and you will be able to see your LAN devices in the web panel, view analytics and apply unique profiles to them. +When Control D upstreams are used on a router type device, `ctrld` will [relay your network topology](https://docs.controld.com/docs/device-clients) to Control D (LAN IPs, MAC addresses, and hostnames), and you will be able to see your LAN devices in the web panel, view analytics and apply unique profiles to them. -In order to stop the service, and restore your DNS to original state, simply run `./ctrld stop`. If you wish to stop and uninstall the service permanently, run `./ctrld uninstall`. +### Command +Windows (Admin Shell) + ```shell + ctrld.exe start + ``` -### Supported Routers -You can run `ctrld` on any supported router, which will function similarly to the Service Mode mentioned above. The list of supported routers and firmware includes: -- Asus Merlin -- DD-WRT -- Firewalla -- FreshTomato -- GL.iNet -- OpenWRT -- pfSense / OPNsense -- Synology -- Ubiquiti (UniFi, EdgeOS) +Linux or Macos + ``` + sudo ctrld start + ``` -`ctrld` will attempt to interface with dnsmasq whenever possible and set itself as the upstream, while running on port 5354. On FreeBSD based OSes, `ctrld` will terminate dnsmasq and unbound in order to be able to listen on port 53 directly. +If `ctrld` is not in your system path (you installed it manually), you will need to run the above commands from the directory where you installed `ctrld`. +In order to stop the service, and restore your DNS to original state, simply run `ctrld stop`. If you wish to stop and uninstall the service permanently, run `ctrld uninstall`. -### Control D Auto Configuration -Application can be started with a specific resolver config, instead of the default one. Simply supply your Resolver ID with a `--cd` flag, when using the `run` (foreground) or `start` (service) modes. +## Unmanaged Service Mode +This mode functions similarly to the "Service Mode" above except it will simply start a system service and the config defined listeners, but **will not make any changes to any network interfaces**. You can then set the `ctrld` listener(s) IP on the desired network interfaces manually. -The following command will start the application in foreground mode, using the free "p2" resolver, which blocks Ads & Trackers. +### Command -```shell -./ctrld run --cd p2 -``` +Windows (Admin Shell) + ```shell + ctrld.exe service start + ``` -Alternatively, you can use your own personal Control D Device resolver, and start the application in service mode. Your resolver ID is displayed on the "Show Resolvers" screen for the relevant Control D Device. - -```shell -./ctrld start --cd abcd1234 -``` - -Once you run the above commands (in service mode only), the following things will happen: -- You resolver configuration will be fetched from the API, and config file templated with the resolver data -- Application will start as a service, and keep running (even after reboot) until you run the `stop` or `uninstall` sub-commands -- Your default network interface will be updated to use the listener started by the service -- All OS DNS queries will be sent to the listener +Linux or Macos + ```shell + sudo ctrld service start + ``` # Configuration -See [Configuration Docs](docs/config.md). +`ctrld` can be configured in variety of different ways, which include: API, local config file or via cli launch args. -## Example -- Start `listener.0` on 127.0.0.1:53 -- Accept queries from any source address -- Send all queries to `upstream.0` via DoH protocol +## API Based Auto Configuration +Application can be started with a specific Control D resolver config, instead of the default one. Simply supply your Resolver ID with a `--cd` flag, when using the `start` (service) mode. In this mode, the application will automatically choose a non-conflicting IP and/or port and configure itself as the upstream to whatever process is running on port 53 (like dnsmasq or Windows DNS Server). This mode is used when the 1 liner installer command from the Control D onboarding guide is executed. -### Default Config +The following command will use your own personal Control D Device resolver, and start the application in service mode. Your resolver ID is displayed on the "Show Resolvers" screen for the relevant Control D Endpoint. + +Windows (Admin Shell) +```shell +ctrld.exe start --cd abcd1234 +``` + +Linux or Macos +```shell +sudo ctrld start --cd abcd1234 +``` + +Once you run the above command, the following things will happen: +- You resolver configuration will be fetched from the API, and config file templated with the resolver data +- Application will start as a service, and keep running (even after reboot) until you run the `stop` or `uninstall` sub-commands +- All physical network interface will be updated to use the listener started by the service or dnsmasq upstream will be switched to `ctrld` +- All DNS queries will be sent to the listener + +## Manual Configuration +`ctrld` is entirely config driven and can be configured in many different ways, please see [Configuration Docs](docs/config.md). + +### Example ```toml [listener] [listener.0] - ip = "" - port = 0 - restricted = false + ip = '0.0.0.0' + port = 53 [network] @@ -203,10 +237,6 @@ See [Configuration Docs](docs/config.md). cidrs = ["0.0.0.0/0"] name = "Network 0" -[service] - log_level = "info" - log_path = "" - [upstream] [upstream.0] @@ -215,28 +245,26 @@ See [Configuration Docs](docs/config.md). name = "Control D - Anti-Malware" timeout = 5000 type = "doh" - - [upstream.1] - bootstrap_ip = "76.76.2.11" - endpoint = "p2.freedns.controld.com" - name = "Control D - No Ads" - timeout = 3000 - type = "doq" - ``` -`ctrld` will pick a working config for `listener.0` then writing the default config to disk for the first run. +The above basic config will: +- Start listener on 0.0.0.0:53 +- Accept queries from any source address +- Send all queries to `https://freedns.controld.com/p1` using DoH protocol -## Advanced Configuration -The above is the most basic example, which will work out of the box. If you're looking to do advanced configurations using policies, see [Configuration Docs](docs/config.md) for complete documentation of the config file. +## CLI Args +If you're unable to use a config file, `ctrld` can be be supplied with basic configuration via launch arguments, in [Ephemeral Mode](docs/ephemeral_mode.md). -You can also supply configuration via launch argeuments, in [Ephemeral Mode](docs/ephemeral_mode.md). +### Example +``` +ctrld run --listen=127.0.0.1:53 --primary_upstream=https://freedns.controld.com/p2 --secondary_upstream=10.0.10.1:53 --domains=*.company.int,very-secure.local --log /path/to/log.log +``` + +The above will start a foreground process and: +- Listen on `127.0.0.1:53` for DNS queries +- Forward all queries to `https://freedns.controld.com/p2` using DoH protocol, while... +- Excluding `*.company.int` and `very-secure.local` matching queries, that are forwarded to `10.0.10.1:53` +- Write a debug log to `/path/to/log.log` ## Contributing See [Contribution Guideline](./docs/contributing.md) - -## Roadmap -The following functionality is on the roadmap and will be available in future releases. -- DNS intercept mode -- Direct listener mode -- Support for more routers (let us know which ones) diff --git a/client_info_darwin.go b/client_info_darwin.go new file mode 100644 index 0000000..4c3d10b --- /dev/null +++ b/client_info_darwin.go @@ -0,0 +1,4 @@ +package ctrld + +// SelfDiscover reports whether ctrld should only do self discover. +func SelfDiscover() bool { return true } diff --git a/client_info_others.go b/client_info_others.go new file mode 100644 index 0000000..d728913 --- /dev/null +++ b/client_info_others.go @@ -0,0 +1,6 @@ +//go:build !windows && !darwin + +package ctrld + +// SelfDiscover reports whether ctrld should only do self discover. +func SelfDiscover() bool { return false } diff --git a/client_info_windows.go b/client_info_windows.go new file mode 100644 index 0000000..f20bca7 --- /dev/null +++ b/client_info_windows.go @@ -0,0 +1,18 @@ +package ctrld + +import ( + "golang.org/x/sys/windows" +) + +// isWindowsWorkStation reports whether ctrld was run on a Windows workstation machine. +func isWindowsWorkStation() bool { + // From https://learn.microsoft.com/en-us/windows/win32/api/winnt/ns-winnt-osversioninfoexa + const VER_NT_WORKSTATION = 0x0000001 + osvi := windows.RtlGetVersion() + return osvi.ProductType == VER_NT_WORKSTATION +} + +// SelfDiscover reports whether ctrld should only do self discover. +func SelfDiscover() bool { + return isWindowsWorkStation() +} diff --git a/cmd/cli/cli.go b/cmd/cli/cli.go index eb3b910..ecdd113 100644 --- a/cmd/cli/cli.go +++ b/cmd/cli/cli.go @@ -25,7 +25,7 @@ import ( "sync/atomic" "time" - "github.com/Masterminds/semver" + "github.com/Masterminds/semver/v3" "github.com/cuonglm/osinfo" "github.com/go-playground/validator/v10" "github.com/kardianos/service" @@ -97,6 +97,9 @@ func curVersion() string { if version != "dev" && !strings.HasPrefix(version, "v") { version = "v" + version } + if version != "" && version != "dev" { + return version + } if len(commit) > 7 { commit = commit[:7] } @@ -199,6 +202,7 @@ func run(appCallback *AppCallback, stopCh chan struct{}) { p := &prog{ waitCh: waitCh, stopCh: stopCh, + pinCodeValidCh: make(chan struct{}, 1), reloadCh: make(chan struct{}), reloadDoneCh: make(chan struct{}), dnsWatcherStopCh: make(chan struct{}), @@ -421,19 +425,28 @@ func run(appCallback *AppCallback, stopCh chan struct{}) { if err := p.router.Cleanup(); err != nil { mainLog.Load().Error().Err(err).Msg("could not cleanup router") } - // restore static DNS settings or DHCP - p.resetDNS(false, true) }) } } + p.onStopped = append(p.onStopped, func() { + // restore static DNS settings or DHCP + p.resetDNS(false, true) + // Iterate over all physical interfaces and restore static DNS if a saved static config exists. + withEachPhysicalInterfaces("", "restore static DNS", func(i *net.Interface) error { + file := savedStaticDnsSettingsFilePath(i) + if _, err := os.Stat(file); err == nil { + if err := restoreDNS(i); err != nil { + mainLog.Load().Error().Err(err).Msgf("Could not restore static DNS on interface %s", i.Name) + } else { + mainLog.Load().Debug().Msgf("Restored static DNS on interface %s successfully", i.Name) + } + } + return nil + }) + }) close(waitCh) <-stopCh - - p.stopDnsWatchers() - for _, f := range p.onStopped { - f() - } } func writeConfigFile(cfg *ctrld.Config) error { @@ -609,9 +622,9 @@ func init() { cdDeactivationPin.Store(defaultDeactivationPin) } -// deactivationPinNotSet reports whether cdDeactivationPin was not set by processCDFlags. -func deactivationPinNotSet() bool { - return cdDeactivationPin.Load() == defaultDeactivationPin +// deactivationPinSet indicates if cdDeactivationPin is non-default.. +func deactivationPinSet() bool { + return cdDeactivationPin.Load() != defaultDeactivationPin } func processCDFlags(cfg *ctrld.Config) (*controld.ResolverConfig, error) { @@ -1061,7 +1074,7 @@ func uninstall(p *prog, s service.Service) { p.resetDNS(false, true) // Iterate over all physical interfaces and restore DNS if a saved static config exists. - withEachPhysicalInterfaces("", "restore static DNS", func(i *net.Interface) error { + withEachPhysicalInterfaces(p.runningIface, "restore static DNS", func(i *net.Interface) error { file := savedStaticDnsSettingsFilePath(i) if _, err := os.Stat(file); err == nil { if err := restoreDNS(i); err != nil { diff --git a/cmd/cli/commands.go b/cmd/cli/commands.go index 6c0c202..048212a 100644 --- a/cmd/cli/commands.go +++ b/cmd/cli/commands.go @@ -234,6 +234,7 @@ NOTE: running "ctrld start" without any arguments will start already installed c mainLog.Load().Error().Msg(err.Error()) return } + p.preRun() status, err := s.Status() isCtrldRunning := status == service.StatusRunning @@ -628,23 +629,6 @@ func initStopCmd() *cobra.Command { os.Exit(deactivationPinInvalidExitCode) } if doTasks([]task{{s.Stop, true, "Stop"}}) { - p.router.Cleanup() - // restore static DNS settings or DHCP - p.resetDNS(false, true) - - // Iterate over all physical interfaces and restore static DNS if a saved static config exists. - withEachPhysicalInterfaces("", "restore static DNS", func(i *net.Interface) error { - file := savedStaticDnsSettingsFilePath(i) - if _, err := os.Stat(file); err == nil { - if err := restoreDNS(i); err != nil { - mainLog.Load().Error().Err(err).Msgf("Could not restore static DNS on interface %s", i.Name) - } else { - mainLog.Load().Debug().Msgf("Restored static DNS on interface %s successfully", i.Name) - } - } - return nil - }) - if router.WaitProcessExited() { ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) defer cancel() @@ -1277,8 +1261,9 @@ func initUpgradeCmd() *cobra.Command { dlUrl := upgradeUrl(baseUrl) mainLog.Load().Debug().Msgf("Downloading binary: %s", dlUrl) - resp, err := getWithRetry(dlUrl) + resp, err := getWithRetry(dlUrl, downloadServerIp) if err != nil { + mainLog.Load().Fatal().Err(err).Msg("failed to download binary") } defer resp.Body.Close() diff --git a/cmd/cli/control_server.go b/cmd/cli/control_server.go index 17f585d..9281b90 100644 --- a/cmd/cli/control_server.go +++ b/cmd/cli/control_server.go @@ -228,7 +228,7 @@ func (p *prog) registerControlServerHandler() { } // If pin code not set, allowing deactivation. - if deactivationPinNotSet() { + if !deactivationPinSet() { w.WriteHeader(http.StatusOK) return } @@ -244,6 +244,10 @@ func (p *prog) registerControlServerHandler() { switch req.Pin { case cdDeactivationPin.Load(): code = http.StatusOK + select { + case p.pinCodeValidCh <- struct{}{}: + default: + } case defaultDeactivationPin: // If the pin code was set, but users do not provide --pin, return proper code to client. code = http.StatusBadRequest diff --git a/cmd/cli/dns_proxy.go b/cmd/cli/dns_proxy.go index e3dbc26..6a214e5 100644 --- a/cmd/cli/dns_proxy.go +++ b/cmd/cli/dns_proxy.go @@ -519,13 +519,8 @@ func (p *prog) proxy(ctx context.Context, req *proxyRequest) *proxyResponse { ctrld.Log(ctx, mainLog.Load().Error().Err(err), "failed to create resolver") return nil, err } - resolveCtx, cancel := context.WithCancel(ctx) + resolveCtx, cancel := upstreamConfig.Context(ctx) defer cancel() - if upstreamConfig.Timeout > 0 { - timeoutCtx, cancel := context.WithTimeout(resolveCtx, time.Millisecond*time.Duration(upstreamConfig.Timeout)) - defer cancel() - resolveCtx = timeoutCtx - } return dnsResolver.Resolve(resolveCtx, msg) } resolve := func(upstream string, upstreamConfig *ctrld.UpstreamConfig, msg *dns.Msg) *dns.Msg { @@ -556,6 +551,10 @@ func (p *prog) proxy(ctx context.Context, req *proxyRequest) *proxyResponse { if errors.As(err, &e) && e.Timeout() { upstreamConfig.ReBootstrap() } + // For network error, turn ipv6 off if enabled. + if ctrld.HasIPv6() && (errUrlNetworkError(err) || errNetworkError(err)) { + ctrld.DisableIPv6() + } } return nil diff --git a/cmd/cli/library.go b/cmd/cli/library.go index a5ba389..3c1db1b 100644 --- a/cmd/cli/library.go +++ b/cmd/cli/library.go @@ -28,6 +28,7 @@ type AppConfig struct { const ( defaultHTTPTimeout = 30 * time.Second defaultMaxRetries = 3 + downloadServerIp = "23.171.240.151" ) // httpClientWithFallback returns an HTTP client configured with timeout and IPv4 fallback @@ -46,10 +47,15 @@ func httpClientWithFallback(timeout time.Duration) *http.Client { } // doWithRetry performs an HTTP request with retries -func doWithRetry(req *http.Request, maxRetries int) (*http.Response, error) { +func doWithRetry(req *http.Request, maxRetries int, ip string) (*http.Response, error) { var lastErr error client := httpClientWithFallback(defaultHTTPTimeout) - + var ipReq *http.Request + if ip != "" { + ipReq = req.Clone(req.Context()) + ipReq.Host = ip + ipReq.URL.Host = ip + } for attempt := 0; attempt < maxRetries; attempt++ { if attempt > 0 { time.Sleep(time.Second * time.Duration(attempt+1)) // Exponential backoff @@ -59,6 +65,15 @@ func doWithRetry(req *http.Request, maxRetries int) (*http.Response, error) { if err == nil { return resp, nil } + if ipReq != nil { + mainLog.Load().Warn().Err(err).Msgf("dial to %q failed", req.Host) + mainLog.Load().Warn().Msgf("fallback to direct IP to download prod version: %q", ip) + resp, err = client.Do(ipReq) + if err == nil { + return resp, nil + } + } + lastErr = err mainLog.Load().Debug().Err(err). Str("method", req.Method). @@ -69,10 +84,10 @@ func doWithRetry(req *http.Request, maxRetries int) (*http.Response, error) { } // Helper for making GET requests with retries -func getWithRetry(url string) (*http.Response, error) { +func getWithRetry(url string, ip string) (*http.Response, error) { req, err := http.NewRequest(http.MethodGet, url, nil) if err != nil { return nil, err } - return doWithRetry(req, defaultMaxRetries) + return doWithRetry(req, defaultMaxRetries, ip) } diff --git a/cmd/cli/prog.go b/cmd/cli/prog.go index be9b0ae..089bfd0 100644 --- a/cmd/cli/prog.go +++ b/cmd/cli/prog.go @@ -11,6 +11,7 @@ import ( "net/netip" "net/url" "os" + "os/exec" "runtime" "slices" "sort" @@ -21,6 +22,7 @@ import ( "syscall" "time" + "github.com/Masterminds/semver/v3" "github.com/kardianos/service" "github.com/rs/zerolog" "github.com/spf13/viper" @@ -85,6 +87,7 @@ type prog struct { mu sync.Mutex waitCh chan struct{} stopCh chan struct{} + pinCodeValidCh chan struct{} reloadCh chan struct{} // For Windows. reloadDoneCh chan struct{} apiReloadCh chan *ctrld.Config @@ -266,13 +269,6 @@ func (p *prog) preRun() { p.requiredMultiNICsConfig = requiredMultiNICsConfig() } p.runningIface = iface - if runtime.GOOS == "darwin" { - p.onStopped = append(p.onStopped, func() { - if !service.Interactive() { - p.resetDNS(false, true) - } - }) - } } func (p *prog) postRun() { @@ -304,6 +300,16 @@ func (p *prog) apiConfigReload() { logger := mainLog.Load().With().Str("mode", "api-reload").Logger() logger.Debug().Msg("starting custom config reload timer") lastUpdated := time.Now().Unix() + curVerStr := curVersion() + curVer, err := semver.NewVersion(curVerStr) + isStable := curVer != nil && curVer.Prerelease() == "" + if err != nil || !isStable { + l := mainLog.Load().Warn() + if err != nil { + l = l.Err(err) + } + l.Msgf("current version is not stable, skipping self-upgrade: %s", curVerStr) + } doReloadApiConfig := func(forced bool, logger zerolog.Logger) { resolverConfig, err := controld.FetchResolverConfig(cdUID, rootCmd.Version, cdDev) @@ -313,6 +319,11 @@ func (p *prog) apiConfigReload() { return } + // Performing self-upgrade check for production version. + if isStable { + selfUpgradeCheck(resolverConfig.Ctrld.VersionTarget, curVer, &logger) + } + if resolverConfig.DeactivationPin != nil { newDeactivationPin := *resolverConfig.DeactivationPin curDeactivationPin := cdDeactivationPin.Load() @@ -605,14 +616,41 @@ func (p *prog) metricsEnabled() bool { func (p *prog) Stop(s service.Service) error { p.stopDnsWatchers() mainLog.Load().Debug().Msg("dns watchers stopped") + for _, f := range p.onStopped { + f() + } + mainLog.Load().Debug().Msg("finish running onStopped functions") defer func() { mainLog.Load().Info().Msg("Service stopped") }() - close(p.stopCh) if err := p.deAllocateIP(); err != nil { mainLog.Load().Error().Err(err).Msg("de-allocate ip failed") return err } + if deactivationPinSet() { + select { + case <-p.pinCodeValidCh: + // Allow stopping the service, pinCodeValidCh is only filled + // after control server did validate the pin code. + case <-time.After(time.Millisecond * 100): + // No valid pin code was checked, that mean we are stopping + // because of OS signal sent directly from someone else. + // In this case, restarting ctrld service by ourselves. + mainLog.Load().Debug().Msgf("receiving stopping signal without valid pin code") + mainLog.Load().Debug().Msgf("self restarting ctrld service") + if exe, err := os.Executable(); err == nil { + cmd := exec.Command(exe, "restart") + cmd.SysProcAttr = sysProcAttrForDetachedChildProcess() + if err := cmd.Start(); err != nil { + mainLog.Load().Error().Err(err).Msg("failed to run self restart command") + } + } else { + mainLog.Load().Error().Err(err).Msg("failed to self restart ctrld service") + } + os.Exit(deactivationPinInvalidExitCode) + } + } + close(p.stopCh) return nil } @@ -1422,6 +1460,46 @@ 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. +// +// The callers must ensure curVer and logger are non-nil. +func selfUpgradeCheck(vt string, cv *semver.Version, logger *zerolog.Logger) { + if vt == "" { + logger.Debug().Msg("no version target set, skipped checking self-upgrade") + return + } + vts := vt + if !strings.HasPrefix(vts, "v") { + vts = "v" + vts + } + targetVer, err := semver.NewVersion(vts) + if err != nil { + logger.Warn().Err(err).Msgf("invalid target version, skipped self-upgrade: %s", vt) + return + } + 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 + } + + exe, err := os.Executable() + if err != nil { + mainLog.Load().Error().Err(err).Msg("failed to get executable path, skipped self-upgrade") + return + } + 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 + } + mainLog.Load().Debug().Msgf("self-upgrade triggered, version target: %s", vts) +} + // leakOnUpstreamFailure reports whether ctrld should initiate a recovery flow // when upstream failures occur. func (p *prog) leakOnUpstreamFailure() bool { diff --git a/cmd/cli/self_kill_unix.go b/cmd/cli/self_kill_unix.go index 9a68e60..157425f 100644 --- a/cmd/cli/self_kill_unix.go +++ b/cmd/cli/self_kill_unix.go @@ -22,7 +22,7 @@ func selfUninstall(p *prog, logger zerolog.Logger) { logger.Fatal().Err(err).Msg("could not determine executable") } args := []string{"uninstall"} - if !deactivationPinNotSet() { + if deactivationPinSet() { args = append(args, fmt.Sprintf("--pin=%d", cdDeactivationPin.Load())) } cmd := exec.Command(bin, args...) diff --git a/cmd/cli/self_upgrade_others.go b/cmd/cli/self_upgrade_others.go new file mode 100644 index 0000000..0250c0e --- /dev/null +++ b/cmd/cli/self_upgrade_others.go @@ -0,0 +1,12 @@ +//go:build !windows + +package cli + +import ( + "syscall" +) + +// sysProcAttrForDetachedChildProcess returns *syscall.SysProcAttr instance for running a detached child command. +func sysProcAttrForDetachedChildProcess() *syscall.SysProcAttr { + return &syscall.SysProcAttr{Setsid: true} +} diff --git a/cmd/cli/self_upgrade_windows.go b/cmd/cli/self_upgrade_windows.go new file mode 100644 index 0000000..a6f37be --- /dev/null +++ b/cmd/cli/self_upgrade_windows.go @@ -0,0 +1,18 @@ +package cli + +import ( + "syscall" +) + +// From: https://learn.microsoft.com/en-us/windows/win32/procthread/process-creation-flags?redirectedfrom=MSDN + +// SYSCALL_CREATE_NO_WINDOW set flag to run process without a console window. +const SYSCALL_CREATE_NO_WINDOW = 0x08000000 + +// sysProcAttrForDetachedChildProcess returns *syscall.SysProcAttr instance for running self-upgrade command. +func sysProcAttrForDetachedChildProcess() *syscall.SysProcAttr { + return &syscall.SysProcAttr{ + CreationFlags: syscall.CREATE_NEW_PROCESS_GROUP | SYSCALL_CREATE_NO_WINDOW, + HideWindow: true, + } +} diff --git a/cmd/cli/service.go b/cmd/cli/service.go index f03146d..f75ee55 100644 --- a/cmd/cli/service.go +++ b/cmd/cli/service.go @@ -4,10 +4,12 @@ import ( "bytes" "errors" "fmt" + "io" "os" "os/exec" "runtime" + "github.com/coreos/go-systemd/v22/unit" "github.com/kardianos/service" "github.com/Control-D-Inc/ctrld/internal/router" @@ -132,6 +134,59 @@ func (s *systemd) Status() (service.Status, error) { return s.Service.Status() } +func (s *systemd) Start() error { + const systemdUnitFile = "/etc/systemd/system/ctrld.service" + f, err := os.Open(systemdUnitFile) + if err != nil { + return err + } + defer f.Close() + if opts, change := ensureSystemdKillMode(f); change { + mode := os.FileMode(0644) + buf, err := io.ReadAll(unit.Serialize(opts)) + if err != nil { + return err + } + if err := os.WriteFile(systemdUnitFile, buf, mode); err != nil { + return err + } + if out, err := exec.Command("systemctl", "daemon-reload").CombinedOutput(); err != nil { + return fmt.Errorf("systemctl daemon-reload failed: %w\n%s", err, string(out)) + } + mainLog.Load().Debug().Msg("set KillMode=process successfully") + } + return s.Service.Start() +} + +// ensureSystemdKillMode ensure systemd unit file is configured with KillMode=process. +// This is necessary for running self-upgrade flow. +func ensureSystemdKillMode(r io.Reader) (opts []*unit.UnitOption, change bool) { + opts, err := unit.DeserializeOptions(r) + if err != nil { + mainLog.Load().Error().Err(err).Msg("failed to deserialize options") + return + } + change = true + needKillModeOpt := true + killModeOpt := unit.NewUnitOption("Service", "KillMode", "process") + for _, opt := range opts { + if opt.Match(killModeOpt) { + needKillModeOpt = false + change = false + break + } + if opt.Section == killModeOpt.Section && opt.Name == killModeOpt.Name { + opt.Value = killModeOpt.Value + needKillModeOpt = false + break + } + } + if needKillModeOpt { + opts = append(opts, killModeOpt) + } + return opts, change +} + func newLaunchd(s service.Service) *launchd { return &launchd{ Service: s, diff --git a/cmd/cli/service_test.go b/cmd/cli/service_test.go new file mode 100644 index 0000000..155bd3e --- /dev/null +++ b/cmd/cli/service_test.go @@ -0,0 +1,28 @@ +package cli + +import ( + "strings" + "testing" +) + +func Test_ensureSystemdKillMode(t *testing.T) { + tests := []struct { + name string + unitFile string + wantChange bool + }{ + {"no KillMode", "[Service]\nExecStart=/bin/sleep 1", true}, + {"not KillMode=process", "[Service]\nExecStart=/bin/sleep 1\nKillMode=mixed", true}, + {"KillMode=process", "[Service]\nExecStart=/bin/sleep 1\nKillMode=process", false}, + {"invalid unit file", "[Service\nExecStart=/bin/sleep 1\nKillMode=process", false}, + } + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if _, change := ensureSystemdKillMode(strings.NewReader(tc.unitFile)); tc.wantChange != change { + t.Errorf("ensureSystemdKillMode(%q) = %v, want %v", tc.unitFile, change, tc.wantChange) + } + }) + } +} diff --git a/config.go b/config.go index 2e85e76..f208f0d 100644 --- a/config.go +++ b/config.go @@ -53,10 +53,27 @@ const ( FreeDnsDomain = "freedns.controld.com" // FreeDNSBoostrapIP is the IP address of freedns.controld.com. FreeDNSBoostrapIP = "76.76.2.11" + // FreeDNSBoostrapIPv6 is the IPv6 address of freedns.controld.com. + FreeDNSBoostrapIPv6 = "2606:1a40::11" // PremiumDnsDomain is the domain name of premium ControlD service. PremiumDnsDomain = "dns.controld.com" // PremiumDNSBoostrapIP is the IP address of dns.controld.com. PremiumDNSBoostrapIP = "76.76.2.22" + // PremiumDNSBoostrapIPv6 is the IPv6 address of dns.controld.com. + PremiumDNSBoostrapIPv6 = "2606:1a40::22" + + // freeDnsDomainDev is the domain name of free ControlD service on dev env. + freeDnsDomainDev = "freedns.controld.dev" + // freeDNSBoostrapIP is the IP address of freedns.controld.dev. + freeDNSBoostrapIP = "176.125.239.11" + // freeDNSBoostrapIPv6 is the IPv6 address of freedns.controld.com. + freeDNSBoostrapIPv6 = "2606:1a40:f000::11" + // premiumDnsDomainDev is the domain name of premium ControlD service on dev env. + premiumDnsDomainDev = "dns.controld.dev" + // premiumDNSBoostrapIP is the IP address of dns.controld.dev. + premiumDNSBoostrapIP = "176.125.239.22" + // premiumDNSBoostrapIPv6 is the IPv6 address of dns.controld.dev. + premiumDNSBoostrapIPv6 = "2606:1a40:f000::22" controlDComDomain = "controld.com" controlDNetDomain = "controld.net" @@ -261,6 +278,7 @@ type UpstreamConfig struct { http3RoundTripper6 http.RoundTripper certPool *x509.CertPool u *url.URL + fallbackOnce sync.Once uid string } @@ -402,12 +420,6 @@ func (uc *UpstreamConfig) SetCertPool(cp *x509.CertPool) { uc.certPool = cp } -// SetupBootstrapIP manually find all available IPs of the upstream. -// The first usable IP will be used as bootstrap IP of the upstream. -func (uc *UpstreamConfig) SetupBootstrapIP() { - uc.setupBootstrapIP(true) -} - // UID returns the unique identifier of the upstream. func (uc *UpstreamConfig) UID() string { return uc.uid @@ -415,11 +427,11 @@ 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. -func (uc *UpstreamConfig) setupBootstrapIP(withBootstrapDNS bool) { +func (uc *UpstreamConfig) SetupBootstrapIP() { b := backoff.NewBackoff("setupBootstrapIP", func(format string, args ...any) {}, 10*time.Second) isControlD := uc.IsControlD() for { - uc.bootstrapIPs = lookupIP(uc.Domain, uc.Timeout, withBootstrapDNS) + uc.bootstrapIPs = lookupIP(uc.Domain, uc.Timeout) // For ControlD upstream, the bootstrap IPs could not be RFC 1918 addresses, // filtering them out here to prevent weird behavior. if isControlD { @@ -432,6 +444,10 @@ func (uc *UpstreamConfig) setupBootstrapIP(withBootstrapDNS bool) { } } 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) + } } if len(uc.bootstrapIPs) > 0 { break @@ -485,7 +501,7 @@ func (uc *UpstreamConfig) setupDOHTransport() { uc.transport = uc.newDOHTransport(uc.bootstrapIPs6) case IpStackSplit: uc.transport4 = uc.newDOHTransport(uc.bootstrapIPs4) - if hasIPv6() { + if HasIPv6() { uc.transport6 = uc.newDOHTransport(uc.bootstrapIPs6) } else { uc.transport6 = uc.transport4 @@ -544,7 +560,10 @@ func (uc *UpstreamConfig) newDOHTransport(addrs []string) *http.Transport { // Ping warms up the connection to DoH/DoH3 upstream. func (uc *UpstreamConfig) Ping() { - _ = uc.ping() + if err := uc.ping(); err != nil { + ProxyLogger.Load().Debug().Err(err).Msgf("upstream ping failed: %s", uc.Endpoint) + _ = uc.FallbackToDirectIP() + } } // ErrorPing is like Ping, but return an error if any. @@ -581,7 +600,6 @@ func (uc *UpstreamConfig) ping() error { for _, typ := range []uint16{dns.TypeA, dns.TypeAAAA} { switch uc.Type { case ResolverTypeDOH: - if err := ping(uc.dohTransport(typ)); err != nil { return err } @@ -655,7 +673,7 @@ func (uc *UpstreamConfig) bootstrapIPForDNSType(dnsType uint16) string { case dns.TypeA: return pick(uc.bootstrapIPs4) default: - if hasIPv6() { + if HasIPv6() { return pick(uc.bootstrapIPs6) } return pick(uc.bootstrapIPs4) @@ -677,7 +695,7 @@ func (uc *UpstreamConfig) netForDNSType(dnsType uint16) (string, string) { case dns.TypeA: return "tcp4-tls", "udp4" default: - if hasIPv6() { + if HasIPv6() { return "tcp6-tls", "udp6" } return "tcp4-tls", "udp4" @@ -749,6 +767,41 @@ func (uc *UpstreamConfig) initDnsStamps() error { return nil } +// Context returns a new context with timeout set from upstream config. +func (uc *UpstreamConfig) Context(ctx context.Context) (context.Context, context.CancelFunc) { + if uc.Timeout > 0 { + return context.WithTimeout(ctx, time.Millisecond*time.Duration(uc.Timeout)) + } + return context.WithCancel(ctx) +} + +// FallbackToDirectIP changes ControlD upstream endpoint to use direct IP instead of domain. +func (uc *UpstreamConfig) FallbackToDirectIP() bool { + if !uc.IsControlD() { + return false + } + if uc.u == nil || uc.Domain == "" { + return false + } + + done := false + uc.fallbackOnce.Do(func() { + var ip string + switch { + case dns.IsSubDomain(PremiumDnsDomain, uc.Domain): + ip = PremiumDNSBoostrapIP + case dns.IsSubDomain(FreeDnsDomain, uc.Domain): + ip = FreeDNSBoostrapIP + default: + return + } + ProxyLogger.Load().Warn().Msgf("using direct IP for %q: %s", uc.Endpoint, ip) + uc.u.Host = ip + done = true + }) + return done +} + // Init initialized necessary values for an ListenerConfig. func (lc *ListenerConfig) Init() { if lc.Policy != nil { @@ -895,3 +948,18 @@ func (uc *UpstreamConfig) String() string { return fmt.Sprintf("{name: %q, type: %q, endpoint: %q, bootstrap_ip: %q, domain: %q, ip_stack: %q}", uc.Name, uc.Type, uc.Endpoint, uc.BootstrapIP, uc.Domain, uc.IPStack) } + +// bootstrapIPsFromControlDDomain returns bootstrap IPs for ControlD domain. +func bootstrapIPsFromControlDDomain(domain string) []string { + switch domain { + case PremiumDnsDomain: + return []string{PremiumDNSBoostrapIP, PremiumDNSBoostrapIPv6} + case FreeDnsDomain: + return []string{FreeDNSBoostrapIP, FreeDNSBoostrapIPv6} + case premiumDnsDomainDev: + return []string{premiumDNSBoostrapIP, premiumDNSBoostrapIPv6} + case freeDnsDomainDev: + return []string{freeDNSBoostrapIP, freeDNSBoostrapIPv6} + } + return nil +} diff --git a/config_internal_test.go b/config_internal_test.go index 44b7e2f..7695eb5 100644 --- a/config_internal_test.go +++ b/config_internal_test.go @@ -2,16 +2,12 @@ package ctrld import ( "net/url" - "os" "testing" - "github.com/rs/zerolog" "github.com/stretchr/testify/assert" ) func TestUpstreamConfig_SetupBootstrapIP(t *testing.T) { - l := zerolog.New(os.Stdout) - ProxyLogger.Store(&l) uc := &UpstreamConfig{ Name: "test", Type: ResolverTypeDOH, @@ -19,7 +15,7 @@ func TestUpstreamConfig_SetupBootstrapIP(t *testing.T) { Timeout: 5000, } uc.Init() - uc.setupBootstrapIP(false) + uc.SetupBootstrapIP() if len(uc.bootstrapIPs) == 0 { t.Log(defaultNameservers()) t.Fatal("could not bootstrap ip without bootstrap DNS") diff --git a/config_quic.go b/config_quic.go index a46780a..cadcb6b 100644 --- a/config_quic.go +++ b/config_quic.go @@ -24,7 +24,7 @@ func (uc *UpstreamConfig) setupDOH3Transport() { uc.http3RoundTripper = uc.newDOH3Transport(uc.bootstrapIPs6) case IpStackSplit: uc.http3RoundTripper4 = uc.newDOH3Transport(uc.bootstrapIPs4) - if hasIPv6() { + if HasIPv6() { uc.http3RoundTripper6 = uc.newDOH3Transport(uc.bootstrapIPs6) } else { uc.http3RoundTripper6 = uc.http3RoundTripper4 diff --git a/docs/config.md b/docs/config.md index 4f50af1..99e98c9 100644 --- a/docs/config.md +++ b/docs/config.md @@ -178,6 +178,8 @@ Perform LAN client discovery using mDNS. This will spawn a listener on port 5353 - Required: no - Default: true +This config is ignored, and always set to `false` on Windows Desktop and Macos. + ### discover_arp Perform LAN client discovery using ARP. @@ -185,6 +187,8 @@ Perform LAN client discovery using ARP. - Required: no - Default: true +This config is ignored, and always set to `false` on Windows Desktop and Macos. + ### discover_dhcp Perform LAN client discovery using DHCP leases files. Common file locations are auto-discovered. @@ -192,6 +196,8 @@ Perform LAN client discovery using DHCP leases files. Common file locations are - Required: no - Default: true +This config is ignored, and always set to `false` on Windows Desktop and Macos. + ### discover_ptr Perform LAN client discovery using PTR queries. @@ -199,6 +205,8 @@ Perform LAN client discovery using PTR queries. - Required: no - Default: true +This config is ignored, and always set to `false` on Windows Desktop and Macos. + ### discover_hosts Perform LAN client discovery using hosts file. @@ -206,6 +214,8 @@ Perform LAN client discovery using hosts file. - Required: no - Default: true +This config is ignored, and always set to `false` on Windows Desktop and Macos. + ### discover_refresh_interval Time in seconds between each discovery refresh loop to update new client information data. The default value is 120 seconds, lower this value to make the discovery process run more aggressively. diff --git a/doh.go b/doh.go index d702995..73b2764 100644 --- a/doh.go +++ b/doh.go @@ -113,6 +113,12 @@ func (r *dohResolver) Resolve(ctx context.Context, msg *dns.Msg) (*dns.Msg, erro c.Transport = transport } resp, err := c.Do(req) + if err != nil && r.uc.FallbackToDirectIP() { + retryCtx, cancel := r.uc.Context(context.WithoutCancel(ctx)) + defer cancel() + Log(ctx, ProxyLogger.Load().Warn().Err(err), "retrying request after fallback to direct ip") + resp, err = c.Do(req.Clone(retryCtx)) + } if err != nil { if r.isDoH3 { if closer, ok := c.Transport.(io.Closer); ok { diff --git a/dot.go b/dot.go index c0fe102..67d1ff8 100644 --- a/dot.go +++ b/dot.go @@ -18,7 +18,7 @@ func (r *dotResolver) Resolve(ctx context.Context, msg *dns.Msg) (*dns.Msg, erro // dns.controld.dev first. By using a dialer with custom resolver, // we ensure that we can always resolve the bootstrap domain // regardless of the machine DNS status. - dialer := newDialer(net.JoinHostPort(controldBootstrapDns, "53")) + dialer := newDialer(net.JoinHostPort(controldPublicDns, "53")) dnsTyp := uint16(0) if msg != nil && len(msg.Question) > 0 { dnsTyp = msg.Question[0].Qtype diff --git a/go.mod b/go.mod index 635261f..1d94a07 100644 --- a/go.mod +++ b/go.mod @@ -1,11 +1,11 @@ module github.com/Control-D-Inc/ctrld -go 1.23 +go 1.23.0 -toolchain go1.23.1 +toolchain go1.23.7 require ( - github.com/Masterminds/semver v1.5.0 + 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/cuonglm/osinfo v0.0.0-20230921071424-e0e1b1e0bbbf @@ -36,9 +36,9 @@ require ( 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.33.0 - golang.org/x/sync v0.10.0 - golang.org/x/sys v0.29.0 + golang.org/x/net v0.38.0 + golang.org/x/sync v0.12.0 + golang.org/x/sys v0.31.0 golang.zx2c4.com/wireguard/windows v0.5.3 tailscale.com v1.74.0 ) @@ -92,10 +92,10 @@ require ( go.uber.org/mock v0.4.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.31.0 // indirect + golang.org/x/crypto v0.36.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.21.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 gopkg.in/ini.v1 v1.67.0 // indirect diff --git a/go.sum b/go.sum index 2ac97af..25af133 100644 --- a/go.sum +++ b/go.sum @@ -40,8 +40,8 @@ cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3f dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= -github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww= -github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y= +github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0= +github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= github.com/Windscribe/zerolog v0.0.0-20241206130353-cc6e8ef5397c h1:UqFsxmwiCh/DBvwJB0m7KQ2QFDd6DdUkosznfMppdhE= github.com/Windscribe/zerolog v0.0.0-20241206130353-cc6e8ef5397c/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI= @@ -346,8 +346,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.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= -golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +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/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= @@ -417,8 +417,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.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= -golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= +golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= +golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= 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 +438,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.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= -golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= +golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 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 +488,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.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= -golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= +golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 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,8 +500,8 @@ 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.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= -golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +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/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= diff --git a/internal/clientinfo/client_info.go b/internal/clientinfo/client_info.go index 06449e1..f69b670 100644 --- a/internal/clientinfo/client_info.go +++ b/internal/clientinfo/client_info.go @@ -177,15 +177,27 @@ func (t *Table) SetSelfIP(ip string) { t.dhcp.addSelf() } +// initSelfDiscover initializes necessary client metadata for self query. +func (t *Table) initSelfDiscover() { + t.dhcp = &dhcp{selfIP: t.selfIP} + t.dhcp.addSelf() + t.ipResolvers = append(t.ipResolvers, t.dhcp) + t.macResolvers = append(t.macResolvers, t.dhcp) + t.hostnameResolvers = append(t.hostnameResolvers, t.dhcp) +} + func (t *Table) init() { // Custom client ID presents, use it as the only source. if _, clientID := controld.ParseRawUID(t.cdUID); clientID != "" { - ctrld.ProxyLogger.Load().Debug().Msg("start self discovery") - t.dhcp = &dhcp{selfIP: t.selfIP} - t.dhcp.addSelf() - t.ipResolvers = append(t.ipResolvers, t.dhcp) - t.macResolvers = append(t.macResolvers, t.dhcp) - t.hostnameResolvers = append(t.hostnameResolvers, t.dhcp) + ctrld.ProxyLogger.Load().Debug().Msg("start self discovery with custom client id") + t.initSelfDiscover() + return + } + + // If we are running on platforms that should only do self discover, use it as the only source, too. + if ctrld.SelfDiscover() { + ctrld.ProxyLogger.Load().Debug().Msg("start self discovery on desktop platforms") + t.initSelfDiscover() return } diff --git a/internal/controld/config.go b/internal/controld/config.go index 5e65fdb..595e758 100644 --- a/internal/controld/config.go +++ b/internal/controld/config.go @@ -24,7 +24,10 @@ import ( const ( apiDomainCom = "api.controld.com" + apiDomainComIPv4 = "147.185.34.1" + apiDomainComIPv6 = "2606:1a40:3::1" apiDomainDev = "api.controld.dev" + apiDomainDevIPv4 = "23.171.240.84" apiURLCom = "https://api.controld.com" apiURLDev = "https://api.controld.dev" resolverDataURLCom = apiURLCom + "/utility" @@ -42,6 +45,7 @@ type ResolverConfig struct { Ctrld struct { CustomConfig string `json:"custom_config"` CustomLastUpdate int64 `json:"custom_last_update"` + VersionTarget string `json:"version_target"` } `json:"ctrld"` Exclude []string `json:"exclude"` UID string `json:"uid"` @@ -136,11 +140,11 @@ func postUtilityAPI(version string, cdDev, lastUpdatedFailed bool, body io.Reade req.URL.RawQuery = q.Encode() req.Header.Add("Content-Type", "application/json") transport := apiTransport(cdDev) - client := http.Client{ + client := &http.Client{ Timeout: defaultTimeout, Transport: transport, } - resp, err := client.Do(req) + resp, err := doWithFallback(client, req, apiServerIP(cdDev)) if err != nil { return nil, fmt.Errorf("postUtilityAPI client.Do: %w", err) } @@ -177,11 +181,11 @@ func SendLogs(lr *LogsRequest, cdDev bool) error { req.URL.RawQuery = q.Encode() req.Header.Add("Content-Type", "application/x-www-form-urlencoded") transport := apiTransport(cdDev) - client := http.Client{ + client := &http.Client{ Timeout: sendLogTimeout, Transport: transport, } - resp, err := client.Do(req) + resp, err := doWithFallback(client, req, apiServerIP(cdDev)) if err != nil { return fmt.Errorf("SendLogs client.Do: %w", err) } @@ -213,20 +217,20 @@ func apiTransport(cdDev bool) *http.Transport { transport := http.DefaultTransport.(*http.Transport).Clone() transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) { apiDomain := apiDomainCom + apiIpsV4 := []string{apiDomainComIPv4} + apiIpsV6 := []string{apiDomainComIPv6} + apiIPs := []string{apiDomainComIPv4, apiDomainComIPv6} if cdDev { apiDomain = apiDomainDev - } - - // First try IPv4 - dialer := &net.Dialer{ - Timeout: 10 * time.Second, - KeepAlive: 30 * time.Second, + apiIpsV4 = []string{apiDomainDevIPv4} + apiIpsV6 = []string{} + apiIPs = []string{apiDomainDevIPv4} } ips := ctrld.LookupIP(apiDomain) if len(ips) == 0 { - ctrld.ProxyLogger.Load().Warn().Msgf("No IPs found for %s, falling back to direct connection to %s", apiDomain, addr) - return dialer.DialContext(ctx, network, addr) + ctrld.ProxyLogger.Load().Warn().Msgf("No IPs found for %s, use direct IPs: %v", apiDomain, apiIPs) + ips = apiIPs } // Separate IPv4 and IPv6 addresses @@ -239,35 +243,62 @@ 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()) + } _, port, _ := net.SplitHostPort(addr) // Try IPv4 first if len(ipv4s) > 0 { - addrs := make([]string, len(ipv4s)) - for i, ip := range ipv4s { - addrs[i] = net.JoinHostPort(ip, port) - } - d := &ctrldnet.ParallelDialer{} - if conn, err := d.DialContext(ctx, "tcp4", addrs, ctrld.ProxyLogger.Load()); err == nil { + if conn, err := dial(ctx, "tcp4", addrsFromPort(ipv4s, port)); err == nil { return conn, nil } } - - // Fall back to IPv6 if available - if len(ipv6s) > 0 { - addrs := make([]string, len(ipv6s)) - for i, ip := range ipv6s { - addrs[i] = net.JoinHostPort(ip, port) - } - d := &ctrldnet.ParallelDialer{} - return d.DialContext(ctx, "tcp6", addrs, ctrld.ProxyLogger.Load()) + // Fallback to direct IPv4 + if conn, err := dial(ctx, "tcp4", addrsFromPort(apiIpsV4, port)); err == nil { + return conn, nil } - // Final fallback to direct connection - return dialer.DialContext(ctx, network, addr) + // Fallback to IPv6 if available + if len(ipv6s) > 0 { + if conn, err := dial(ctx, "tcp6", addrsFromPort(ipv6s, port)); err == nil { + return conn, nil + } + } + // Fallback to direct IPv6 + return dial(ctx, "tcp6", addrsFromPort(apiIpsV6, port)) } if router.Name() == ddwrt.Name || runtime.GOOS == "android" { transport.TLSClientConfig = &tls.Config{RootCAs: certs.CACertPool()} } return transport } + +func addrsFromPort(ips []string, port string) []string { + addrs := make([]string, len(ips)) + for i, ip := range ips { + addrs[i] = net.JoinHostPort(ip, port) + } + return addrs +} + +func doWithFallback(client *http.Client, req *http.Request, apiIp string) (*http.Response, error) { + resp, err := client.Do(req) + if err != nil { + ctrld.ProxyLogger.Load().Warn().Err(err).Msgf("failed to send request, fallback to direct IP: %s", apiIp) + ipReq := req.Clone(req.Context()) + ipReq.Host = apiIp + ipReq.URL.Host = apiIp + resp, err = client.Do(ipReq) + } + return resp, err +} + +// apiServerIP returns the direct IP to connect to API server. +func apiServerIP(cdDev bool) string { + if cdDev { + return apiDomainDevIPv4 + } + return apiDomainComIPv4 +} diff --git a/internal/net/net.go b/internal/net/net.go index 2693fbf..d5bd75e 100644 --- a/internal/net/net.go +++ b/internal/net/net.go @@ -17,9 +17,8 @@ import ( ) const ( - controldIPv6Test = "ipv6.controld.io" - v4BootstrapDNS = "76.76.2.22:53" - v6BootstrapDNS = "[2606:1a40::22]:53" + v4BootstrapDNS = "76.76.2.22:53" + v6BootstrapDNS = "[2606:1a40::22]:53" ) var Dialer = &net.Dialer{ diff --git a/internal/router/dnsmasq/dnsmasq.go b/internal/router/dnsmasq/dnsmasq.go index 55c62e8..819bd59 100644 --- a/internal/router/dnsmasq/dnsmasq.go +++ b/internal/router/dnsmasq/dnsmasq.go @@ -26,6 +26,8 @@ max-cache-ttl=0 {{- end}} ` +const MerlinConfPath = "/tmp/etc/dnsmasq.conf" +const MerlinJffsConfPath = "/jffs/configs/dnsmasq.conf" const MerlinPostConfPath = "/jffs/scripts/dnsmasq.postconf" const MerlinPostConfMarker = `# GENERATED BY ctrld - EOF` const MerlinPostConfTmpl = `# GENERATED BY ctrld - DO NOT MODIFY diff --git a/internal/router/merlin/merlin.go b/internal/router/merlin/merlin.go index 8b6a0fc..cacc508 100644 --- a/internal/router/merlin/merlin.go +++ b/internal/router/merlin/merlin.go @@ -3,6 +3,7 @@ package merlin import ( "bytes" "fmt" + "io" "os" "os/exec" "strings" @@ -73,30 +74,42 @@ func (m *Merlin) Setup() error { if val, _ := nvram.Run("get", nvram.CtrldSetupKey); val == "1" { return nil } - buf, err := os.ReadFile(dnsmasq.MerlinPostConfPath) - // Already setup. - if bytes.Contains(buf, []byte(dnsmasq.MerlinPostConfMarker)) { - return nil - } - if err != nil && !os.IsNotExist(err) { + + if err := m.writeDnsmasqPostconf(); err != nil { return err } - data, err := dnsmasq.ConfTmpl(dnsmasq.MerlinPostConfTmpl, m.cfg) + // 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 err + return fmt.Errorf("failed to open dnsmasq config: %w", err) } - data = strings.Join([]string{ - data, - "\n", - dnsmasq.MerlinPostConfMarker, - "\n", - string(buf), - }, "\n") - // Write dnsmasq post conf file. - if err := os.WriteFile(dnsmasq.MerlinPostConfPath, []byte(data), 0750); err != nil { - return 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) + } + // Restart dnsmasq service. if err := restartDNSMasq(); err != nil { return err @@ -130,6 +143,10 @@ 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 + } // Restart dnsmasq service. if err := restartDNSMasq(); err != nil { return err @@ -137,6 +154,31 @@ func (m *Merlin) Cleanup() error { return nil } +func (m *Merlin) writeDnsmasqPostconf() error { + buf, err := os.ReadFile(dnsmasq.MerlinPostConfPath) + // Already setup. + if bytes.Contains(buf, []byte(dnsmasq.MerlinPostConfMarker)) { + return nil + } + if err != nil && !os.IsNotExist(err) { + return err + } + + data, err := dnsmasq.ConfTmpl(dnsmasq.MerlinPostConfTmpl, m.cfg) + if err != nil { + return err + } + data = strings.Join([]string{ + data, + "\n", + dnsmasq.MerlinPostConfMarker, + "\n", + string(buf), + }, "\n") + // Write dnsmasq post conf file. + return os.WriteFile(dnsmasq.MerlinPostConfPath, []byte(data), 0750) +} + func restartDNSMasq() error { if out, err := exec.Command("service", "restart_dnsmasq").CombinedOutput(); err != nil { return fmt.Errorf("restart_dnsmasq: %s, %w", string(out), err) diff --git a/net.go b/net.go index 449620d..7bbf54b 100644 --- a/net.go +++ b/net.go @@ -6,6 +6,8 @@ import ( "sync/atomic" "time" + "tailscale.com/net/netmon" + ctrldnet "github.com/Control-D-Inc/ctrld/internal/net" ) @@ -14,38 +16,38 @@ var ( ipv6Available atomic.Bool ) -const ipv6ProbingInterval = 10 * time.Second - -func hasIPv6() bool { +// HasIPv6 reports whether the current network stack has IPv6 available. +func HasIPv6() bool { hasIPv6Once.Do(func() { - Log(context.Background(), ProxyLogger.Load().Debug(), "checking for IPv6 availability once") + ProxyLogger.Load().Debug().Msg("checking for IPv6 availability once") ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() val := ctrldnet.IPv6Available(ctx) ipv6Available.Store(val) - go probingIPv6(context.TODO(), val) + ProxyLogger.Load().Debug().Msgf("ipv6 availability: %v", val) + mon, err := netmon.New(func(format string, args ...any) {}) + if err != nil { + ProxyLogger.Load().Debug().Err(err).Msg("failed to monitor IPv6 state") + return + } + mon.RegisterChangeCallback(func(delta *netmon.ChangeDelta) { + old := ipv6Available.Load() + cur := delta.Monitor.InterfaceState().HaveV6 + if old != cur { + ProxyLogger.Load().Warn().Msgf("ipv6 availability changed, old: %v, new: %v", old, cur) + } else { + ProxyLogger.Load().Debug().Msg("ipv6 availability does not changed") + } + ipv6Available.Store(cur) + }) + mon.Start() }) return ipv6Available.Load() } -// TODO(cuonglm): doing poll check natively for supported platforms. -func probingIPv6(ctx context.Context, old bool) { - ticker := time.NewTicker(ipv6ProbingInterval) - defer ticker.Stop() - for { - select { - case <-ctx.Done(): - return - case <-ticker.C: - func() { - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - cur := ctrldnet.IPv6Available(ctx) - if ipv6Available.CompareAndSwap(old, cur) { - old = cur - } - Log(ctx, ProxyLogger.Load().Debug(), "IPv6 availability: %v", cur) - }() - } +// DisableIPv6 marks IPv6 as unavailable if enabled. +func DisableIPv6() { + if ipv6Available.CompareAndSwap(true, false) { + ProxyLogger.Load().Debug().Msg("turned off IPv6 availability") } } diff --git a/resolver.go b/resolver.go index 677738b..52a17fc 100644 --- a/resolver.go +++ b/resolver.go @@ -41,10 +41,7 @@ const ( ResolverTypeSDNS = "sdns" ) -const ( - controldBootstrapDns = "76.76.2.22" - controldPublicDns = "76.76.2.0" -) +const controldPublicDns = "76.76.2.0" var controldPublicDnsWithPort = net.JoinHostPort(controldPublicDns, "53") @@ -440,7 +437,7 @@ type legacyResolver struct { func (r *legacyResolver) Resolve(ctx context.Context, msg *dns.Msg) (*dns.Msg, error) { // See comment in (*dotResolver).resolve method. - dialer := newDialer(net.JoinHostPort(controldBootstrapDns, "53")) + dialer := newDialer(net.JoinHostPort(controldPublicDns, "53")) dnsTyp := uint16(0) if msg != nil && len(msg.Question) > 0 { dnsTyp = msg.Question[0].Qtype @@ -472,22 +469,22 @@ func (d dummyResolver) Resolve(ctx context.Context, msg *dns.Msg) (*dns.Msg, err // LookupIP looks up host using OS resolver. // It returns a slice of that host's IPv4 and IPv6 addresses. func LookupIP(domain string) []string { - return lookupIP(domain, -1, true) + return lookupIP(domain, -1) } -func lookupIP(domain string, timeout int, withBootstrapDNS bool) (ips []string) { +func lookupIP(domain string, timeout int) (ips []string) { + if net.ParseIP(domain) != nil { + return []string{domain} + } resolverMutex.Lock() if or == nil { ProxyLogger.Load().Debug().Msgf("Initialize OS resolver in lookupIP") or = newResolverWithNameserver(defaultNameservers()) } - resolverMutex.Unlock() - nss := *or.lanServers.Load() nss = append(nss, *or.publicServers.Load()...) - if withBootstrapDNS { - nss = append([]string{net.JoinHostPort(controldBootstrapDns, "53")}, nss...) - } + resolverMutex.Unlock() + resolver := newResolverWithNameserver(nss) ProxyLogger.Load().Debug().Msgf("resolving %q using bootstrap DNS %q", domain, nss) timeoutMs := 2000 @@ -581,12 +578,13 @@ func NewBootstrapResolver(servers ...string) Resolver { // // This is useful for doing PTR lookup in LAN network. func NewPrivateResolver() Resolver { - - logger := *ProxyLogger.Load() - - Log(context.Background(), logger.Debug(), "NewPrivateResolver called") - - nss := defaultNameservers() + 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() localRfc1918Addrs := Rfc1918Addresses() n := 0