diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..95d7144 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,2 @@ +Dockerfile +.git/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f328790 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,32 @@ +# Using Debian bullseye for building regular image. +# Using scratch image for minimal image size. +# The final image has: +# +# - Timezone info file. +# - CA certs file. +# - /etc/{passwd,group} file. +# - Non-cgo ctrld binary. +# +# CI_COMMIT_TAG is used to set the version of ctrld binary. +FROM golang:bullseye as base + +WORKDIR /app + +RUN apt-get update && apt-get install -y upx-ucl + +COPY . . + +ARG tag=master +ENV CI_COMMIT_TAG=$tag +RUN CTRLD_NO_QF=yes CGO_ENABLED=0 ./scripts/build.sh + +FROM scratch + +COPY --from=base /usr/share/zoneinfo /usr/share/zoneinfo +COPY --from=base /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ +COPY --from=base /etc/passwd /etc/passwd +COPY --from=base /etc/group /etc/group + +COPY --from=base /app/ctrld-linux-*-nocgo ctrld + +ENTRYPOINT ["./ctrld", "run"] diff --git a/README.md b/README.md index 3db1536..20f9047 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ A highly configurable DNS forwarding proxy with support for: - Multiple network policy driven DNS query steering - Policy driven domain based "split horizon" DNS with wildcard support - Integrations with common router vendors and firmware +- LAN client discovery via DHCP, mDNS, and ARP ## TLDR Proxy legacy DNS traffic to secure DNS upstreams in highly configurable ways. @@ -51,6 +52,11 @@ Windows user and prefer Powershell (who doesn't)? No problem, execute this comma powershell -Command "(Invoke-WebRequest -Uri 'https://api.controld.com/dl' -UseBasicParsing).Content | Set-Content 'ctrld_install.bat'" && ctrld_install.bat ``` +Or you can pull and run a Docker container from [Docker Hub](https://hub.docker.com/r/controldns/ctrld) +``` +$ docker pull controldns/ctrld +``` + ## Download Manually Alternatively, if you know what you're doing you can download pre-compiled binaries from the [Releases](https://github.com/Control-D-Inc/ctrld/releases) section for the appropriate platform. @@ -67,6 +73,13 @@ or $ go install github.com/Control-D-Inc/ctrld/cmd/ctrld@latest ``` +or + +``` +$ docker build -t controld/ctrld . +$ docker run -d --name=ctrld -p 53:53/tcp -p 53:53/udp controld/ctrld --cd=RESOLVER_ID_GOES_HERE -vv +``` + # Usage The cli is self documenting, so free free to run `--help` on any sub-command to get specific usages. @@ -88,16 +101,10 @@ Available Commands: service Manage ctrld service start Quick start service and configure DNS on interface stop Quick stop service and remove DNS from interface - setup Auto-setup Control D on a router. - -Supported platforms: - - ₒ ddwrt - ₒ merlin - ₒ openwrt - ₒ ubios - ₒ auto - detect the platform you are running on - + restart Restart the ctrld service + status Show status of the ctrld service + uninstall Stop and uninstall the ctrld service + clients Manage clients Flags: -h, --help help for ctrld @@ -125,52 +132,30 @@ To start the server with default configuration, simply run: `./ctrld run`. This 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. ## Service Mode -To run the application in service mode on any Windows, MacOS or Linux distibution, 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/` (everywhere else), start the system service, and configure the listener on the default network interface. Service will start on OS boot. +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. -In order to stop the service, and restore your DNS to original state, simply run `./ctrld stop`. If you wish to uninstall the service permanently, run `./ctrld service uninstall`. +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. -For granular control of the service, run the `service` command. Each sub-command has its own help section so you can see what arguments you can supply. +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`. -``` - Manage ctrld service - Usage: - ctrld service [command] - - Available Commands: - interfaces Manage network interfaces - restart Restart the ctrld service - start Start the ctrld service - status Show status of the ctrld service - stop Stop the ctrld service - uninstall Uninstall the ctrld service - - Flags: - -h, --help help for service - - Global Flags: - -v, --verbose count verbose log output, "-v" basic logging, "-vv" debug level logging - - Use "ctrld service [command] --help" for more information about a command. -``` - -## Router Mode +### 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 +- pfSense / OPNsense - Synology - Ubiquiti (UniFi, EdgeOS) -In order to start `ctrld` as a DNS provider, simply run `./ctrld setup auto` command. +`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. -In this mode, and when Control D upstreams are used, the router 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. ### 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) or `setup` (router) modes. +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. The following command will start the application in foreground mode, using the free "p2" resolver, which blocks Ads & Trackers. @@ -184,14 +169,9 @@ Alternatively, you can use your own personal Control D Device resolver, and star ./ctrld start --cd abcd1234 ``` -You can do the same while starting in router mode: -```shell -./ctrld setup auto --cd abcd1234 -``` - -Once you run the above commands (in service or router modes only), the following things will happen: +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 `service uninstall` sub-commands +- 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 @@ -252,4 +232,5 @@ See [Contribution Guideline](./docs/contributing.md) The following functionality is on the roadmap and will be available in future releases. - Prometheus metrics exporter - DNS intercept mode +- Direct listener mode - Support for more routers (let us know which ones) diff --git a/client_info.go b/client_info.go index d0d993a..c4494f7 100644 --- a/client_info.go +++ b/client_info.go @@ -9,3 +9,11 @@ type ClientInfo struct { IP string Hostname string } + +// LeaseFileFormat specifies the format of DHCP lease file. +type LeaseFileFormat string + +const ( + Dnsmasq LeaseFileFormat = "dnsmasq" + IscDhcpd LeaseFileFormat = "isc-dhcpd" +) diff --git a/cmd/cli/cli.go b/cmd/cli/cli.go new file mode 100644 index 0000000..894efc0 --- /dev/null +++ b/cmd/cli/cli.go @@ -0,0 +1,1696 @@ +package cli + +import ( + "bytes" + "context" + "crypto/x509" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/netip" + "os" + "os/exec" + "path/filepath" + "reflect" + "runtime" + "sort" + "strconv" + "strings" + "sync" + "time" + + "github.com/cuonglm/osinfo" + "github.com/fsnotify/fsnotify" + "github.com/go-playground/validator/v10" + "github.com/kardianos/service" + "github.com/miekg/dns" + "github.com/olekukonko/tablewriter" + "github.com/pelletier/go-toml/v2" + "github.com/rs/zerolog" + "github.com/spf13/cobra" + "github.com/spf13/pflag" + "github.com/spf13/viper" + "tailscale.com/logtail/backoff" + "tailscale.com/net/interfaces" + + "github.com/Control-D-Inc/ctrld" + "github.com/Control-D-Inc/ctrld/internal/clientinfo" + "github.com/Control-D-Inc/ctrld/internal/controld" + ctrldnet "github.com/Control-D-Inc/ctrld/internal/net" + "github.com/Control-D-Inc/ctrld/internal/router" +) + +var ( + version = "dev" + commit = "none" +) + +var ( + v = viper.NewWithOptions(viper.KeyDelimiter("::")) + defaultConfigFile = "ctrld.toml" + rootCertPool *x509.CertPool +) + +var basicModeFlags = []string{"listen", "primary_upstream", "secondary_upstream", "domains"} + +func isNoConfigStart(cmd *cobra.Command) bool { + for _, flagName := range basicModeFlags { + if cmd.Flags().Lookup(flagName).Changed { + return true + } + } + return false +} + +const rootShortDesc = ` + __ .__ .___ + _____/ |________| | __| _/ +_/ ___\ __\_ __ \ | / __ | +\ \___| | | | \/ |__/ /_/ | + \___ >__| |__| |____/\____ | + \/ dns forwarding proxy \/ +` + +var rootCmd = &cobra.Command{ + Use: "ctrld", + Short: strings.TrimLeft(rootShortDesc, "\n"), + Version: curVersion(), + PreRun: func(cmd *cobra.Command, args []string) { + initConsoleLogging() + }, +} + +func curVersion() string { + if version != "dev" && !strings.HasPrefix(version, "v") { + version = "v" + version + } + if len(commit) > 7 { + commit = commit[:7] + } + return fmt.Sprintf("%s-%s", version, commit) +} + +func initCLI() { + // Enable opening via explorer.exe on Windows. + // See: https://github.com/spf13/cobra/issues/844. + cobra.MousetrapHelpText = "" + cobra.EnableCommandSorting = false + + rootCmd.PersistentFlags().CountVarP( + &verbose, + "verbose", + "v", + `verbose log output, "-v" basic logging, "-vv" debug level logging`, + ) + rootCmd.PersistentFlags().BoolVarP( + &silent, + "silent", + "s", + false, + `do not write any log output`, + ) + rootCmd.SetHelpCommand(&cobra.Command{Hidden: true}) + rootCmd.CompletionOptions.HiddenDefaultCmd = true + + runCmd := &cobra.Command{ + Use: "run", + Short: "Run the DNS proxy server", + Args: cobra.NoArgs, + PreRun: func(cmd *cobra.Command, args []string) { + initConsoleLogging() + }, + Run: func(cmd *cobra.Command, args []string) { + waitCh := make(chan struct{}) + stopCh := make(chan struct{}) + p := &prog{ + waitCh: waitCh, + stopCh: stopCh, + cfg: &cfg, + } + if homedir == "" { + if dir, err := userHomeDir(); err == nil { + homedir = dir + } + } + sockPath := filepath.Join(homedir, ctrldLogUnixSock) + if addr, err := net.ResolveUnixAddr("unix", sockPath); err == nil { + if conn, err := net.Dial(addr.Network(), addr.String()); err == nil { + lc := &logConn{conn: conn} + consoleWriter.Out = io.MultiWriter(os.Stdout, lc) + p.logConn = lc + } + } + + if daemon && runtime.GOOS == "windows" { + mainLog.Load().Fatal().Msg("Cannot run in daemon mode. Please install a Windows service.") + } + + if !daemon { + // We need to call s.Run() as soon as possible to response to the OS manager, so it + // can see ctrld is running and don't mark ctrld as failed service. + go func() { + s, err := newService(p, svcConfig) + if err != nil { + mainLog.Load().Fatal().Err(err).Msg("failed create new service") + } + if err := s.Run(); err != nil { + mainLog.Load().Error().Err(err).Msg("failed to start service") + } + }() + } + noConfigStart := isNoConfigStart(cmd) + writeDefaultConfig := !noConfigStart && configBase64 == "" + tryReadingConfig(writeDefaultConfig) + + readBase64Config(configBase64) + processNoConfigFlags(noConfigStart) + if err := v.Unmarshal(&cfg); err != nil { + mainLog.Load().Fatal().Msgf("failed to unmarshal config: %v", err) + } + + processLogAndCacheFlags() + + // Log config do not have thing to validate, so it's safe to init log here, + // so it's able to log information in processCDFlags. + initLogging() + + mainLog.Load().Info().Msgf("starting ctrld %s", curVersion()) + mainLog.Load().Info().Msgf("os: %s", osVersion()) + + // Wait for network up. + if !ctrldnet.Up() { + mainLog.Load().Fatal().Msg("network is not up yet") + } + + p.router = router.New(&cfg, cdUID != "") + cs, err := newControlServer(filepath.Join(homedir, ctrldControlUnixSock)) + if err != nil { + mainLog.Load().Warn().Err(err).Msg("could not create control server") + } + p.cs = cs + + // Processing --cd flag require connecting to ControlD API, which needs valid + // time for validating server certificate. Some routers need NTP synchronization + // to set the current time, so this check must happen before processCDFlags. + if err := p.router.PreRun(); err != nil { + mainLog.Load().Fatal().Err(err).Msg("failed to perform router pre-run check") + } + + oldLogPath := cfg.Service.LogPath + if uid := cdUIDFromProvToken(); uid != "" { + cdUID = uid + } + if cdUID != "" { + processCDFlags() + } + + updateListenerConfig() + + if cdUID != "" { + processLogAndCacheFlags() + } + + if err := writeConfigFile(); err != nil { + mainLog.Load().Fatal().Err(err).Msg("failed to write config file") + } else { + mainLog.Load().Info().Msg("writing config file to: " + defaultConfigFile) + } + + if newLogPath := cfg.Service.LogPath; newLogPath != "" && oldLogPath != newLogPath { + // After processCDFlags, log config may change, so reset mainLog and re-init logging. + l := zerolog.New(io.Discard) + mainLog.Store(&l) + + // Copy logs written so far to new log file if possible. + if buf, err := os.ReadFile(oldLogPath); err == nil { + if err := os.WriteFile(newLogPath, buf, os.FileMode(0o600)); err != nil { + mainLog.Load().Warn().Err(err).Msg("could not copy old log file") + } + } + initLoggingWithBackup(false) + } + + validateConfig(&cfg) + initCache() + + if daemon { + exe, err := os.Executable() + if err != nil { + mainLog.Load().Error().Err(err).Msg("failed to find the binary") + os.Exit(1) + } + curDir, err := os.Getwd() + if err != nil { + mainLog.Load().Error().Err(err).Msg("failed to get current working directory") + os.Exit(1) + } + // If running as daemon, re-run the command in background, with daemon off. + cmd := exec.Command(exe, append(os.Args[1:], "-d=false")...) + cmd.Dir = curDir + if err := cmd.Start(); err != nil { + mainLog.Load().Error().Err(err).Msg("failed to start process as daemon") + os.Exit(1) + } + mainLog.Load().Info().Int("pid", cmd.Process.Pid).Msg("DNS proxy started") + os.Exit(0) + } + + p.onStarted = append(p.onStarted, func() { + for _, lc := range p.cfg.Listener { + if shouldAllocateLoopbackIP(lc.IP) { + if err := allocateIP(lc.IP); err != nil { + mainLog.Load().Error().Err(err).Msgf("could not allocate IP: %s", lc.IP) + } + } + } + }) + p.onStopped = append(p.onStopped, func() { + for _, lc := range p.cfg.Listener { + if shouldAllocateLoopbackIP(lc.IP) { + if err := deAllocateIP(lc.IP); err != nil { + mainLog.Load().Error().Err(err).Msgf("could not de-allocate IP: %s", lc.IP) + } + } + } + }) + if platform := router.Name(); platform != "" { + if cp := router.CertPool(); cp != nil { + rootCertPool = cp + } + p.onStarted = append(p.onStarted, func() { + mainLog.Load().Debug().Msg("router setup on start") + if err := p.router.Setup(); err != nil { + mainLog.Load().Error().Err(err).Msg("could not configure router") + } + }) + p.onStopped = append(p.onStopped, func() { + mainLog.Load().Debug().Msg("router cleanup on stop") + if err := p.router.Cleanup(); err != nil { + mainLog.Load().Error().Err(err).Msg("could not cleanup router") + } + p.resetDNS() + }) + } + + close(waitCh) + <-stopCh + for _, f := range p.onStopped { + f() + } + }, + } + runCmd.Flags().BoolVarP(&daemon, "daemon", "d", false, "Run as daemon") + runCmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file") + runCmd.Flags().StringVarP(&configBase64, "base64_config", "", "", "Base64 encoded config") + runCmd.Flags().StringVarP(&listenAddress, "listen", "", "", "Listener address and port, in format: address:port") + runCmd.Flags().StringVarP(&primaryUpstream, "primary_upstream", "", "", "Primary upstream endpoint") + runCmd.Flags().StringVarP(&secondaryUpstream, "secondary_upstream", "", "", "Secondary upstream endpoint") + runCmd.Flags().StringSliceVarP(&domains, "domains", "", nil, "List of domain to apply in a split DNS policy") + runCmd.Flags().StringVarP(&logPath, "log", "", "", "Path to log file") + runCmd.Flags().IntVarP(&cacheSize, "cache_size", "", 0, "Enable cache with size items") + runCmd.Flags().StringVarP(&cdUID, "cd", "", "", "Control D resolver uid") + runCmd.Flags().StringVarP(&cdOrg, "cd-org", "", "", "Control D provision token") + runCmd.Flags().BoolVarP(&cdDev, "dev", "", false, "Use Control D dev resolver/domain") + _ = runCmd.Flags().MarkHidden("dev") + runCmd.Flags().StringVarP(&homedir, "homedir", "", "", "") + _ = runCmd.Flags().MarkHidden("homedir") + runCmd.Flags().StringVarP(&iface, "iface", "", "", `Update DNS setting for iface, "auto" means the default interface gateway`) + _ = runCmd.Flags().MarkHidden("iface") + + rootCmd.AddCommand(runCmd) + + startCmd := &cobra.Command{ + PreRun: func(cmd *cobra.Command, args []string) { + initConsoleLogging() + checkHasElevatedPrivilege() + }, + Use: "start", + Short: "Install and start the ctrld service", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + sc := &service.Config{} + *sc = *svcConfig + osArgs := os.Args[2:] + if os.Args[1] == "service" { + osArgs = os.Args[3:] + } + setDependencies(sc) + sc.Arguments = append([]string{"run"}, osArgs...) + if cdUID != "" { + if _, err := controld.FetchResolverConfig(cdUID, rootCmd.Version, cdDev); err != nil { + mainLog.Load().Fatal().Err(err).Msgf("failed to fetch resolver uid: %s", cdUID) + } + } else if uid := cdUIDFromProvToken(); uid != "" { + cdUID = uid + removeProvTokenFromArgs(sc) + // Pass --cd flag to "ctrld run" command, so the provision token takes no effect. + sc.Arguments = append(sc.Arguments, "--cd="+cdUID) + } + + p := &prog{ + router: router.New(&cfg, cdUID != ""), + cfg: &cfg, + } + if err := p.router.ConfigureService(sc); err != nil { + mainLog.Load().Fatal().Err(err).Msg("failed to configure service on router") + } + + // No config path, generating config in HOME directory. + noConfigStart := isNoConfigStart(cmd) + writeDefaultConfig := !noConfigStart && configBase64 == "" + if configPath != "" { + v.SetConfigFile(configPath) + } + + // A buffer channel to gather log output from runCmd and report + // to user in case self-check process failed. + runCmdLogCh := make(chan string, 256) + if dir, err := userHomeDir(); err == nil { + setWorkingDirectory(sc, dir) + if configPath == "" && writeDefaultConfig { + defaultConfigFile = filepath.Join(dir, defaultConfigFile) + } + sc.Arguments = append(sc.Arguments, "--homedir="+dir) + sockPath := filepath.Join(dir, ctrldLogUnixSock) + _ = os.Remove(sockPath) + go func() { + defer func() { + close(runCmdLogCh) + _ = os.Remove(sockPath) + }() + if conn := runLogServer(sockPath); conn != nil { + // Enough buffer for log message, we don't produce + // such long log message, but just in case. + buf := make([]byte, 1024) + for { + n, err := conn.Read(buf) + if err != nil { + return + } + runCmdLogCh <- string(buf[:n]) + } + } + }() + } + + tryReadingConfig(writeDefaultConfig) + + if err := v.Unmarshal(&cfg); err != nil { + mainLog.Load().Fatal().Msgf("failed to unmarshal config: %v", err) + } + + initLogging() + + // Explicitly passing config, so on system where home directory could not be obtained, + // or sub-process env is different with the parent, we still behave correctly and use + // the expected config file. + if configPath == "" { + sc.Arguments = append(sc.Arguments, "--config="+defaultConfigFile) + } + + s, err := newService(p, sc) + if err != nil { + mainLog.Load().Error().Msg(err.Error()) + return + } + + if router.Name() != "" { + mainLog.Load().Debug().Msg("cleaning up router before installing") + _ = p.router.Cleanup() + } + + tasks := []task{ + {s.Stop, false}, + {s.Uninstall, false}, + {s.Install, false}, + {s.Start, true}, + } + mainLog.Load().Notice().Msg("Starting service") + if doTasks(tasks) { + if err := p.router.Install(sc); err != nil { + mainLog.Load().Warn().Err(err).Msg("post installation failed, please check system/service log for details error") + return + } + + status := selfCheckStatus(s) + switch status { + case service.StatusRunning: + mainLog.Load().Notice().Msg("Service started") + default: + marker := bytes.Repeat([]byte("="), 32) + mainLog.Load().Error().Msg("ctrld service may not have started due to an error or misconfiguration, service log:") + _, _ = mainLog.Load().Write(marker) + for msg := range runCmdLogCh { + _, _ = mainLog.Load().Write([]byte(msg)) + } + _, _ = mainLog.Load().Write(marker) + uninstall(p, s) + os.Exit(1) + } + p.setDNS() + } + }, + } + // Keep these flags in sync with runCmd above, except for "-d". + startCmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file") + startCmd.Flags().StringVarP(&configBase64, "base64_config", "", "", "Base64 encoded config") + startCmd.Flags().StringVarP(&listenAddress, "listen", "", "", "Listener address and port, in format: address:port") + startCmd.Flags().StringVarP(&primaryUpstream, "primary_upstream", "", "", "Primary upstream endpoint") + startCmd.Flags().StringVarP(&secondaryUpstream, "secondary_upstream", "", "", "Secondary upstream endpoint") + startCmd.Flags().StringSliceVarP(&domains, "domains", "", nil, "List of domain to apply in a split DNS policy") + startCmd.Flags().StringVarP(&logPath, "log", "", "", "Path to log file") + startCmd.Flags().IntVarP(&cacheSize, "cache_size", "", 0, "Enable cache with size items") + startCmd.Flags().StringVarP(&cdUID, "cd", "", "", "Control D resolver uid") + startCmd.Flags().StringVarP(&cdOrg, "cd-org", "", "", "Control D provision token") + startCmd.Flags().BoolVarP(&cdDev, "dev", "", false, "Use Control D dev resolver/domain") + _ = startCmd.Flags().MarkHidden("dev") + startCmd.Flags().StringVarP(&iface, "iface", "", "", `Update DNS setting for iface, "auto" means the default interface gateway`) + + routerCmd := &cobra.Command{ + Use: "setup", + PreRun: func(cmd *cobra.Command, args []string) { + initConsoleLogging() + }, + Run: func(cmd *cobra.Command, _ []string) { + exe, err := os.Executable() + if err != nil { + mainLog.Load().Fatal().Msgf("could not find executable path: %v", err) + os.Exit(1) + } + flags := make([]string, 0) + cmd.Flags().Visit(func(flag *pflag.Flag) { + flags = append(flags, fmt.Sprintf("--%s=%s", flag.Name, flag.Value)) + }) + cmdArgs := []string{"start"} + cmdArgs = append(cmdArgs, flags...) + command := exec.Command(exe, cmdArgs...) + command.Stdout = os.Stdout + command.Stderr = os.Stderr + command.Stdin = os.Stdin + if err := command.Run(); err != nil { + mainLog.Load().Fatal().Msg(err.Error()) + } + }, + } + routerCmd.Flags().AddFlagSet(startCmd.Flags()) + routerCmd.Hidden = true + rootCmd.AddCommand(routerCmd) + + stopCmd := &cobra.Command{ + PreRun: func(cmd *cobra.Command, args []string) { + initConsoleLogging() + checkHasElevatedPrivilege() + }, + Use: "stop", + Short: "Stop the ctrld service", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + readConfig(false) + v.Unmarshal(&cfg) + p := &prog{router: router.New(&cfg, cdUID != "")} + s, err := newService(p, svcConfig) + if err != nil { + mainLog.Load().Error().Msg(err.Error()) + return + } + initLogging() + if doTasks([]task{{s.Stop, true}}) { + p.router.Cleanup() + p.resetDNS() + mainLog.Load().Notice().Msg("Service stopped") + } + }, + } + stopCmd.Flags().StringVarP(&iface, "iface", "", "", `Reset DNS setting for iface, "auto" means the default interface gateway`) + + restartCmd := &cobra.Command{ + PreRun: func(cmd *cobra.Command, args []string) { + initConsoleLogging() + checkHasElevatedPrivilege() + }, + Use: "restart", + Short: "Restart the ctrld service", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + s, err := newService(&prog{}, svcConfig) + if err != nil { + mainLog.Load().Error().Msg(err.Error()) + return + } + initLogging() + + tasks := []task{ + {s.Stop, false}, + {s.Start, true}, + } + if doTasks(tasks) { + dir, err := userHomeDir() + if err != nil { + mainLog.Load().Warn().Err(err).Msg("Service was restarted, but could not ping the control server") + return + } + if cc := newSocketControlClient(s, dir); cc == nil { + mainLog.Load().Notice().Msg("Service was not restarted") + os.Exit(1) + } + mainLog.Load().Notice().Msg("Service restarted") + } + }, + } + + statusCmd := &cobra.Command{ + Use: "status", + Short: "Show status of the ctrld service", + Args: cobra.NoArgs, + PreRun: func(cmd *cobra.Command, args []string) { + initConsoleLogging() + }, + Run: func(cmd *cobra.Command, args []string) { + s, err := newService(&prog{}, svcConfig) + if err != nil { + mainLog.Load().Error().Msg(err.Error()) + return + } + status, err := s.Status() + if err != nil { + mainLog.Load().Error().Msg(err.Error()) + os.Exit(1) + } + switch status { + case service.StatusUnknown: + mainLog.Load().Notice().Msg("Unknown status") + os.Exit(2) + case service.StatusRunning: + mainLog.Load().Notice().Msg("Service is running") + os.Exit(0) + case service.StatusStopped: + mainLog.Load().Notice().Msg("Service is stopped") + os.Exit(1) + } + }, + } + if runtime.GOOS == "darwin" { + // On darwin, running status command without privileges may return wrong information. + statusCmd.PreRun = func(cmd *cobra.Command, args []string) { + initConsoleLogging() + checkHasElevatedPrivilege() + } + } + + uninstallCmd := &cobra.Command{ + PreRun: func(cmd *cobra.Command, args []string) { + initConsoleLogging() + checkHasElevatedPrivilege() + }, + Use: "uninstall", + Short: "Stop and uninstall the ctrld service", + Long: `Stop and uninstall the ctrld service. + +NOTE: Uninstalling will set DNS to values provided by DHCP.`, + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + readConfig(false) + v.Unmarshal(&cfg) + p := &prog{router: router.New(&cfg, cdUID != "")} + s, err := newService(p, svcConfig) + if err != nil { + mainLog.Load().Error().Msg(err.Error()) + return + } + if iface == "" { + iface = "auto" + } + uninstall(p, s) + }, + } + uninstallCmd.Flags().StringVarP(&iface, "iface", "", "", `Reset DNS setting for iface, use "auto" for the default gateway interface`) + + listIfacesCmd := &cobra.Command{ + Use: "list", + Short: "List network interfaces of the host", + Args: cobra.NoArgs, + PreRun: func(cmd *cobra.Command, args []string) { + initConsoleLogging() + }, + Run: func(cmd *cobra.Command, args []string) { + err := interfaces.ForeachInterface(func(i interfaces.Interface, prefixes []netip.Prefix) { + fmt.Printf("Index : %d\n", i.Index) + fmt.Printf("Name : %s\n", i.Name) + addrs, _ := i.Addrs() + for i, ipaddr := range addrs { + if i == 0 { + fmt.Printf("Addrs : %v\n", ipaddr) + continue + } + fmt.Printf(" %v\n", ipaddr) + } + for i, dns := range currentDNS(i.Interface) { + if i == 0 { + fmt.Printf("DNS : %s\n", dns) + continue + } + fmt.Printf(" : %s\n", dns) + } + println() + }) + if err != nil { + mainLog.Load().Error().Msg(err.Error()) + } + }, + } + interfacesCmd := &cobra.Command{ + Use: "interfaces", + Short: "Manage network interfaces", + Args: cobra.OnlyValidArgs, + ValidArgs: []string{ + listIfacesCmd.Use, + }, + } + interfacesCmd.AddCommand(listIfacesCmd) + + serviceCmd := &cobra.Command{ + Use: "service", + Short: "Manage ctrld service", + Args: cobra.OnlyValidArgs, + ValidArgs: []string{ + statusCmd.Use, + stopCmd.Use, + restartCmd.Use, + statusCmd.Use, + uninstallCmd.Use, + interfacesCmd.Use, + }, + } + serviceCmd.AddCommand(startCmd) + serviceCmd.AddCommand(stopCmd) + serviceCmd.AddCommand(restartCmd) + serviceCmd.AddCommand(statusCmd) + serviceCmd.AddCommand(uninstallCmd) + serviceCmd.AddCommand(interfacesCmd) + rootCmd.AddCommand(serviceCmd) + startCmdAlias := &cobra.Command{ + PreRun: func(cmd *cobra.Command, args []string) { + initConsoleLogging() + checkHasElevatedPrivilege() + }, + Use: "start", + Short: "Quick start service and configure DNS on interface", + Run: func(cmd *cobra.Command, args []string) { + if !cmd.Flags().Changed("iface") { + os.Args = append(os.Args, "--iface="+ifaceStartStop) + } + iface = ifaceStartStop + startCmd.Run(cmd, args) + }, + } + startCmdAlias.Flags().StringVarP(&ifaceStartStop, "iface", "", "auto", `Update DNS setting for iface, "auto" means the default interface gateway`) + startCmdAlias.Flags().AddFlagSet(startCmd.Flags()) + rootCmd.AddCommand(startCmdAlias) + stopCmdAlias := &cobra.Command{ + PreRun: func(cmd *cobra.Command, args []string) { + initConsoleLogging() + checkHasElevatedPrivilege() + }, + Use: "stop", + Short: "Quick stop service and remove DNS from interface", + Run: func(cmd *cobra.Command, args []string) { + if !cmd.Flags().Changed("iface") { + os.Args = append(os.Args, "--iface="+ifaceStartStop) + } + iface = ifaceStartStop + stopCmd.Run(cmd, args) + }, + } + stopCmdAlias.Flags().StringVarP(&ifaceStartStop, "iface", "", "auto", `Reset DNS setting for iface, "auto" means the default interface gateway`) + stopCmdAlias.Flags().AddFlagSet(stopCmd.Flags()) + rootCmd.AddCommand(stopCmdAlias) + + restartCmdAlias := &cobra.Command{ + PreRun: func(cmd *cobra.Command, args []string) { + initConsoleLogging() + checkHasElevatedPrivilege() + }, + Use: "restart", + Short: "Restart the ctrld service", + Run: func(cmd *cobra.Command, args []string) { + restartCmd.Run(cmd, args) + }, + } + rootCmd.AddCommand(restartCmdAlias) + + statusCmdAlias := &cobra.Command{ + Use: "status", + Short: "Show status of the ctrld service", + Args: cobra.NoArgs, + PreRun: func(cmd *cobra.Command, args []string) { + initConsoleLogging() + }, + Run: statusCmd.Run, + } + rootCmd.AddCommand(statusCmdAlias) + + uninstallCmdAlias := &cobra.Command{ + PreRun: func(cmd *cobra.Command, args []string) { + initConsoleLogging() + checkHasElevatedPrivilege() + }, + Use: "uninstall", + Short: "Stop and uninstall the ctrld service", + Long: `Stop and uninstall the ctrld service. + +NOTE: Uninstalling will set DNS to values provided by DHCP.`, + Run: func(cmd *cobra.Command, args []string) { + if !cmd.Flags().Changed("iface") { + os.Args = append(os.Args, "--iface="+ifaceStartStop) + } + iface = ifaceStartStop + uninstallCmd.Run(cmd, args) + }, + } + uninstallCmdAlias.Flags().StringVarP(&ifaceStartStop, "iface", "", "auto", `Reset DNS setting for iface, "auto" means the default interface gateway`) + uninstallCmdAlias.Flags().AddFlagSet(stopCmd.Flags()) + rootCmd.AddCommand(uninstallCmdAlias) + + listClientsCmd := &cobra.Command{ + Use: "list", + Short: "List clients that ctrld discovered", + Args: cobra.NoArgs, + PreRun: func(cmd *cobra.Command, args []string) { + initConsoleLogging() + checkHasElevatedPrivilege() + }, + Run: func(cmd *cobra.Command, args []string) { + dir, err := userHomeDir() + if err != nil { + mainLog.Load().Fatal().Err(err).Msg("failed to find ctrld home dir") + } + cc := newControlClient(filepath.Join(dir, ctrldControlUnixSock)) + resp, err := cc.post(listClientsPath, nil) + if err != nil { + mainLog.Load().Fatal().Err(err).Msg("failed to get clients list") + } + defer resp.Body.Close() + + var clients []*clientinfo.Client + if err := json.NewDecoder(resp.Body).Decode(&clients); err != nil { + mainLog.Load().Fatal().Err(err).Msg("failed to decode clients list result") + } + map2Slice := func(m map[string]struct{}) []string { + s := make([]string, 0, len(m)) + for k := range m { + s = append(s, k) + } + sort.Strings(s) + return s + } + data := make([][]string, len(clients)) + for i, c := range clients { + row := []string{ + c.IP.String(), + c.Hostname, + c.Mac, + strings.Join(map2Slice(c.Source), ","), + } + data[i] = row + } + table := tablewriter.NewWriter(os.Stdout) + table.SetHeader([]string{"IP", "Hostname", "Mac", "Discovered"}) + table.SetAutoFormatHeaders(false) + table.AppendBulk(data) + table.Render() + }, + } + clientsCmd := &cobra.Command{ + Use: "clients", + Short: "Manage clients", + Args: cobra.OnlyValidArgs, + ValidArgs: []string{ + listClientsCmd.Use, + }, + } + clientsCmd.AddCommand(listClientsCmd) + rootCmd.AddCommand(clientsCmd) +} + +func writeConfigFile() error { + if cfu := v.ConfigFileUsed(); cfu != "" { + defaultConfigFile = cfu + } else if configPath != "" { + defaultConfigFile = configPath + } + f, err := os.OpenFile(defaultConfigFile, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, os.FileMode(0o644)) + if err != nil { + return err + } + defer f.Close() + if cdUID != "" { + if _, err := f.WriteString("# AUTO-GENERATED VIA CD FLAG - DO NOT MODIFY\n\n"); err != nil { + return err + } + } + enc := toml.NewEncoder(f).SetIndentTables(true) + if err := enc.Encode(&cfg); err != nil { + return err + } + if err := f.Close(); err != nil { + return err + } + return nil +} + +func readConfigFile(writeDefaultConfig bool) bool { + // If err == nil, there's a config supplied via `--config`, no default config written. + err := v.ReadInConfig() + if err == nil { + mainLog.Load().Info().Msg("loading config file from: " + v.ConfigFileUsed()) + defaultConfigFile = v.ConfigFileUsed() + return true + } + + if !writeDefaultConfig { + return false + } + + // If error is viper.ConfigFileNotFoundError, write default config. + if _, ok := err.(viper.ConfigFileNotFoundError); ok { + if err := v.Unmarshal(&cfg); err != nil { + mainLog.Load().Fatal().Msgf("failed to unmarshal default config: %v", err) + } + if err := writeConfigFile(); err != nil { + mainLog.Load().Fatal().Msgf("failed to write default config file: %v", err) + } else { + fp, err := filepath.Abs(defaultConfigFile) + if err != nil { + mainLog.Load().Fatal().Msgf("failed to get default config file path: %v", err) + } + mainLog.Load().Info().Msg("writing default config file to: " + fp) + } + return false + } + + if _, ok := err.(viper.ConfigParseError); ok { + if f, _ := os.Open(v.ConfigFileUsed()); f != nil { + var i any + if err, ok := toml.NewDecoder(f).Decode(&i).(*toml.DecodeError); ok { + row, col := err.Position() + mainLog.Load().Fatal().Msgf("failed to decode config file at line: %d, column: %d, error: %v", row, col, err) + } + } + } + + // Otherwise, report fatal error and exit. + mainLog.Load().Fatal().Msgf("failed to decode config file: %v", err) + return false +} + +func readBase64Config(configBase64 string) { + if configBase64 == "" { + return + } + configStr, err := base64.StdEncoding.DecodeString(configBase64) + if err != nil { + mainLog.Load().Fatal().Msgf("invalid base64 config: %v", err) + } + + // readBase64Config is called when: + // + // - "--base64_config" flag set. + // - Reading custom config when "--cd" flag set. + // + // So we need to re-create viper instance to discard old one. + v = viper.NewWithOptions(viper.KeyDelimiter("::")) + v.SetConfigType("toml") + if err := v.ReadConfig(bytes.NewReader(configStr)); err != nil { + mainLog.Load().Fatal().Msgf("failed to read base64 config: %v", err) + } +} + +func processNoConfigFlags(noConfigStart bool) { + if !noConfigStart { + return + } + if listenAddress == "" || primaryUpstream == "" { + mainLog.Load().Fatal().Msg(`"listen" and "primary_upstream" flags must be set in no config mode`) + } + processListenFlag() + + endpointAndTyp := func(endpoint string) (string, string) { + typ := ctrld.ResolverTypeFromEndpoint(endpoint) + return strings.TrimPrefix(endpoint, "quic://"), typ + } + pEndpoint, pType := endpointAndTyp(primaryUpstream) + upstream := map[string]*ctrld.UpstreamConfig{ + "0": { + Name: pEndpoint, + Endpoint: pEndpoint, + Type: pType, + Timeout: 5000, + }, + } + if secondaryUpstream != "" { + sEndpoint, sType := endpointAndTyp(secondaryUpstream) + upstream["1"] = &ctrld.UpstreamConfig{ + Name: sEndpoint, + Endpoint: sEndpoint, + Type: sType, + Timeout: 5000, + } + rules := make([]ctrld.Rule, 0, len(domains)) + for _, domain := range domains { + rules = append(rules, ctrld.Rule{domain: []string{"upstream.1"}}) + } + lc := v.Get("listener").(map[string]*ctrld.ListenerConfig)["0"] + lc.Policy = &ctrld.ListenerPolicyConfig{Name: "My Policy", Rules: rules} + } + v.Set("upstream", upstream) +} + +func processCDFlags() { + logger := mainLog.Load().With().Str("mode", "cd").Logger() + logger.Info().Msgf("fetching Controld D configuration from API: %s", cdUID) + bo := backoff.NewBackoff("processCDFlags", logf, 30*time.Second) + bo.LogLongerThan = 30 * time.Second + ctx := context.Background() + resolverConfig, err := controld.FetchResolverConfig(cdUID, rootCmd.Version, cdDev) + for { + if errUrlNetworkError(err) { + bo.BackOff(ctx, err) + logger.Warn().Msg("could not fetch resolver using bootstrap DNS, retrying...") + resolverConfig, err = controld.FetchResolverConfig(cdUID, rootCmd.Version, cdDev) + continue + } + break + } + if uer, ok := err.(*controld.UtilityErrorResponse); ok && uer.ErrorField.Code == controld.InvalidConfigCode { + s, err := newService(&prog{}, svcConfig) + if err != nil { + logger.Warn().Err(err).Msg("failed to create new service") + return + } + if netIface, _ := netInterface(iface); netIface != nil { + if err := restoreNetworkManager(); err != nil { + logger.Error().Err(err).Msg("could not restore NetworkManager") + return + } + logger.Debug().Str("iface", netIface.Name).Msg("Restoring DNS for interface") + if err := resetDNS(netIface); err != nil { + logger.Warn().Err(err).Msg("something went wrong while restoring DNS") + } else { + logger.Debug().Str("iface", netIface.Name).Msg("Restoring DNS successfully") + } + } + + tasks := []task{{s.Uninstall, true}} + if doTasks(tasks) { + logger.Info().Msg("uninstalled service") + } + logger.Fatal().Err(uer).Msg("failed to fetch resolver config") + } + if err != nil { + logger.Warn().Err(err).Msg("could not fetch resolver config") + return + } + + logger.Info().Msg("generating ctrld config from Control-D configuration") + cfg = ctrld.Config{} + + // Fetch config, unmarshal to cfg. + if resolverConfig.Ctrld.CustomConfig != "" { + logger.Info().Msg("using defined custom config of Control-D resolver") + readBase64Config(resolverConfig.Ctrld.CustomConfig) + if err := v.Unmarshal(&cfg); err != nil { + mainLog.Load().Fatal().Msgf("failed to unmarshal config: %v", err) + } + } else { + cfg.Network = make(map[string]*ctrld.NetworkConfig) + cfg.Network["0"] = &ctrld.NetworkConfig{ + Name: "Network 0", + Cidrs: []string{"0.0.0.0/0"}, + } + cfg.Upstream = make(map[string]*ctrld.UpstreamConfig) + cfg.Upstream["0"] = &ctrld.UpstreamConfig{ + Endpoint: resolverConfig.DOH, + Type: ctrld.ResolverTypeDOH, + Timeout: 5000, + } + rules := make([]ctrld.Rule, 0, len(resolverConfig.Exclude)) + for _, domain := range resolverConfig.Exclude { + rules = append(rules, ctrld.Rule{domain: []string{}}) + } + cfg.Listener = make(map[string]*ctrld.ListenerConfig) + lc := &ctrld.ListenerConfig{ + Policy: &ctrld.ListenerPolicyConfig{ + Name: "My Policy", + Rules: rules, + }, + } + cfg.Listener["0"] = lc + } + // Set default value. + if len(cfg.Listener) == 0 { + cfg.Listener = map[string]*ctrld.ListenerConfig{ + "0": {IP: "", Port: 0}, + } + } +} + +func processListenFlag() { + if listenAddress == "" { + return + } + host, portStr, err := net.SplitHostPort(listenAddress) + if err != nil { + mainLog.Load().Fatal().Msgf("invalid listener address: %v", err) + } + port, err := strconv.Atoi(portStr) + if err != nil { + mainLog.Load().Fatal().Msgf("invalid port number: %v", err) + } + lc := &ctrld.ListenerConfig{ + IP: host, + Port: port, + } + v.Set("listener", map[string]*ctrld.ListenerConfig{ + "0": lc, + }) +} + +func processLogAndCacheFlags() { + if logPath != "" { + cfg.Service.LogPath = logPath + } + if logPath != "" && cfg.Service.LogLevel == "" { + cfg.Service.LogLevel = "debug" + } + + if cacheSize != 0 { + cfg.Service.CacheEnable = true + cfg.Service.CacheSize = cacheSize + } + v.Set("service", cfg.Service) +} + +func netInterface(ifaceName string) (*net.Interface, error) { + if ifaceName == "auto" { + ifaceName = defaultIfaceName() + } + var iface *net.Interface + err := interfaces.ForeachInterface(func(i interfaces.Interface, prefixes []netip.Prefix) { + if i.Name == ifaceName { + iface = i.Interface + } + }) + if iface == nil { + return nil, errors.New("interface not found") + } + if err := patchNetIfaceName(iface); err != nil { + return nil, err + } + return iface, err +} + +func defaultIfaceName() string { + if ifaceName := router.DefaultInterfaceName(); ifaceName != "" { + return ifaceName + } + dri, err := interfaces.DefaultRouteInterface() + if err != nil { + // On WSL 1, the route table does not have any default route. But the fact that + // it only uses /etc/resolv.conf for setup DNS, so we can use "lo" here. + if oi := osinfo.New(); strings.Contains(oi.String(), "Microsoft") { + return "lo" + } + mainLog.Load().Fatal().Err(err).Msg("failed to get default route interface") + } + return dri +} + +func selfCheckStatus(s service.Service) service.Status { + status, err := s.Status() + if err != nil { + mainLog.Load().Warn().Err(err).Msg("could not get service status") + return status + } + // If ctrld is not running, do nothing, just return the status as-is. + if status != service.StatusRunning { + return status + } + dir, err := userHomeDir() + if err != nil { + mainLog.Load().Error().Err(err).Msg("failed to check ctrld listener status: could not get home directory") + return service.StatusUnknown + } + mainLog.Load().Debug().Msg("waiting for ctrld listener to be ready") + cc := newSocketControlClient(s, dir) + if cc == nil { + return service.StatusUnknown + } + + resp, err := cc.post(startedPath, nil) + if err != nil { + mainLog.Load().Error().Err(err).Msg("failed to connect to control server") + return service.StatusUnknown + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + mainLog.Load().Error().Msg("ctrld listener is not ready") + return service.StatusUnknown + } + + mainLog.Load().Debug().Msg("ctrld listener is ready") + mainLog.Load().Debug().Msg("performing self-check") + bo := backoff.NewBackoff("self-check", logf, 10*time.Second) + bo.LogLongerThan = 500 * time.Millisecond + ctx := context.Background() + maxAttempts := 20 + c := new(dns.Client) + var ( + lcChanged map[string]*ctrld.ListenerConfig + ucChanged map[string]*ctrld.UpstreamConfig + mu sync.Mutex + ) + + if err := v.ReadInConfig(); err != nil { + mainLog.Load().Fatal().Err(err).Msg("failed to read new config") + } + if err := v.Unmarshal(&cfg); err != nil { + mainLog.Load().Fatal().Err(err).Msg("failed to update new config") + } + domain := cfg.FirstUpstream().VerifyDomain() + if domain == "" { + // Nothing to do, return the status as-is. + return status + } + watcher, err := fsnotify.NewWatcher() + if err != nil { + mainLog.Load().Error().Err(err).Msg("could not watch config change") + return service.StatusUnknown + } + defer watcher.Close() + + v.OnConfigChange(func(in fsnotify.Event) { + mu.Lock() + defer mu.Unlock() + if err := v.UnmarshalKey("listener", &lcChanged); err != nil { + mainLog.Load().Error().Msgf("failed to unmarshal listener config: %v", err) + return + } + if err := v.UnmarshalKey("upstream", &ucChanged); err != nil { + mainLog.Load().Error().Msgf("failed to unmarshal upstream config: %v", err) + return + } + }) + v.WatchConfig() + var ( + lastAnswer *dns.Msg + lastErr error + ) + for i := 0; i < maxAttempts; i++ { + mu.Lock() + if lcChanged != nil { + cfg.Listener = lcChanged + } + if ucChanged != nil { + cfg.Upstream = ucChanged + } + mu.Unlock() + lc := cfg.FirstListener() + domain = cfg.FirstUpstream().VerifyDomain() + if domain == "" { + continue + } + + m := new(dns.Msg) + m.SetQuestion(domain+".", dns.TypeA) + m.RecursionDesired = true + r, _, err := c.ExchangeContext(ctx, m, net.JoinHostPort(lc.IP, strconv.Itoa(lc.Port))) + if r != nil && r.Rcode == dns.RcodeSuccess && len(r.Answer) > 0 { + mainLog.Load().Debug().Msgf("self-check against %q succeeded", domain) + return status + } + lastAnswer = r + lastErr = err + bo.BackOff(ctx, fmt.Errorf("ExchangeContext: %w", err)) + } + mainLog.Load().Debug().Msgf("self-check against %q failed", domain) + lc := cfg.FirstListener() + addr := net.JoinHostPort(lc.IP, strconv.Itoa(lc.Port)) + marker := strings.Repeat("=", 32) + mainLog.Load().Debug().Msg(marker) + mainLog.Load().Debug().Msgf("listener address : %s", addr) + mainLog.Load().Debug().Msgf("last error : %v", lastErr) + if lastAnswer != nil { + mainLog.Load().Debug().Msgf("last answer from ctrld :") + mainLog.Load().Debug().Msg(marker) + for _, s := range strings.Split(lastAnswer.String(), "\n") { + mainLog.Load().Debug().Msgf("%s", s) + } + mainLog.Load().Debug().Msg(marker) + } + return service.StatusUnknown +} + +func userHomeDir() (string, error) { + dir, err := router.HomeDir() + if err != nil { + return "", err + } + if dir != "" { + return dir, nil + } + // viper will expand for us. + if runtime.GOOS == "windows" { + return os.UserHomeDir() + } + dir = "/etc/controld" + if err := os.MkdirAll(dir, 0750); err != nil { + return os.UserHomeDir() // fallback to user home directory + } + if ok, _ := dirWritable(dir); !ok { + return os.UserHomeDir() + } + return dir, nil +} + +func tryReadingConfig(writeDefaultConfig bool) { + // --config is specified. + if configPath != "" { + v.SetConfigFile(configPath) + readConfigFile(false) + return + } + // no config start or base64 config mode. + if !writeDefaultConfig { + return + } + readConfig(writeDefaultConfig) +} + +func readConfig(writeDefaultConfig bool) { + configs := []struct { + name string + written bool + }{ + // For compatibility, we check for config.toml first, but only read it if exists. + {"config", false}, + {"ctrld", writeDefaultConfig}, + } + + dir, err := userHomeDir() + if err != nil { + mainLog.Load().Fatal().Msgf("failed to get user home dir: %v", err) + } + for _, config := range configs { + ctrld.SetConfigNameWithPath(v, config.name, dir) + v.SetConfigFile(configPath) + if readConfigFile(config.written) { + break + } + } +} + +func uninstall(p *prog, s service.Service) { + tasks := []task{ + {s.Stop, false}, + {s.Uninstall, true}, + } + initLogging() + if doTasks(tasks) { + if err := p.router.ConfigureService(svcConfig); err != nil { + mainLog.Load().Fatal().Err(err).Msg("could not configure service") + } + if err := p.router.Uninstall(svcConfig); err != nil { + mainLog.Load().Warn().Err(err).Msg("post uninstallation failed, please check system/service log for details error") + return + } + p.resetDNS() + if router.Name() != "" { + mainLog.Load().Debug().Msg("Router cleanup") + } + // Stop already did router.Cleanup and report any error if happens, + // ignoring error here to prevent false positive. + _ = p.router.Cleanup() + mainLog.Load().Notice().Msg("Service uninstalled") + return + } +} + +func validateConfig(cfg *ctrld.Config) { + err := ctrld.ValidateConfig(validator.New(), cfg) + if err == nil { + return + } + var ve validator.ValidationErrors + if errors.As(err, &ve) { + for _, fe := range ve { + mainLog.Load().Error().Msgf("invalid config: %s: %s", fe.Namespace(), fieldErrorMsg(fe)) + } + } + os.Exit(1) +} + +// NOTE: Add more case here once new validation tag is used in ctrld.Config struct. +func fieldErrorMsg(fe validator.FieldError) string { + switch fe.Tag() { + case "oneof": + return fmt.Sprintf("must be one of: %q", fe.Param()) + case "min": + if fe.Kind() == reflect.Map || fe.Kind() == reflect.Slice { + return fmt.Sprintf("must define at least %s element", fe.Param()) + } + return fmt.Sprintf("minimum value: %q", fe.Param()) + case "len": + if fe.Kind() == reflect.Slice { + return fmt.Sprintf("must have at least %s element", fe.Param()) + } + return fmt.Sprintf("minimum len: %q", fe.Param()) + case "gte": + return fmt.Sprintf("must be greater than or equal to: %s", fe.Param()) + case "cidr": + return fmt.Sprintf("invalid value: %s", fe.Value()) + case "required_unless", "required": + return "value is required" + case "dnsrcode": + return fmt.Sprintf("invalid DNS rcode value: %s", fe.Value()) + case "ipstack": + ipStacks := []string{ctrld.IpStackV4, ctrld.IpStackV6, ctrld.IpStackSplit, ctrld.IpStackBoth} + return fmt.Sprintf("must be one of: %q", strings.Join(ipStacks, " ")) + case "iporempty": + return fmt.Sprintf("invalid IP format: %s", fe.Value()) + case "file": + return fmt.Sprintf("filed does not exist: %s", fe.Value()) + } + return "" +} + +func isLoopback(ipStr string) bool { + ip := net.ParseIP(ipStr) + if ip == nil { + return false + } + return ip.IsLoopback() +} + +func shouldAllocateLoopbackIP(ipStr string) bool { + ip := net.ParseIP(ipStr) + if ip == nil || ip.To4() == nil { + return false + } + return ip.IsLoopback() && ip.String() != "127.0.0.1" +} + +type listenerConfigCheck struct { + IP bool + Port bool +} + +// updateListenerConfig updates the config for listeners if not defined, +// or defined but invalid to be used, e.g: using loopback address other +// than 127.0.0.1 with sytemd-resolved. +func updateListenerConfig() { + lcc := make(map[string]*listenerConfigCheck) + cdMode := cdUID != "" + for n, listener := range cfg.Listener { + lcc[n] = &listenerConfigCheck{} + if listener.IP == "" { + listener.IP = "0.0.0.0" + lcc[n].IP = true + } + if listener.Port == 0 { + listener.Port = 53 + lcc[n].Port = true + } + // In cd mode, we always try to pick an ip:port pair to work. + if cdMode { + lcc[n].IP = true + lcc[n].Port = true + } + } + + var closers []io.Closer + defer func() { + for _, closer := range closers { + _ = closer.Close() + } + }() + // tryListen attempts to listen on given udp and tcp address. + // Created listeners will be kept in listeners slice above, and close + // before function finished. + tryListen := func(addr string) error { + udpLn, udpErr := net.ListenPacket("udp", addr) + if udpLn != nil { + closers = append(closers, udpLn) + } + tcpLn, tcpErr := net.Listen("tcp", addr) + if tcpLn != nil { + closers = append(closers, tcpLn) + } + return errors.Join(udpErr, tcpErr) + } + + logMsg := func(e *zerolog.Event, listenerNum int, format string, v ...any) { + e.MsgFunc(func() string { + return fmt.Sprintf("listener.%d %s", listenerNum, fmt.Sprintf(format, v...)) + }) + } + + listeners := make([]int, 0, len(cfg.Listener)) + for k := range cfg.Listener { + n, err := strconv.Atoi(k) + if err != nil { + continue + } + listeners = append(listeners, n) + } + sort.Ints(listeners) + + for _, n := range listeners { + listener := cfg.Listener[strconv.Itoa(n)] + check := lcc[strconv.Itoa(n)] + oldIP := listener.IP + oldPort := listener.Port + isZeroIP := listener.IP == "0.0.0.0" || listener.IP == "::" + + // Check if we could listen on the current IP + Port, if not, try following thing, pick first one success: + // - Try 127.0.0.1:53 + // - Pick a random port until success. + localhostIP := func(ipStr string) string { + if ip := net.ParseIP(ipStr); ip != nil && ip.To4() == nil { + return "::1" + } + return "127.0.0.1" + } + + // On firewalla, we don't need to check localhost, because the lo interface is excluded in dnsmasq + // config, so we can always listen on localhost port 53, but no traffic could be routed there. + tryLocalhost := !isLoopback(listener.IP) && router.CanListenLocalhost() + tryAllPort53 := true + tryOldIPPort5354 := true + tryPort5354 := true + attempts := 0 + maxAttempts := 10 + for { + if attempts == maxAttempts { + logMsg(mainLog.Load().Fatal(), n, "could not find available listen ip and port") + } + addr := net.JoinHostPort(listener.IP, strconv.Itoa(listener.Port)) + err := tryListen(addr) + if err == nil { + break + } + if !check.IP && !check.Port { + logMsg(mainLog.Load().Fatal(), n, "failed to listen: %v", err) + } + if tryAllPort53 { + tryAllPort53 = false + if check.IP { + listener.IP = "0.0.0.0" + } + if check.Port { + listener.Port = 53 + } + if check.IP { + logMsg(mainLog.Load().Warn(), n, "could not listen on address: %s, trying: %s", addr, net.JoinHostPort(listener.IP, strconv.Itoa(listener.Port))) + } + continue + } + if tryLocalhost { + tryLocalhost = false + if check.IP { + listener.IP = localhostIP(listener.IP) + } + if check.Port { + listener.Port = 53 + } + if check.IP { + logMsg(mainLog.Load().Warn(), n, "could not listen on address: %s, trying localhost: %s", addr, net.JoinHostPort(listener.IP, strconv.Itoa(listener.Port))) + } + continue + } + if tryOldIPPort5354 { + tryOldIPPort5354 = false + if check.IP { + listener.IP = oldIP + } + if check.Port { + listener.Port = 5354 + } + logMsg(mainLog.Load().Warn(), n, "could not listen on address: %s, trying current ip with port 5354", addr) + continue + } + if tryPort5354 { + tryPort5354 = false + if check.IP { + listener.IP = "0.0.0.0" + } + if check.Port { + listener.Port = 5354 + } + logMsg(mainLog.Load().Warn(), n, "could not listen on address: %s, trying 0.0.0.0:5354", addr) + continue + } + if check.IP && !isZeroIP { // for "0.0.0.0" or "::", we only need to try new port. + listener.IP = randomLocalIP() + } else { + listener.IP = oldIP + } + if check.Port { + listener.Port = randomPort() + } else { + listener.Port = oldPort + } + if listener.IP == oldIP && listener.Port == oldPort { + logMsg(mainLog.Load().Fatal(), n, "could not listener on %s: %v", net.JoinHostPort(listener.IP, strconv.Itoa(listener.Port)), err) + } + logMsg(mainLog.Load().Warn(), n, "could not listen on address: %s, pick a random ip+port", addr) + attempts++ + } + } + + // Specific case for systemd-resolved. + if useSystemdResolved { + if listener := cfg.FirstListener(); listener != nil && listener.Port == 53 { + n := listeners[0] + // systemd-resolved does not allow forwarding DNS queries from 127.0.0.53 to loopback + // ip address, other than "127.0.0.1", so trying to listen on default route interface + // address instead. + if ip := net.ParseIP(listener.IP); ip != nil && ip.IsLoopback() && ip.String() != "127.0.0.1" { + logMsg(mainLog.Load().Warn(), n, "using loopback interface do not work with systemd-resolved") + found := false + if netIface, _ := net.InterfaceByName(defaultIfaceName()); netIface != nil { + addrs, _ := netIface.Addrs() + for _, addr := range addrs { + if netIP, ok := addr.(*net.IPNet); ok && netIP.IP.To4() != nil { + addr := net.JoinHostPort(netIP.IP.String(), strconv.Itoa(listener.Port)) + if err := tryListen(addr); err == nil { + found = true + listener.IP = netIP.IP.String() + logMsg(mainLog.Load().Warn(), n, "use %s as listener address", listener.IP) + break + } + } + } + } + if !found { + logMsg(mainLog.Load().Fatal(), n, "could not use %q as DNS nameserver with systemd resolved", listener.IP) + } + } + } + } +} + +func dirWritable(dir string) (bool, error) { + f, err := os.CreateTemp(dir, "") + if err != nil { + return false, err + } + defer os.Remove(f.Name()) + return true, f.Close() +} + +func osVersion() string { + oi := osinfo.New() + if runtime.GOOS == "freebsd" { + if ver, _, found := strings.Cut(oi.String(), ":"); found { + return ver + } + } + return oi.String() +} + +// cdUIDFromProvToken fetch UID from ControlD API using provision token. +func cdUIDFromProvToken() string { + // --cd flag supersedes --cd-org, ignore it if both are supplied. + if cdUID != "" { + return "" + } + // --cd-org is empty, nothing to do. + if cdOrg == "" { + return "" + } + // Process provision token if provided. + resolverConfig, err := controld.FetchResolverUID(cdOrg, rootCmd.Version, cdDev) + if err != nil { + mainLog.Load().Fatal().Err(err).Msgf("failed to fetch resolver uid with provision token: %s", cdOrg) + } + return resolverConfig.UID +} + +// removeProvTokenFromArgs removes the --cd-org from command line arguments. +func removeProvTokenFromArgs(sc *service.Config) { + a := sc.Arguments[:0] + skip := false + for _, x := range sc.Arguments { + if skip { + skip = false + continue + } + // For "--cd-org XXX", skip it and mark next arg skipped. + if x == "--cd-org" { + skip = true + continue + } + // For "--cd-org=XXX", just skip it. + if strings.HasPrefix(x, "--cd-org=") { + continue + } + a = append(a, x) + } + sc.Arguments = a +} + +// newSocketControlClient returns new control client after control server was started. +func newSocketControlClient(s service.Service, dir string) *controlClient { + bo := backoff.NewBackoff("self-check", logf, 10*time.Second) + bo.LogLongerThan = 10 * time.Second + ctx := context.Background() + + cc := newControlClient(filepath.Join(dir, ctrldControlUnixSock)) + + // The socket control server may not start yet, so attempt to ping + // it until we got a response. For each iteration, check ctrld status + // to make sure ctrld is still running. + for { + curStatus, err := s.Status() + if err != nil { + mainLog.Load().Warn().Err(err).Msg("could not get service status while doing self-check") + return nil + } + if curStatus != service.StatusRunning { + return nil + } + if _, err := cc.post("/", nil); err == nil { + // Server was started, stop pinging. + break + } + // The socket control server is not ready yet, backoff for waiting it to be ready. + bo.BackOff(ctx, err) + continue + } + + return cc +} diff --git a/cmd/ctrld/cli_test.go b/cmd/cli/cli_test.go similarity index 97% rename from cmd/ctrld/cli_test.go rename to cmd/cli/cli_test.go index 23746b7..01f2586 100644 --- a/cmd/ctrld/cli_test.go +++ b/cmd/cli/cli_test.go @@ -1,4 +1,4 @@ -package main +package cli import ( "os" diff --git a/cmd/cli/conn.go b/cmd/cli/conn.go new file mode 100644 index 0000000..82e6468 --- /dev/null +++ b/cmd/cli/conn.go @@ -0,0 +1,51 @@ +package cli + +import ( + "net" + "time" +) + +// logConn wraps a net.Conn, override the Write behavior. +// runCmd uses this wrapper, so as long as startCmd finished, +// ctrld log won't be flushed with un-necessary write errors. +type logConn struct { + conn net.Conn +} + +func (lc *logConn) Read(b []byte) (n int, err error) { + return lc.conn.Read(b) +} + +func (lc *logConn) Close() error { + return lc.conn.Close() +} + +func (lc *logConn) LocalAddr() net.Addr { + return lc.conn.LocalAddr() +} + +func (lc *logConn) RemoteAddr() net.Addr { + return lc.conn.RemoteAddr() +} + +func (lc *logConn) SetDeadline(t time.Time) error { + return lc.conn.SetDeadline(t) +} + +func (lc *logConn) SetReadDeadline(t time.Time) error { + return lc.conn.SetReadDeadline(t) +} + +func (lc *logConn) SetWriteDeadline(t time.Time) error { + return lc.conn.SetWriteDeadline(t) +} + +func (lc *logConn) Write(b []byte) (int, error) { + // Write performs writes with underlying net.Conn, ignore any errors happen. + // "ctrld run" command use this wrapper to report errors to "ctrld start". + // If no error occurred, "ctrld start" may finish before "ctrld run" attempt + // to close the connection, so ignore errors conservatively here, prevent + // un-necessary error "write to closed connection" flushed to ctrld log. + _, _ = lc.conn.Write(b) + return len(b), nil +} diff --git a/cmd/cli/control_client.go b/cmd/cli/control_client.go new file mode 100644 index 0000000..c626602 --- /dev/null +++ b/cmd/cli/control_client.go @@ -0,0 +1,29 @@ +package cli + +import ( + "context" + "io" + "net" + "net/http" + "time" +) + +type controlClient struct { + c *http.Client +} + +func newControlClient(addr string) *controlClient { + return &controlClient{c: &http.Client{ + Transport: &http.Transport{ + DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { + d := net.Dialer{} + return d.DialContext(ctx, "unix", addr) + }, + }, + Timeout: time.Second * 30, + }} +} + +func (c *controlClient) post(path string, data io.Reader) (*http.Response, error) { + return c.c.Post("http://unix"+path, contentTypeJson, data) +} diff --git a/cmd/cli/control_server.go b/cmd/cli/control_server.go new file mode 100644 index 0000000..5f5ac51 --- /dev/null +++ b/cmd/cli/control_server.go @@ -0,0 +1,85 @@ +package cli + +import ( + "context" + "encoding/json" + "net" + "net/http" + "os" + "sort" + "time" +) + +const ( + contentTypeJson = "application/json" + listClientsPath = "/clients" + startedPath = "/started" +) + +type controlServer struct { + server *http.Server + mux *http.ServeMux + addr string +} + +func newControlServer(addr string) (*controlServer, error) { + mux := http.NewServeMux() + s := &controlServer{ + server: &http.Server{Handler: mux}, + mux: mux, + } + s.addr = addr + return s, nil +} + +func (s *controlServer) start() error { + _ = os.Remove(s.addr) + unixListener, err := net.Listen("unix", s.addr) + if l, ok := unixListener.(*net.UnixListener); ok { + l.SetUnlinkOnClose(true) + } + if err != nil { + return err + } + go s.server.Serve(unixListener) + return nil +} + +func (s *controlServer) stop() error { + _ = os.Remove(s.addr) + ctx, cancel := context.WithTimeout(context.Background(), time.Second*2) + defer cancel() + return s.server.Shutdown(ctx) +} + +func (s *controlServer) register(pattern string, handler http.Handler) { + s.mux.Handle(pattern, jsonResponse(handler)) +} + +func (p *prog) registerControlServerHandler() { + p.cs.register(listClientsPath, http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + clients := p.ciTable.ListClients() + sort.Slice(clients, func(i, j int) bool { + return clients[i].IP.Less(clients[j].IP) + }) + if err := json.NewEncoder(w).Encode(&clients); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + })) + p.cs.register(startedPath, http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + select { + case <-p.onStartedDone: + w.WriteHeader(http.StatusOK) + case <-time.After(10 * time.Second): + w.WriteHeader(http.StatusRequestTimeout) + } + })) +} + +func jsonResponse(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + next.ServeHTTP(w, r) + }) +} diff --git a/cmd/cli/control_server_test.go b/cmd/cli/control_server_test.go new file mode 100644 index 0000000..297b37d --- /dev/null +++ b/cmd/cli/control_server_test.go @@ -0,0 +1,54 @@ +package cli + +import ( + "bytes" + "io" + "net/http" + "os" + "testing" +) + +func TestControlServer(t *testing.T) { + f, err := os.CreateTemp("", "") + if err != nil { + t.Fatal(err) + } + defer os.Remove(f.Name()) + f.Close() + + s, err := newControlServer(f.Name()) + if err != nil { + t.Fatal(err) + } + pattern := "/ping" + respBody := []byte("pong") + s.register(pattern, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(respBody) + })) + if err := s.start(); err != nil { + t.Fatal(err) + } + + c := newControlClient(f.Name()) + resp, err := c.post(pattern, nil) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + t.Fatalf("unepxected response code: %d", resp.StatusCode) + } + if ct := resp.Header.Get("content-type"); ct != contentTypeJson { + t.Fatalf("unexpected content type: %s", ct) + } + buf, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(buf, respBody) { + t.Errorf("unexpected response body, want: %q, got: %q", string(respBody), string(buf)) + } + if err := s.stop(); err != nil { + t.Fatal(err) + } +} diff --git a/cmd/ctrld/dns.go b/cmd/cli/dns.go similarity index 86% rename from cmd/ctrld/dns.go rename to cmd/cli/dns.go index 770a630..cf9d779 100644 --- a/cmd/ctrld/dns.go +++ b/cmd/cli/dns.go @@ -1,4 +1,4 @@ -package main +package cli //lint:ignore U1000 use in os_linux.go type getDNS func(iface string) []string diff --git a/cmd/ctrld/dns_proxy.go b/cmd/cli/dns_proxy.go similarity index 67% rename from cmd/ctrld/dns_proxy.go rename to cmd/cli/dns_proxy.go index 9f9fa30..23ae03e 100644 --- a/cmd/ctrld/dns_proxy.go +++ b/cmd/cli/dns_proxy.go @@ -1,11 +1,14 @@ -package main +package cli import ( "context" "crypto/rand" "encoding/hex" "fmt" + "io" "net" + "net/netip" + "os" "runtime" "strconv" "strings" @@ -13,12 +16,14 @@ import ( "time" "github.com/miekg/dns" + "go4.org/mem" "golang.org/x/sync/errgroup" + "tailscale.com/net/interfaces" + "tailscale.com/util/lineread" "github.com/Control-D-Inc/ctrld" "github.com/Control-D-Inc/ctrld/internal/dnscache" ctrldnet "github.com/Control-D-Inc/ctrld/internal/net" - "github.com/Control-D-Inc/ctrld/internal/router" ) const ( @@ -39,7 +44,7 @@ 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 { - mainLog.Error().Err(allocErr).Str("ip", listenerConfig.IP).Msg("serveUDP: failed to allocate listen ip") + mainLog.Load().Error().Err(allocErr).Str("ip", listenerConfig.IP).Msg("serveUDP: failed to allocate listen ip") return allocErr } var failoverRcodes []int @@ -47,27 +52,31 @@ func (p *prog) serveDNS(listenerNum string) error { failoverRcodes = listenerConfig.Policy.FailoverRcodeNumbers } handler := dns.HandlerFunc(func(w dns.ResponseWriter, m *dns.Msg) { + p.sema.acquire() + defer p.sema.release() q := m.Question[0] domain := canonicalName(q.Name) reqId := requestID() - remoteAddr := spoofRemoteAddr(w.RemoteAddr(), router.GetClientInfoByMac(macFromMsg(m))) + remoteIP, _, _ := net.SplitHostPort(w.RemoteAddr().String()) + mac := macFromMsg(m) + ci := p.getClientInfo(remoteIP, mac) + remoteAddr := spoofRemoteAddr(w.RemoteAddr(), ci) fmtSrcToDest := fmtRemoteToLocal(listenerNum, remoteAddr.String(), w.LocalAddr().String()) t := time.Now() ctx := context.WithValue(context.Background(), ctrld.ReqIdCtxKey{}, reqId) - ctrld.Log(ctx, mainLog.Debug(), "%s received query: %s %s", fmtSrcToDest, dns.TypeToString[q.Qtype], domain) + ctrld.Log(ctx, mainLog.Load().Debug(), "%s received query: %s %s", fmtSrcToDest, dns.TypeToString[q.Qtype], domain) upstreams, matched := p.upstreamFor(ctx, listenerNum, listenerConfig, remoteAddr, domain) var answer *dns.Msg if !matched && listenerConfig.Restricted { answer = new(dns.Msg) answer.SetRcode(m, dns.RcodeRefused) - } else { - answer = p.proxy(ctx, upstreams, failoverRcodes, m) + answer = p.proxy(ctx, upstreams, failoverRcodes, m, ci) rtt := time.Since(t) - ctrld.Log(ctx, mainLog.Debug(), "received response of %d bytes in %s", answer.Len(), rtt) + ctrld.Log(ctx, mainLog.Load().Debug(), "received response of %d bytes in %s", answer.Len(), rtt) } if err := w.WriteMsg(answer); err != nil { - ctrld.Log(ctx, mainLog.Error().Err(err), "serveUDP: failed to send DNS response to client") + ctrld.Log(ctx, mainLog.Load().Error().Err(err), "serveUDP: failed to send DNS response to client") } }) @@ -79,32 +88,54 @@ func (p *prog) serveDNS(listenerNum string) error { s, errCh := runDNSServer(net.JoinHostPort("::1", strconv.Itoa(listenerConfig.Port)), proto, handler) defer s.Shutdown() select { + case <-p.stopCh: case <-ctx.Done(): case err := <-errCh: // Local ipv6 listener should not terminate ctrld. // It's a workaround for a quirk on Windows. - mainLog.Warn().Err(err).Msg("local ipv6 listener failed") + mainLog.Load().Warn().Err(err).Msg("local ipv6 listener failed") + } + 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. + if needRFC1918Listeners(listenerConfig) { + g.Go(func() error { + for _, addr := range rfc1918Addresses() { + func() { + listenAddr := net.JoinHostPort(addr, strconv.Itoa(listenerConfig.Port)) + s, errCh := runDNSServer(listenAddr, proto, handler) + defer s.Shutdown() + select { + case <-p.stopCh: + case <-ctx.Done(): + case err := <-errCh: + // RFC1918 listener should not terminate ctrld. + // It's a workaround for a quirk on system with systemd-resolved. + mainLog.Load().Warn().Err(err).Msgf("could not listen on %s: %s", proto, listenAddr) + } + }() } return nil }) } g.Go(func() error { - s, errCh := runDNSServer(dnsListenAddress(listenerNum, listenerConfig), proto, handler) + s, errCh := runDNSServer(dnsListenAddress(listenerConfig), proto, handler) defer s.Shutdown() - if listenerConfig.Port == 0 { - switch s.Net { - case "udp": - mainLog.Info().Msgf("Random port chosen for udp listener.%s: %s", listenerNum, s.PacketConn.LocalAddr()) - case "tcp": - mainLog.Info().Msgf("Random port chosen for tcp listener.%s: %s", listenerNum, s.Listener.Addr()) - } + select { + case err := <-errCh: + return err + case <-time.After(5 * time.Second): + p.started <- struct{}{} } select { + case <-p.stopCh: case <-ctx.Done(): - return nil case err := <-errCh: return err } + return nil }) } return g.Wait() @@ -126,10 +157,14 @@ func (p *prog) upstreamFor(ctx context.Context, defaultUpstreamNum string, lc *c defer func() { if !matched && lc.Restricted { - ctrld.Log(ctx, mainLog.Info(), "query refused, %s does not match any network policy", addr.String()) + ctrld.Log(ctx, mainLog.Load().Info(), "query refused, %s does not match any network policy", addr.String()) return } - ctrld.Log(ctx, mainLog.Info(), "%s, %s, %s -> %v", matchedPolicy, matchedNetwork, matchedRule, upstreams) + if matched { + ctrld.Log(ctx, mainLog.Load().Info(), "%s, %s, %s -> %v", matchedPolicy, matchedNetwork, matchedRule, upstreams) + } else { + ctrld.Log(ctx, mainLog.Load().Info(), "no explicit policy matched, using default routing -> %v", upstreams) + } }() if lc.Policy == nil { @@ -192,7 +227,7 @@ networkRules: return upstreams, matched } -func (p *prog) proxy(ctx context.Context, upstreams []string, failoverRcodes []int, msg *dns.Msg) *dns.Msg { +func (p *prog) proxy(ctx context.Context, upstreams []string, failoverRcodes []int, msg *dns.Msg, ci *ctrld.ClientInfo) *dns.Msg { var staleAnswer *dns.Msg serveStaleCache := p.cache != nil && p.cfg.Service.CacheServeStale upstreamConfigs := p.upstreamConfigsFromUpstreamNumbers(upstreams) @@ -211,7 +246,7 @@ func (p *prog) proxy(ctx context.Context, upstreams []string, failoverRcodes []i answer.SetRcode(msg, answer.Rcode) now := time.Now() if cachedValue.Expire.After(now) { - ctrld.Log(ctx, mainLog.Debug(), "hit cached response") + ctrld.Log(ctx, mainLog.Load().Debug(), "hit cached response") setCachedAnswerTTL(answer, now, cachedValue.Expire) return answer } @@ -219,10 +254,10 @@ func (p *prog) proxy(ctx context.Context, upstreams []string, failoverRcodes []i } } resolve1 := func(n int, upstreamConfig *ctrld.UpstreamConfig, msg *dns.Msg) (*dns.Msg, error) { - ctrld.Log(ctx, mainLog.Debug(), "sending query to %s: %s", upstreams[n], upstreamConfig.Name) + ctrld.Log(ctx, mainLog.Load().Debug(), "sending query to %s: %s", upstreams[n], upstreamConfig.Name) dnsResolver, err := ctrld.NewResolver(upstreamConfig) if err != nil { - ctrld.Log(ctx, mainLog.Error().Err(err), "failed to create resolver") + ctrld.Log(ctx, mainLog.Load().Error().Err(err), "failed to create resolver") return nil, err } resolveCtx, cancel := context.WithCancel(ctx) @@ -235,24 +270,13 @@ func (p *prog) proxy(ctx context.Context, upstreams []string, failoverRcodes []i return dnsResolver.Resolve(resolveCtx, msg) } resolve := func(n int, upstreamConfig *ctrld.UpstreamConfig, msg *dns.Msg) *dns.Msg { - if upstreamConfig.UpstreamSendClientInfo() { - ci := router.GetClientInfoByMac(macFromMsg(msg)) - if ci != nil { - ctrld.Log(ctx, mainLog.Debug(), "including client info with the request") - ctx = context.WithValue(ctx, ctrld.ClientInfoCtxKey{}, ci) - } + if upstreamConfig.UpstreamSendClientInfo() && ci != nil { + ctrld.Log(ctx, mainLog.Load().Debug(), "including client info with the request") + ctx = context.WithValue(ctx, ctrld.ClientInfoCtxKey{}, ci) } answer, err := resolve1(n, upstreamConfig, msg) - // Only do re-bootstrapping if bootstrap ip is not explicitly set by user. - if err != nil && upstreamConfig.BootstrapIP == "" { - ctrld.Log(ctx, mainLog.Debug().Err(err), "could not resolve query on first attempt, retrying...") - // If any error occurred, re-bootstrap transport/ip, retry the request. - upstreamConfig.ReBootstrap() - answer, err = resolve1(n, upstreamConfig, msg) - if err == nil { - return answer - } - ctrld.Log(ctx, mainLog.Error().Err(err), "failed to resolve query") + if err != nil { + ctrld.Log(ctx, mainLog.Load().Error().Err(err), "failed to resolve query") return nil } return answer @@ -264,7 +288,7 @@ func (p *prog) proxy(ctx context.Context, upstreams []string, failoverRcodes []i answer := resolve(n, upstreamConfig, msg) if answer == nil { if serveStaleCache && staleAnswer != nil { - ctrld.Log(ctx, mainLog.Debug(), "serving stale cached response") + ctrld.Log(ctx, mainLog.Load().Debug(), "serving stale cached response") now := time.Now() setCachedAnswerTTL(staleAnswer, now, now.Add(staleTTL)) return staleAnswer @@ -272,7 +296,7 @@ func (p *prog) proxy(ctx context.Context, upstreams []string, failoverRcodes []i continue } if answer.Rcode != dns.RcodeSuccess && len(upstreamConfigs) > 1 && containRcode(failoverRcodes, answer.Rcode) { - ctrld.Log(ctx, mainLog.Debug(), "failover rcode matched, process to next upstream") + ctrld.Log(ctx, mainLog.Load().Debug(), "failover rcode matched, process to next upstream") continue } @@ -288,11 +312,11 @@ func (p *prog) proxy(ctx context.Context, upstreams []string, failoverRcodes []i } setCachedAnswerTTL(answer, now, expired) p.cache.Add(dnscache.NewKey(msg, upstreams[n]), dnscache.NewValue(answer, expired)) - ctrld.Log(ctx, mainLog.Debug(), "add cached response") + ctrld.Log(ctx, mainLog.Load().Debug(), "add cached response") } return answer } - ctrld.Log(ctx, mainLog.Error(), "all upstreams failed") + ctrld.Log(ctx, mainLog.Load().Error(), "all upstreams failed") answer := new(dns.Msg) answer.SetRcode(msg, dns.RcodeServerFailure) return answer @@ -398,9 +422,13 @@ func needLocalIPv6Listener() bool { return ctrldnet.SupportsIPv6ListenLocal() && runtime.GOOS == "windows" } -func dnsListenAddress(lcNum string, lc *ctrld.ListenerConfig) string { - if addr := router.ListenAddress(); setupRouter && addr != "" && lcNum == "0" { - return addr +func dnsListenAddress(lc *ctrld.ListenerConfig) string { + // If we are inside container and the listener loopback address, change + // the address to something like 0.0.0.0:53, so user can expose the port to outside. + if inContainer() { + if ip := net.ParseIP(lc.IP); ip != nil && ip.IsLoopback() { + return net.JoinHostPort("0.0.0.0", strconv.Itoa(lc.Port)) + } } return net.JoinHostPort(lc.IP, strconv.Itoa(lc.Port)) } @@ -462,7 +490,7 @@ func runDNSServer(addr, network string, handler dns.Handler) (*dns.Server, <-cha defer close(errCh) if err := s.ListenAndServe(); err != nil { waitLock.Unlock() - mainLog.Error().Err(err).Msgf("could not listen and serve on: %s", s.Addr) + mainLog.Load().Error().Err(err).Msgf("could not listen and serve on: %s", s.Addr) errCh <- err } }() @@ -470,50 +498,72 @@ func runDNSServer(addr, network string, handler dns.Handler) (*dns.Server, <-cha return s, errCh } -// runDNSServerForNTPD starts a DNS server listening on router.ListenAddress(). It must only be called when ctrld -// running on router, before router.PreRun() to serve DNS request for NTP synchronization. The caller must call -// s.Shutdown() explicitly when NTP is synced successfully. -func runDNSServerForNTPD(addr string) (*dns.Server, <-chan error) { - if addr == "" { - return &dns.Server{}, nil - } - dnsResolver := ctrld.NewBootstrapResolver() - s := &dns.Server{ - Addr: addr, - Net: "udp", - Handler: dns.HandlerFunc(func(w dns.ResponseWriter, m *dns.Msg) { - mainLog.Debug().Msg("Serving query for ntpd") - resolveCtx, cancel := context.WithCancel(context.Background()) - defer cancel() - if osUpstreamConfig.Timeout > 0 { - timeoutCtx, cancel := context.WithTimeout(resolveCtx, time.Millisecond*time.Duration(osUpstreamConfig.Timeout)) - defer cancel() - resolveCtx = timeoutCtx - } - answer, err := dnsResolver.Resolve(resolveCtx, m) - if err != nil { - mainLog.Error().Err(err).Msgf("could not resolve: %v", m) - return - } - if err := w.WriteMsg(answer); err != nil { - mainLog.Error().Err(err).Msg("runDNSServerForNTPD: failed to send DNS response") - } - }), +// inContainer reports whether we're running in a container. +// +// Copied from https://github.com/tailscale/tailscale/blob/v1.42.0/hostinfo/hostinfo.go#L260 +// with modification for ctrld usage. +func inContainer() bool { + if runtime.GOOS != "linux" { + return false } - waitLock := sync.Mutex{} - waitLock.Lock() - s.NotifyStartedFunc = waitLock.Unlock - - errCh := make(chan error) - go func() { - defer close(errCh) - if err := s.ListenAndServe(); err != nil { - waitLock.Unlock() - mainLog.Error().Err(err).Msgf("could not listen and serve on: %s", s.Addr) - errCh <- err + var ret bool + if _, err := os.Stat("/.dockerenv"); err == nil { + return true + } + if _, err := os.Stat("/run/.containerenv"); err == nil { + // See https://github.com/cri-o/cri-o/issues/5461 + return true + } + lineread.File("/proc/1/cgroup", func(line []byte) error { + if mem.Contains(mem.B(line), mem.S("/docker/")) || + mem.Contains(mem.B(line), mem.S("/lxc/")) { + ret = true + return io.EOF // arbitrary non-nil error to stop loop } - }() - waitLock.Lock() - return s, errCh + return nil + }) + lineread.File("/proc/mounts", func(line []byte) error { + if mem.Contains(mem.B(line), mem.S("lxcfs /proc/cpuinfo fuse.lxcfs")) { + ret = true + return io.EOF + } + return nil + }) + return ret +} + +func (p *prog) getClientInfo(ip, mac string) *ctrld.ClientInfo { + ci := &ctrld.ClientInfo{} + if mac != "" { + ci.Mac = mac + ci.IP = p.ciTable.LookupIP(mac) + } else { + ci.IP = ip + ci.Mac = p.ciTable.LookupMac(ip) + if ip == "127.0.0.1" || ip == "::1" { + ci.IP = p.ciTable.LookupIP(ci.Mac) + } + } + ci.Hostname = p.ciTable.LookupHostname(ci.IP, ci.Mac) + return ci +} + +func needRFC1918Listeners(lc *ctrld.ListenerConfig) bool { + return lc.IP == "127.0.0.1" && lc.Port == 53 +} + +func rfc1918Addresses() []string { + var res []string + interfaces.ForeachInterface(func(i interfaces.Interface, prefixes []netip.Prefix) { + addrs, _ := i.Addrs() + for _, addr := range addrs { + ipNet, ok := addr.(*net.IPNet) + if !ok || !ipNet.IP.IsPrivate() { + continue + } + res = append(res, ipNet.IP.String()) + } + }) + return res } diff --git a/cmd/ctrld/dns_proxy_test.go b/cmd/cli/dns_proxy_test.go similarity index 99% rename from cmd/ctrld/dns_proxy_test.go rename to cmd/cli/dns_proxy_test.go index 2d29bc3..b7b0dbd 100644 --- a/cmd/ctrld/dns_proxy_test.go +++ b/cmd/cli/dns_proxy_test.go @@ -1,4 +1,4 @@ -package main +package cli import ( "context" @@ -149,8 +149,8 @@ func TestCache(t *testing.T) { answer2.SetRcode(msg, dns.RcodeRefused) prog.cache.Add(dnscache.NewKey(msg, "upstream.0"), dnscache.NewValue(answer2, time.Now().Add(time.Minute))) - got1 := prog.proxy(context.Background(), []string{"upstream.1"}, nil, msg) - got2 := prog.proxy(context.Background(), []string{"upstream.0"}, nil, msg) + got1 := prog.proxy(context.Background(), []string{"upstream.1"}, nil, msg, nil) + got2 := prog.proxy(context.Background(), []string{"upstream.0"}, nil, msg, nil) assert.NotSame(t, got1, got2) assert.Equal(t, answer1.Rcode, got1.Rcode) assert.Equal(t, answer2.Rcode, got2.Rcode) diff --git a/cmd/cli/main.go b/cmd/cli/main.go new file mode 100644 index 0000000..e7376be --- /dev/null +++ b/cmd/cli/main.go @@ -0,0 +1,161 @@ +package cli + +import ( + "io" + "os" + "path/filepath" + "sync/atomic" + "time" + + "github.com/kardianos/service" + "github.com/rs/zerolog" + + "github.com/Control-D-Inc/ctrld" +) + +var ( + configPath string + configBase64 string + daemon bool + listenAddress string + primaryUpstream string + secondaryUpstream string + domains []string + logPath string + homedir string + cacheSize int + cfg ctrld.Config + verbose int + silent bool + cdUID string + cdOrg string + cdDev bool + iface string + ifaceStartStop string + + mainLog atomic.Pointer[zerolog.Logger] + consoleWriter zerolog.ConsoleWriter +) + +func init() { + l := zerolog.New(io.Discard) + mainLog.Store(&l) +} + +func Main() { + ctrld.InitConfig(v, "ctrld") + initCLI() + if err := rootCmd.Execute(); err != nil { + mainLog.Load().Error().Msg(err.Error()) + os.Exit(1) + } +} + +func normalizeLogFilePath(logFilePath string) string { + if logFilePath == "" || filepath.IsAbs(logFilePath) || service.Interactive() { + return logFilePath + } + if homedir != "" { + return filepath.Join(homedir, logFilePath) + } + dir, _ := userHomeDir() + if dir == "" { + return logFilePath + } + return filepath.Join(dir, logFilePath) +} + +func initConsoleLogging() { + consoleWriter = zerolog.NewConsoleWriter(func(w *zerolog.ConsoleWriter) { + w.TimeFormat = time.StampMilli + }) + multi := zerolog.MultiLevelWriter(consoleWriter) + l := mainLog.Load().Output(multi).With().Timestamp().Logger() + mainLog.Store(&l) + switch { + case silent: + zerolog.SetGlobalLevel(zerolog.NoLevel) + case verbose == 1: + zerolog.SetGlobalLevel(zerolog.InfoLevel) + case verbose > 1: + zerolog.SetGlobalLevel(zerolog.DebugLevel) + default: + zerolog.SetGlobalLevel(zerolog.NoticeLevel) + } +} + +// initLogging initializes global logging setup. +func initLogging() { + initLoggingWithBackup(true) +} + +// initLoggingWithBackup initializes log setup base on current config. +// If doBackup is true, backup old log file with ".1" suffix. +// +// This is only used in runCmd for special handling in case of logging config +// change in cd mode. Without special reason, the caller should use initLogging +// wrapper instead of calling this function directly. +func initLoggingWithBackup(doBackup bool) { + writers := []io.Writer{io.Discard} + if logFilePath := normalizeLogFilePath(cfg.Service.LogPath); logFilePath != "" { + // Create parent directory if necessary. + if err := os.MkdirAll(filepath.Dir(logFilePath), 0750); err != nil { + mainLog.Load().Error().Msgf("failed to create log path: %v", err) + os.Exit(1) + } + + // Default open log file in append mode. + flags := os.O_CREATE | os.O_RDWR | os.O_APPEND + if doBackup { + // Backup old log file with .1 suffix. + if err := os.Rename(logFilePath, logFilePath+".1"); err != nil && !os.IsNotExist(err) { + mainLog.Load().Error().Msgf("could not backup old log file: %v", err) + } else { + // Backup was created, set flags for truncating old log file. + flags = os.O_CREATE | os.O_RDWR + } + } + logFile, err := os.OpenFile(logFilePath, flags, os.FileMode(0o600)) + if err != nil { + mainLog.Load().Error().Msgf("failed to create log file: %v", err) + os.Exit(1) + } + writers = append(writers, logFile) + } + writers = append(writers, consoleWriter) + multi := zerolog.MultiLevelWriter(writers...) + l := mainLog.Load().Output(multi).With().Timestamp().Logger() + mainLog.Store(&l) + // TODO: find a better way. + ctrld.ProxyLogger.Store(&l) + + zerolog.SetGlobalLevel(zerolog.NoticeLevel) + logLevel := cfg.Service.LogLevel + switch { + case silent: + zerolog.SetGlobalLevel(zerolog.NoLevel) + return + case verbose == 1: + logLevel = "info" + case verbose > 1: + logLevel = "debug" + } + if logLevel == "" { + return + } + level, err := zerolog.ParseLevel(logLevel) + if err != nil { + mainLog.Load().Warn().Err(err).Msg("could not set log level") + return + } + zerolog.SetGlobalLevel(level) +} + +func initCache() { + if !cfg.Service.CacheEnable { + return + } + if cfg.Service.CacheSize == 0 { + cfg.Service.CacheSize = 4096 + } +} diff --git a/cmd/ctrld/main_test.go b/cmd/cli/main_test.go similarity index 70% rename from cmd/ctrld/main_test.go rename to cmd/cli/main_test.go index 2a2e079..6ed26c7 100644 --- a/cmd/ctrld/main_test.go +++ b/cmd/cli/main_test.go @@ -1,4 +1,4 @@ -package main +package cli import ( "os" @@ -11,6 +11,7 @@ import ( var logOutput strings.Builder func TestMain(m *testing.M) { - mainLog = zerolog.New(&logOutput) + l := zerolog.New(&logOutput) + mainLog.Store(&l) os.Exit(m.Run()) } diff --git a/cmd/ctrld/net_darwin.go b/cmd/cli/net_darwin.go similarity index 87% rename from cmd/ctrld/net_darwin.go rename to cmd/cli/net_darwin.go index 0939c85..f456327 100644 --- a/cmd/ctrld/net_darwin.go +++ b/cmd/cli/net_darwin.go @@ -1,4 +1,4 @@ -package main +package cli import ( "bufio" @@ -17,7 +17,7 @@ func patchNetIfaceName(iface *net.Interface) error { if name := networkServiceName(iface.Name, bytes.NewReader(b)); name != "" { iface.Name = name - mainLog.Debug().Str("network_service", name).Msg("found network service name for interface") + mainLog.Load().Debug().Str("network_service", name).Msg("found network service name for interface") } return nil } diff --git a/cmd/ctrld/net_darwin_test.go b/cmd/cli/net_darwin_test.go similarity index 98% rename from cmd/ctrld/net_darwin_test.go rename to cmd/cli/net_darwin_test.go index 7110d15..443a9d1 100644 --- a/cmd/ctrld/net_darwin_test.go +++ b/cmd/cli/net_darwin_test.go @@ -1,4 +1,4 @@ -package main +package cli import ( "strings" diff --git a/cmd/ctrld/net_others.go b/cmd/cli/net_others.go similarity index 88% rename from cmd/ctrld/net_others.go rename to cmd/cli/net_others.go index 9093395..2f7aec8 100644 --- a/cmd/ctrld/net_others.go +++ b/cmd/cli/net_others.go @@ -1,6 +1,6 @@ //go:build !darwin -package main +package cli import "net" diff --git a/cmd/ctrld/netlink_linux.go b/cmd/cli/netlink_linux.go similarity index 74% rename from cmd/ctrld/netlink_linux.go rename to cmd/cli/netlink_linux.go index 86eb45b..0faae84 100644 --- a/cmd/ctrld/netlink_linux.go +++ b/cmd/cli/netlink_linux.go @@ -1,4 +1,4 @@ -package main +package cli import ( "github.com/vishvananda/netlink" @@ -10,7 +10,7 @@ func (p *prog) watchLinkState() { done := make(chan struct{}) defer close(done) if err := netlink.LinkSubscribe(ch, done); err != nil { - mainLog.Warn().Err(err).Msg("could not subscribe link") + mainLog.Load().Warn().Err(err).Msg("could not subscribe link") return } for lu := range ch { @@ -18,7 +18,7 @@ func (p *prog) watchLinkState() { continue } if lu.Change&unix.IFF_UP != 0 { - mainLog.Debug().Msgf("link state changed, re-bootstrapping") + mainLog.Load().Debug().Msgf("link state changed, re-bootstrapping") for _, uc := range p.cfg.Upstream { uc.ReBootstrap() } diff --git a/cmd/ctrld/netlink_others.go b/cmd/cli/netlink_others.go similarity index 80% rename from cmd/ctrld/netlink_others.go rename to cmd/cli/netlink_others.go index d069661..f0afd21 100644 --- a/cmd/ctrld/netlink_others.go +++ b/cmd/cli/netlink_others.go @@ -1,5 +1,5 @@ //go:build !linux -package main +package cli func (p *prog) watchLinkState() {} diff --git a/cmd/ctrld/network_manager_linux.go b/cmd/cli/network_manager_linux.go similarity index 66% rename from cmd/ctrld/network_manager_linux.go rename to cmd/cli/network_manager_linux.go index fe00f3a..5e7b540 100644 --- a/cmd/ctrld/network_manager_linux.go +++ b/cmd/cli/network_manager_linux.go @@ -1,4 +1,4 @@ -package main +package cli import ( "context" @@ -24,37 +24,37 @@ var networkManagerCtrldConfFile = filepath.Join(nmConfDir, nmCtrldConfFilename) func setupNetworkManager() error { if content, _ := os.ReadFile(nmCtrldConfContent); string(content) == nmCtrldConfContent { - mainLog.Debug().Msg("NetworkManager already setup, nothing to do") + mainLog.Load().Debug().Msg("NetworkManager already setup, nothing to do") return nil } err := os.WriteFile(networkManagerCtrldConfFile, []byte(nmCtrldConfContent), os.FileMode(0644)) if os.IsNotExist(err) { - mainLog.Debug().Msg("NetworkManager is not available") + mainLog.Load().Debug().Msg("NetworkManager is not available") return nil } if err != nil { - mainLog.Debug().Err(err).Msg("could not write NetworkManager ctrld config file") + mainLog.Load().Debug().Err(err).Msg("could not write NetworkManager ctrld config file") return err } reloadNetworkManager() - mainLog.Debug().Msg("setup NetworkManager done") + mainLog.Load().Debug().Msg("setup NetworkManager done") return nil } func restoreNetworkManager() error { err := os.Remove(networkManagerCtrldConfFile) if os.IsNotExist(err) { - mainLog.Debug().Msg("NetworkManager is not available") + mainLog.Load().Debug().Msg("NetworkManager is not available") return nil } if err != nil { - mainLog.Debug().Err(err).Msg("could not remove NetworkManager ctrld config file") + mainLog.Load().Debug().Err(err).Msg("could not remove NetworkManager ctrld config file") return err } reloadNetworkManager() - mainLog.Debug().Msg("restore NetworkManager done") + mainLog.Load().Debug().Msg("restore NetworkManager done") return nil } @@ -63,14 +63,14 @@ func reloadNetworkManager() { defer cancel() conn, err := dbus.NewSystemConnectionContext(ctx) if err != nil { - mainLog.Error().Err(err).Msg("could not create new system connection") + mainLog.Load().Error().Err(err).Msg("could not create new system connection") return } defer conn.Close() waitCh := make(chan string) if _, err := conn.ReloadUnitContext(ctx, nmSystemdUnitName, "ignore-dependencies", waitCh); err != nil { - mainLog.Debug().Err(err).Msg("could not reload NetworkManager") + mainLog.Load().Debug().Err(err).Msg("could not reload NetworkManager") } <-waitCh } diff --git a/cmd/ctrld/network_manager_others.go b/cmd/cli/network_manager_others.go similarity index 93% rename from cmd/ctrld/network_manager_others.go rename to cmd/cli/network_manager_others.go index cd43bbc..323d2f2 100644 --- a/cmd/ctrld/network_manager_others.go +++ b/cmd/cli/network_manager_others.go @@ -1,6 +1,6 @@ //go:build !linux -package main +package cli func setupNetworkManager() error { reloadNetworkManager() diff --git a/cmd/ctrld/os_darwin.go b/cmd/cli/os_darwin.go similarity index 81% rename from cmd/ctrld/os_darwin.go rename to cmd/cli/os_darwin.go index 04bc66b..5931819 100644 --- a/cmd/ctrld/os_darwin.go +++ b/cmd/cli/os_darwin.go @@ -1,4 +1,4 @@ -package main +package cli import ( "net" @@ -12,7 +12,7 @@ import ( func allocateIP(ip string) error { cmd := exec.Command("ifconfig", "lo0", "alias", ip, "up") if err := cmd.Run(); err != nil { - mainLog.Error().Err(err).Msg("allocateIP failed") + mainLog.Load().Error().Err(err).Msg("allocateIP failed") return err } return nil @@ -21,7 +21,7 @@ func allocateIP(ip string) error { func deAllocateIP(ip string) error { cmd := exec.Command("ifconfig", "lo0", "-alias", ip) if err := cmd.Run(); err != nil { - mainLog.Error().Err(err).Msg("deAllocateIP failed") + mainLog.Load().Error().Err(err).Msg("deAllocateIP failed") return err } return nil @@ -36,7 +36,7 @@ func setDNS(iface *net.Interface, nameservers []string) error { args = append(args, nameservers...) if err := exec.Command(cmd, args...).Run(); err != nil { - mainLog.Error().Err(err).Msgf("setDNS failed, ips = %q", nameservers) + mainLog.Load().Error().Err(err).Msgf("setDNS failed, ips = %q", nameservers) return err } return nil @@ -48,7 +48,7 @@ func resetDNS(iface *net.Interface) error { args := []string{"-setdnsservers", iface.Name, "empty"} if err := exec.Command(cmd, args...).Run(); err != nil { - mainLog.Error().Err(err).Msgf("resetDNS failed") + mainLog.Load().Error().Err(err).Msgf("resetDNS failed") return err } return nil diff --git a/cmd/ctrld/os_freebsd.go b/cmd/cli/os_freebsd.go similarity index 74% rename from cmd/ctrld/os_freebsd.go rename to cmd/cli/os_freebsd.go index da1a05a..a6d6dde 100644 --- a/cmd/ctrld/os_freebsd.go +++ b/cmd/cli/os_freebsd.go @@ -1,4 +1,4 @@ -package main +package cli import ( "net" @@ -14,7 +14,7 @@ import ( func allocateIP(ip string) error { cmd := exec.Command("ifconfig", "lo0", ip, "alias") if err := cmd.Run(); err != nil { - mainLog.Error().Err(err).Msg("allocateIP failed") + mainLog.Load().Error().Err(err).Msg("allocateIP failed") return err } return nil @@ -23,7 +23,7 @@ func allocateIP(ip string) error { func deAllocateIP(ip string) error { cmd := exec.Command("ifconfig", "lo0", ip, "-alias") if err := cmd.Run(); err != nil { - mainLog.Error().Err(err).Msg("deAllocateIP failed") + mainLog.Load().Error().Err(err).Msg("deAllocateIP failed") return err } return nil @@ -33,7 +33,7 @@ func deAllocateIP(ip string) error { func setDNS(iface *net.Interface, nameservers []string) error { r, err := dns.NewOSConfigurator(logf, iface.Name) if err != nil { - mainLog.Error().Err(err).Msg("failed to create DNS OS configurator") + mainLog.Load().Error().Err(err).Msg("failed to create DNS OS configurator") return err } @@ -43,7 +43,7 @@ func setDNS(iface *net.Interface, nameservers []string) error { } if err := r.SetDNS(dns.OSConfig{Nameservers: ns}); err != nil { - mainLog.Error().Err(err).Msg("failed to set DNS") + mainLog.Load().Error().Err(err).Msg("failed to set DNS") return err } return nil @@ -52,12 +52,12 @@ func setDNS(iface *net.Interface, nameservers []string) error { func resetDNS(iface *net.Interface) error { r, err := dns.NewOSConfigurator(logf, iface.Name) if err != nil { - mainLog.Error().Err(err).Msg("failed to create DNS OS configurator") + mainLog.Load().Error().Err(err).Msg("failed to create DNS OS configurator") return err } if err := r.Close(); err != nil { - mainLog.Error().Err(err).Msg("failed to rollback DNS setting") + mainLog.Load().Error().Err(err).Msg("failed to rollback DNS setting") return err } return nil diff --git a/cmd/ctrld/os_linux.go b/cmd/cli/os_linux.go similarity index 54% rename from cmd/ctrld/os_linux.go rename to cmd/cli/os_linux.go index 307ee3a..004e863 100644 --- a/cmd/ctrld/os_linux.go +++ b/cmd/cli/os_linux.go @@ -1,10 +1,11 @@ -package main +package cli import ( "bufio" "bytes" "context" "fmt" + "io" "net" "net/netip" "os/exec" @@ -23,12 +24,14 @@ import ( "github.com/Control-D-Inc/ctrld/internal/resolvconffile" ) +const resolvConfBackupFailedMsg = "open /etc/resolv.pre-ctrld-backup.conf: read-only file system" + // allocate loopback ip // sudo ip a add 127.0.0.2/24 dev lo func allocateIP(ip string) error { cmd := exec.Command("ip", "a", "add", ip+"/24", "dev", "lo") if out, err := cmd.CombinedOutput(); err != nil { - mainLog.Error().Err(err).Msgf("allocateIP failed: %s", string(out)) + mainLog.Load().Error().Err(err).Msgf("allocateIP failed: %s", string(out)) return err } return nil @@ -37,7 +40,7 @@ func allocateIP(ip string) error { func deAllocateIP(ip string) error { cmd := exec.Command("ip", "a", "del", ip+"/24", "dev", "lo") if err := cmd.Run(); err != nil { - mainLog.Error().Err(err).Msg("deAllocateIP failed") + mainLog.Load().Error().Err(err).Msg("deAllocateIP failed") return err } return nil @@ -49,7 +52,7 @@ const maxSetDNSAttempts = 5 func setDNS(iface *net.Interface, nameservers []string) error { r, err := dns.NewOSConfigurator(logf, iface.Name) if err != nil { - mainLog.Error().Err(err).Msg("failed to create DNS OS configurator") + mainLog.Load().Error().Err(err).Msg("failed to create DNS OS configurator") return err } @@ -63,8 +66,23 @@ func setDNS(iface *net.Interface, nameservers []string) error { SearchDomains: []dnsname.FQDN{}, } + trySystemdResolve := false for i := 0; i < maxSetDNSAttempts; i++ { if err := r.SetDNS(osConfig); err != nil { + if strings.Contains(err.Error(), "Rejected send message") && + strings.Contains(err.Error(), "org.freedesktop.network1.Manager") { + mainLog.Load().Warn().Msg("Interfaces are managed by systemd-networkd, switch to systemd-resolve for setting DNS") + trySystemdResolve = true + break + } + // This error happens on read-only file system, which causes ctrld failed to create backup + // for /etc/resolv.conf file. It is ok, because the DNS is still set anyway, and restore + // DNS will fallback to use DHCP if there's no backup /etc/resolv.conf file. + // The error format is controlled by us, so checking for error string is fine. + // See: ../../internal/dns/direct.go:L278 + if r.Mode() == "direct" && strings.Contains(err.Error(), resolvConfBackupFailedMsg) { + return nil + } return err } currentNS := currentDNS(iface) @@ -72,7 +90,27 @@ func setDNS(iface *net.Interface, nameservers []string) error { return nil } } - mainLog.Debug().Msg("DNS was not set for some reason") + if trySystemdResolve { + // Stop systemd-networkd and retry setting DNS. + if out, err := exec.Command("systemctl", "stop", "systemd-networkd").CombinedOutput(); err != nil { + return fmt.Errorf("%s: %w", string(out), err) + } + args := []string{"--interface=" + iface.Name, "--set-domain=~"} + for _, nameserver := range nameservers { + args = append(args, "--set-dns="+nameserver) + } + for i := 0; i < maxSetDNSAttempts; i++ { + if out, err := exec.Command("systemd-resolve", args...).CombinedOutput(); err != nil { + return fmt.Errorf("%s: %w", string(out), err) + } + currentNS := currentDNS(iface) + if reflect.DeepEqual(currentNS, nameservers) { + return nil + } + time.Sleep(time.Second) + } + } + mainLog.Load().Debug().Msg("DNS was not set for some reason") return nil } @@ -81,10 +119,14 @@ func resetDNS(iface *net.Interface) (err error) { if err == nil { return } + // Start systemd-networkd if present. + if exe, _ := exec.LookPath("/lib/systemd/systemd-networkd"); exe != "" { + _ = exec.Command("systemctl", "start", "systemd-networkd").Run() + } if r, oerr := dns.NewOSConfigurator(logf, iface.Name); oerr == nil { _ = r.SetDNS(dns.OSConfig{}) if err := r.Close(); err != nil { - mainLog.Error().Err(err).Msg("failed to rollback DNS setting") + mainLog.Load().Error().Err(err).Msg("failed to rollback DNS setting") return } err = nil @@ -115,14 +157,14 @@ func resetDNS(iface *net.Interface) (err error) { if ctrldnet.IPv6Available(ctx) { c := client6.NewClient() conversation, err := c.Exchange(iface.Name) - if err != nil { - mainLog.Debug().Err(err).Msg("could not exchange DHCPv6") + if err != nil && !errAddrInUse(err) { + mainLog.Load().Debug().Err(err).Msg("could not exchange DHCPv6") } for _, packet := range conversation { if packet.Type() == dhcpv6.MessageTypeReply { msg, err := packet.GetInnerMessage() if err != nil { - mainLog.Debug().Err(err).Msg("could not get inner DHCPv6 message") + mainLog.Load().Debug().Err(err).Msg("could not get inner DHCPv6 message") return nil } nameservers := msg.Options.DNS() @@ -139,7 +181,7 @@ func resetDNS(iface *net.Interface) (err error) { } func currentDNS(iface *net.Interface) []string { - for _, fn := range []getDNS{getDNSByResolvectl, getDNSByNmcli, resolvconffile.NameServers} { + for _, fn := range []getDNS{getDNSByResolvectl, getDNSBySystemdResolved, getDNSByNmcli, resolvconffile.NameServers} { if ns := fn(iface.Name); len(ns) > 0 { return ns } @@ -160,6 +202,36 @@ func getDNSByResolvectl(iface string) []string { return nil } +func getDNSBySystemdResolved(iface string) []string { + b, err := exec.Command("systemd-resolve", "--status", iface).Output() + if err != nil { + return nil + } + return getDNSBySystemdResolvedFromReader(bytes.NewReader(b)) +} + +func getDNSBySystemdResolvedFromReader(r io.Reader) []string { + scanner := bufio.NewScanner(r) + var ret []string + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if len(ret) > 0 { + if net.ParseIP(line) != nil { + ret = append(ret, line) + } + continue + } + after, found := strings.CutPrefix(line, "DNS Servers: ") + if !found { + continue + } + if net.ParseIP(after) != nil { + ret = append(ret, after) + } + } + return ret +} + func getDNSByNmcli(iface string) []string { b, err := exec.Command("nmcli", "dev", "show", iface).Output() if err != nil { diff --git a/cmd/cli/os_linux_test.go b/cmd/cli/os_linux_test.go new file mode 100644 index 0000000..694fb18 --- /dev/null +++ b/cmd/cli/os_linux_test.go @@ -0,0 +1,23 @@ +package cli + +import ( + "reflect" + "strings" + "testing" +) + +func Test_getDNSBySystemdResolvedFromReader(t *testing.T) { + r := strings.NewReader(`Link 2 (eth0) + Current Scopes: DNS + LLMNR setting: yes +MulticastDNS setting: no + DNSSEC setting: no + DNSSEC supported: no + DNS Servers: 8.8.8.8 + 8.8.4.4`) + want := []string{"8.8.8.8", "8.8.4.4"} + ns := getDNSBySystemdResolvedFromReader(r) + if !reflect.DeepEqual(ns, want) { + t.Logf("unexpected result, want: %v, got: %v", want, ns) + } +} diff --git a/cmd/ctrld/os_others.go b/cmd/cli/os_others.go similarity index 93% rename from cmd/ctrld/os_others.go rename to cmd/cli/os_others.go index 3807bcc..45edf0a 100644 --- a/cmd/ctrld/os_others.go +++ b/cmd/cli/os_others.go @@ -1,6 +1,6 @@ //go:build !linux && !darwin && !freebsd -package main +package cli // TODO(cuonglm): implement. func allocateIP(ip string) error { diff --git a/cmd/ctrld/os_windows.go b/cmd/cli/os_windows.go similarity index 80% rename from cmd/ctrld/os_windows.go rename to cmd/cli/os_windows.go index 8858027..a58411e 100644 --- a/cmd/ctrld/os_windows.go +++ b/cmd/cli/os_windows.go @@ -1,4 +1,4 @@ -package main +package cli import ( "errors" @@ -30,12 +30,12 @@ func setDNS(iface *net.Interface, nameservers []string) error { func resetDNS(iface *net.Interface) error { if ctrldnet.SupportsIPv6ListenLocal() { if output, err := netsh("interface", "ipv6", "set", "dnsserver", strconv.Itoa(iface.Index), "dhcp"); err != nil { - mainLog.Warn().Err(err).Msgf("failed to reset ipv6 DNS: %s", string(output)) + mainLog.Load().Warn().Err(err).Msgf("failed to reset ipv6 DNS: %s", string(output)) } } output, err := netsh("interface", "ipv4", "set", "dnsserver", strconv.Itoa(iface.Index), "dhcp") if err != nil { - mainLog.Error().Err(err).Msgf("failed to reset ipv4 DNS: %s", string(output)) + mainLog.Load().Error().Err(err).Msgf("failed to reset ipv4 DNS: %s", string(output)) return err } return nil @@ -49,7 +49,7 @@ func setPrimaryDNS(iface *net.Interface, dns string) error { idx := strconv.Itoa(iface.Index) output, err := netsh("interface", ipVer, "set", "dnsserver", idx, "static", dns) if err != nil { - mainLog.Error().Err(err).Msgf("failed to set primary DNS: %s", string(output)) + mainLog.Load().Error().Err(err).Msgf("failed to set primary DNS: %s", string(output)) return err } if ipVer == "ipv4" && ctrldnet.SupportsIPv6ListenLocal() { @@ -67,7 +67,7 @@ func addSecondaryDNS(iface *net.Interface, dns string) error { } output, err := netsh("interface", ipVer, "add", "dns", strconv.Itoa(iface.Index), dns, "index=2") if err != nil { - mainLog.Warn().Err(err).Msgf("failed to add secondary DNS: %s", string(output)) + mainLog.Load().Warn().Err(err).Msgf("failed to add secondary DNS: %s", string(output)) } return nil } @@ -79,12 +79,12 @@ func netsh(args ...string) ([]byte, error) { func currentDNS(iface *net.Interface) []string { luid, err := winipcfg.LUIDFromIndex(uint32(iface.Index)) if err != nil { - mainLog.Error().Err(err).Msg("failed to get interface LUID") + mainLog.Load().Error().Err(err).Msg("failed to get interface LUID") return nil } nameservers, err := luid.DNS() if err != nil { - mainLog.Error().Err(err).Msg("failed to get interface DNS") + mainLog.Load().Error().Err(err).Msg("failed to get interface DNS") return nil } ns := make([]string, 0, len(nameservers)) diff --git a/cmd/cli/prog.go b/cmd/cli/prog.go new file mode 100644 index 0000000..4169fb8 --- /dev/null +++ b/cmd/cli/prog.go @@ -0,0 +1,392 @@ +package cli + +import ( + "errors" + "fmt" + "math/rand" + "net" + "net/url" + "os" + "runtime" + "strconv" + "sync" + "syscall" + + "github.com/kardianos/service" + "tailscale.com/net/interfaces" + + "github.com/Control-D-Inc/ctrld" + "github.com/Control-D-Inc/ctrld/internal/clientinfo" + "github.com/Control-D-Inc/ctrld/internal/dnscache" + "github.com/Control-D-Inc/ctrld/internal/router" +) + +const ( + defaultSemaphoreCap = 256 + ctrldLogUnixSock = "ctrld_start.sock" + ctrldControlUnixSock = "ctrld_control.sock" +) + +var logf = func(format string, args ...any) { + mainLog.Load().Debug().Msgf(format, args...) +} + +var svcConfig = &service.Config{ + Name: "ctrld", + DisplayName: "Control-D Helper Service", + Option: service.KeyValue{}, +} + +var useSystemdResolved = false + +type prog struct { + mu sync.Mutex + waitCh chan struct{} + stopCh chan struct{} + logConn net.Conn + cs *controlServer + + cfg *ctrld.Config + cache dnscache.Cacher + sema semaphore + ciTable *clientinfo.Table + router router.Router + + started chan struct{} + onStartedDone chan struct{} + onStarted []func() + onStopped []func() +} + +func (p *prog) Start(s service.Service) error { + p.cfg = &cfg + go p.run() + return nil +} + +func (p *prog) preRun() { + if !service.Interactive() { + p.setDNS() + } + if runtime.GOOS == "darwin" { + p.onStopped = append(p.onStopped, func() { + if !service.Interactive() { + p.resetDNS() + } + }) + } +} + +func (p *prog) run() { + // Wait the caller to signal that we can do our logic. + <-p.waitCh + p.preRun() + numListeners := len(p.cfg.Listener) + p.started = make(chan struct{}, numListeners) + p.onStartedDone = make(chan struct{}) + if p.cfg.Service.CacheEnable { + cacher, err := dnscache.NewLRUCache(p.cfg.Service.CacheSize) + if err != nil { + mainLog.Load().Error().Err(err).Msg("failed to create cacher, caching is disabled") + } else { + p.cache = cacher + } + } + p.sema = &chanSemaphore{ready: make(chan struct{}, defaultSemaphoreCap)} + if mcr := p.cfg.Service.MaxConcurrentRequests; mcr != nil { + n := *mcr + if n == 0 { + p.sema = &noopSemaphore{} + } else { + p.sema = &chanSemaphore{ready: make(chan struct{}, n)} + } + } + var wg sync.WaitGroup + wg.Add(len(p.cfg.Listener)) + + for _, nc := range p.cfg.Network { + for _, cidr := range nc.Cidrs { + _, ipNet, err := net.ParseCIDR(cidr) + if err != nil { + mainLog.Load().Error().Err(err).Str("network", nc.Name).Str("cidr", cidr).Msg("invalid cidr") + continue + } + nc.IPNets = append(nc.IPNets, ipNet) + } + } + for n := range p.cfg.Upstream { + uc := p.cfg.Upstream[n] + uc.Init() + if uc.BootstrapIP == "" { + uc.SetupBootstrapIP() + mainLog.Load().Info().Msgf("bootstrap IPs for upstream.%s: %q", n, uc.BootstrapIPs()) + } else { + mainLog.Load().Info().Str("bootstrap_ip", uc.BootstrapIP).Msgf("using bootstrap IP for upstream.%s", n) + } + uc.SetCertPool(rootCertPool) + go uc.Ping() + } + + p.ciTable = clientinfo.NewTable(&cfg, defaultRouteIP(), cdUID) + if leaseFile := p.cfg.Service.DHCPLeaseFile; leaseFile != "" { + mainLog.Load().Debug().Msgf("watching custom lease file: %s", leaseFile) + format := ctrld.LeaseFileFormat(p.cfg.Service.DHCPLeaseFileFormat) + p.ciTable.AddLeaseFile(leaseFile, format) + } + + go func() { + p.ciTable.Init() + p.ciTable.RefreshLoop(p.stopCh) + }() + go p.watchLinkState() + + for listenerNum := range p.cfg.Listener { + p.cfg.Listener[listenerNum].Init() + go func(listenerNum string) { + defer wg.Done() + listenerConfig := p.cfg.Listener[listenerNum] + upstreamConfig := p.cfg.Upstream[listenerNum] + if upstreamConfig == nil { + mainLog.Load().Warn().Msgf("no default upstream for: [listener.%s]", listenerNum) + } + 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(listenerNum); err != nil { + mainLog.Load().Fatal().Err(err).Msgf("unable to start dns proxy on listener.%s", listenerNum) + } + }(listenerNum) + } + + for i := 0; i < numListeners; i++ { + <-p.started + } + for _, f := range p.onStarted { + f() + } + close(p.onStartedDone) + + // Stop writing log to unix socket. + consoleWriter.Out = os.Stdout + initLoggingWithBackup(false) + if p.logConn != nil { + _ = p.logConn.Close() + } + if p.cs != nil { + p.registerControlServerHandler() + if err := p.cs.start(); err != nil { + mainLog.Load().Warn().Err(err).Msg("could not start control server") + } + } + wg.Wait() +} + +func (p *prog) Stop(s service.Service) error { + 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 + } + return nil +} + +func (p *prog) allocateIP(ip string) error { + p.mu.Lock() + defer p.mu.Unlock() + if !p.cfg.Service.AllocateIP { + return nil + } + return allocateIP(ip) +} + +func (p *prog) deAllocateIP() error { + p.mu.Lock() + defer p.mu.Unlock() + if !p.cfg.Service.AllocateIP { + return nil + } + for _, lc := range p.cfg.Listener { + if err := deAllocateIP(lc.IP); err != nil { + return err + } + } + return nil +} + +func (p *prog) setDNS() { + if cfg.Listener == nil { + return + } + if iface == "" { + return + } + if iface == "auto" { + iface = defaultIfaceName() + } + lc := cfg.FirstListener() + if lc == nil { + return + } + logger := mainLog.Load().With().Str("iface", iface).Logger() + netIface, err := netInterface(iface) + if err != nil { + logger.Error().Err(err).Msg("could not get interface") + return + } + if err := setupNetworkManager(); err != nil { + logger.Error().Err(err).Msg("could not patch NetworkManager") + return + } + + logger.Debug().Msg("setting DNS for interface") + ns := lc.IP + switch { + case lc.IsDirectDnsListener(): + // If ctrld is direct listener, use 127.0.0.1 as nameserver. + ns = "127.0.0.1" + case lc.Port != 53: + ns = "127.0.0.1" + if resolver := router.LocalResolverIP(); resolver != "" { + ns = resolver + } + default: + // If we ever reach here, it means ctrld is running on lc.IP port 53, + // so we could just use lc.IP as nameserver. + } + + nameservers := []string{ns} + if needRFC1918Listeners(lc) { + nameservers = append(nameservers, rfc1918Addresses()...) + } + if err := setDNS(netIface, nameservers); err != nil { + logger.Error().Err(err).Msgf("could not set DNS for interface") + return + } + logger.Debug().Msg("setting DNS successfully") +} + +func (p *prog) resetDNS() { + if iface == "" { + return + } + if iface == "auto" { + iface = defaultIfaceName() + } + logger := mainLog.Load().With().Str("iface", iface).Logger() + netIface, err := netInterface(iface) + if err != nil { + logger.Error().Err(err).Msg("could not get interface") + return + } + if err := restoreNetworkManager(); err != nil { + logger.Error().Err(err).Msg("could not restore NetworkManager") + return + } + logger.Debug().Msg("Restoring DNS for interface") + if err := resetDNS(netIface); err != nil { + logger.Error().Err(err).Msgf("could not reset DNS") + return + } + logger.Debug().Msg("Restoring DNS successfully") +} + +func randomLocalIP() string { + n := rand.Intn(254-2) + 2 + return fmt.Sprintf("127.0.0.%d", n) +} + +func randomPort() int { + max := 1<<16 - 1 + min := 1025 + n := rand.Intn(max-min) + min + return n +} + +// runLogServer starts a unix listener, use by startCmd to gather log from runCmd. +func runLogServer(sockPath string) net.Conn { + addr, err := net.ResolveUnixAddr("unix", sockPath) + if err != nil { + mainLog.Load().Warn().Err(err).Msg("invalid log sock path") + return nil + } + ln, err := net.ListenUnix("unix", addr) + if err != nil { + mainLog.Load().Warn().Err(err).Msg("could not listen log socket") + return nil + } + defer ln.Close() + + server, err := ln.Accept() + if err != nil { + mainLog.Load().Warn().Err(err).Msg("could not accept connection") + return nil + } + return server +} + +func errAddrInUse(err error) bool { + var opErr *net.OpError + if errors.As(err, &opErr) { + return errors.Is(opErr.Err, syscall.EADDRINUSE) || errors.Is(opErr.Err, windowsEADDRINUSE) + } + return false +} + +var _ = errAddrInUse + +// https://learn.microsoft.com/en-us/windows/win32/winsock/windows-sockets-error-codes-2 +var ( + windowsECONNREFUSED = syscall.Errno(10061) + windowsENETUNREACH = syscall.Errno(10051) + windowsEINVAL = syscall.Errno(10022) + windowsEADDRINUSE = syscall.Errno(10048) +) + +func errUrlNetworkError(err error) bool { + var urlErr *url.Error + if errors.As(err, &urlErr) { + var opErr *net.OpError + if errors.As(urlErr.Err, &opErr) { + if opErr.Temporary() { + return true + } + switch { + case errors.Is(opErr.Err, syscall.ECONNREFUSED), + errors.Is(opErr.Err, syscall.EINVAL), + errors.Is(opErr.Err, syscall.ENETUNREACH), + errors.Is(opErr.Err, windowsENETUNREACH), + errors.Is(opErr.Err, windowsEINVAL), + errors.Is(opErr.Err, windowsECONNREFUSED): + return true + } + } + } + return false +} + +// defaultRouteIP returns IP string of the default route if present, prefer IPv4 over IPv6. +func defaultRouteIP() string { + if dr, err := interfaces.DefaultRoute(); err == nil { + if netIface, err := netInterface(dr.InterfaceName); err == nil { + addrs, _ := netIface.Addrs() + do := func(v4 bool) net.IP { + for _, addr := range addrs { + if netIP, ok := addr.(*net.IPNet); ok && netIP.IP.IsPrivate() { + if v4 { + return netIP.IP.To4() + } + return netIP.IP + } + } + return nil + } + if ip := do(true); ip != nil { + return ip.String() + } + if ip := do(false); ip != nil { + return ip.String() + } + } + } + return "" +} diff --git a/cmd/ctrld/prog_darwin.go b/cmd/cli/prog_darwin.go similarity index 52% rename from cmd/ctrld/prog_darwin.go rename to cmd/cli/prog_darwin.go index 2b82eb5..9cd5786 100644 --- a/cmd/ctrld/prog_darwin.go +++ b/cmd/cli/prog_darwin.go @@ -1,23 +1,11 @@ -package main +package cli import ( "github.com/kardianos/service" ) -func (p *prog) preRun() { - if !service.Interactive() { - p.setDNS() - } -} - func setDependencies(svc *service.Config) {} func setWorkingDirectory(svc *service.Config, dir string) { svc.WorkingDirectory = dir } - -func (p *prog) preStop() { - if !service.Interactive() { - p.resetDNS() - } -} diff --git a/cmd/ctrld/prog_freebsd.go b/cmd/cli/prog_freebsd.go similarity index 71% rename from cmd/ctrld/prog_freebsd.go rename to cmd/cli/prog_freebsd.go index 24a90ba..93d737f 100644 --- a/cmd/ctrld/prog_freebsd.go +++ b/cmd/cli/prog_freebsd.go @@ -1,4 +1,4 @@ -package main +package cli import ( "os" @@ -6,17 +6,9 @@ import ( "github.com/kardianos/service" ) -func (p *prog) preRun() { - if !service.Interactive() { - p.setDNS() - } -} - func setDependencies(svc *service.Config) { // TODO(cuonglm): remove once https://github.com/kardianos/service/issues/359 fixed. _ = os.MkdirAll("/usr/local/etc/rc.d", 0755) } func setWorkingDirectory(svc *service.Config, dir string) {} - -func (p *prog) preStop() {} diff --git a/cmd/cli/prog_linux.go b/cmd/cli/prog_linux.go new file mode 100644 index 0000000..6f28083 --- /dev/null +++ b/cmd/cli/prog_linux.go @@ -0,0 +1,32 @@ +package cli + +import ( + "github.com/kardianos/service" + + "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) {}, "lo"); err == nil { + useSystemdResolved = r.Mode() == "systemd-resolved" + } +} + +func setDependencies(svc *service.Config) { + svc.Dependencies = []string{ + "Wants=network-online.target", + "After=network-online.target", + "Wants=NetworkManager-wait-online.service", + "After=NetworkManager-wait-online.service", + "Wants=systemd-networkd-wait-online.service", + "After=systemd-networkd-wait-online.service", + } + if routerDeps := router.ServiceDependencies(); len(routerDeps) > 0 { + svc.Dependencies = append(svc.Dependencies, routerDeps...) + } +} + +func setWorkingDirectory(svc *service.Config, dir string) { + svc.WorkingDirectory = dir +} diff --git a/cmd/ctrld/prog_others.go b/cmd/cli/prog_others.go similarity index 79% rename from cmd/ctrld/prog_others.go rename to cmd/cli/prog_others.go index b26c0b6..92f3a9f 100644 --- a/cmd/ctrld/prog_others.go +++ b/cmd/cli/prog_others.go @@ -1,16 +1,12 @@ //go:build !linux && !freebsd && !darwin -package main +package cli import "github.com/kardianos/service" -func (p *prog) preRun() {} - func setDependencies(svc *service.Config) {} func setWorkingDirectory(svc *service.Config, dir string) { // WorkingDirectory is not supported on Windows. svc.WorkingDirectory = dir } - -func (p *prog) preStop() {} diff --git a/cmd/cli/sema.go b/cmd/cli/sema.go new file mode 100644 index 0000000..92b6ce0 --- /dev/null +++ b/cmd/cli/sema.go @@ -0,0 +1,24 @@ +package cli + +type semaphore interface { + acquire() + release() +} + +type noopSemaphore struct{} + +func (n noopSemaphore) acquire() {} + +func (n noopSemaphore) release() {} + +type chanSemaphore struct { + ready chan struct{} +} + +func (c *chanSemaphore) acquire() { + c.ready <- struct{}{} +} + +func (c *chanSemaphore) release() { + <-c.ready +} diff --git a/cmd/cli/service.go b/cmd/cli/service.go new file mode 100644 index 0000000..c6ed68c --- /dev/null +++ b/cmd/cli/service.go @@ -0,0 +1,167 @@ +package cli + +import ( + "bytes" + "errors" + "fmt" + "os" + "os/exec" + + "github.com/kardianos/service" + + "github.com/Control-D-Inc/ctrld/internal/router" +) + +// newService wraps service.New call to return service.Service +// wrapper which is suitable for the current platform. +func newService(i service.Interface, c *service.Config) (service.Service, error) { + s, err := service.New(i, c) + if err != nil { + return nil, err + } + switch { + case router.IsOldOpenwrt(): + return &procd{&sysV{s}}, nil + case router.IsGLiNet(): + return &sysV{s}, nil + case s.Platform() == "unix-systemv": + return &sysV{s}, nil + case s.Platform() == "linux-systemd": + return &systemd{s}, nil + } + return s, nil +} + +// sysV wraps a service.Service, and provide start/stop/status command +// base on "/etc/init.d/". +// +// Use this on system where "service" command is not available, like GL.iNET router. +type sysV struct { + service.Service +} + +func (s *sysV) installed() bool { + fi, err := os.Stat("/etc/init.d/ctrld") + if err != nil { + return false + } + mode := fi.Mode() + return mode.IsRegular() && (mode&0111) != 0 +} + +func (s *sysV) Start() error { + if !s.installed() { + return service.ErrNotInstalled + } + _, err := exec.Command("/etc/init.d/ctrld", "start").CombinedOutput() + return err +} + +func (s *sysV) Stop() error { + if !s.installed() { + return service.ErrNotInstalled + } + _, err := exec.Command("/etc/init.d/ctrld", "stop").CombinedOutput() + return err +} + +func (s *sysV) Restart() error { + if !s.installed() { + return service.ErrNotInstalled + } + // We don't care about error returned by s.Stop, + // because the service may already be stopped. + _ = s.Stop() + return s.Start() +} + +func (s *sysV) Status() (service.Status, error) { + if !s.installed() { + return service.StatusUnknown, service.ErrNotInstalled + } + return unixSystemVServiceStatus() +} + +// procd wraps a service.Service, and provide start/stop command +// base on "/etc/init.d/", status command base on parsing "ps" command output. +// +// Use this on system where "/etc/init.d/ status" command is not available, +// like old GL.iNET Opal router. +type procd struct { + *sysV +} + +func (s *procd) Status() (service.Status, error) { + if !s.installed() { + return service.StatusUnknown, service.ErrNotInstalled + } + exe, err := os.Executable() + if err != nil { + return service.StatusUnknown, nil + } + // Looking for something like "/sbin/ctrld run ". + shellCmd := fmt.Sprintf("ps | grep -q %q", exe+" [r]un ") + if err := exec.Command("sh", "-c", shellCmd).Run(); err != nil { + return service.StatusStopped, nil + } + return service.StatusRunning, nil +} + +// procd wraps a service.Service, and provide status command to +// report the status correctly. +type systemd struct { + service.Service +} + +func (s *systemd) Status() (service.Status, error) { + out, _ := exec.Command("systemctl", "status", "ctrld").CombinedOutput() + if bytes.Contains(out, []byte("/FAILURE)")) { + return service.StatusStopped, nil + } + return s.Service.Status() +} + +type task struct { + f func() error + abortOnError bool +} + +func doTasks(tasks []task) bool { + var prevErr error + for _, task := range tasks { + if err := task.f(); err != nil { + if task.abortOnError { + mainLog.Load().Error().Msg(errors.Join(prevErr, err).Error()) + return false + } + prevErr = err + } + } + return true +} + +func checkHasElevatedPrivilege() { + ok, err := hasElevatedPrivilege() + if err != nil { + mainLog.Load().Error().Msgf("could not detect user privilege: %v", err) + return + } + if !ok { + mainLog.Load().Error().Msg("Please relaunch process with admin/root privilege.") + os.Exit(1) + } +} + +func unixSystemVServiceStatus() (service.Status, error) { + out, err := exec.Command("/etc/init.d/ctrld", "status").CombinedOutput() + if err != nil { + return service.StatusUnknown, nil + } + + switch string(bytes.ToLower(bytes.TrimSpace(out))) { + case "running": + return service.StatusRunning, nil + default: + return service.StatusStopped, nil + } +} diff --git a/cmd/ctrld/service_others.go b/cmd/cli/service_others.go similarity index 90% rename from cmd/ctrld/service_others.go rename to cmd/cli/service_others.go index 82a6ea3..e9522f4 100644 --- a/cmd/ctrld/service_others.go +++ b/cmd/cli/service_others.go @@ -1,6 +1,6 @@ //go:build !windows -package main +package cli import ( "os" diff --git a/cmd/ctrld/service_windows.go b/cmd/cli/service_windows.go similarity index 96% rename from cmd/ctrld/service_windows.go rename to cmd/cli/service_windows.go index 0ce8d3a..a1010a8 100644 --- a/cmd/ctrld/service_windows.go +++ b/cmd/cli/service_windows.go @@ -1,4 +1,4 @@ -package main +package cli import "golang.org/x/sys/windows" diff --git a/cmd/ctrld/cli.go b/cmd/ctrld/cli.go deleted file mode 100644 index 8cfca02..0000000 --- a/cmd/ctrld/cli.go +++ /dev/null @@ -1,959 +0,0 @@ -package main - -import ( - "bytes" - "context" - "crypto/x509" - "encoding/base64" - "errors" - "fmt" - "net" - "net/netip" - "os" - "os/exec" - "path/filepath" - "runtime" - "strconv" - "strings" - "sync" - "time" - - "github.com/cuonglm/osinfo" - "github.com/fsnotify/fsnotify" - "github.com/go-playground/validator/v10" - "github.com/kardianos/service" - "github.com/miekg/dns" - "github.com/pelletier/go-toml/v2" - "github.com/spf13/cobra" - "github.com/spf13/viper" - "tailscale.com/logtail/backoff" - "tailscale.com/net/interfaces" - - "github.com/Control-D-Inc/ctrld" - "github.com/Control-D-Inc/ctrld/internal/certs" - "github.com/Control-D-Inc/ctrld/internal/controld" - ctrldnet "github.com/Control-D-Inc/ctrld/internal/net" - "github.com/Control-D-Inc/ctrld/internal/router" -) - -var ( - version = "dev" - commit = "none" -) - -var ( - v = viper.NewWithOptions(viper.KeyDelimiter("::")) - defaultConfigWritten = false - defaultConfigFile = "ctrld.toml" - rootCertPool *x509.CertPool -) - -var basicModeFlags = []string{"listen", "primary_upstream", "secondary_upstream", "domains"} - -func isNoConfigStart(cmd *cobra.Command) bool { - for _, flagName := range basicModeFlags { - if cmd.Flags().Lookup(flagName).Changed { - return true - } - } - return false -} - -const rootShortDesc = ` - __ .__ .___ - _____/ |________| | __| _/ -_/ ___\ __\_ __ \ | / __ | -\ \___| | | | \/ |__/ /_/ | - \___ >__| |__| |____/\____ | - \/ dns forwarding proxy \/ -` - -var rootCmd = &cobra.Command{ - Use: "ctrld", - Short: strings.TrimLeft(rootShortDesc, "\n"), - Version: curVersion(), - PreRun: func(cmd *cobra.Command, args []string) { - initConsoleLogging() - }, -} - -func curVersion() string { - if version != "dev" && !strings.HasPrefix(version, "v") { - version = "v" + version - } - if len(commit) > 7 { - commit = commit[:7] - } - return fmt.Sprintf("%s-%s", version, commit) -} - -func initCLI() { - // Enable opening via explorer.exe on Windows. - // See: https://github.com/spf13/cobra/issues/844. - cobra.MousetrapHelpText = "" - cobra.EnableCommandSorting = false - - rootCmd.PersistentFlags().CountVarP( - &verbose, - "verbose", - "v", - `verbose log output, "-v" basic logging, "-vv" debug level logging`, - ) - rootCmd.PersistentFlags().BoolVarP( - &silent, - "silent", - "s", - false, - `do not write any log output`, - ) - rootCmd.SetHelpCommand(&cobra.Command{Hidden: true}) - rootCmd.CompletionOptions.HiddenDefaultCmd = true - - runCmd := &cobra.Command{ - Use: "run", - Short: "Run the DNS proxy server", - Args: cobra.NoArgs, - PreRun: func(cmd *cobra.Command, args []string) { - initConsoleLogging() - }, - Run: func(cmd *cobra.Command, args []string) { - if daemon && runtime.GOOS == "windows" { - mainLog.Fatal().Msg("Cannot run in daemon mode. Please install a Windows service.") - } - - waitCh := make(chan struct{}) - stopCh := make(chan struct{}) - if !daemon { - // We need to call s.Run() as soon as possible to response to the OS manager, so it - // can see ctrld is running and don't mark ctrld as failed service. - go func() { - p := &prog{ - waitCh: waitCh, - stopCh: stopCh, - } - s, err := service.New(p, svcConfig) - if err != nil { - mainLog.Fatal().Err(err).Msg("failed create new service") - } - s = newService(s) - if err := s.Run(); err != nil { - mainLog.Error().Err(err).Msg("failed to start service") - } - }() - } - noConfigStart := isNoConfigStart(cmd) - writeDefaultConfig := !noConfigStart && configBase64 == "" - tryReadingConfig(writeDefaultConfig) - - readBase64Config(configBase64) - processNoConfigFlags(noConfigStart) - if err := v.Unmarshal(&cfg); err != nil { - mainLog.Fatal().Msgf("failed to unmarshal config: %v", err) - } - - mainLog.Info().Msgf("starting ctrld %s", curVersion()) - oi := osinfo.New() - mainLog.Info().Msgf("os: %s", oi.String()) - - // Wait for network up. - if !ctrldnet.Up() { - mainLog.Fatal().Msg("network is not up yet") - } - processLogAndCacheFlags() - // Log config do not have thing to validate, so it's safe to init log here, - // so it's able to log information in processCDFlags. - initLogging() - - if setupRouter { - s, errCh := runDNSServerForNTPD(router.ListenAddress()) - if err := router.PreRun(); err != nil { - mainLog.Fatal().Err(err).Msg("failed to perform router pre-start check") - } - if err := s.Shutdown(); err != nil && errCh != nil { - mainLog.Fatal().Err(err).Msg("failed to shutdown dns server for ntpd") - } - } - - processCDFlags() - if err := ctrld.ValidateConfig(validator.New(), &cfg); err != nil { - mainLog.Fatal().Msgf("invalid config: %v", err) - } - initCache() - - if daemon { - exe, err := os.Executable() - if err != nil { - mainLog.Error().Err(err).Msg("failed to find the binary") - os.Exit(1) - } - curDir, err := os.Getwd() - if err != nil { - mainLog.Error().Err(err).Msg("failed to get current working directory") - os.Exit(1) - } - // If running as daemon, re-run the command in background, with daemon off. - cmd := exec.Command(exe, append(os.Args[1:], "-d=false")...) - cmd.Dir = curDir - if err := cmd.Start(); err != nil { - mainLog.Error().Err(err).Msg("failed to start process as daemon") - os.Exit(1) - } - mainLog.Info().Int("pid", cmd.Process.Pid).Msg("DNS proxy started") - os.Exit(0) - } - - if setupRouter { - switch platform := router.Name(); { - case platform == router.DDWrt: - rootCertPool = certs.CACertPool() - fallthrough - case platform != "": - mainLog.Debug().Msg("Router setup") - err := router.Configure(&cfg) - if errors.Is(err, router.ErrNotSupported) { - unsupportedPlatformHelp(cmd) - os.Exit(1) - } - if err != nil { - mainLog.Fatal().Err(err).Msg("failed to configure router") - } - } - } - - close(waitCh) - <-stopCh - }, - } - runCmd.Flags().BoolVarP(&daemon, "daemon", "d", false, "Run as daemon") - runCmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file") - runCmd.Flags().StringVarP(&configBase64, "base64_config", "", "", "Base64 encoded config") - runCmd.Flags().StringVarP(&listenAddress, "listen", "", "", "Listener address and port, in format: address:port") - runCmd.Flags().StringVarP(&primaryUpstream, "primary_upstream", "", "", "Primary upstream endpoint") - runCmd.Flags().StringVarP(&secondaryUpstream, "secondary_upstream", "", "", "Secondary upstream endpoint") - runCmd.Flags().StringSliceVarP(&domains, "domains", "", nil, "List of domain to apply in a split DNS policy") - runCmd.Flags().StringVarP(&logPath, "log", "", "", "Path to log file") - runCmd.Flags().IntVarP(&cacheSize, "cache_size", "", 0, "Enable cache with size items") - runCmd.Flags().StringVarP(&cdUID, "cd", "", "", "Control D resolver uid") - runCmd.Flags().BoolVarP(&cdDev, "dev", "", false, "Use Control D dev resolver/domain") - _ = runCmd.Flags().MarkHidden("dev") - runCmd.Flags().StringVarP(&homedir, "homedir", "", "", "") - _ = runCmd.Flags().MarkHidden("homedir") - runCmd.Flags().StringVarP(&iface, "iface", "", "", `Update DNS setting for iface, "auto" means the default interface gateway`) - _ = runCmd.Flags().MarkHidden("iface") - runCmd.Flags().BoolVarP(&setupRouter, "router", "", false, `setup for running on router platforms`) - _ = runCmd.Flags().MarkHidden("router") - - rootCmd.AddCommand(runCmd) - - startCmd := &cobra.Command{ - PreRun: func(cmd *cobra.Command, args []string) { - initConsoleLogging() - checkHasElevatedPrivilege() - }, - Use: "start", - Short: "Install and start the ctrld service", - Args: cobra.NoArgs, - Run: func(cmd *cobra.Command, args []string) { - sc := &service.Config{} - *sc = *svcConfig - osArgs := os.Args[2:] - if os.Args[1] == "service" { - osArgs = os.Args[3:] - } - setDependencies(sc) - sc.Arguments = append([]string{"run"}, osArgs...) - if err := router.ConfigureService(sc); err != nil { - mainLog.Fatal().Err(err).Msg("failed to configure service on router") - } - - // No config path, generating config in HOME directory. - noConfigStart := isNoConfigStart(cmd) - writeDefaultConfig := !noConfigStart && configBase64 == "" - if configPath != "" { - v.SetConfigFile(configPath) - } - if dir, err := userHomeDir(); err == nil { - setWorkingDirectory(sc, dir) - if configPath == "" && writeDefaultConfig { - defaultConfigFile = filepath.Join(dir, defaultConfigFile) - } - sc.Arguments = append(sc.Arguments, "--homedir="+dir) - } - - tryReadingConfig(writeDefaultConfig) - - if err := v.Unmarshal(&cfg); err != nil { - mainLog.Fatal().Msgf("failed to unmarshal config: %v", err) - } - - logPath := cfg.Service.LogPath - cfg.Service.LogPath = "" - initLogging() - cfg.Service.LogPath = logPath - - processCDFlags() - - if err := ctrld.ValidateConfig(validator.New(), &cfg); err != nil { - mainLog.Fatal().Msgf("invalid config: %v", err) - } - - // Explicitly passing config, so on system where home directory could not be obtained, - // or sub-process env is different with the parent, we still behave correctly and use - // the expected config file. - if configPath == "" { - sc.Arguments = append(sc.Arguments, "--config="+defaultConfigFile) - } - - prog := &prog{} - s, err := service.New(prog, sc) - if err != nil { - mainLog.Error().Msg(err.Error()) - return - } - s = newService(s) - tasks := []task{ - {s.Stop, false}, - {s.Uninstall, false}, - {s.Install, false}, - {s.Start, true}, - } - if doTasks(tasks) { - if err := router.PostInstall(svcConfig); err != nil { - mainLog.Warn().Err(err).Msg("post installation failed, please check system/service log for details error") - return - } - status, err := serviceStatus(s) - if err != nil { - mainLog.Warn().Err(err).Msg("could not get service status") - return - } - - domain := cfg.Upstream["0"].VerifyDomain() - status = selfCheckStatus(status, domain) - switch status { - case service.StatusRunning: - mainLog.Notice().Msg("Service started") - default: - mainLog.Error().Msg("Service did not start, please check system/service log for details error") - if runtime.GOOS == "linux" { - prog.resetDNS() - } - os.Exit(1) - } - prog.setDNS() - } - }, - } - // Keep these flags in sync with runCmd above, except for "-d". - startCmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file") - startCmd.Flags().StringVarP(&configBase64, "base64_config", "", "", "Base64 encoded config") - startCmd.Flags().StringVarP(&listenAddress, "listen", "", "", "Listener address and port, in format: address:port") - startCmd.Flags().StringVarP(&primaryUpstream, "primary_upstream", "", "", "Primary upstream endpoint") - startCmd.Flags().StringVarP(&secondaryUpstream, "secondary_upstream", "", "", "Secondary upstream endpoint") - startCmd.Flags().StringSliceVarP(&domains, "domains", "", nil, "List of domain to apply in a split DNS policy") - startCmd.Flags().StringVarP(&logPath, "log", "", "", "Path to log file") - startCmd.Flags().IntVarP(&cacheSize, "cache_size", "", 0, "Enable cache with size items") - startCmd.Flags().StringVarP(&cdUID, "cd", "", "", "Control D resolver uid") - startCmd.Flags().BoolVarP(&cdDev, "dev", "", false, "Use Control D dev resolver/domain") - _ = startCmd.Flags().MarkHidden("dev") - startCmd.Flags().StringVarP(&iface, "iface", "", "", `Update DNS setting for iface, "auto" means the default interface gateway`) - startCmd.Flags().BoolVarP(&setupRouter, "router", "", false, `setup for running on router platforms`) - _ = startCmd.Flags().MarkHidden("router") - - stopCmd := &cobra.Command{ - PreRun: func(cmd *cobra.Command, args []string) { - initConsoleLogging() - checkHasElevatedPrivilege() - }, - Use: "stop", - Short: "Stop the ctrld service", - Args: cobra.NoArgs, - Run: func(cmd *cobra.Command, args []string) { - prog := &prog{} - s, err := service.New(prog, svcConfig) - if err != nil { - mainLog.Error().Msg(err.Error()) - return - } - s = newService(s) - initLogging() - if doTasks([]task{{s.Stop, true}}) { - prog.resetDNS() - mainLog.Notice().Msg("Service stopped") - } - }, - } - stopCmd.Flags().StringVarP(&iface, "iface", "", "", `Reset DNS setting for iface, "auto" means the default interface gateway`) - - restartCmd := &cobra.Command{ - PreRun: func(cmd *cobra.Command, args []string) { - initConsoleLogging() - checkHasElevatedPrivilege() - }, - Use: "restart", - Short: "Restart the ctrld service", - Args: cobra.NoArgs, - Run: func(cmd *cobra.Command, args []string) { - s, err := service.New(&prog{}, svcConfig) - if err != nil { - mainLog.Error().Msg(err.Error()) - return - } - s = newService(s) - initLogging() - if doTasks([]task{{s.Restart, true}}) { - mainLog.Notice().Msg("Service restarted") - } - }, - } - - statusCmd := &cobra.Command{ - Use: "status", - Short: "Show status of the ctrld service", - Args: cobra.NoArgs, - PreRun: func(cmd *cobra.Command, args []string) { - initConsoleLogging() - }, - Run: func(cmd *cobra.Command, args []string) { - s, err := service.New(&prog{}, svcConfig) - if err != nil { - mainLog.Error().Msg(err.Error()) - return - } - s = newService(s) - status, err := serviceStatus(s) - if err != nil { - mainLog.Error().Msg(err.Error()) - os.Exit(1) - } - switch status { - case service.StatusUnknown: - mainLog.Notice().Msg("Unknown status") - os.Exit(2) - case service.StatusRunning: - mainLog.Notice().Msg("Service is running") - os.Exit(0) - case service.StatusStopped: - mainLog.Notice().Msg("Service is stopped") - os.Exit(1) - } - }, - } - if runtime.GOOS == "darwin" { - // On darwin, running status command without privileges may return wrong information. - statusCmd.PreRun = func(cmd *cobra.Command, args []string) { - initConsoleLogging() - checkHasElevatedPrivilege() - } - } - - uninstallCmd := &cobra.Command{ - PreRun: func(cmd *cobra.Command, args []string) { - initConsoleLogging() - checkHasElevatedPrivilege() - }, - Use: "uninstall", - Short: "Stop and uninstall the ctrld service", - Long: `Stop and uninstall the ctrld service. - -NOTE: Uninstalling will set DNS to values provided by DHCP.`, - Args: cobra.NoArgs, - Run: func(cmd *cobra.Command, args []string) { - prog := &prog{} - s, err := service.New(prog, svcConfig) - if err != nil { - mainLog.Error().Msg(err.Error()) - return - } - tasks := []task{ - {s.Stop, false}, - {s.Uninstall, true}, - } - initLogging() - if doTasks(tasks) { - if iface == "" { - iface = "auto" - } - prog.resetDNS() - mainLog.Debug().Msg("Router cleanup") - if err := router.Cleanup(svcConfig); err != nil { - mainLog.Warn().Err(err).Msg("could not cleanup router") - } - mainLog.Notice().Msg("Service uninstalled") - return - } - }, - } - uninstallCmd.Flags().StringVarP(&iface, "iface", "", "", `Reset DNS setting for iface, use "auto" for the default gateway interface`) - - listIfacesCmd := &cobra.Command{ - Use: "list", - Short: "List network interfaces of the host", - Args: cobra.NoArgs, - PreRun: func(cmd *cobra.Command, args []string) { - initConsoleLogging() - }, - Run: func(cmd *cobra.Command, args []string) { - err := interfaces.ForeachInterface(func(i interfaces.Interface, prefixes []netip.Prefix) { - fmt.Printf("Index : %d\n", i.Index) - fmt.Printf("Name : %s\n", i.Name) - addrs, _ := i.Addrs() - for i, ipaddr := range addrs { - if i == 0 { - fmt.Printf("Addrs : %v\n", ipaddr) - continue - } - fmt.Printf(" %v\n", ipaddr) - } - for i, dns := range currentDNS(i.Interface) { - if i == 0 { - fmt.Printf("DNS : %s\n", dns) - continue - } - fmt.Printf(" : %s\n", dns) - } - println() - }) - if err != nil { - mainLog.Error().Msg(err.Error()) - } - }, - } - interfacesCmd := &cobra.Command{ - Use: "interfaces", - Short: "Manage network interfaces", - Args: cobra.OnlyValidArgs, - ValidArgs: []string{ - listIfacesCmd.Use, - }, - } - interfacesCmd.AddCommand(listIfacesCmd) - - serviceCmd := &cobra.Command{ - Use: "service", - Short: "Manage ctrld service", - Args: cobra.OnlyValidArgs, - ValidArgs: []string{ - statusCmd.Use, - stopCmd.Use, - restartCmd.Use, - statusCmd.Use, - uninstallCmd.Use, - interfacesCmd.Use, - }, - } - serviceCmd.AddCommand(startCmd) - serviceCmd.AddCommand(stopCmd) - serviceCmd.AddCommand(restartCmd) - serviceCmd.AddCommand(statusCmd) - serviceCmd.AddCommand(uninstallCmd) - serviceCmd.AddCommand(interfacesCmd) - rootCmd.AddCommand(serviceCmd) - startCmdAlias := &cobra.Command{ - PreRun: func(cmd *cobra.Command, args []string) { - initConsoleLogging() - checkHasElevatedPrivilege() - }, - Use: "start", - Short: "Quick start service and configure DNS on interface", - Run: func(cmd *cobra.Command, args []string) { - if !cmd.Flags().Changed("iface") { - os.Args = append(os.Args, "--iface="+ifaceStartStop) - } - iface = ifaceStartStop - startCmd.Run(cmd, args) - }, - } - startCmdAlias.Flags().StringVarP(&ifaceStartStop, "iface", "", "auto", `Update DNS setting for iface, "auto" means the default interface gateway`) - startCmdAlias.Flags().AddFlagSet(startCmd.Flags()) - rootCmd.AddCommand(startCmdAlias) - stopCmdAlias := &cobra.Command{ - PreRun: func(cmd *cobra.Command, args []string) { - initConsoleLogging() - checkHasElevatedPrivilege() - }, - Use: "stop", - Short: "Quick stop service and remove DNS from interface", - Run: func(cmd *cobra.Command, args []string) { - if !cmd.Flags().Changed("iface") { - os.Args = append(os.Args, "--iface="+ifaceStartStop) - } - iface = ifaceStartStop - stopCmd.Run(cmd, args) - }, - } - stopCmdAlias.Flags().StringVarP(&ifaceStartStop, "iface", "", "auto", `Reset DNS setting for iface, "auto" means the default interface gateway`) - stopCmdAlias.Flags().AddFlagSet(stopCmd.Flags()) - rootCmd.AddCommand(stopCmdAlias) -} - -func writeConfigFile() error { - if cfu := v.ConfigFileUsed(); cfu != "" { - defaultConfigFile = cfu - } else if configPath != "" { - defaultConfigFile = configPath - } - f, err := os.OpenFile(defaultConfigFile, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, os.FileMode(0o644)) - if err != nil { - return err - } - defer f.Close() - if cdUID != "" { - if _, err := f.WriteString("# AUTO-GENERATED VIA CD FLAG - DO NOT MODIFY\n\n"); err != nil { - return err - } - } - enc := toml.NewEncoder(f).SetIndentTables(true) - if err := enc.Encode(&cfg); err != nil { - return err - } - if err := f.Close(); err != nil { - return err - } - return nil -} - -func readConfigFile(writeDefaultConfig bool) bool { - // If err == nil, there's a config supplied via `--config`, no default config written. - err := v.ReadInConfig() - if err == nil { - mainLog.Info().Msg("loading config file from: " + v.ConfigFileUsed()) - defaultConfigFile = v.ConfigFileUsed() - return true - } - - if !writeDefaultConfig { - return false - } - - // If error is viper.ConfigFileNotFoundError, write default config. - if _, ok := err.(viper.ConfigFileNotFoundError); ok { - if err := v.Unmarshal(&cfg); err != nil { - mainLog.Fatal().Msgf("failed to unmarshal default config: %v", err) - } - if err := writeConfigFile(); err != nil { - mainLog.Fatal().Msgf("failed to write default config file: %v", err) - } else { - fp, err := filepath.Abs(defaultConfigFile) - if err != nil { - mainLog.Fatal().Msgf("failed to get default config file path: %v", err) - } - mainLog.Info().Msg("writing default config file to: " + fp) - } - defaultConfigWritten = true - return false - } - // Otherwise, report fatal error and exit. - mainLog.Fatal().Msgf("failed to decode config file: %v", err) - return false -} - -func readBase64Config(configBase64 string) { - if configBase64 == "" { - return - } - configStr, err := base64.StdEncoding.DecodeString(configBase64) - if err != nil { - mainLog.Fatal().Msgf("invalid base64 config: %v", err) - } - if err := v.ReadConfig(bytes.NewReader(configStr)); err != nil { - mainLog.Fatal().Msgf("failed to read base64 config: %v", err) - } -} - -func processNoConfigFlags(noConfigStart bool) { - if !noConfigStart { - return - } - if listenAddress == "" || primaryUpstream == "" { - mainLog.Fatal().Msg(`"listen" and "primary_upstream" flags must be set in no config mode`) - } - processListenFlag() - - endpointAndTyp := func(endpoint string) (string, string) { - typ := ctrld.ResolverTypeFromEndpoint(endpoint) - return strings.TrimPrefix(endpoint, "quic://"), typ - } - pEndpoint, pType := endpointAndTyp(primaryUpstream) - upstream := map[string]*ctrld.UpstreamConfig{ - "0": { - Name: pEndpoint, - Endpoint: pEndpoint, - Type: pType, - Timeout: 5000, - }, - } - if secondaryUpstream != "" { - sEndpoint, sType := endpointAndTyp(secondaryUpstream) - upstream["1"] = &ctrld.UpstreamConfig{ - Name: sEndpoint, - Endpoint: sEndpoint, - Type: sType, - Timeout: 5000, - } - rules := make([]ctrld.Rule, 0, len(domains)) - for _, domain := range domains { - rules = append(rules, ctrld.Rule{domain: []string{"upstream.1"}}) - } - lc := v.Get("listener").(map[string]*ctrld.ListenerConfig)["0"] - lc.Policy = &ctrld.ListenerPolicyConfig{Name: "My Policy", Rules: rules} - } - v.Set("upstream", upstream) -} - -func processCDFlags() { - if cdUID == "" { - return - } - if iface == "" { - iface = "auto" - } - logger := mainLog.With().Str("mode", "cd").Logger() - logger.Info().Msgf("fetching Controld D configuration from API: %s", cdUID) - resolverConfig, err := controld.FetchResolverConfig(cdUID, rootCmd.Version, cdDev) - if uer, ok := err.(*controld.UtilityErrorResponse); ok && uer.ErrorField.Code == controld.InvalidConfigCode { - s, err := service.New(&prog{}, svcConfig) - if err != nil { - logger.Warn().Err(err).Msg("failed to create new service") - return - } - - if netIface, _ := netInterface(iface); netIface != nil { - if err := restoreNetworkManager(); err != nil { - logger.Error().Err(err).Msg("could not restore NetworkManager") - return - } - logger.Debug().Str("iface", netIface.Name).Msg("Restoring DNS for interface") - if err := resetDNS(netIface); err != nil { - logger.Warn().Err(err).Msg("something went wrong while restoring DNS") - } else { - logger.Debug().Str("iface", netIface.Name).Msg("Restoring DNS successfully") - } - } - - tasks := []task{{s.Uninstall, true}} - if doTasks(tasks) { - logger.Info().Msg("uninstalled service") - } - logger.Fatal().Err(uer).Msg("failed to fetch resolver config") - } - if err != nil { - logger.Warn().Err(err).Msg("could not fetch resolver config") - return - } - - logger.Info().Msg("generating ctrld config from Control-D configuration") - if resolverConfig.Ctrld.CustomConfig != "" { - logger.Info().Msg("using defined custom config of Control-D resolver") - readBase64Config(resolverConfig.Ctrld.CustomConfig) - if err := v.Unmarshal(&cfg); err != nil { - mainLog.Fatal().Msgf("failed to unmarshal config: %v", err) - } - for _, listener := range cfg.Listener { - if listener.IP == "" { - listener.IP = randomLocalIP() - } - if listener.Port == 0 { - listener.Port = 53 - } - } - // On router, we want to keep the listener address point to dnsmasq listener, aka 127.0.0.1:53. - if router.Name() != "" { - if lc := cfg.Listener["0"]; lc != nil { - lc.IP = "127.0.0.1" - lc.Port = 53 - } - } - } else { - cfg = ctrld.Config{} - cfg.Network = make(map[string]*ctrld.NetworkConfig) - cfg.Network["0"] = &ctrld.NetworkConfig{ - Name: "Network 0", - Cidrs: []string{"0.0.0.0/0"}, - } - cfg.Upstream = make(map[string]*ctrld.UpstreamConfig) - cfg.Upstream["0"] = &ctrld.UpstreamConfig{ - Endpoint: resolverConfig.DOH, - Type: ctrld.ResolverTypeDOH, - Timeout: 5000, - } - rules := make([]ctrld.Rule, 0, len(resolverConfig.Exclude)) - for _, domain := range resolverConfig.Exclude { - rules = append(rules, ctrld.Rule{domain: []string{}}) - } - cfg.Listener = make(map[string]*ctrld.ListenerConfig) - cfg.Listener["0"] = &ctrld.ListenerConfig{ - IP: "127.0.0.1", - Port: 53, - Policy: &ctrld.ListenerPolicyConfig{ - Name: "My Policy", - Rules: rules, - }, - } - processLogAndCacheFlags() - } - - if err := writeConfigFile(); err != nil { - logger.Fatal().Err(err).Msg("failed to write config file") - } else { - logger.Info().Msg("writing config file to: " + defaultConfigFile) - } -} - -func processListenFlag() { - if listenAddress == "" { - return - } - host, portStr, err := net.SplitHostPort(listenAddress) - if err != nil { - mainLog.Fatal().Msgf("invalid listener address: %v", err) - } - port, err := strconv.Atoi(portStr) - if err != nil { - mainLog.Fatal().Msgf("invalid port number: %v", err) - } - lc := &ctrld.ListenerConfig{ - IP: host, - Port: port, - } - v.Set("listener", map[string]*ctrld.ListenerConfig{ - "0": lc, - }) -} - -func processLogAndCacheFlags() { - if logPath != "" { - cfg.Service.LogLevel = "debug" - cfg.Service.LogPath = logPath - } - - if cacheSize != 0 { - cfg.Service.CacheEnable = true - cfg.Service.CacheSize = cacheSize - } - v.Set("service", cfg.Service) -} - -func netInterface(ifaceName string) (*net.Interface, error) { - if ifaceName == "auto" { - ifaceName = defaultIfaceName() - } - var iface *net.Interface - err := interfaces.ForeachInterface(func(i interfaces.Interface, prefixes []netip.Prefix) { - if i.Name == ifaceName { - iface = i.Interface - } - }) - if iface == nil { - return nil, errors.New("interface not found") - } - if err := patchNetIfaceName(iface); err != nil { - return nil, err - } - return iface, err -} - -func defaultIfaceName() string { - dri, err := interfaces.DefaultRouteInterface() - if err != nil { - // On WSL 1, the route table does not have any default route. But the fact that - // it only uses /etc/resolv.conf for setup DNS, so we can use "lo" here. - if oi := osinfo.New(); strings.Contains(oi.String(), "Microsoft") { - return "lo" - } - mainLog.Fatal().Err(err).Msg("failed to get default route interface") - } - return dri -} - -func selfCheckStatus(status service.Status, domain string) service.Status { - if domain == "" { - // Nothing to do, return the status as-is. - return status - } - c := new(dns.Client) - bo := backoff.NewBackoff("self-check", logf, 10*time.Second) - bo.LogLongerThan = 500 * time.Millisecond - ctx := context.Background() - maxAttempts := 20 - mainLog.Debug().Msg("Performing self-check") - var ( - lcChanged map[string]*ctrld.ListenerConfig - mu sync.Mutex - ) - v.OnConfigChange(func(in fsnotify.Event) { - mu.Lock() - defer mu.Unlock() - if err := v.UnmarshalKey("listener", &lcChanged); err != nil { - mainLog.Error().Msgf("failed to unmarshal listener config: %v", err) - return - } - }) - v.WatchConfig() - for i := 0; i < maxAttempts; i++ { - lc := cfg.Listener["0"] - mu.Lock() - if lcChanged != nil { - lc = lcChanged["0"] - } - mu.Unlock() - m := new(dns.Msg) - m.SetQuestion(domain+".", dns.TypeA) - m.RecursionDesired = true - r, _, err := c.ExchangeContext(ctx, m, net.JoinHostPort(lc.IP, strconv.Itoa(lc.Port))) - if r != nil && r.Rcode == dns.RcodeSuccess && len(r.Answer) > 0 { - mainLog.Debug().Msgf("self-check against %q succeeded", domain) - return status - } - bo.BackOff(ctx, fmt.Errorf("ExchangeContext: %w", err)) - } - mainLog.Debug().Msgf("self-check against %q failed", domain) - return service.StatusUnknown -} - -func unsupportedPlatformHelp(cmd *cobra.Command) { - mainLog.Error().Msg("Unsupported or incorrectly chosen router platform. Please open an issue and provide all relevant information: https://github.com/Control-D-Inc/ctrld/issues/new") -} - -func userHomeDir() (string, error) { - switch router.Name() { - case router.DDWrt, router.Merlin, router.Tomato: - exe, err := os.Executable() - if err != nil { - return "", err - } - return filepath.Dir(exe), nil - } - // viper will expand for us. - if runtime.GOOS == "windows" { - return os.UserHomeDir() - } - dir := "/etc/controld" - if err := os.MkdirAll(dir, 0750); err != nil { - return "", err - } - return dir, nil -} - -func tryReadingConfig(writeDefaultConfig bool) { - configs := []struct { - name string - written bool - }{ - // For compatibility, we check for config.toml first, but only read it if exists. - {"config", false}, - {"ctrld", writeDefaultConfig}, - } - - dir, err := userHomeDir() - if err != nil { - mainLog.Fatal().Msgf("failed to get config dir: %v", err) - } - for _, config := range configs { - ctrld.SetConfigNameWithPath(v, config.name, dir) - v.SetConfigFile(configPath) - if readConfigFile(config.written) { - break - } - } -} diff --git a/cmd/ctrld/cli_router.go b/cmd/ctrld/cli_router.go deleted file mode 100644 index 7688e9a..0000000 --- a/cmd/ctrld/cli_router.go +++ /dev/null @@ -1,100 +0,0 @@ -//go:build linux || freebsd - -package main - -import ( - "os" - "os/exec" - "strings" - - "github.com/spf13/cobra" - - "github.com/Control-D-Inc/ctrld/internal/router" -) - -func initRouterCLI() { - validArgs := append(router.SupportedPlatforms(), "auto") - var b strings.Builder - b.WriteString("Auto-setup Control D on a router.\n\nSupported platforms:\n\n") - for _, arg := range validArgs { - b.WriteString(" ₒ ") - b.WriteString(arg) - if arg == "auto" { - b.WriteString(" - detect the platform you are running on") - } - b.WriteString("\n") - } - - routerCmd := &cobra.Command{ - Use: "setup", - Short: b.String(), - PreRun: func(cmd *cobra.Command, args []string) { - initConsoleLogging() - }, - Run: func(cmd *cobra.Command, args []string) { - if len(args) == 0 { - _ = cmd.Help() - return - } - if len(args) != 1 { - _ = cmd.Help() - return - } - platform := args[0] - if platform == "auto" { - platform = router.Name() - } - if !router.IsSupported(platform) { - unsupportedPlatformHelp(cmd) - os.Exit(1) - } - - exe, err := os.Executable() - if err != nil { - mainLog.Fatal().Msgf("could not find executable path: %v", err) - os.Exit(1) - } - - cmdArgs := []string{"start"} - cmdArgs = append(cmdArgs, osArgs(platform)...) - cmdArgs = append(cmdArgs, "--router") - command := exec.Command(exe, cmdArgs...) - command.Stdout = os.Stdout - command.Stderr = os.Stderr - command.Stdin = os.Stdin - if err := command.Run(); err != nil { - mainLog.Fatal().Msg(err.Error()) - } - }, - } - // Keep these flags in sync with startCmd, except for "--router". - routerCmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file") - routerCmd.Flags().StringVarP(&configBase64, "base64_config", "", "", "Base64 encoded config") - routerCmd.Flags().StringVarP(&listenAddress, "listen", "", "", "Listener address and port, in format: address:port") - routerCmd.Flags().StringVarP(&primaryUpstream, "primary_upstream", "", "", "Primary upstream endpoint") - routerCmd.Flags().StringVarP(&secondaryUpstream, "secondary_upstream", "", "", "Secondary upstream endpoint") - routerCmd.Flags().StringSliceVarP(&domains, "domains", "", nil, "List of domain to apply in a split DNS policy") - routerCmd.Flags().StringVarP(&logPath, "log", "", "", "Path to log file") - routerCmd.Flags().IntVarP(&cacheSize, "cache_size", "", 0, "Enable cache with size items") - routerCmd.Flags().StringVarP(&cdUID, "cd", "", "", "Control D resolver uid") - routerCmd.Flags().BoolVarP(&cdDev, "dev", "", false, "Use Control D dev resolver/domain") - _ = routerCmd.Flags().MarkHidden("dev") - routerCmd.Flags().StringVarP(&iface, "iface", "", "", `Update DNS setting for iface, "auto" means the default interface gateway`) - - tmpl := routerCmd.UsageTemplate() - tmpl = strings.Replace(tmpl, "{{.UseLine}}", "{{.UseLine}} [platform]", 1) - routerCmd.SetUsageTemplate(tmpl) - rootCmd.AddCommand(routerCmd) -} - -func osArgs(platform string) []string { - args := os.Args[2:] - n := 0 - for _, x := range args { - if x != platform && x != "auto" { - args[n] = x - n++ - } - } - return args[:n] -} diff --git a/cmd/ctrld/cli_router_others.go b/cmd/ctrld/cli_router_others.go deleted file mode 100644 index 4934b5c..0000000 --- a/cmd/ctrld/cli_router_others.go +++ /dev/null @@ -1,5 +0,0 @@ -//go:build !linux && !freebsd - -package main - -func initRouterCLI() {} diff --git a/cmd/ctrld/main.go b/cmd/ctrld/main.go index bc3edf0..af204ad 100644 --- a/cmd/ctrld/main.go +++ b/cmd/ctrld/main.go @@ -1,135 +1,7 @@ package main -import ( - "io" - "os" - "path/filepath" - "time" - - "github.com/kardianos/service" - "github.com/rs/zerolog" - - "github.com/Control-D-Inc/ctrld" -) - -var ( - configPath string - configBase64 string - daemon bool - listenAddress string - primaryUpstream string - secondaryUpstream string - domains []string - logPath string - homedir string - cacheSize int - cfg ctrld.Config - verbose int - silent bool - cdUID string - cdDev bool - iface string - ifaceStartStop string - setupRouter bool - - mainLog = zerolog.New(io.Discard) - consoleWriter zerolog.ConsoleWriter -) +import "github.com/Control-D-Inc/ctrld/cmd/cli" func main() { - ctrld.InitConfig(v, "ctrld") - initCLI() - initRouterCLI() - if err := rootCmd.Execute(); err != nil { - mainLog.Error().Msg(err.Error()) - os.Exit(1) - } -} - -func normalizeLogFilePath(logFilePath string) string { - if logFilePath == "" || filepath.IsAbs(logFilePath) || service.Interactive() { - return logFilePath - } - if homedir != "" { - return filepath.Join(homedir, logFilePath) - } - dir, _ := userHomeDir() - if dir == "" { - return logFilePath - } - return filepath.Join(dir, logFilePath) -} - -func initConsoleLogging() { - consoleWriter = zerolog.NewConsoleWriter(func(w *zerolog.ConsoleWriter) { - w.TimeFormat = time.StampMilli - }) - multi := zerolog.MultiLevelWriter(consoleWriter) - mainLog = mainLog.Output(multi).With().Timestamp().Logger() - switch { - case silent: - zerolog.SetGlobalLevel(zerolog.NoLevel) - case verbose == 1: - zerolog.SetGlobalLevel(zerolog.InfoLevel) - case verbose > 1: - zerolog.SetGlobalLevel(zerolog.DebugLevel) - default: - zerolog.SetGlobalLevel(zerolog.NoticeLevel) - } -} - -func initLogging() { - writers := []io.Writer{io.Discard} - if logFilePath := normalizeLogFilePath(cfg.Service.LogPath); logFilePath != "" { - // Create parent directory if necessary. - if err := os.MkdirAll(filepath.Dir(logFilePath), 0750); err != nil { - mainLog.Error().Msgf("failed to create log path: %v", err) - os.Exit(1) - } - // Backup old log file with .1 suffix. - if err := os.Rename(logFilePath, logFilePath+".1"); err != nil && !os.IsNotExist(err) { - mainLog.Error().Msgf("could not backup old log file: %v", err) - } - logFile, err := os.OpenFile(logFilePath, os.O_CREATE|os.O_RDWR, os.FileMode(0o600)) - if err != nil { - mainLog.Error().Msgf("failed to create log file: %v", err) - os.Exit(1) - } - writers = append(writers, logFile) - } - writers = append(writers, consoleWriter) - multi := zerolog.MultiLevelWriter(writers...) - mainLog = mainLog.Output(multi).With().Timestamp().Logger() - // TODO: find a better way. - ctrld.ProxyLog = mainLog - - zerolog.SetGlobalLevel(zerolog.NoticeLevel) - logLevel := cfg.Service.LogLevel - switch { - case silent: - zerolog.SetGlobalLevel(zerolog.NoLevel) - return - case verbose == 1: - logLevel = "info" - case verbose > 1: - logLevel = "debug" - } - if logLevel == "" { - return - } - level, err := zerolog.ParseLevel(logLevel) - if err != nil { - mainLog.Warn().Err(err).Msg("could not set log level") - return - } - zerolog.SetGlobalLevel(level) -} - -func initCache() { - if !cfg.Service.CacheEnable { - return - } - if cfg.Service.CacheSize == 0 { - cfg.Service.CacheSize = 4096 - } + cli.Main() } diff --git a/cmd/ctrld/prog.go b/cmd/ctrld/prog.go deleted file mode 100644 index 807d22c..0000000 --- a/cmd/ctrld/prog.go +++ /dev/null @@ -1,244 +0,0 @@ -package main - -import ( - "errors" - "fmt" - "math/rand" - "net" - "os" - "strconv" - "sync" - "syscall" - - "github.com/kardianos/service" - - "github.com/Control-D-Inc/ctrld" - "github.com/Control-D-Inc/ctrld/internal/dnscache" - "github.com/Control-D-Inc/ctrld/internal/router" -) - -var logf = func(format string, args ...any) { - mainLog.Debug().Msgf(format, args...) -} - -var errWindowsAddrInUse = syscall.Errno(0x2740) - -var svcConfig = &service.Config{ - Name: "ctrld", - DisplayName: "Control-D Helper Service", - Option: service.KeyValue{}, -} - -type prog struct { - mu sync.Mutex - waitCh chan struct{} - stopCh chan struct{} - - cfg *ctrld.Config - cache dnscache.Cacher -} - -func (p *prog) Start(s service.Service) error { - p.cfg = &cfg - go p.run() - return nil -} - -func (p *prog) run() { - // Wait the caller to signal that we can do our logic. - <-p.waitCh - p.preRun() - if p.cfg.Service.CacheEnable { - cacher, err := dnscache.NewLRUCache(p.cfg.Service.CacheSize) - if err != nil { - mainLog.Error().Err(err).Msg("failed to create cacher, caching is disabled") - } else { - p.cache = cacher - } - } - var wg sync.WaitGroup - wg.Add(len(p.cfg.Listener)) - - for _, nc := range p.cfg.Network { - for _, cidr := range nc.Cidrs { - _, ipNet, err := net.ParseCIDR(cidr) - if err != nil { - mainLog.Error().Err(err).Str("network", nc.Name).Str("cidr", cidr).Msg("invalid cidr") - continue - } - nc.IPNets = append(nc.IPNets, ipNet) - } - } - for n := range p.cfg.Upstream { - uc := p.cfg.Upstream[n] - uc.Init() - if uc.BootstrapIP == "" { - uc.SetupBootstrapIP() - mainLog.Info().Msgf("Bootstrap IPs for upstream.%s: %q", n, uc.BootstrapIPs()) - } else { - mainLog.Info().Str("bootstrap_ip", uc.BootstrapIP).Msgf("Using bootstrap IP for upstream.%s", n) - } - uc.SetCertPool(rootCertPool) - uc.SetupTransport() - } - - go p.watchLinkState() - - for listenerNum := range p.cfg.Listener { - p.cfg.Listener[listenerNum].Init() - go func(listenerNum string) { - defer wg.Done() - listenerConfig := p.cfg.Listener[listenerNum] - upstreamConfig := p.cfg.Upstream[listenerNum] - if upstreamConfig == nil { - mainLog.Warn().Msgf("no default upstream for: [listener.%s]", listenerNum) - } - addr := net.JoinHostPort(listenerConfig.IP, strconv.Itoa(listenerConfig.Port)) - mainLog.Info().Msgf("Starting DNS server on listener.%s: %s", listenerNum, addr) - err := p.serveDNS(listenerNum) - if err != nil && !defaultConfigWritten && cdUID == "" { - mainLog.Fatal().Err(err).Msgf("Unable to start dns proxy on listener.%s", listenerNum) - return - } - if err == nil { - return - } - - if opErr, ok := err.(*net.OpError); ok && listenerNum == "0" { - if sErr, ok := opErr.Err.(*os.SyscallError); ok && errors.Is(opErr.Err, syscall.EADDRINUSE) || errors.Is(sErr.Err, errWindowsAddrInUse) { - mainLog.Warn().Msgf("Address %s already in used, pick a random one", addr) - ip := randomLocalIP() - listenerConfig.IP = ip - port := listenerConfig.Port - cfg.Upstream = map[string]*ctrld.UpstreamConfig{"0": cfg.Upstream["0"]} - if err := writeConfigFile(); err != nil { - mainLog.Fatal().Err(err).Msg("failed to write config file") - } else { - mainLog.Info().Msg("writing config file to: " + defaultConfigFile) - } - p.mu.Lock() - p.cfg.Service.AllocateIP = true - p.mu.Unlock() - p.preRun() - mainLog.Info().Msgf("Starting DNS server on listener.%s: %s", listenerNum, net.JoinHostPort(ip, strconv.Itoa(port))) - if err := p.serveDNS(listenerNum); err != nil { - mainLog.Fatal().Err(err).Msgf("Unable to start dns proxy on listener.%s", listenerNum) - return - } - } - } - mainLog.Fatal().Err(err).Msgf("Unable to start dns proxy on listener.%s", listenerNum) - }(listenerNum) - } - - wg.Wait() -} - -func (p *prog) Stop(s service.Service) error { - if err := p.deAllocateIP(); err != nil { - mainLog.Error().Err(err).Msg("de-allocate ip failed") - return err - } - p.preStop() - if err := router.Stop(); err != nil { - mainLog.Warn().Err(err).Msg("problem occurred while stopping router") - } - mainLog.Info().Msg("Service stopped") - close(p.stopCh) - return nil -} - -func (p *prog) allocateIP(ip string) error { - p.mu.Lock() - defer p.mu.Unlock() - if !p.cfg.Service.AllocateIP { - return nil - } - return allocateIP(ip) -} - -func (p *prog) deAllocateIP() error { - p.mu.Lock() - defer p.mu.Unlock() - if !p.cfg.Service.AllocateIP { - return nil - } - for _, lc := range p.cfg.Listener { - if err := deAllocateIP(lc.IP); err != nil { - return err - } - } - return nil -} - -func (p *prog) setDNS() { - switch router.Name() { - case router.DDWrt, router.OpenWrt, router.Ubios: - // On router, ctrld run as a DNS forwarder, it does not have to change system DNS. - // Except for: - // + EdgeOS, which /etc/resolv.conf could be managed by vyatta_update_resolv.pl script. - // + Merlin/Tomato, which has WAN DNS setup on boot for NTP. - // + Synology, which /etc/resolv.conf is not configured to point to localhost. - return - } - if cfg.Listener == nil || cfg.Listener["0"] == nil { - return - } - if iface == "" { - return - } - if iface == "auto" { - iface = defaultIfaceName() - } - logger := mainLog.With().Str("iface", iface).Logger() - netIface, err := netInterface(iface) - if err != nil { - logger.Error().Err(err).Msg("could not get interface") - return - } - if err := setupNetworkManager(); err != nil { - logger.Error().Err(err).Msg("could not patch NetworkManager") - return - } - logger.Debug().Msg("setting DNS for interface") - if err := setDNS(netIface, []string{cfg.Listener["0"].IP}); err != nil { - logger.Error().Err(err).Msgf("could not set DNS for interface") - return - } - logger.Debug().Msg("setting DNS successfully") -} - -func (p *prog) resetDNS() { - switch router.Name() { - case router.DDWrt, router.OpenWrt, router.Ubios: - // See comment in p.setDNS method. - return - } - if iface == "" { - return - } - if iface == "auto" { - iface = defaultIfaceName() - } - logger := mainLog.With().Str("iface", iface).Logger() - netIface, err := netInterface(iface) - if err != nil { - logger.Error().Err(err).Msg("could not get interface") - return - } - if err := restoreNetworkManager(); err != nil { - logger.Error().Err(err).Msg("could not restore NetworkManager") - return - } - logger.Debug().Msg("Restoring DNS for interface") - if err := resetDNS(netIface); err != nil { - logger.Error().Err(err).Msgf("could not reset DNS") - return - } - logger.Debug().Msg("Restoring DNS successfully") -} - -func randomLocalIP() string { - n := rand.Intn(254-2) + 2 - return fmt.Sprintf("127.0.0.%d", n) -} diff --git a/cmd/ctrld/prog_linux.go b/cmd/ctrld/prog_linux.go deleted file mode 100644 index 0b49a33..0000000 --- a/cmd/ctrld/prog_linux.go +++ /dev/null @@ -1,35 +0,0 @@ -package main - -import ( - "github.com/kardianos/service" - - "github.com/Control-D-Inc/ctrld/internal/router" -) - -func (p *prog) preRun() { - if !service.Interactive() { - p.setDNS() - } -} - -func setDependencies(svc *service.Config) { - svc.Dependencies = []string{ - "Wants=network-online.target", - "After=network-online.target", - "Wants=NetworkManager-wait-online.service", - "After=NetworkManager-wait-online.service", - } - // On EdeOS, ctrld needs to start after vyatta-dhcpd, so it can read leases file. - if router.Name() == router.EdgeOS { - svc.Dependencies = append(svc.Dependencies, "Wants=vyatta-dhcpd.service") - svc.Dependencies = append(svc.Dependencies, "After=vyatta-dhcpd.service") - svc.Dependencies = append(svc.Dependencies, "Wants=dnsmasq.service") - svc.Dependencies = append(svc.Dependencies, "After=dnsmasq.service") - } -} - -func setWorkingDirectory(svc *service.Config, dir string) { - svc.WorkingDirectory = dir -} - -func (p *prog) preStop() {} diff --git a/cmd/ctrld/service.go b/cmd/ctrld/service.go deleted file mode 100644 index bce6503..0000000 --- a/cmd/ctrld/service.go +++ /dev/null @@ -1,95 +0,0 @@ -package main - -import ( - "bytes" - "errors" - "os" - "os/exec" - - "github.com/kardianos/service" - - "github.com/Control-D-Inc/ctrld/internal/router" -) - -func newService(s service.Service) service.Service { - // TODO: unify for other SysV system. - switch { - case router.IsGLiNet(), router.IsOldOpenwrt(): - return &sysV{s} - } - return s -} - -// sysV wraps a service.Service, and provide start/stop/status command -// base on "/etc/init.d/". -// -// Use this on system wherer "service" command is not available, like GL.iNET router. -type sysV struct { - service.Service -} - -func (s *sysV) Start() error { - _, err := exec.Command("/etc/init.d/ctrld", "start").CombinedOutput() - return err -} - -func (s *sysV) Stop() error { - _, err := exec.Command("/etc/init.d/ctrld", "stop").CombinedOutput() - return err -} - -func (s *sysV) Status() (service.Status, error) { - return unixSystemVServiceStatus() -} - -type task struct { - f func() error - abortOnError bool -} - -func doTasks(tasks []task) bool { - var prevErr error - for _, task := range tasks { - if err := task.f(); err != nil { - if task.abortOnError { - mainLog.Error().Msg(errors.Join(prevErr, err).Error()) - return false - } - prevErr = err - } - } - return true -} - -func checkHasElevatedPrivilege() { - ok, err := hasElevatedPrivilege() - if err != nil { - mainLog.Error().Msgf("could not detect user privilege: %v", err) - return - } - if !ok { - mainLog.Error().Msg("Please relaunch process with admin/root privilege.") - os.Exit(1) - } -} - -func serviceStatus(s service.Service) (service.Status, error) { - status, err := s.Status() - if err != nil && service.Platform() == "unix-systemv" { - return unixSystemVServiceStatus() - } - return status, err -} - -func unixSystemVServiceStatus() (service.Status, error) { - out, err := exec.Command("/etc/init.d/ctrld", "status").CombinedOutput() - if err != nil { - return service.StatusUnknown, nil - } - switch string(bytes.TrimSpace(out)) { - case "running": - return service.StatusRunning, nil - default: - return service.StatusStopped, nil - } -} diff --git a/config.go b/config.go index bdd335b..70b165b 100644 --- a/config.go +++ b/config.go @@ -5,13 +5,18 @@ import ( "crypto/tls" "crypto/x509" "errors" + "io" "math/rand" "net" "net/http" "net/url" "os" + "runtime" + "sort" + "strconv" "strings" "sync" + "sync/atomic" "time" "github.com/go-playground/validator/v10" @@ -120,16 +125,61 @@ func (c *Config) HasUpstreamSendClientInfo() bool { return false } +// FirstListener returns the first listener config of current config. Listeners are sorted numerically. +// +// It panics if Config has no listeners configured. +func (c *Config) FirstListener() *ListenerConfig { + listeners := make([]int, 0, len(c.Listener)) + for k := range c.Listener { + n, err := strconv.Atoi(k) + if err != nil { + continue + } + listeners = append(listeners, n) + } + if len(listeners) == 0 { + panic("missing listener config") + } + sort.Ints(listeners) + return c.Listener[strconv.Itoa(listeners[0])] +} + +// FirstUpstream returns the first upstream of current config. Upstreams are sorted numerically. +// +// It panics if Config has no upstreams configured. +func (c *Config) FirstUpstream() *UpstreamConfig { + upstreams := make([]int, 0, len(c.Upstream)) + for k := range c.Upstream { + n, err := strconv.Atoi(k) + if err != nil { + continue + } + upstreams = append(upstreams, n) + } + if len(upstreams) == 0 { + panic("missing listener config") + } + sort.Ints(upstreams) + return c.Upstream[strconv.Itoa(upstreams[0])] +} + // ServiceConfig specifies the general ctrld config. type ServiceConfig struct { - LogLevel string `mapstructure:"log_level" toml:"log_level,omitempty"` - LogPath string `mapstructure:"log_path" toml:"log_path,omitempty"` - CacheEnable bool `mapstructure:"cache_enable" toml:"cache_enable,omitempty"` - CacheSize int `mapstructure:"cache_size" toml:"cache_size,omitempty"` - CacheTTLOverride int `mapstructure:"cache_ttl_override" toml:"cache_ttl_override,omitempty"` - CacheServeStale bool `mapstructure:"cache_serve_stale" toml:"cache_serve_stale,omitempty"` - Daemon bool `mapstructure:"-" toml:"-"` - AllocateIP bool `mapstructure:"-" toml:"-"` + LogLevel string `mapstructure:"log_level" toml:"log_level,omitempty"` + LogPath string `mapstructure:"log_path" toml:"log_path,omitempty"` + CacheEnable bool `mapstructure:"cache_enable" toml:"cache_enable,omitempty"` + CacheSize int `mapstructure:"cache_size" toml:"cache_size,omitempty"` + CacheTTLOverride int `mapstructure:"cache_ttl_override" toml:"cache_ttl_override,omitempty"` + CacheServeStale bool `mapstructure:"cache_serve_stale" toml:"cache_serve_stale,omitempty"` + MaxConcurrentRequests *int `mapstructure:"max_concurrent_requests" toml:"max_concurrent_requests,omitempty" validate:"omitempty,gte=0"` + DHCPLeaseFile string `mapstructure:"dhcp_lease_file_path" toml:"dhcp_lease_file_path" validate:"omitempty,file"` + DHCPLeaseFileFormat string `mapstructure:"dhcp_lease_file_format" toml:"dhcp_lease_file_format" validate:"required_unless=DHCPLeaseFile '',omitempty,oneof=dnsmasq isc-dhcp"` + DiscoverMDNS *bool `mapstructure:"discover_mdns" toml:"discover_mdns,omitempty"` + DiscoverARP *bool `mapstructure:"discover_arp" toml:"discover_dhcp,omitempty"` + DiscoverDHCP *bool `mapstructure:"discover_dhcp" toml:"discover_dhcp,omitempty"` + DiscoverPtr *bool `mapstructure:"discover_ptr" toml:"discover_ptr,omitempty"` + Daemon bool `mapstructure:"-" toml:"-"` + AllocateIP bool `mapstructure:"-" toml:"-"` } // NetworkConfig specifies configuration for networks where ctrld will handle requests. @@ -153,11 +203,12 @@ type UpstreamConfig struct { SendClientInfo *bool `mapstructure:"send_client_info" toml:"send_client_info,omitempty"` g singleflight.Group - mu sync.Mutex + rebootstrap atomic.Bool bootstrapIPs []string bootstrapIPs4 []string bootstrapIPs6 []string transport *http.Transport + transportOnce sync.Once transport4 *http.Transport transport6 *http.Transport http3RoundTripper http.RoundTripper @@ -175,6 +226,24 @@ type ListenerConfig struct { Policy *ListenerPolicyConfig `mapstructure:"policy" toml:"policy,omitempty"` } +// IsDirectDnsListener reports whether ctrld can be a direct listener on port 53. +// It returns true only if ctrld can listen on port 53 for all interfaces. That means +// there's no other software listening on port 53. +// +// If someone listening on port 53, or ctrld could only listen on port 53 for a specific +// interface, ctrld could only be configured as a DNS forwarder. +func (lc *ListenerConfig) IsDirectDnsListener() bool { + if lc == nil || lc.Port != 53 { + return false + } + switch lc.IP { + case "", "::", "0.0.0.0": + return true + default: + return false + } +} + // ListenerPolicyConfig specifies the policy rules for ctrld to filter incoming requests. type ListenerPolicyConfig struct { Name string `mapstructure:"name" toml:"name,omitempty"` @@ -243,11 +312,8 @@ func (uc *UpstreamConfig) VerifyDomain() string { // - Lan IP // - Hostname func (uc *UpstreamConfig) UpstreamSendClientInfo() bool { - if uc.SendClientInfo != nil && !(*uc.SendClientInfo) { - return false - } - if uc.SendClientInfo == nil { - return true + if uc.SendClientInfo != nil { + return *uc.SendClientInfo } switch uc.Type { case ResolverTypeDOH, ResolverTypeDOH3: @@ -277,13 +343,27 @@ func (uc *UpstreamConfig) SetupBootstrapIP() { // 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) { - b := backoff.NewBackoff("setupBootstrapIP", func(format string, args ...any) {}, 2*time.Second) + b := backoff.NewBackoff("setupBootstrapIP", func(format string, args ...any) {}, 10*time.Second) + isControlD := uc.isControlD() for { uc.bootstrapIPs = lookupIP(uc.Domain, uc.Timeout, withBootstrapDNS) + // For ControlD upstream, the bootstrap IPs could not be RFC 1918 addresses, + // filtering them out here to prevent weird behavior. + if isControlD { + n := 0 + for _, ip := range uc.bootstrapIPs { + netIP := net.ParseIP(ip) + if netIP != nil && !netIP.IsPrivate() { + uc.bootstrapIPs[n] = ip + n++ + } + } + uc.bootstrapIPs = uc.bootstrapIPs[:n] + } if len(uc.bootstrapIPs) > 0 { break } - ProxyLog.Warn().Msg("could not resolve bootstrap IPs, retrying...") + ProxyLogger.Load().Warn().Msg("could not resolve bootstrap IPs, retrying...") b.BackOff(context.Background(), errors.New("no bootstrap IPs")) } for _, ip := range uc.bootstrapIPs { @@ -293,7 +373,7 @@ func (uc *UpstreamConfig) setupBootstrapIP(withBootstrapDNS bool) { uc.bootstrapIPs4 = append(uc.bootstrapIPs4, ip) } } - ProxyLog.Debug().Msgf("Bootstrap IPs: %v", uc.bootstrapIPs) + ProxyLogger.Load().Debug().Msgf("bootstrap IPs: %v", uc.bootstrapIPs) } // ReBootstrap re-setup the bootstrap IP and the transport. @@ -304,21 +384,12 @@ func (uc *UpstreamConfig) ReBootstrap() { return } _, _, _ = uc.g.Do("ReBootstrap", func() (any, error) { - ProxyLog.Debug().Msg("re-bootstrapping upstream ip") - uc.setupTransportWithoutPingUpstream() + ProxyLogger.Load().Debug().Msg("re-bootstrapping upstream ip") + uc.rebootstrap.Store(true) return true, nil }) } -func (uc *UpstreamConfig) setupTransportWithoutPingUpstream() { - switch uc.Type { - case ResolverTypeDOH: - uc.setupDOHTransportWithoutPingUpstream() - case ResolverTypeDOH3: - uc.setupDOH3TransportWithoutPingUpstream() - } -} - // SetupTransport initializes the network transport used to connect to upstream server. // For now, only DoH upstream is supported. func (uc *UpstreamConfig) SetupTransport() { @@ -331,48 +402,6 @@ func (uc *UpstreamConfig) SetupTransport() { } func (uc *UpstreamConfig) setupDOHTransport() { - uc.setupDOHTransportWithoutPingUpstream() - go uc.pingUpstream() -} - -func (uc *UpstreamConfig) newDOHTransport(addrs []string) *http.Transport { - transport := http.DefaultTransport.(*http.Transport).Clone() - transport.IdleConnTimeout = 5 * time.Second - transport.TLSClientConfig = &tls.Config{RootCAs: uc.certPool} - - dialerTimeoutMs := 2000 - if uc.Timeout > 0 && uc.Timeout < dialerTimeoutMs { - dialerTimeoutMs = uc.Timeout - } - dialerTimeout := time.Duration(dialerTimeoutMs) * time.Millisecond - 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} - addr := net.JoinHostPort(uc.BootstrapIP, port) - Log(ctx, ProxyLog.Debug(), "sending doh request to: %s", addr) - return dialer.DialContext(ctx, network, addr) - } - pd := &ctrldnet.ParallelDialer{} - pd.Timeout = dialerTimeout - pd.KeepAlive = dialerTimeout - dialAddrs := make([]string, len(addrs)) - for i := range addrs { - dialAddrs[i] = net.JoinHostPort(addrs[i], port) - } - conn, err := pd.DialContext(ctx, network, dialAddrs) - if err != nil { - return nil, err - } - Log(ctx, ProxyLog.Debug(), "sending doh request to: %s", conn.RemoteAddr()) - return conn, nil - } - return transport -} - -func (uc *UpstreamConfig) setupDOHTransportWithoutPingUpstream() { - uc.mu.Lock() - defer uc.mu.Unlock() switch uc.IPStack { case IpStackBoth, "": uc.transport = uc.newDOHTransport(uc.bootstrapIPs) @@ -387,24 +416,82 @@ func (uc *UpstreamConfig) setupDOHTransportWithoutPingUpstream() { } else { uc.transport6 = uc.transport4 } - uc.transport = uc.newDOHTransport(uc.bootstrapIPs) } } -func (uc *UpstreamConfig) pingUpstream() { - // Warming up the transport by querying a test packet. - dnsResolver, err := NewResolver(uc) - if err != nil { - ProxyLog.Error().Err(err).Msgf("failed to create resolver for upstream: %s", uc.Name) +func (uc *UpstreamConfig) newDOHTransport(addrs []string) *http.Transport { + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.MaxIdleConnsPerHost = 100 + transport.TLSClientConfig = &tls.Config{ + RootCAs: uc.certPool, + ClientSessionCache: tls.NewLRUClientSessionCache(0), + } + + dialerTimeoutMs := 2000 + if uc.Timeout > 0 && uc.Timeout < dialerTimeoutMs { + dialerTimeoutMs = uc.Timeout + } + dialerTimeout := time.Duration(dialerTimeoutMs) * time.Millisecond + 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} + addr := net.JoinHostPort(uc.BootstrapIP, port) + Log(ctx, ProxyLogger.Load().Debug(), "sending doh request to: %s", addr) + return dialer.DialContext(ctx, network, addr) + } + pd := &ctrldnet.ParallelDialer{} + pd.Timeout = dialerTimeout + pd.KeepAlive = dialerTimeout + dialAddrs := make([]string, len(addrs)) + for i := range addrs { + dialAddrs[i] = net.JoinHostPort(addrs[i], port) + } + conn, err := pd.DialContext(ctx, network, dialAddrs) + if err != nil { + return nil, err + } + Log(ctx, ProxyLogger.Load().Debug(), "sending doh request to: %s", conn.RemoteAddr()) + return conn, nil + } + runtime.SetFinalizer(transport, func(transport *http.Transport) { + transport.CloseIdleConnections() + }) + return transport +} + +// Ping warms up the connection to DoH/DoH3 upstream. +func (uc *UpstreamConfig) Ping() { + switch uc.Type { + case ResolverTypeDOH, ResolverTypeDOH3: + default: return } - msg := new(dns.Msg) - msg.SetQuestion(".", dns.TypeNS) - msg.MsgHdr.RecursionDesired = true - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - _, _ = dnsResolver.Resolve(ctx, msg) + + ping := func(t http.RoundTripper) { + if t == nil { + return + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + req, _ := http.NewRequestWithContext(ctx, "HEAD", uc.Endpoint, nil) + resp, _ := t.RoundTrip(req) + if resp == nil { + return + } + defer resp.Body.Close() + _, _ = io.Copy(io.Discard, resp.Body) + } + + for _, typ := range []uint16{dns.TypeA, dns.TypeAAAA} { + switch uc.Type { + case ResolverTypeDOH: + ping(uc.dohTransport(typ)) + case ResolverTypeDOH3: + ping(uc.doh3Transport(typ)) + } + } } func (uc *UpstreamConfig) isControlD() bool { @@ -423,8 +510,12 @@ func (uc *UpstreamConfig) isControlD() bool { } func (uc *UpstreamConfig) dohTransport(dnsType uint16) http.RoundTripper { - uc.mu.Lock() - defer uc.mu.Unlock() + uc.transportOnce.Do(func() { + uc.SetupTransport() + }) + if uc.rebootstrap.CompareAndSwap(true, false) { + uc.SetupTransport() + } switch uc.IPStack { case IpStackBoth, IpStackV4, IpStackV6: return uc.transport diff --git a/config_internal_test.go b/config_internal_test.go index fb3692e..6fc1844 100644 --- a/config_internal_test.go +++ b/config_internal_test.go @@ -223,6 +223,61 @@ func TestUpstreamConfig_VerifyDomain(t *testing.T) { }) } } + +func TestUpstreamConfig_UpstreamSendClientInfo(t *testing.T) { + tests := []struct { + name string + uc *UpstreamConfig + sendClientInfo bool + }{ + { + "default with controld upstream DoH", + &UpstreamConfig{Endpoint: "https://freedns.controld.com/p2", Type: ResolverTypeDOH}, + true, + }, + { + "default with controld upstream DoH3", + &UpstreamConfig{Endpoint: "https://freedns.controld.com/p2", Type: ResolverTypeDOH3}, + true, + }, + { + "default with non-ControlD upstream", + &UpstreamConfig{Endpoint: "https://dns.google/dns-query", Type: ResolverTypeDOH}, + false, + }, + { + "set false with controld upstream", + &UpstreamConfig{Endpoint: "https://freedns.controld.com/p2", Type: ResolverTypeDOH, SendClientInfo: ptrBool(false)}, + false, + }, + { + "set true with controld upstream", + &UpstreamConfig{Endpoint: "https://freedns.controld.com/p2", SendClientInfo: ptrBool(true)}, + true, + }, + { + "set false with non-ControlD upstream", + &UpstreamConfig{Endpoint: "https://dns.google/dns-query", SendClientInfo: ptrBool(false)}, + false, + }, + { + "set true with non-ControlD upstream", + &UpstreamConfig{Endpoint: "https://dns.google/dns-query", Type: ResolverTypeDOH, SendClientInfo: ptrBool(true)}, + true, + }, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := tc.uc.UpstreamSendClientInfo(); got != tc.sendClientInfo { + t.Errorf("unexpected result, want: %v, got: %v", tc.sendClientInfo, got) + } + }) + } +} + func ptrBool(b bool) *bool { return &b } diff --git a/config_quic.go b/config_quic.go index 085476e..e953c72 100644 --- a/config_quic.go +++ b/config_quic.go @@ -19,48 +19,6 @@ import ( ) func (uc *UpstreamConfig) setupDOH3Transport() { - uc.setupDOH3TransportWithoutPingUpstream() - go uc.pingUpstream() -} - -func (uc *UpstreamConfig) newDOH3Transport(addrs []string) http.RoundTripper { - rt := &http3.RoundTripper{} - rt.TLSClientConfig = &tls.Config{RootCAs: uc.certPool} - rt.Dial = func(ctx context.Context, addr string, tlsCfg *tls.Config, cfg *quic.Config) (quic.EarlyConnection, error) { - domain := addr - _, port, _ := net.SplitHostPort(addr) - // if we have a bootstrap ip set, use it to avoid DNS lookup - if uc.BootstrapIP != "" { - addr = net.JoinHostPort(uc.BootstrapIP, port) - ProxyLog.Debug().Msgf("sending doh3 request to: %s", addr) - udpConn, err := net.ListenUDP("udp", nil) - if err != nil { - return nil, err - } - remoteAddr, err := net.ResolveUDPAddr("udp", addr) - if err != nil { - return nil, err - } - return quic.DialEarlyContext(ctx, udpConn, remoteAddr, domain, tlsCfg, cfg) - } - dialAddrs := make([]string, len(addrs)) - for i := range addrs { - dialAddrs[i] = net.JoinHostPort(addrs[i], port) - } - pd := &quicParallelDialer{} - conn, err := pd.Dial(ctx, domain, dialAddrs, tlsCfg, cfg) - if err != nil { - return nil, err - } - ProxyLog.Debug().Msgf("sending doh3 request to: %s", conn.RemoteAddr()) - return conn, err - } - return rt -} - -func (uc *UpstreamConfig) setupDOH3TransportWithoutPingUpstream() { - uc.mu.Lock() - defer uc.mu.Unlock() switch uc.IPStack { case IpStackBoth, "": uc.http3RoundTripper = uc.newDOH3Transport(uc.bootstrapIPs) @@ -81,9 +39,48 @@ func (uc *UpstreamConfig) setupDOH3TransportWithoutPingUpstream() { } } +func (uc *UpstreamConfig) newDOH3Transport(addrs []string) http.RoundTripper { + rt := &http3.RoundTripper{} + rt.TLSClientConfig = &tls.Config{RootCAs: uc.certPool} + rt.Dial = func(ctx context.Context, addr string, tlsCfg *tls.Config, cfg *quic.Config) (quic.EarlyConnection, error) { + domain := addr + _, port, _ := net.SplitHostPort(addr) + // if we have a bootstrap ip set, use it to avoid DNS lookup + if uc.BootstrapIP != "" { + addr = net.JoinHostPort(uc.BootstrapIP, port) + ProxyLogger.Load().Debug().Msgf("sending doh3 request to: %s", addr) + udpConn, err := net.ListenUDP("udp", nil) + if err != nil { + return nil, err + } + remoteAddr, err := net.ResolveUDPAddr("udp", addr) + if err != nil { + return nil, err + } + return quic.DialEarlyContext(ctx, udpConn, remoteAddr, domain, tlsCfg, cfg) + } + dialAddrs := make([]string, len(addrs)) + for i := range addrs { + dialAddrs[i] = net.JoinHostPort(addrs[i], port) + } + pd := &quicParallelDialer{} + conn, err := pd.Dial(ctx, domain, dialAddrs, tlsCfg, cfg) + if err != nil { + return nil, err + } + ProxyLogger.Load().Debug().Msgf("sending doh3 request to: %s", conn.RemoteAddr()) + return conn, err + } + return rt +} + func (uc *UpstreamConfig) doh3Transport(dnsType uint16) http.RoundTripper { - uc.mu.Lock() - defer uc.mu.Unlock() + uc.transportOnce.Do(func() { + uc.SetupTransport() + }) + if uc.rebootstrap.CompareAndSwap(true, false) { + uc.SetupTransport() + } switch uc.IPStack { case IpStackBoth, IpStackV4, IpStackV6: return uc.http3RoundTripper diff --git a/config_quic_free.go b/config_quic_free.go index a4b1bdd..a674a1b 100644 --- a/config_quic_free.go +++ b/config_quic_free.go @@ -6,5 +6,4 @@ import "net/http" func (uc *UpstreamConfig) setupDOH3Transport() {} -func (uc *UpstreamConfig) setupDOH3TransportWithoutPingUpstream() {} func (uc *UpstreamConfig) doh3Transport(dnsType uint16) http.RoundTripper { return nil } diff --git a/config_test.go b/config_test.go index ddbc97b..83a3386 100644 --- a/config_test.go +++ b/config_test.go @@ -1,6 +1,8 @@ package ctrld_test import ( + "os" + "strings" "testing" "github.com/go-playground/validator/v10" @@ -56,6 +58,20 @@ func TestLoadDefaultConfig(t *testing.T) { assert.Len(t, cfg.Upstream, 2) } +func TestConfigOverride(t *testing.T) { + v := viper.NewWithOptions(viper.KeyDelimiter("::")) + ctrld.InitConfig(v, "test_load_config") + v.SetConfigType("toml") + require.NoError(t, v.ReadConfig(strings.NewReader(testhelper.SampleConfigStr(t)))) + cfg := ctrld.Config{Listener: map[string]*ctrld.ListenerConfig{ + "0": {IP: "127.0.0.1", Port: 53}, + }} + require.NoError(t, v.Unmarshal(&cfg)) + + assert.Equal(t, "10.10.42.69", cfg.Listener["1"].IP) + assert.Equal(t, 1337, cfg.Listener["1"].Port) +} + func TestConfigValidation(t *testing.T) { tests := []struct { name string @@ -75,6 +91,10 @@ func TestConfigValidation(t *testing.T) { {"os upstream", configWithOsUpstream(t), false}, {"invalid rules", configWithInvalidRules(t), true}, {"invalid dns rcodes", configWithInvalidRcodes(t), true}, + {"invalid max concurrent requests", configWithInvalidMaxConcurrentRequests(t), true}, + {"non-existed lease file", configWithNonExistedLeaseFile(t), true}, + {"lease file format required if lease file exist", configWithExistedLeaseFile(t), true}, + {"invalid lease file format", configWithInvalidLeaseFileFormat(t), true}, } for _, tc := range tests { @@ -176,3 +196,32 @@ func configWithInvalidRcodes(t *testing.T) *ctrld.Config { } return cfg } + +func configWithInvalidMaxConcurrentRequests(t *testing.T) *ctrld.Config { + cfg := defaultConfig(t) + n := -1 + cfg.Service.MaxConcurrentRequests = &n + return cfg +} + +func configWithNonExistedLeaseFile(t *testing.T) *ctrld.Config { + cfg := defaultConfig(t) + cfg.Service.DHCPLeaseFile = "non-existed" + return cfg +} + +func configWithExistedLeaseFile(t *testing.T) *ctrld.Config { + cfg := defaultConfig(t) + exe, err := os.Executable() + if err != nil { + t.Fatal(err) + } + cfg.Service.DHCPLeaseFile = exe + return cfg +} + +func configWithInvalidLeaseFileFormat(t *testing.T) *ctrld.Config { + cfg := defaultConfig(t) + cfg.Service.DHCPLeaseFileFormat = "invalid" + return cfg +} diff --git a/docs/config.md b/docs/config.md index f699b4b..f2b5554 100644 --- a/docs/config.md +++ b/docs/config.md @@ -157,6 +157,57 @@ stale cached records (regardless of their TTLs) until upstream comes online. - Required: no - Default: false +### max_concurrent_requests +The number of concurrent requests that will be handled, must be a non-negative integer. +Tweaking this value depends on the capacity of your system. + +- Type: number +- Required: no +- Default: 256 + +### discover_mdns +Perform LAN client discovery using mDNS. This will spawn a listener on port 5353. + +- Type: boolean +- Required: no +- Default: true + +### discover_arp +Perform LAN client discovery using ARP. + +- Type: boolean +- Required: no +- Default: true + +### discover_dhcp +Perform LAN client discovery using DHCP leases files. Common file locations are auto-discovered. + +- Type: boolean +- Required: no +- Default: true + +### discover_ptr +Perform LAN client discovery using PTR queries. + +- Type: boolean +- Required: no +- Default: true + +### dhcp_lease_file_path +Relative or absolute path to a custom DHCP leases file location. + +- Type: string +- Required: no +- Default: "" + +### dhcp_lease_file_format +DHCP leases file format. + +- Type: string +- Required: no +- Valid values: `dnsmasq`, `isc-dhcp` +- Default: "" + ## Upstream The `[upstream]` section specifies the DNS upstream servers that `ctrld` will forward DNS requests to. @@ -308,14 +359,14 @@ IP address that serves the incoming requests. If `ip` is empty, ctrld will liste - Type: ip address string - Required: no -- Default: "" +- Default: "0.0.0.0" or RFC1918 addess or "127.0.0.1" (depending on platform) ### port Port number that the listener will listen on for incoming requests. If `port` is `0`, a random available port will be chosen. - Type: number - Required: no -- Default: 0 +- Default: 0 or 53 or 5354 (depending on platform) ### restricted If set to `true` makes the listener `REFUSE` DNS queries from all source IP addresses that are not explicitly defined in the policy using a `network`. diff --git a/doh.go b/doh.go index f861f2f..5886881 100644 --- a/doh.go +++ b/doh.go @@ -110,5 +110,5 @@ func addHeader(ctx context.Context, req *http.Request, sendClientInfo bool) { } } } - Log(ctx, ProxyLog.Debug().Interface("header", req.Header), "sending request header") + Log(ctx, ProxyLogger.Load().Debug().Interface("header", req.Header), "sending request header") } diff --git a/go.mod b/go.mod index 169dc34..1229987 100644 --- a/go.mod +++ b/go.mod @@ -5,28 +5,31 @@ go 1.20 require ( github.com/coreos/go-systemd/v22 v22.5.0 github.com/cuonglm/osinfo v0.0.0-20230329055532-c513f836da19 - github.com/frankban/quicktest v1.14.3 + github.com/frankban/quicktest v1.14.5 github.com/fsnotify/fsnotify v1.6.0 github.com/go-playground/validator/v10 v10.11.1 - github.com/godbus/dbus/v5 v5.0.6 + github.com/godbus/dbus/v5 v5.1.0 github.com/hashicorp/golang-lru/v2 v2.0.1 github.com/illarion/gonotify v1.0.1 - github.com/insomniacslk/dhcp v0.0.0-20221215072855-de60144f33f8 + github.com/insomniacslk/dhcp v0.0.0-20230407062729-974c6f05fe16 github.com/josharian/native v1.1.1-0.20230202152459-5c7d0dd6ab86 github.com/kardianos/service v1.2.1 - github.com/miekg/dns v1.1.50 - github.com/pelletier/go-toml/v2 v2.0.6 + github.com/miekg/dns v1.1.55 + github.com/olekukonko/tablewriter v0.0.5 + github.com/pelletier/go-toml/v2 v2.0.8 github.com/quic-go/quic-go v0.32.0 github.com/rs/zerolog v1.28.0 - github.com/spf13/cobra v1.4.0 - github.com/spf13/viper v1.14.0 - github.com/stretchr/testify v1.8.1 - github.com/vishvananda/netlink v1.1.1-0.20211118161826-650dca95af54 - golang.org/x/net v0.7.0 - golang.org/x/sync v0.1.0 - golang.org/x/sys v0.5.0 + github.com/spf13/cobra v1.7.0 + github.com/spf13/pflag v1.0.5 + github.com/spf13/viper v1.16.0 + github.com/stretchr/testify v1.8.3 + github.com/vishvananda/netlink v1.2.1-beta.2 + go4.org/mem v0.0.0-20220726221520-4f986261bf13 + golang.org/x/net v0.10.0 + golang.org/x/sync v0.2.0 + golang.org/x/sys v0.8.1-0.20230609144347-5059a07aa46a golang.zx2c4.com/wireguard/windows v0.5.3 - tailscale.com v1.38.3 + tailscale.com v1.44.0 ) require ( @@ -39,42 +42,41 @@ require ( github.com/google/go-cmp v0.5.9 // indirect github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38 // indirect github.com/hashicorp/hcl v1.0.0 // indirect - github.com/inconshreveable/mousetrap v1.0.0 // indirect - github.com/jsimonetti/rtnetlink v1.1.2-0.20220408201609-d380b505068b // indirect - github.com/kr/pretty v0.3.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jsimonetti/rtnetlink v1.3.2 // indirect + github.com/kr/pretty v0.3.1 // indirect github.com/kr/text v0.2.0 // indirect github.com/leodido/go-urn v1.2.1 // indirect github.com/magiconair/properties v1.8.7 // indirect - github.com/mattn/go-colorable v0.1.12 // indirect - github.com/mattn/go-isatty v0.0.14 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.18 // indirect + github.com/mattn/go-runewidth v0.0.14 // indirect github.com/mdlayher/ethernet v0.0.0-20190606142754-0394541c37b7 // indirect - github.com/mdlayher/netlink v1.7.1 // indirect + github.com/mdlayher/netlink v1.7.2 // indirect github.com/mdlayher/raw v0.0.0-20191009151244-50f2db8cc065 // indirect - github.com/mdlayher/socket v0.4.0 // indirect + github.com/mdlayher/socket v0.4.1 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/onsi/ginkgo/v2 v2.2.0 // indirect - github.com/pelletier/go-toml v1.9.5 // indirect + github.com/pierrec/lz4/v4 v4.1.17 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/quic-go/qpack v0.4.0 // indirect github.com/quic-go/qtls-go1-18 v0.2.0 // indirect github.com/quic-go/qtls-go1-19 v0.2.0 // indirect github.com/quic-go/qtls-go1-20 v0.1.0 // indirect - github.com/rogpeppe/go-internal v1.8.1-0.20211023094830-115ce09fd6b4 // indirect - github.com/spf13/afero v1.9.3 // indirect - github.com/spf13/cast v1.5.0 // indirect + github.com/rivo/uniseg v0.4.4 // indirect + github.com/rogpeppe/go-internal v1.10.0 // indirect + github.com/spf13/afero v1.9.5 // indirect + github.com/spf13/cast v1.5.1 // indirect github.com/spf13/jwalterweatherman v1.1.0 // indirect - github.com/spf13/pflag v1.0.5 // indirect - github.com/subosito/gotenv v1.4.1 // indirect - github.com/u-root/uio v0.0.0-20221213070652-c3537552635f // indirect - github.com/vishvananda/netns v0.0.0-20211101163701-50045581ed74 // indirect - go4.org/mem v0.0.0-20210711025021-927187094b94 // indirect - golang.org/x/crypto v0.6.0 // indirect - golang.org/x/exp v0.0.0-20221205204356-47842c84f3db // indirect - golang.org/x/mod v0.7.0 // indirect - golang.org/x/text v0.7.0 // indirect - golang.org/x/tools v0.4.1-0.20221208213631-3f74d914ae6d // indirect + github.com/subosito/gotenv v1.4.2 // indirect + github.com/u-root/uio v0.0.0-20230305220412-3e8cd9d6bf63 // indirect + github.com/vishvananda/netns v0.0.4 // indirect + golang.org/x/crypto v0.9.0 // indirect + golang.org/x/exp v0.0.0-20230425010034-47ecfdc1ba53 // indirect + golang.org/x/mod v0.10.0 // indirect + golang.org/x/text v0.9.0 // indirect + golang.org/x/tools v0.9.1 // indirect gopkg.in/ini.v1 v1.67.0 // indirect - gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 3b884b7..bdd9bef 100644 --- a/go.sum +++ b/go.sum @@ -46,15 +46,14 @@ github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= -github.com/cilium/ebpf v0.8.1 h1:bLSSEbBLqGPXxls55pGr5qWZaTqcmfDJHhou7t254ao= -github.com/cilium/ebpf v0.8.1/go.mod h1:f5zLIM0FSNuAkSyLAN7X+Hy6yznlF1mNiWUMfxMtrgk= +github.com/cilium/ebpf v0.10.0 h1:nk5HPMeoBXtOzbkZBWym+ZWq1GIiHUsBFXxwewXAHLQ= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= 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/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/cuonglm/osinfo v0.0.0-20230329055532-c513f836da19 h1:7P/f19Mr0oa3ug8BYt4JuRe/Zq3dF4Mrr4m8+Kw+Hcs= github.com/cuonglm/osinfo v0.0.0-20230329055532-c513f836da19/go.mod h1:G45410zMgmnSjLVKCq4f6GpbYAzoP2plX9rPwgx6C24= @@ -67,10 +66,8 @@ github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1m github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po= github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= -github.com/fanliao/go-promise v0.0.0-20141029170127-1890db352a72/go.mod h1:PjfxuH4FZdUyfMdtBio2lsRr1AKEaVPwelzuHuh8Lqc= -github.com/frankban/quicktest v1.14.0/go.mod h1:NeW+ay9A/U67EYXNFA1nPE8e/tnQv/09mUdL/ijj8og= -github.com/frankban/quicktest v1.14.3 h1:FJKSZTDHjyhriyC81FLQ0LY93eSai0ZyR/ZIkd3ZUKE= -github.com/frankban/quicktest v1.14.3/go.mod h1:mgiwOwqx65TmIk1wJ6Q7wvnVMocbUorkibMOrVTHZps= +github.com/frankban/quicktest v1.14.5 h1:dfYrrRyLtiqT9GyKXgdh+k4inNeTvmGbuSgZ3lx3GhA= +github.com/frankban/quicktest v1.14.5/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= @@ -87,8 +84,8 @@ github.com/go-playground/validator/v10 v10.11.1/go.mod h1:i+3WkQ1FvaUjjxh1kSvIA4 github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0 h1:p104kn46Q8WdvHunIJ9dAyjPVtrBPhSr3KT2yUst43I= github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/godbus/dbus/v5 v5.0.6 h1:mkgN1ofwASrYnJ5W6U/BxG15eXXXjirgZc7CLqkcaro= -github.com/godbus/dbus/v5 v5.0.6/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= @@ -116,7 +113,7 @@ github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvq github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= -github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= 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/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= @@ -128,8 +125,6 @@ github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 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.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= @@ -152,7 +147,6 @@ github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+ github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g= -github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru/v2 v2.0.1 h1:5pv5N1lT1fjLg2VQ5KWc7kmucp2x/kvFOnxuVTqZ6x4= @@ -160,36 +154,30 @@ github.com/hashicorp/golang-lru/v2 v2.0.1/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyf github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/hugelgupf/socketpair v0.0.0-20190730060125-05d35a94e714 h1:/jC7qQFrv8CrSJVmaolDVOxTfS9kc36uB6H40kdbQq8= -github.com/hugelgupf/socketpair v0.0.0-20190730060125-05d35a94e714/go.mod h1:2Goc3h8EklBH5mspfHFxBnEoURQCGzQQH1ga9Myjvis= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/illarion/gonotify v1.0.1 h1:F1d+0Fgbq/sDWjj/r66ekjDG+IDeecQKUFH4wNwsoio= github.com/illarion/gonotify v1.0.1/go.mod h1:zt5pmDofZpU1f8aqlK0+95eQhoEAn/d4G4B/FjVW4jE= -github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= -github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= -github.com/insomniacslk/dhcp v0.0.0-20221215072855-de60144f33f8 h1:Z72DOke2yOK0Ms4Z2LK1E1OrRJXOxSj5DllTz2FYTRg= -github.com/insomniacslk/dhcp v0.0.0-20221215072855-de60144f33f8/go.mod h1:m5WMe03WCvWcXjRnhvaAbAAXdCnu20J5P+mmH44ZzpE= -github.com/josharian/native v1.0.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/insomniacslk/dhcp v0.0.0-20230407062729-974c6f05fe16 h1:+aAGyK41KRn8jbF2Q7PLL0Sxwg6dShGcQSeCC7nZQ8E= +github.com/insomniacslk/dhcp v0.0.0-20230407062729-974c6f05fe16/go.mod h1:IKrnDWs3/Mqq5n0lI+RxA2sB7MvN/vbMBP3ehXg65UI= github.com/josharian/native v1.0.1-0.20221213033349-c1e37c09b531/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w= github.com/josharian/native v1.1.1-0.20230202152459-5c7d0dd6ab86 h1:elKwZS1OcdQ0WwEDBeqxKwb7WB62QX8bvZ/FJnVXIfk= github.com/josharian/native v1.1.1-0.20230202152459-5c7d0dd6ab86/go.mod h1:aFAMtuldEgx/4q7iSGazk22+IcgvtiC+HIimFO9XlS8= -github.com/jsimonetti/rtnetlink v0.0.0-20190606172950-9527aa82566a/go.mod h1:Oz+70psSo5OFh8DBl0Zv2ACw7Esh6pPUphlvZG9x7uw= -github.com/jsimonetti/rtnetlink v0.0.0-20200117123717-f846d4f6c1f4/go.mod h1:WGuG/smIU4J/54PblvSbh+xvCZmpJnFgr3ds6Z55XMQ= -github.com/jsimonetti/rtnetlink v0.0.0-20201009170750-9c6f07d100c1/go.mod h1:hqoO/u39cqLeBLebZ8fWdE96O7FxrAsRYhnVOdgHxok= -github.com/jsimonetti/rtnetlink v0.0.0-20201110080708-d2c240429e6c/go.mod h1:huN4d1phzjhlOsNIjFsw2SVRbwIHj3fJDMEU2SDPTmg= -github.com/jsimonetti/rtnetlink v1.1.2-0.20220408201609-d380b505068b h1:Yws7RV6kZr2O7PPdT+RkbSmmOponA8i/1DuGHe8BRsM= -github.com/jsimonetti/rtnetlink v1.1.2-0.20220408201609-d380b505068b/go.mod h1:TzDCVOZKUa79z6iXbbXqhtAflVgUKaFkZ21M5tK5tzY= +github.com/jsimonetti/rtnetlink v1.3.2 h1:dcn0uWkfxycEEyNy0IGfx3GrhQ38LH7odjxAghimsVI= +github.com/jsimonetti/rtnetlink v1.3.2/go.mod h1:BBu4jZCpTjP6Gk0/wfrO8qcqymnN3g0hoFqObRmUo6U= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= -github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/kardianos/service v1.2.1 h1:AYndMsehS+ywIS6RB9KOlcXzteWUzxgMgBymJD7+BYk= github.com/kardianos/service v1.2.1/go.mod h1:CIMRFEJVL+0DS1a3Nx06NaMn4Dz63Ng6O7dl0qH0zVM= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= -github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -198,36 +186,39 @@ github.com/leodido/go-urn v1.2.1 h1:BqpAaACuzVSgi/VLzGZIobT2z4v53pjosyNd9Yv6n/w= github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY= github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= -github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40= github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= -github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98= +github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU= +github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mdlayher/ethernet v0.0.0-20190606142754-0394541c37b7 h1:lez6TS6aAau+8wXUP3G9I3TGlmPFEq2CTxBaRqY6AGE= github.com/mdlayher/ethernet v0.0.0-20190606142754-0394541c37b7/go.mod h1:U6ZQobyTjI/tJyq2HG+i/dfSoFUt8/aZCM+GKtmFk/Y= -github.com/mdlayher/netlink v0.0.0-20190409211403-11939a169225/go.mod h1:eQB3mZE4aiYnlUsyGGCOpPETfdQq4Jhsgf1fk3cwQaA= -github.com/mdlayher/netlink v1.0.0/go.mod h1:KxeJAFOFLG6AjpyDkQ/iIhxygIUKD+vcwqcnu43w/+M= -github.com/mdlayher/netlink v1.1.0/go.mod h1:H4WCitaheIsdF9yOYu8CFmCgQthAPIWZmcKp9uZHgmY= -github.com/mdlayher/netlink v1.1.1/go.mod h1:WTYpFb/WTvlRJAyKhZL5/uy69TDDpHHu2VZmb2XgV7o= -github.com/mdlayher/netlink v1.6.0/go.mod h1:0o3PlBmGst1xve7wQ7j/hwpNaFaH4qCRyWCdcZk8/vA= -github.com/mdlayher/netlink v1.7.1 h1:FdUaT/e33HjEXagwELR8R3/KL1Fq5x3G5jgHLp/BTmg= -github.com/mdlayher/netlink v1.7.1/go.mod h1:nKO5CSjE/DJjVhk/TNp6vCE1ktVxEA8VEh8drhZzxsQ= +github.com/mdlayher/netlink v1.7.2 h1:/UtM3ofJap7Vl4QWCPDGXY8d3GIY2UGSDbK+QWmY8/g= +github.com/mdlayher/netlink v1.7.2/go.mod h1:xraEF7uJbxLhc5fpHL4cPe221LI2bdttWlU+ZGLfQSw= github.com/mdlayher/raw v0.0.0-20190606142536-fef19f00fc18/go.mod h1:7EpbotpCmVZcu+KCX4g9WaRNuu11uyhiW7+Le1dKawg= github.com/mdlayher/raw v0.0.0-20191009151244-50f2db8cc065 h1:aFkJ6lx4FPip+S+Uw4aTegFMct9shDvP+79PsSxpm3w= github.com/mdlayher/raw v0.0.0-20191009151244-50f2db8cc065/go.mod h1:7EpbotpCmVZcu+KCX4g9WaRNuu11uyhiW7+Le1dKawg= -github.com/mdlayher/socket v0.1.1/go.mod h1:mYV5YIZAfHh4dzDVzI8x8tWLWCliuX8Mon5Awbj+qDs= -github.com/mdlayher/socket v0.4.0 h1:280wsy40IC9M9q1uPGcLBwXpcTQDtoGwVt+BNoITxIw= -github.com/mdlayher/socket v0.4.0/go.mod h1:xxFqz5GRCUN3UEOm9CZqEJsAbe1C8OwSK46NlmWuVoc= -github.com/miekg/dns v1.1.50 h1:DQUfb9uc6smULcREF09Uc+/Gd46YWqJd5DbpPE9xkcA= -github.com/miekg/dns v1.1.50/go.mod h1:e3IlAVfNqAllflbibAZEWOXOQ+Ynzk/dDozDxY7XnME= +github.com/mdlayher/socket v0.4.1 h1:eM9y2/jlbs1M615oshPQOHZzj6R6wMT7bX5NPiQvn2U= +github.com/mdlayher/socket v0.4.1/go.mod h1:cAqeGjoufqdxWkD7DkpyS+wcefOtmu5OQ8KuoJGIReA= +github.com/miekg/dns v1.1.55 h1:GoQ4hpsj0nFLYe+bWiCToyrBEJXkQfOOIvFGFy0lEgo= +github.com/miekg/dns v1.1.55/go.mod h1:uInx36IzPl7FYnDcMeVWxj9byh7DutNykX4G9Sj60FY= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= 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.2.0 h1:3ZNA3L1c5FYDFTTxbFeVGGD8jYvjYauHD30YgLxVsNI= github.com/onsi/ginkgo/v2 v2.2.0/go.mod h1:MEH45j8TBi6u9BMogfbp0stKC5cdGjumZj5Y7AG4VIk= github.com/onsi/gomega v1.20.1 h1:PA/3qinGoukvymdIDV8pii6tiZgC8kbmJO6Z5+b002Q= -github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= -github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= -github.com/pelletier/go-toml/v2 v2.0.6 h1:nrzqCb7j9cDFj2coyLNLaZuJTLjWjlaz6nvTvIwycIU= -github.com/pelletier/go-toml/v2 v2.0.6/go.mod h1:eumQOmlWiOPt5WriQQqoM5y18pDHwha2N+QD+EUNTek= +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= +github.com/pierrec/lz4/v4 v4.1.17 h1:kV4Ip+/hUBC+8T6+2EgburRtkE9ef4nbY3f4dFhGjMc= +github.com/pierrec/lz4/v4 v4.1.17/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg= @@ -244,27 +235,29 @@ github.com/quic-go/qtls-go1-20 v0.1.0 h1:d1PK3ErFy9t7zxKsG3NXBJXZjp/kMLoIb3y/kV5 github.com/quic-go/qtls-go1-20 v0.1.0/go.mod h1:JKtK6mjbAVcUTN/9jZpvLbGxvdWIKS8uT7EiStoU1SM= github.com/quic-go/quic-go v0.32.0 h1:lY02md31s1JgPiiyfqJijpu/UX/Iun304FI3yUqX7tA= github.com/quic-go/quic-go v0.32.0/go.mod h1:/fCsKANhQIeD5l76c2JFU+07gVE3KaA0FP+0zMWwfwo= +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= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= -github.com/rogpeppe/go-internal v1.8.1-0.20211023094830-115ce09fd6b4 h1:Ha8xCaq6ln1a+R91Km45Oq6lPXj2Mla6CRJYcuV2h1w= -github.com/rogpeppe/go-internal v1.8.1-0.20211023094830-115ce09fd6b4/go.mod h1:JeRgkft04UBgHMgCIwADu4Pn6Mtm5d4nPKWu0nJ5d+o= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= -github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= -github.com/spf13/afero v1.9.3 h1:41FoI0fD7OR7mGcKE/aOiLkGreyf8ifIOQmJANWogMk= -github.com/spf13/afero v1.9.3/go.mod h1:iUV7ddyEEZPO5gA3zD4fJt6iStLlL+Lg4m2cihcDf8Y= -github.com/spf13/cast v1.5.0 h1:rj3WzYc11XZaIZMPKmwP96zkFEnnAmV8s6XbB2aY32w= -github.com/spf13/cast v1.5.0/go.mod h1:SpXXQ5YoyJw6s3/6cMTQuxvgRl3PCJiyaX9p6b155UU= -github.com/spf13/cobra v1.4.0 h1:y+wJpx64xcgO1V+RcnwW0LEHxTKRi2ZDPSBjWnrg88Q= -github.com/spf13/cobra v1.4.0/go.mod h1:Wo4iy3BUC+X2Fybo0PDqwJIv3dNRiZLHQymsfxlB84g= +github.com/spf13/afero v1.9.5 h1:stMpOSZFs//0Lv29HduCmli3GUfpFoF3Y1Q/aXj/wVM= +github.com/spf13/afero v1.9.5/go.mod h1:UBogFpq8E9Hx+xc5CNTTEpTnuHVmXDwZcZcE1eb/UhQ= +github.com/spf13/cast v1.5.1 h1:R+kOtfhWQE6TVQzY+4D7wJLBgkdVasCEFxSUBYBYIlA= +github.com/spf13/cast v1.5.1/go.mod h1:b9PdjNptOpzXr7Rq1q9gJML/2cdGQAo69NKzQ10KN48= +github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= +github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= github.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmqnqMYADFk= github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/viper v1.14.0 h1:Rg7d3Lo706X9tHsJMUjdiwMpHB7W8WnSVOssIY+JElU= -github.com/spf13/viper v1.14.0/go.mod h1:WT//axPky3FdvXHzGw33dNdXXXfFQqmEalje+egj8As= +github.com/spf13/viper v1.16.0 h1:rGGH0XDZhdUOryiDWjmIvUSWpbNqisK8Wk0Vyefw8hc= +github.com/spf13/viper v1.16.0/go.mod h1:yg78JgCJcbrQOvV9YLXgkLaZqUidkY9K+Dd1FofRzQg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= @@ -276,17 +269,17 @@ github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/subosito/gotenv v1.4.1 h1:jyEFiXpy21Wm81FBN71l9VoMMV8H8jG+qIK3GCpY6Qs= -github.com/subosito/gotenv v1.4.1/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0= -github.com/u-root/uio v0.0.0-20221213070652-c3537552635f h1:dpx1PHxYqAnXzbryJrWP1NQLzEjwcVgFLhkknuFQ7ww= -github.com/u-root/uio v0.0.0-20221213070652-c3537552635f/go.mod h1:IogEAUBXDEwX7oR/BMmCctShYs80ql4hF0ySdzGxf7E= -github.com/vishvananda/netlink v1.1.1-0.20211118161826-650dca95af54 h1:8mhqcHPqTMhSPoslhGYihEgSfc77+7La1P6kiB6+9So= -github.com/vishvananda/netlink v1.1.1-0.20211118161826-650dca95af54/go.mod h1:twkDnbuQxJYemMlGd4JFIcuhgX83tXhKS2B/PRMpOho= +github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY= +github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/subosito/gotenv v1.4.2 h1:X1TuBLAMDFbaTAChgCBLu3DU3UPyELpnF2jjJ2cz/S8= +github.com/subosito/gotenv v1.4.2/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0= +github.com/u-root/uio v0.0.0-20230305220412-3e8cd9d6bf63 h1:YcojQL98T/OO+rybuzn2+5KrD5dBwXIvYBvQ2cD3Avg= +github.com/u-root/uio v0.0.0-20230305220412-3e8cd9d6bf63/go.mod h1:eLL9Nub3yfAho7qB0MzZizFhTU2QkLeoVsWdHtDW264= +github.com/vishvananda/netlink v1.2.1-beta.2 h1:Llsql0lnQEbHj0I1OuKyp8otXp0r3q0mPkuhwHfStVs= +github.com/vishvananda/netlink v1.2.1-beta.2/go.mod h1:twkDnbuQxJYemMlGd4JFIcuhgX83tXhKS2B/PRMpOho= github.com/vishvananda/netns v0.0.0-20200728191858-db3c7e526aae/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0= -github.com/vishvananda/netns v0.0.0-20211101163701-50045581ed74 h1:gga7acRE695APm9hlsSMoOoE65U4/TcqNj90mc69Rlg= -github.com/vishvananda/netns v0.0.0-20211101163701-50045581ed74/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0= +github.com/vishvananda/netns v0.0.4 h1:Oeaw1EM2JMxD51g9uhtC0D7erkIjgmj8+JZc26m1YX8= +github.com/vishvananda/netns v0.0.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= @@ -298,18 +291,18 @@ 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= -go4.org/mem v0.0.0-20210711025021-927187094b94 h1:OAAkygi2Js191AJP1Ds42MhJRgeofeKGjuoUqNp1QC4= -go4.org/mem v0.0.0-20210711025021-927187094b94/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g= +go4.org/mem v0.0.0-20220726221520-4f986261bf13 h1:CbZeCBZ0aZj8EfVgnqQcYZgf0lpZ3H9rmp5nkDTAst8= +go4.org/mem v0.0.0-20220726221520-4f986261bf13/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= -golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.6.0 h1:qfktjS5LUO+fFKeJXZ+ikTRijMmljikvG68fpMMruSc= -golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= +golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g= +golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0= 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= @@ -320,8 +313,8 @@ golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u0 golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= -golang.org/x/exp v0.0.0-20221205204356-47842c84f3db h1:D/cFflL63o2KSLJIwjlcIt8PR064j/xsmdEJL/YvY/o= -golang.org/x/exp v0.0.0-20221205204356-47842c84f3db/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= +golang.org/x/exp v0.0.0-20230425010034-47ecfdc1ba53 h1:5llv2sWeaMSnA3w2kS57ouQQ4pudlXrR0dCgw51QK9o= +golang.org/x/exp v0.0.0-20230425010034-47ecfdc1ba53/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= @@ -346,8 +339,8 @@ 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.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.7.0 h1:LapD9S96VoQRhi/GrNTqeBJFrUjs5UHCAtTlgwA5oZA= -golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.10.0 h1:lFO9qtOdlre5W1jxS3r/4szv2/6iXxScdzjoBMXNhYk= +golang.org/x/mod v0.10.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 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= @@ -361,8 +354,6 @@ golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190827160401-ba9fcec4b297/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20191007182048-72f939374954/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -377,20 +368,15 @@ golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/ golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 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-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= -golang.org/x/net v0.0.0-20210726213435-c6fcb2dbf985/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20210928044308-7d9f5e0b762b/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= -golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g= -golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= 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= @@ -411,12 +397,11 @@ golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJ 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.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= -golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.2.0 h1:PUR+T4wwASmuSTYdKjYHI5TD22Wy5ogLU5qZCOLxBrI= +golang.org/x/sync v0.2.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 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= -golang.org/x/sys v0.0.0-20190411185658-b44545bcd369/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190418153312-f0ce4c0180be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -425,9 +410,7 @@ golang.org/x/sys v0.0.0-20190606122018-79a91cf218c4/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -447,9 +430,7 @@ golang.org/x/sys v0.0.0-20200728102440-3e129f6d46b1/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201009025420-dfb3f7c4e634/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201015000850-e3ed0017c211/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201101102859-da207088b7d1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -462,17 +443,15 @@ golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210906170528-6f6e22806c34/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220128215802-99c3d69c2c27/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220622161953-175b2fd9d664/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220908164124-27713097b956/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.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU= -golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.1-0.20230609144347-5059a07aa46a h1:qMsju+PNttu/NMbq8bQ9waDdxgJMu9QNoUDuhnBaYt0= +golang.org/x/sys v0.8.1-0.20230609144347-5059a07aa46a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -481,8 +460,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.7.0 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo= -golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 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= @@ -492,7 +471,6 @@ golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3 golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= @@ -535,9 +513,8 @@ golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4f 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.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/tools v0.1.6-0.20210726203631-07bc1bf47fb2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/tools v0.4.1-0.20221208213631-3f74d914ae6d h1:9ZNWAi4CYhNv60mXGgAncgq7SGc5qa7C8VZV8Tg7Ggs= -golang.org/x/tools v0.4.1-0.20221208213631-3f74d914ae6d/go.mod h1:UE5sM2OK9E/d67R0ANs2xJizIymRP5gJU295PvKXxjQ= +golang.org/x/tools v0.9.1 h1:8WMNJAz3zrtPmnYC7ISf5dEn3MT0gY7jBJfw27yrrLo= +golang.org/x/tools v0.9.1/go.mod h1:owI94Op576fPu3cIGQeHs3joujW/2Oc6MtlxbF5dfNc= 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= @@ -632,7 +609,7 @@ 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.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w= +google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng= 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= @@ -641,8 +618,6 @@ gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= -gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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= @@ -657,5 +632,5 @@ honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9 rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= -tailscale.com v1.38.3 h1:2aX3+u0Re8QcN6nq7zf9Aa4ZCR2Nf6Imv3isqdQrb58= -tailscale.com v1.38.3/go.mod h1:UWLQxcd8dz+lds2I+HpfXSruHrvXM1j4zd4zdx86t7w= +tailscale.com v1.44.0 h1:MPos9n30kJvdyfL52045gVFyNg93K+bwgDsr8gqKq2o= +tailscale.com v1.44.0/go.mod h1:+iYwTdeHyVJuNDu42Zafwihq1Uqfh+pW7pRaY1GD328= diff --git a/internal/clientinfo/arp.go b/internal/clientinfo/arp.go new file mode 100644 index 0000000..8429b56 --- /dev/null +++ b/internal/clientinfo/arp.go @@ -0,0 +1,46 @@ +package clientinfo + +import "sync" + +type arpDiscover struct { + mac sync.Map // ip => mac + ip sync.Map // mac => ip +} + +func (a *arpDiscover) refresh() error { + a.scan() + return nil +} + +func (a *arpDiscover) LookupIP(mac string) string { + val, ok := a.ip.Load(mac) + if !ok { + return "" + } + return val.(string) +} + +func (a *arpDiscover) LookupMac(ip string) string { + val, ok := a.mac.Load(ip) + if !ok { + return "" + } + return val.(string) +} + +func (a *arpDiscover) String() string { + return "arp" +} + +func (a *arpDiscover) List() []string { + var ips []string + a.ip.Range(func(key, value any) bool { + ips = append(ips, value.(string)) + return true + }) + a.mac.Range(func(key, value any) bool { + ips = append(ips, key.(string)) + return true + }) + return ips +} diff --git a/internal/clientinfo/arp_linux.go b/internal/clientinfo/arp_linux.go new file mode 100644 index 0000000..3e48337 --- /dev/null +++ b/internal/clientinfo/arp_linux.go @@ -0,0 +1,28 @@ +package clientinfo + +import ( + "bufio" + "os" + "strings" +) + +const procNetArpFile = "/proc/net/arp" + +func (a *arpDiscover) scan() { + f, err := os.Open(procNetArpFile) + if err != nil { + return + } + defer f.Close() + + s := bufio.NewScanner(f) + s.Scan() // skip header + for s.Scan() { + line := s.Text() + fields := strings.Fields(line) + ip := fields[0] + mac := fields[3] + a.mac.Store(ip, mac) + a.ip.Store(mac, ip) + } +} diff --git a/internal/clientinfo/arp_test.go b/internal/clientinfo/arp_test.go new file mode 100644 index 0000000..08a75f8 --- /dev/null +++ b/internal/clientinfo/arp_test.go @@ -0,0 +1,23 @@ +package clientinfo + +import ( + "sync" + "testing" +) + +func TestArpScan(t *testing.T) { + a := &arpDiscover{} + a.scan() + + for _, table := range []*sync.Map{&a.mac, &a.ip} { + count := 0 + table.Range(func(key, value any) bool { + count++ + t.Logf("%s => %s", key, value) + return true + }) + if count == 0 { + t.Error("empty result from arp scan") + } + } +} diff --git a/internal/clientinfo/arp_unix.go b/internal/clientinfo/arp_unix.go new file mode 100644 index 0000000..f5d8f88 --- /dev/null +++ b/internal/clientinfo/arp_unix.go @@ -0,0 +1,30 @@ +//go:build !linux && !windows + +package clientinfo + +import ( + "os/exec" + "strings" +) + +func (a *arpDiscover) scan() { + data, err := exec.Command("arp", "-an").Output() + if err != nil { + return + } + + for _, line := range strings.Split(string(data), "\n") { + fields := strings.Fields(line) + if len(fields) <= 3 { + continue + } + + // trim brackets + ip := strings.ReplaceAll(fields[1], "(", "") + ip = strings.ReplaceAll(ip, ")", "") + + mac := fields[3] + a.mac.Store(ip, mac) + a.ip.Store(mac, ip) + } +} diff --git a/internal/clientinfo/arp_windows.go b/internal/clientinfo/arp_windows.go new file mode 100644 index 0000000..016b752 --- /dev/null +++ b/internal/clientinfo/arp_windows.go @@ -0,0 +1,38 @@ +package clientinfo + +import ( + "os/exec" + "strings" +) + +func (a *arpDiscover) scan() { + data, err := exec.Command("arp", "-a").Output() + if err != nil { + return + } + + header := false + for _, line := range strings.Split(string(data), "\n") { + if len(line) == 0 { + continue // empty lines + } + if line[0] != ' ' { + header = true // "Interface:" lines, next is header line. + continue + } + if header { + header = false // header lines + continue + } + + fields := strings.Fields(line) + if len(fields) < 2 { + continue + } + + ip := fields[0] + mac := strings.ReplaceAll(fields[1], "-", ":") + a.mac.Store(ip, mac) + a.ip.Store(mac, ip) + } +} diff --git a/internal/clientinfo/client_info.go b/internal/clientinfo/client_info.go new file mode 100644 index 0000000..9235ca9 --- /dev/null +++ b/internal/clientinfo/client_info.go @@ -0,0 +1,346 @@ +package clientinfo + +import ( + "fmt" + "net/netip" + "strings" + "sync" + "time" + + "github.com/Control-D-Inc/ctrld" + "github.com/Control-D-Inc/ctrld/internal/controld" +) + +// IpResolver is the interface for retrieving IP from Mac. +type IpResolver interface { + fmt.Stringer + // LookupIP returns ip of the device with given mac. + LookupIP(mac string) string +} + +// MacResolver is the interface for retrieving Mac from IP. +type MacResolver interface { + fmt.Stringer + // LookupMac returns mac of the device with given ip. + LookupMac(ip string) string +} + +// HostnameByIpResolver is the interface for retrieving hostname from IP. +type HostnameByIpResolver interface { + // LookupHostnameByIP returns hostname of the given ip. + LookupHostnameByIP(ip string) string +} + +// HostnameByMacResolver is the interface for retrieving hostname from Mac. +type HostnameByMacResolver interface { + // LookupHostnameByMac returns hostname of the device with given mac. + LookupHostnameByMac(mac string) string +} + +// HostnameResolver is the interface for retrieving hostname from either IP or Mac. +type HostnameResolver interface { + fmt.Stringer + HostnameByIpResolver + HostnameByMacResolver +} + +type refresher interface { + refresh() error +} + +type ipLister interface { + fmt.Stringer + // List returns list of ip known by the resolver. + List() []string +} + +type Client struct { + IP netip.Addr + Mac string + Hostname string + Source map[string]struct{} +} + +type Table struct { + ipResolvers []IpResolver + macResolvers []MacResolver + hostnameResolvers []HostnameResolver + refreshers []refresher + initOnce sync.Once + + dhcp *dhcp + merlin *merlinDiscover + arp *arpDiscover + ptr *ptrDiscover + mdns *mdns + cfg *ctrld.Config + quitCh chan struct{} + selfIP string + cdUID string +} + +func NewTable(cfg *ctrld.Config, selfIP, cdUID string) *Table { + return &Table{ + cfg: cfg, + quitCh: make(chan struct{}), + selfIP: selfIP, + cdUID: cdUID, + } +} + +func (t *Table) AddLeaseFile(name string, format ctrld.LeaseFileFormat) { + if !t.discoverDHCP() { + return + } + clientInfoFiles[name] = format +} + +func (t *Table) RefreshLoop(stopCh chan struct{}) { + timer := time.NewTicker(time.Minute * 5) + defer timer.Stop() + for { + select { + case <-timer.C: + for _, r := range t.refreshers { + _ = r.refresh() + } + case <-stopCh: + close(t.quitCh) + return + } + } +} + +func (t *Table) Init() { + t.initOnce.Do(t.init) +} + +func (t *Table) init() { + 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) + return + } + if t.discoverDHCP() || t.discoverARP() { + t.merlin = &merlinDiscover{} + if err := t.merlin.refresh(); err != nil { + ctrld.ProxyLogger.Load().Error().Err(err).Msg("could not init Merlin discover") + } else { + t.hostnameResolvers = append(t.hostnameResolvers, t.merlin) + t.refreshers = append(t.refreshers, t.merlin) + } + } + if t.discoverDHCP() { + t.dhcp = &dhcp{selfIP: t.selfIP} + ctrld.ProxyLogger.Load().Debug().Msg("start dhcp discovery") + if err := t.dhcp.init(); err != nil { + ctrld.ProxyLogger.Load().Error().Err(err).Msg("could not init DHCP discover") + } else { + t.ipResolvers = append(t.ipResolvers, t.dhcp) + t.macResolvers = append(t.macResolvers, t.dhcp) + t.hostnameResolvers = append(t.hostnameResolvers, t.dhcp) + } + go t.dhcp.watchChanges() + } + if t.discoverARP() { + t.arp = &arpDiscover{} + ctrld.ProxyLogger.Load().Debug().Msg("start arp discovery") + if err := t.arp.refresh(); err != nil { + ctrld.ProxyLogger.Load().Error().Err(err).Msg("could not init ARP discover") + } else { + t.ipResolvers = append(t.ipResolvers, t.arp) + t.macResolvers = append(t.macResolvers, t.arp) + t.refreshers = append(t.refreshers, t.arp) + } + } + if t.discoverPTR() { + t.ptr = &ptrDiscover{resolver: ctrld.NewPrivateResolver()} + ctrld.ProxyLogger.Load().Debug().Msg("start ptr discovery") + if err := t.ptr.refresh(); err != nil { + ctrld.ProxyLogger.Load().Error().Err(err).Msg("could not init PTR discover") + } else { + t.hostnameResolvers = append(t.hostnameResolvers, t.ptr) + t.refreshers = append(t.refreshers, t.ptr) + } + } + if t.discoverMDNS() { + t.mdns = &mdns{} + ctrld.ProxyLogger.Load().Debug().Msg("start mdns discovery") + if err := t.mdns.init(t.quitCh); err != nil { + ctrld.ProxyLogger.Load().Error().Err(err).Msg("could not init mDNS discover") + } else { + t.hostnameResolvers = append(t.hostnameResolvers, t.mdns) + } + } +} + +func (t *Table) LookupIP(mac string) string { + t.initOnce.Do(t.init) + for _, r := range t.ipResolvers { + if ip := r.LookupIP(mac); ip != "" { + return ip + } + } + return "" +} + +func (t *Table) LookupMac(ip string) string { + t.initOnce.Do(t.init) + for _, r := range t.macResolvers { + if mac := r.LookupMac(ip); mac != "" { + return mac + } + } + return "" +} + +func (t *Table) LookupHostname(ip, mac string) string { + t.initOnce.Do(t.init) + for _, r := range t.hostnameResolvers { + if name := r.LookupHostnameByIP(ip); name != "" { + return name + } + if name := r.LookupHostnameByMac(mac); name != "" { + return name + } + } + return "" +} + +type macEntry struct { + mac string + src string +} + +type hostnameEntry struct { + name string + src string +} + +func (t *Table) lookupMacAll(ip string) []*macEntry { + var res []*macEntry + for _, r := range t.macResolvers { + res = append(res, &macEntry{mac: r.LookupMac(ip), src: r.String()}) + } + return res +} + +func (t *Table) lookupHostnameAll(ip, mac string) []*hostnameEntry { + var res []*hostnameEntry + for _, r := range t.hostnameResolvers { + src := r.String() + // For ptrDiscover, lookup hostname may block due to server unavailable, + // so only lookup from cache to prevent timeout reached. + if ptrResolver, ok := r.(*ptrDiscover); ok { + if name := ptrResolver.lookupHostnameFromCache(ip); name != "" { + res = append(res, &hostnameEntry{name: name, src: src}) + } + continue + } + if name := r.LookupHostnameByIP(ip); name != "" { + res = append(res, &hostnameEntry{name: name, src: src}) + continue + } + if name := r.LookupHostnameByMac(mac); name != "" { + res = append(res, &hostnameEntry{name: name, src: src}) + continue + } + } + return res +} + +// ListClients returns list of clients discovered by ctrld. +func (t *Table) ListClients() []*Client { + for _, r := range t.refreshers { + _ = r.refresh() + } + ipMap := make(map[string]*Client) + il := []ipLister{t.dhcp, t.arp, t.ptr, t.mdns} + for _, ir := range il { + for _, ip := range ir.List() { + c, ok := ipMap[ip] + if !ok { + c = &Client{ + IP: netip.MustParseAddr(ip), + Source: map[string]struct{}{ir.String(): {}}, + } + ipMap[ip] = c + } else { + c.Source[ir.String()] = struct{}{} + } + } + } + for ip := range ipMap { + c := ipMap[ip] + for _, e := range t.lookupMacAll(ip) { + if c.Mac == "" && e.mac != "" { + c.Mac = e.mac + } + if e.mac != "" { + c.Source[e.src] = struct{}{} + } + } + for _, e := range t.lookupHostnameAll(ip, c.Mac) { + if c.Hostname == "" && e.name != "" { + c.Hostname = e.name + } + if e.name != "" { + c.Source[e.src] = struct{}{} + } + } + } + clients := make([]*Client, 0, len(ipMap)) + for _, c := range ipMap { + clients = append(clients, c) + } + return clients +} + +func (t *Table) discoverDHCP() bool { + if t.cfg.Service.DiscoverDHCP == nil { + return true + } + return *t.cfg.Service.DiscoverDHCP +} + +func (t *Table) discoverARP() bool { + if t.cfg.Service.DiscoverARP == nil { + return true + } + return *t.cfg.Service.DiscoverARP +} + +func (t *Table) discoverMDNS() bool { + if t.cfg.Service.DiscoverMDNS == nil { + return true + } + return *t.cfg.Service.DiscoverMDNS +} + +func (t *Table) discoverPTR() bool { + if t.cfg.Service.DiscoverPtr == nil { + return true + } + return *t.cfg.Service.DiscoverPtr +} + +// normalizeIP normalizes the ip parsed from dnsmasq/dhcpd lease file. +func normalizeIP(in string) string { + // dnsmasq may put ip with interface index in lease file, strip it here. + ip, _, found := strings.Cut(in, "%") + if found { + return ip + } + return in +} + +func normalizeHostname(name string) string { + if before, _, found := strings.Cut(name, "."); found { + return before // remove ".local.", ".lan.", ... suffix + } + return name +} diff --git a/internal/clientinfo/client_info_test.go b/internal/clientinfo/client_info_test.go new file mode 100644 index 0000000..79e5912 --- /dev/null +++ b/internal/clientinfo/client_info_test.go @@ -0,0 +1,27 @@ +package clientinfo + +import ( + "testing" +) + +func Test_normalizeIP(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + {"v4", "127.0.0.1", "127.0.0.1"}, + {"v4 with index", "127.0.0.1%lo", "127.0.0.1"}, + {"v6", "fe80::1", "fe80::1"}, + {"v6 with index", "fe80::1%22002", "fe80::1"}, + } + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := normalizeIP(tc.in); got != tc.want { + t.Errorf("normalizeIP() = %v, want %v", got, tc.want) + } + }) + } +} diff --git a/internal/clientinfo/dhcp.go b/internal/clientinfo/dhcp.go new file mode 100644 index 0000000..27e2bf4 --- /dev/null +++ b/internal/clientinfo/dhcp.go @@ -0,0 +1,301 @@ +package clientinfo + +import ( + "bufio" + "bytes" + "fmt" + "io" + "net" + "net/netip" + "os" + "strings" + "sync" + + "github.com/fsnotify/fsnotify" + "tailscale.com/net/interfaces" + "tailscale.com/util/lineread" + + "github.com/Control-D-Inc/ctrld" + "github.com/Control-D-Inc/ctrld/internal/router" +) + +type dhcp struct { + mac2name sync.Map // mac => name + ip2name sync.Map // ip => name + ip sync.Map // mac => ip + mac sync.Map // ip => mac + + watcher *fsnotify.Watcher + selfIP string +} + +func (d *dhcp) init() error { + watcher, err := fsnotify.NewWatcher() + if err != nil { + return err + } + d.addSelf() + d.watcher = watcher + for file, format := range clientInfoFiles { + // Ignore errors for default lease files. + _ = d.addLeaseFile(file, format) + } + return nil +} + +func (d *dhcp) watchChanges() { + if d.watcher == nil { + return + } + for { + select { + case event, ok := <-d.watcher.Events: + if !ok { + return + } + if event.Has(fsnotify.Write) || event.Has(fsnotify.Rename) || event.Has(fsnotify.Chmod) || event.Has(fsnotify.Remove) { + format := clientInfoFiles[event.Name] + if err := d.readLeaseFile(event.Name, format); err != nil && !os.IsNotExist(err) { + ctrld.ProxyLogger.Load().Err(err).Str("file", event.Name).Msg("leases file changed but failed to update client info") + } + } + case err, ok := <-d.watcher.Errors: + if !ok { + return + } + ctrld.ProxyLogger.Load().Err(err).Msg("could not watch client info file") + } + } + +} + +func (d *dhcp) LookupIP(mac string) string { + val, ok := d.ip.Load(mac) + if !ok { + return "" + } + return val.(string) +} + +func (d *dhcp) LookupMac(ip string) string { + val, ok := d.mac.Load(ip) + if !ok { + return "" + } + return val.(string) +} + +func (d *dhcp) LookupHostnameByIP(ip string) string { + val, ok := d.ip2name.Load(ip) + if !ok { + return "" + } + return val.(string) +} + +func (d *dhcp) LookupHostnameByMac(mac string) string { + val, ok := d.mac2name.Load(mac) + if !ok { + return "" + } + return val.(string) +} + +func (d *dhcp) String() string { + return "dhcp" +} + +func (d *dhcp) List() []string { + var ips []string + d.ip.Range(func(key, value any) bool { + ips = append(ips, value.(string)) + return true + }) + d.mac.Range(func(key, value any) bool { + ips = append(ips, key.(string)) + return true + }) + return ips +} + +// AddLeaseFile adds given lease file for reading/watching clients info. +func (d *dhcp) addLeaseFile(name string, format ctrld.LeaseFileFormat) error { + if d.watcher == nil { + return nil + } + if err := d.readLeaseFile(name, format); err != nil { + return fmt.Errorf("could not read lease file: %w", err) + } + clientInfoFiles[name] = format + return d.watcher.Add(name) +} + +// readLeaseFile reads the lease file with given format, saving client information to dhcp table. +func (d *dhcp) readLeaseFile(name string, format ctrld.LeaseFileFormat) error { + switch format { + case ctrld.Dnsmasq: + return d.dnsmasqReadClientInfoFile(name) + case ctrld.IscDhcpd: + return d.iscDHCPReadClientInfoFile(name) + } + return fmt.Errorf("unsupported format: %s, file: %s", format, name) +} + +// dnsmasqReadClientInfoFile populates dhcp table with client info reading from dnsmasq lease file. +func (d *dhcp) dnsmasqReadClientInfoFile(name string) error { + f, err := os.Open(name) + if err != nil { + return err + } + defer f.Close() + return d.dnsmasqReadClientInfoReader(f) + +} + +// dnsmasqReadClientInfoReader likes ctrld.Dnsmasq, but reading from an io.Reader instead of file. +func (d *dhcp) dnsmasqReadClientInfoReader(reader io.Reader) error { + return lineread.Reader(reader, func(line []byte) error { + fields := bytes.Fields(line) + if len(fields) < 4 { + return nil + } + + mac := string(fields[1]) + if _, err := net.ParseMAC(mac); err != nil { + // The second field is not a dhcp, skip. + return nil + } + ip := normalizeIP(string(fields[2])) + if net.ParseIP(ip) == nil { + ctrld.ProxyLogger.Load().Warn().Msgf("invalid ip address entry: %q", ip) + ip = "" + } + + d.mac.Store(ip, mac) + d.ip.Store(mac, ip) + hostname := string(fields[3]) + if hostname == "*" { + return nil + } + name := normalizeHostname(hostname) + d.mac2name.Store(mac, name) + d.ip2name.Store(ip, name) + return nil + }) +} + +// iscDHCPReadClientInfoFile populates dhcp table with client info reading from isc-dhcpd lease file. +func (d *dhcp) iscDHCPReadClientInfoFile(name string) error { + f, err := os.Open(name) + if err != nil { + return err + } + defer f.Close() + return d.iscDHCPReadClientInfoReader(f) +} + +// iscDHCPReadClientInfoReader likes ctrld.IscDhcpd, but reading from an io.Reader instead of file. +func (d *dhcp) iscDHCPReadClientInfoReader(reader io.Reader) error { + s := bufio.NewScanner(reader) + var ip, mac, hostname string + for s.Scan() { + line := s.Text() + if strings.HasPrefix(line, "}") { + d.mac.Store(ip, mac) + d.ip.Store(mac, ip) + if hostname != "" && hostname != "*" { + name := normalizeHostname(hostname) + d.mac2name.Store(mac, name) + d.ip2name.Store(ip, hostname) + ip, mac, hostname = "", "", "" + } + continue + } + fields := strings.Fields(line) + if len(fields) < 2 { + continue + } + switch fields[0] { + case "lease": + ip = normalizeIP(strings.ToLower(fields[1])) + if net.ParseIP(ip) == nil { + ctrld.ProxyLogger.Load().Warn().Msgf("invalid ip address entry: %q", ip) + ip = "" + } + case "hardware": + if len(fields) >= 3 { + mac = strings.ToLower(strings.TrimRight(fields[2], ";")) + if _, err := net.ParseMAC(mac); err != nil { + // Invalid dhcp, skip. + mac = "" + } + } + case "client-hostname": + hostname = strings.Trim(fields[1], `";`) + } + } + return nil +} + +// addSelf populates current host info to dhcp, so queries from +// the host itself can be attached with proper client info. +func (d *dhcp) addSelf() { + hostname, err := os.Hostname() + if err != nil { + ctrld.ProxyLogger.Load().Err(err).Msg("could not get hostname") + return + } + hostname = normalizeHostname(hostname) + d.ip2name.Store("127.0.0.1", hostname) + d.ip2name.Store("::1", hostname) + found := false + interfaces.ForeachInterface(func(i interfaces.Interface, prefixes []netip.Prefix) { + mac := i.HardwareAddr.String() + // Skip loopback interfaces, info was stored above. + if mac == "" { + return + } + addrs, _ := i.Addrs() + for _, addr := range addrs { + if found { + return + } + ipNet, ok := addr.(*net.IPNet) + if !ok { + continue + } + ip := ipNet.IP + d.mac.Store(ip.String(), mac) + d.ip.Store(mac, ip.String()) + if ip.To4() != nil { + d.mac.Store("127.0.0.1", mac) + } else { + d.mac.Store("::1", mac) + } + d.mac2name.Store(mac, hostname) + d.ip2name.Store(ip.String(), hostname) + // If we have self IP set, and this IP is it, use this IP only. + if ip.String() == d.selfIP { + found = true + } + } + }) + for _, netIface := range router.SelfInterfaces() { + mac := netIface.HardwareAddr.String() + if mac == "" { + return + } + d.mac2name.Store(mac, hostname) + addrs, _ := netIface.Addrs() + for _, addr := range addrs { + ipNet, ok := addr.(*net.IPNet) + if !ok { + continue + } + ip := ipNet.IP + d.mac.LoadOrStore(ip.String(), mac) + d.ip.LoadOrStore(mac, ip.String()) + d.ip2name.Store(ip.String(), hostname) + } + } +} diff --git a/internal/clientinfo/dhcp_lease_files.go b/internal/clientinfo/dhcp_lease_files.go new file mode 100644 index 0000000..4932a4b --- /dev/null +++ b/internal/clientinfo/dhcp_lease_files.go @@ -0,0 +1,18 @@ +package clientinfo + +import "github.com/Control-D-Inc/ctrld" + +// clientInfoFiles specifies client info files and how to read them on supported platforms. +var clientInfoFiles = map[string]ctrld.LeaseFileFormat{ + "/tmp/dnsmasq.leases": ctrld.Dnsmasq, // ddwrt + "/tmp/dhcp.leases": ctrld.Dnsmasq, // openwrt + "/var/lib/misc/dnsmasq.leases": ctrld.Dnsmasq, // merlin + "/mnt/data/udapi-config/dnsmasq.lease": ctrld.Dnsmasq, // UDM Pro + "/data/udapi-config/dnsmasq.lease": ctrld.Dnsmasq, // UDR + "/etc/dhcpd/dhcpd-leases.log": ctrld.Dnsmasq, // Synology + "/tmp/var/lib/misc/dnsmasq.leases": ctrld.Dnsmasq, // Tomato + "/run/dnsmasq-dhcp.leases": ctrld.Dnsmasq, // EdgeOS + "/run/dhcpd.leases": ctrld.IscDhcpd, // EdgeOS + "/var/dhcpd/var/db/dhcpd.leases": ctrld.IscDhcpd, // Pfsense + "/home/pi/.router/run/dhcp/dnsmasq.leases": ctrld.Dnsmasq, // Firewalla +} diff --git a/internal/clientinfo/dhcp_test.go b/internal/clientinfo/dhcp_test.go new file mode 100644 index 0000000..af3a168 --- /dev/null +++ b/internal/clientinfo/dhcp_test.go @@ -0,0 +1,88 @@ +package clientinfo + +import ( + "io" + "strings" + "testing" +) + +func Test_readClientInfoReader(t *testing.T) { + d := &dhcp{} + tests := []struct { + name string + in string + readFunc func(r io.Reader) error + mac string + hostname string + }{ + { + "good dnsmasq", + `1683329857 e6:20:59:b8:c1:6d 192.168.1.186 host1 01:e6:20:59:b8:c1:6d +`, + d.dnsmasqReadClientInfoReader, + "e6:20:59:b8:c1:6d", + "host1", + }, + { + "bad dnsmasq seen on UDMdream machine", + `1683329857 e6:20:59:b8:c1:6e 192.168.1.111 host1 01:e6:20:59:b8:c1:6e +duid 00:01:00:01:2b:e4:2e:2c:52:52:14:26:dc:1c +1683322985 117442354 2600:4040:b0e6:b700::111 ASDASD 00:01:00:01:2a:d0:b9:81:00:07:32:4c:1c:07 +`, + d.dnsmasqReadClientInfoReader, + "e6:20:59:b8:c1:6e", + "host1", + }, + { + "isc-dhcpd good", + `lease 192.168.1.1 { + hardware ethernet 00:00:00:00:00:01; + client-hostname "host-1"; +} +`, + d.iscDHCPReadClientInfoReader, + "00:00:00:00:00:01", + "host-1", + }, + { + "isc-dhcpd bad dhcp", + `lease 192.168.1.1 { + hardware ethernet invalid-dhcp; + client-hostname "host-1"; +} + +lease 192.168.1.2 { + hardware ethernet 00:00:00:00:00:02; + client-hostname "host-2"; +} +`, + d.iscDHCPReadClientInfoReader, + "00:00:00:00:00:02", + "host-2", + }, + { + "", + `1685794060 00:00:00:00:00:04 192.168.0.209 example 00:00:00:00:00:04 9`, + d.dnsmasqReadClientInfoReader, + "00:00:00:00:00:04", + "example", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + d.mac2name.Delete(tc.mac) + if err := tc.readFunc(strings.NewReader(tc.in)); err != nil { + t.Errorf("readClientInfoReader() error = %v", err) + } + val, existed := d.mac2name.Load(tc.mac) + if !existed { + t.Error("client info missing") + } + hostname := val.(string) + if existed && hostname != tc.hostname { + t.Errorf("hostname mismatched, want: %q, got: %q", tc.hostname, hostname) + } + }) + } +} diff --git a/internal/clientinfo/mdns.go b/internal/clientinfo/mdns.go new file mode 100644 index 0000000..c9d97e5 --- /dev/null +++ b/internal/clientinfo/mdns.go @@ -0,0 +1,209 @@ +package clientinfo + +import ( + "context" + "errors" + "net" + "os" + "sync" + "syscall" + "time" + + "github.com/miekg/dns" + "tailscale.com/logtail/backoff" + + "github.com/Control-D-Inc/ctrld" + ctrldnet "github.com/Control-D-Inc/ctrld/internal/net" +) + +var ( + mdnsV4Addr = &net.UDPAddr{ + IP: net.ParseIP("224.0.0.251"), + Port: 5353, + } + mdnsV6Addr = &net.UDPAddr{ + IP: net.ParseIP("ff02::fb"), + Port: 5353, + } +) + +type mdns struct { + name sync.Map // ip => hostname +} + +func (m *mdns) LookupHostnameByIP(ip string) string { + val, ok := m.name.Load(ip) + if !ok { + return "" + } + return val.(string) +} + +func (m *mdns) LookupHostnameByMac(mac string) string { + return "" +} + +func (m *mdns) String() string { + return "mdns" +} + +func (m *mdns) List() []string { + var ips []string + m.name.Range(func(key, value any) bool { + ips = append(ips, key.(string)) + return true + }) + return ips +} + +func (m *mdns) init(quitCh chan struct{}) error { + ifaces, err := multicastInterfaces() + if err != nil { + return err + } + + v4ConnList := make([]*net.UDPConn, 0, len(ifaces)) + v6ConnList := make([]*net.UDPConn, 0, len(ifaces)) + for _, iface := range ifaces { + if iface.Flags&net.FlagLoopback != 0 { + continue + } + if conn, err := net.ListenMulticastUDP("udp4", &iface, mdnsV4Addr); err == nil { + v4ConnList = append(v4ConnList, conn) + go m.readLoop(conn) + } + if ctrldnet.IPv6Available(context.Background()) { + if conn, err := net.ListenMulticastUDP("udp6", &iface, mdnsV6Addr); err == nil { + v6ConnList = append(v6ConnList, conn) + go m.readLoop(conn) + } + } + } + + go m.probeLoop(v4ConnList, mdnsV4Addr, quitCh) + go m.probeLoop(v6ConnList, mdnsV6Addr, quitCh) + + return nil +} + +// probeLoop performs mdns probe actively to get hostname updates. +func (m *mdns) probeLoop(conns []*net.UDPConn, remoteAddr net.Addr, quitCh chan struct{}) { + bo := backoff.NewBackoff("mdns probe", func(format string, args ...any) {}, time.Second*30) + for { + err := m.probe(conns, remoteAddr) + if isErrNetUnreachableOrInvalid(err) { + ctrld.ProxyLogger.Load().Warn().Msgf("stop probing %q: network unreachable or invalid", remoteAddr) + break + } + if err != nil { + ctrld.ProxyLogger.Load().Warn().Err(err).Msg("error while probing mdns") + bo.BackOff(context.Background(), errors.New("mdns probe backoff")) + continue + } + break + } + <-quitCh + for _, conn := range conns { + _ = conn.Close() + } +} + +// readLoop reads from mdns connection, save/update any hostnames found. +func (m *mdns) readLoop(conn *net.UDPConn) { + defer conn.Close() + buf := make([]byte, dns.MaxMsgSize) + + for { + _ = conn.SetReadDeadline(time.Now().Add(time.Second * 30)) + n, _, err := conn.ReadFromUDP(buf) + if err != nil { + if err, ok := err.(*net.OpError); ok && (err.Timeout() || err.Temporary()) { + continue + } + ctrld.ProxyLogger.Load().Debug().Err(err).Msg("mdns readLoop error") + return + } + + var msg dns.Msg + if err := msg.Unpack(buf[:n]); err != nil { + continue + } + + var ip, name string + rrs := make([]dns.RR, 0, len(msg.Answer)+len(msg.Extra)) + rrs = append(rrs, msg.Answer...) + rrs = append(rrs, msg.Extra...) + for _, rr := range rrs { + switch ar := rr.(type) { + case *dns.A: + ip, name = ar.A.String(), ar.Hdr.Name + case *dns.AAAA: + ip, name = ar.AAAA.String(), ar.Hdr.Name + } + if ip != "" && name != "" { + name = normalizeHostname(name) + if val, loaded := m.name.LoadOrStore(ip, name); !loaded { + ctrld.ProxyLogger.Load().Debug().Msgf("found hostname: %q, ip: %q via mdns", name, ip) + } else { + old := val.(string) + if old != name { + ctrld.ProxyLogger.Load().Debug().Msgf("update hostname: %q, ip: %q, old: %q via mdns", name, ip, old) + m.name.Store(ip, name) + } + } + ip, name = "", "" + } + } + } +} + +// probe performs mdns queries with known services. +func (m *mdns) probe(conns []*net.UDPConn, remoteAddr net.Addr) error { + msg := new(dns.Msg) + msg.Question = make([]dns.Question, len(services)) + msg.Compress = true + for i, service := range services { + msg.Question[i] = dns.Question{ + Name: dns.CanonicalName(service), + Qtype: dns.TypePTR, + Qclass: dns.ClassINET, + } + } + + buf, err := msg.Pack() + if err != nil { + return err + } + for _, conn := range conns { + _ = conn.SetWriteDeadline(time.Now().Add(time.Second * 30)) + if _, werr := conn.WriteTo(buf, remoteAddr); werr != nil { + err = werr + } + } + return err +} + +func multicastInterfaces() ([]net.Interface, error) { + ifaces, err := net.Interfaces() + if err != nil { + return nil, err + } + interfaces := make([]net.Interface, 0, len(ifaces)) + for _, ifi := range ifaces { + if (ifi.Flags & net.FlagUp) == 0 { + continue + } + if (ifi.Flags & net.FlagMulticast) > 0 { + interfaces = append(interfaces, ifi) + } + } + return interfaces, nil +} + +func isErrNetUnreachableOrInvalid(err error) bool { + var se *os.SyscallError + if errors.As(err, &se) { + return se.Err == syscall.ENETUNREACH || se.Err == syscall.EINVAL + } + return false +} diff --git a/internal/clientinfo/mdns_services.go b/internal/clientinfo/mdns_services.go new file mode 100644 index 0000000..d7869c8 --- /dev/null +++ b/internal/clientinfo/mdns_services.go @@ -0,0 +1,70 @@ +package clientinfo + +var services = [...]string{ + // From: https://jonathanmumm.com/tech-it/mdns-bonjour-bible-common-service-strings-for-various-vendors/ + "_afpovertcp._tcp.local.", + "_airdroid._tcp.local.", + "_airdrop._tcp.local.", + "_airplay._tcp.local.", + "_airport._tcp.local.", + "_amzn-wplay._tcp.local.", + "_sub._apple-mobdev2._tcp.local.", + "_apple-mobdev2._tcp.local.", + "_apple-sasl._tcp.local.", + "_atc._tcp.local.", + "_sketchmirror._tcp.local.", + "_bp2p._tcp.local.", + "_Friendly._sub._bp2p._tcp.local.", + "_invoke._sub._bp2p._tcp.local.", + "_webdav._sub._bp2p._tcp.local.", + "_device-info._tcp.local.", + "_distcc._tcp.local.", + "_dpap._tcp.local.", + "_eppc._tcp.local.", + "_esdevice._tcp.local.", + "_esfileshare._tcp.local.", + "_ftp._tcp.local.", + "_googlecast._tcp.local.", + "_googlezone._tcp.local.", + "_hap._tcp.local.", + "_homekit._tcp.local.", + "_home-sharing._tcp.local.", + "_http._tcp.local.", + "_hudson._tcp.local.", + "_ica-networking._tcp.local.", + "_print._sub._ipp._tcp.local.", + "_cups._sub._ipps._tcp.local.", + "_print._sub._ipps._tcp.local.", + "_jenkins._tcp.local.", + "_KeynoteControl._tcp.local.", + "_keynotepair._tcp.local.", + "_mediaremotetv._tcp.local.", + "_nfs._tcp.local.", + "_nvstream._tcp.local.", + "_androidtvremote._tcp.local.", + "_omnistate._tcp.local.", + "_photoshopserver._tcp.local.", + "_printer._tcp.local.", + "_raop._tcp.local.", + "_readynas._tcp.local.", + "_rfb._tcp.local.", + "_physicalweb._tcp.local.", + "_rsp._tcp.local.", + "_scanner._tcp.local.", + "_sftp-ssh._tcp.local.", + "_sleep-proxy._udp.local.", + "_smb._tcp.local.", + "_spotify-connect._tcp.local.", + "_ssh._tcp.local.", + "_teamviewer._tcp.local.", + "_telnet._tcp.local.", + "_touch-able._tcp.local.", + "_tunnel._tcp.local.", + "_webdav._tcp.local.", + "_webdav._tcp.local.", + "_workstation._tcp.local.", + "_xserveraid._tcp.local.", + + // Merlin + "_alexa._tcp", +} diff --git a/internal/clientinfo/merlin.go b/internal/clientinfo/merlin.go new file mode 100644 index 0000000..8a39398 --- /dev/null +++ b/internal/clientinfo/merlin.go @@ -0,0 +1,71 @@ +package clientinfo + +import ( + "strings" + "sync" + + "github.com/Control-D-Inc/ctrld/internal/router" + "github.com/Control-D-Inc/ctrld/internal/router/merlin" + + "github.com/Control-D-Inc/ctrld" + "github.com/Control-D-Inc/ctrld/internal/router/nvram" +) + +const merlinNvramCustomClientListKey = "custom_clientlist" + +type merlinDiscover struct { + hostname sync.Map // mac => hostname +} + +func (m *merlinDiscover) refresh() error { + if router.Name() != merlin.Name { + return nil + } + out, err := nvram.Run("get", merlinNvramCustomClientListKey) + if err != nil { + return err + } + ctrld.ProxyLogger.Load().Debug().Msg("reading Merlin custom client list") + m.parseMerlinCustomClientList(out) + return nil +} + +func (m *merlinDiscover) LookupHostnameByIP(ip string) string { + return "" +} + +func (m *merlinDiscover) LookupHostnameByMac(mac string) string { + val, ok := m.hostname.Load(mac) + if !ok { + return "" + } + return val.(string) +} + +// "nvram get custom_clientlist" output: +// +// 00:00:00:00:00:01>0>4>>00:00:00:00:00:02>0>24>>... +// +// So to parse it, do the following steps: +// +// - Split by "<" => entries +// - For each entry, split by ">" => parts +// - Empty parts => skip +// - Empty parts[0] => skip empty hostname +// - Empty parts[1] => skip empty MAC +func (m *merlinDiscover) parseMerlinCustomClientList(data string) { + entries := strings.Split(data, "<") + for _, entry := range entries { + parts := strings.SplitN(string(entry), ">", 3) + if len(parts) < 2 || len(parts[0]) == 0 || len(parts[1]) == 0 { + continue + } + hostname := normalizeHostname(parts[0]) + mac := strings.ToLower(parts[1]) + m.hostname.Store(mac, hostname) + } +} + +func (m *merlinDiscover) String() string { + return "merlin" +} diff --git a/internal/clientinfo/merlin_test.go b/internal/clientinfo/merlin_test.go new file mode 100644 index 0000000..0437035 --- /dev/null +++ b/internal/clientinfo/merlin_test.go @@ -0,0 +1,82 @@ +package clientinfo + +import ( + "testing" +) + +func TestParseMerlinCustomClientList(t *testing.T) { + tests := []struct { + name string + clientList string + macList []string + hostnameList []string + macNotPresentList []string + }{ + { + "normal", + "00:00:00:00:00:01>0>4>>", + []string{"00:00:00:00:00:01"}, + []string{"client1"}, + nil, + }, + { + "multiple clients", + "00:00:00:00:00:01>0>4>>00:00:00:00:00:02>0>24>>", + []string{"00:00:00:00:00:01", "00:00:00:00:00:02"}, + []string{"client1", "client2"}, + nil, + }, + { + "empty hostname", + "00:00:00:00:00:01>0>4>><>00:00:00:00:00:02>0>24>>", + []string{"00:00:00:00:00:01"}, + []string{"client1"}, + []string{"00:00:00:00:00:02"}, + }, + { + "empty dhcp", + "00:00:00:00:00:01>0>4>>>>", + []string{"00:00:00:00:00:01"}, + []string{"client1"}, + []string{""}, + }, + { + "invalid", + "qwerty", + nil, + nil, + nil, + }, + { + "empty", + "", + + nil, + nil, + nil, + }, + } + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + m := &merlinDiscover{} + m.parseMerlinCustomClientList(tc.clientList) + for i, mac := range tc.macList { + val, ok := m.hostname.Load(mac) + if !ok { + t.Errorf("missing hostname: %s", mac) + } + hostname := val.(string) + if hostname != tc.hostnameList[i] { + t.Errorf("hostname mismatch, want: %q, got: %q", tc.hostnameList[i], hostname) + } + } + for _, mac := range tc.macNotPresentList { + if _, ok := m.hostname.Load(mac); ok { + t.Errorf("mac2name address %q should not be present", mac) + } + } + }) + } +} diff --git a/internal/clientinfo/ptr_lookup.go b/internal/clientinfo/ptr_lookup.go new file mode 100644 index 0000000..9c02fa1 --- /dev/null +++ b/internal/clientinfo/ptr_lookup.go @@ -0,0 +1,82 @@ +package clientinfo + +import ( + "context" + "sync" + "time" + + "github.com/miekg/dns" + + "github.com/Control-D-Inc/ctrld" +) + +type ptrDiscover struct { + hostname sync.Map // ip => hostname + resolver ctrld.Resolver +} + +func (p *ptrDiscover) refresh() error { + p.hostname.Range(func(key, value any) bool { + ip := key.(string) + if name := p.lookupHostname(ip); name != "" { + p.hostname.Store(ip, name) + } + return true + }) + return nil +} + +func (p *ptrDiscover) LookupHostnameByIP(ip string) string { + if val, ok := p.hostname.Load(ip); ok { + return val.(string) + } + return p.lookupHostname(ip) +} +func (p *ptrDiscover) LookupHostnameByMac(mac string) string { + return "" +} + +func (p *ptrDiscover) String() string { + return "ptr" +} + +func (p *ptrDiscover) List() []string { + var ips []string + p.hostname.Range(func(key, value any) bool { + ips = append(ips, key.(string)) + return true + }) + return ips +} + +func (p *ptrDiscover) lookupHostnameFromCache(ip string) string { + if val, ok := p.hostname.Load(ip); ok { + return val.(string) + } + return "" +} + +func (p *ptrDiscover) lookupHostname(ip string) string { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + msg := new(dns.Msg) + addr, err := dns.ReverseAddr(ip) + if err != nil { + ctrld.ProxyLogger.Load().Error().Err(err).Msg("invalid ip address") + return "" + } + msg.SetQuestion(addr, dns.TypePTR) + ans, err := p.resolver.Resolve(ctx, msg) + if err != nil { + ctrld.ProxyLogger.Load().Error().Err(err).Msg("could not lookup IP") + return "" + } + for _, rr := range ans.Answer { + if ptr, ok := rr.(*dns.PTR); ok { + hostname := normalizeHostname(ptr.Ptr) + p.hostname.Store(ip, hostname) + return hostname + } + } + return "" +} diff --git a/internal/controld/config.go b/internal/controld/config.go index eef98f9..4e4bc2e 100644 --- a/internal/controld/config.go +++ b/internal/controld/config.go @@ -6,14 +6,18 @@ import ( "crypto/tls" "encoding/json" "fmt" + "io" "net" "net/http" + "os" + "strings" "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" ) const ( @@ -31,6 +35,7 @@ type ResolverConfig struct { CustomConfig string `json:"custom_config"` } `json:"ctrld"` Exclude []string `json:"exclude"` + UID string `json:"uid"` } type utilityResponse struct { @@ -52,17 +57,39 @@ func (u UtilityErrorResponse) Error() string { } type utilityRequest struct { - UID string `json:"uid"` + UID string `json:"uid"` + ClientID string `json:"client_id,omitempty"` +} + +type utilityOrgRequest struct { + ProvToken string `json:"prov_token"` + Hostname string `json:"hostname"` } // FetchResolverConfig fetch Control D config for given uid. -func FetchResolverConfig(uid, version string, cdDev bool) (*ResolverConfig, error) { - body, _ := json.Marshal(utilityRequest{UID: uid}) +func FetchResolverConfig(rawUID, version string, cdDev bool) (*ResolverConfig, error) { + uid, clientID := ParseRawUID(rawUID) + req := utilityRequest{UID: uid} + if clientID != "" { + req.ClientID = clientID + } + body, _ := json.Marshal(req) + return postUtilityAPI(version, cdDev, bytes.NewReader(body)) +} + +// FetchResolverUID fetch resolver uid from provision token. +func FetchResolverUID(pt, version string, cdDev bool) (*ResolverConfig, error) { + hostname, _ := os.Hostname() + body, _ := json.Marshal(utilityOrgRequest{ProvToken: pt, Hostname: hostname}) + return postUtilityAPI(version, cdDev, bytes.NewReader(body)) +} + +func postUtilityAPI(version string, cdDev bool, body io.Reader) (*ResolverConfig, error) { apiUrl := resolverDataURLCom if cdDev { apiUrl = resolverDataURLDev } - req, err := http.NewRequest("POST", apiUrl, bytes.NewReader(body)) + req, err := http.NewRequest("POST", apiUrl, body) if err != nil { return nil, fmt.Errorf("http.NewRequest: %w", err) } @@ -79,10 +106,10 @@ func FetchResolverConfig(uid, version string, cdDev bool) (*ResolverConfig, erro } ips := ctrld.LookupIP(apiDomain) if len(ips) == 0 { - ctrld.ProxyLog.Warn().Msgf("No IPs found for %s, connecting to %s", apiDomain, addr) + ctrld.ProxyLogger.Load().Warn().Msgf("No IPs found for %s, connecting to %s", apiDomain, addr) return ctrldnet.Dialer.DialContext(ctx, network, addr) } - ctrld.ProxyLog.Debug().Msgf("API IPs: %v", ips) + ctrld.ProxyLogger.Load().Debug().Msgf("API IPs: %v", ips) _, port, _ := net.SplitHostPort(addr) addrs := make([]string, len(ips)) for i := range ips { @@ -92,7 +119,7 @@ func FetchResolverConfig(uid, version string, cdDev bool) (*ResolverConfig, erro return d.DialContext(ctx, network, addrs) } - if router.Name() == router.DDWrt { + if router.Name() == ddwrt.Name { transport.TLSClientConfig = &tls.Config{RootCAs: certs.CACertPool()} } client := http.Client{ @@ -119,3 +146,13 @@ func FetchResolverConfig(uid, version string, cdDev bool) (*ResolverConfig, erro } return &ur.Body.Resolver, nil } + +// ParseRawUID parse the input raw UID, returning real UID and ClientID. +// The raw UID can have 2 forms: +// +// - +// - / +func ParseRawUID(rawUID string) (string, string) { + uid, clientID, _ := strings.Cut(rawUID, "/") + return uid, clientID +} diff --git a/internal/controld/config_test.go b/internal/controld/config_test.go index 2c00247..b266142 100644 --- a/internal/controld/config_test.go +++ b/internal/controld/config_test.go @@ -1,34 +1,31 @@ -//go:build controld - package controld import ( "testing" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) -func TestFetchResolverConfig(t *testing.T) { +func Test_parseUID(t *testing.T) { tests := []struct { - name string - uid string - dev bool - wantErr bool + name string + uid string + wantUID string + wantClientID string }{ - {"valid com", "p2", false, false}, - {"valid dev", "p2", true, false}, - {"invalid uid", "abcd1234", false, true}, + {"empty", "", "", ""}, + {"only uid", "abcd1234", "abcd1234", ""}, + {"with client id", "abcd1234/clientID", "abcd1234", "clientID"}, + {"with empty clientID", "abcd1234/", "abcd1234", ""}, } + for _, tc := range tests { tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() - got, err := FetchResolverConfig(tc.uid, "dev-test", tc.dev) - require.False(t, (err != nil) != tc.wantErr, err) - if !tc.wantErr { - assert.NotEmpty(t, got.DOH) - } + gotUID, gotClientID := ParseRawUID(tc.uid) + assert.Equal(t, tc.wantUID, gotUID) + assert.Equal(t, tc.wantClientID, gotClientID) }) } } diff --git a/internal/controld/controld_test.go b/internal/controld/controld_test.go new file mode 100644 index 0000000..2c00247 --- /dev/null +++ b/internal/controld/controld_test.go @@ -0,0 +1,34 @@ +//go:build controld + +package controld + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFetchResolverConfig(t *testing.T) { + tests := []struct { + name string + uid string + dev bool + wantErr bool + }{ + {"valid com", "p2", false, false}, + {"valid dev", "p2", true, false}, + {"invalid uid", "abcd1234", false, true}, + } + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got, err := FetchResolverConfig(tc.uid, "dev-test", tc.dev) + require.False(t, (err != nil) != tc.wantErr, err) + if !tc.wantErr { + assert.NotEmpty(t, got.DOH) + } + }) + } +} diff --git a/internal/dns/direct.go b/internal/dns/direct.go index e11be05..a825e6d 100644 --- a/internal/dns/direct.go +++ b/internal/dns/direct.go @@ -2,7 +2,8 @@ // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. -//lint:file-ignore U1000 satisfy CI. +//lint:file-ignore U1000 Ignore, this file is forked from upstream code. +//lint:file-ignore ST1005 Ignore, this file is forked from upstream code. package dns diff --git a/internal/dns/manager_linux.go b/internal/dns/manager_linux.go index 1fa1650..2886090 100644 --- a/internal/dns/manager_linux.go +++ b/internal/dns/manager_linux.go @@ -2,6 +2,8 @@ // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. +//lint:file-ignore U1000 Ignore this file, it's a copy. + package dns import ( diff --git a/internal/dns/nm.go b/internal/dns/nm.go index a8ea923..b8bc0c7 100644 --- a/internal/dns/nm.go +++ b/internal/dns/nm.go @@ -267,5 +267,5 @@ func (m *nmManager) Close() error { } func (m *nmManager) Mode() string { - return "network-maanger" + return "network-manager" } diff --git a/internal/net/net.go b/internal/net/net.go index 1c43bbb..5f2c509 100644 --- a/internal/net/net.go +++ b/internal/net/net.go @@ -4,8 +4,11 @@ import ( "context" "errors" "net" + "os" + "os/signal" "sync" "sync/atomic" + "syscall" "time" "tailscale.com/logtail/backoff" @@ -13,17 +16,17 @@ import ( const ( controldIPv6Test = "ipv6.controld.io" - bootstrapDNS = "76.76.2.0:53" + v4BootstrapDNS = "76.76.2.0:53" + v6BootstrapDNS = "[2606:1a40::]:53" ) var Dialer = &net.Dialer{ Resolver: &net.Resolver{ PreferGo: true, Dial: func(ctx context.Context, network, address string) (net.Conn, error) { - d := net.Dialer{ - Timeout: 10 * time.Second, - } - return d.DialContext(ctx, "udp", bootstrapDNS) + d := ParallelDialer{} + d.Timeout = 10 * time.Second + return d.DialContext(ctx, "udp", []string{v4BootstrapDNS, v6BootstrapDNS}) }, }, } @@ -59,14 +62,32 @@ func supportListenIPv6Local() bool { } func probeStack() { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go func() { + sigs := make(chan os.Signal, 1) + signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) + <-sigs + cancel() + }() + b := backoff.NewBackoff("probeStack", func(format string, args ...any) {}, 5*time.Second) for { - if _, err := probeStackDialer.Dial("udp", bootstrapDNS); err == nil { + if _, err := probeStackDialer.DialContext(ctx, "udp", v4BootstrapDNS); err == nil { hasNetworkUp = true break - } else { - b.BackOff(context.Background(), err) } + if _, err := probeStackDialer.DialContext(ctx, "udp", v6BootstrapDNS); err == nil { + hasNetworkUp = true + break + } + select { + case <-ctx.Done(): + return + default: + } + b.BackOff(context.Background(), errors.New("network is down")) } canListenIPv6Local = supportListenIPv6Local() } @@ -110,6 +131,8 @@ func (d *ParallelDialer) DialContext(ctx context.Context, network string, addrs ctx, cancel := context.WithCancel(ctx) defer cancel() + done := make(chan struct{}) + defer close(done) ch := make(chan *parallelDialerResult, len(addrs)) var wg sync.WaitGroup wg.Add(len(addrs)) @@ -122,7 +145,13 @@ func (d *ParallelDialer) DialContext(ctx context.Context, network string, addrs go func(addr string) { defer wg.Done() conn, err := d.Dialer.DialContext(ctx, network, addr) - ch <- ¶llelDialerResult{conn: conn, err: err} + select { + case ch <- ¶llelDialerResult{conn: conn, err: err}: + case <-done: + if conn != nil { + conn.Close() + } + } }(addr) } @@ -134,6 +163,5 @@ func (d *ParallelDialer) DialContext(ctx context.Context, network string, addrs } errs = append(errs, res.err) } - return nil, errors.Join(errs...) } diff --git a/internal/router/client_info.go b/internal/router/client_info.go deleted file mode 100644 index 8fc5709..0000000 --- a/internal/router/client_info.go +++ /dev/null @@ -1,193 +0,0 @@ -package router - -import ( - "bufio" - "bytes" - "io" - "log" - "net" - "os" - "strings" - "time" - - "github.com/fsnotify/fsnotify" - "tailscale.com/util/lineread" - - "github.com/Control-D-Inc/ctrld" -) - -// readClientInfoFunc represents the function for reading client info. -type readClientInfoFunc func(name string) error - -// clientInfoFiles specifies client info files and how to read them on supported platforms. -var clientInfoFiles = map[string]readClientInfoFunc{ - "/tmp/dnsmasq.leases": dnsmasqReadClientInfoFile, // ddwrt - "/tmp/dhcp.leases": dnsmasqReadClientInfoFile, // openwrt - "/var/lib/misc/dnsmasq.leases": dnsmasqReadClientInfoFile, // merlin - "/mnt/data/udapi-config/dnsmasq.lease": dnsmasqReadClientInfoFile, // UDM Pro - "/data/udapi-config/dnsmasq.lease": dnsmasqReadClientInfoFile, // UDR - "/etc/dhcpd/dhcpd-leases.log": dnsmasqReadClientInfoFile, // Synology - "/tmp/var/lib/misc/dnsmasq.leases": dnsmasqReadClientInfoFile, // Tomato - "/run/dnsmasq-dhcp.leases": dnsmasqReadClientInfoFile, // EdgeOS - "/run/dhcpd.leases": iscDHCPReadClientInfoFile, // EdgeOS - "/var/dhcpd/var/db/dhcpd.leases": iscDHCPReadClientInfoFile, // Pfsense -} - -// watchClientInfoTable watches changes happens in dnsmasq/dhcpd -// lease files, perform updating to mac table if necessary. -func (r *router) watchClientInfoTable() { - if r.watcher == nil { - return - } - timer := time.NewTicker(time.Minute * 5) - for { - select { - case <-timer.C: - for _, name := range r.watcher.WatchList() { - _ = clientInfoFiles[name](name) - } - case event, ok := <-r.watcher.Events: - if !ok { - return - } - if event.Has(fsnotify.Write) { - readFunc := clientInfoFiles[event.Name] - if readFunc == nil { - log.Println("unknown file format:", event.Name) - continue - } - if err := readFunc(event.Name); err != nil && !os.IsNotExist(err) { - log.Println("could not read client info file:", err) - } - } - case err, ok := <-r.watcher.Errors: - if !ok { - return - } - log.Println("error:", err) - } - } -} - -// Stop performs tasks need to be done before the router stopped. -func Stop() error { - if Name() == "" { - return nil - } - r := routerPlatform.Load() - if r.watcher != nil { - if err := r.watcher.Close(); err != nil { - return err - } - } - return nil -} - -// GetClientInfoByMac returns ClientInfo for the client associated with the given mac. -func GetClientInfoByMac(mac string) *ctrld.ClientInfo { - if mac == "" { - return nil - } - _ = Name() - r := routerPlatform.Load() - val, ok := r.mac.Load(mac) - if !ok { - return nil - } - return val.(*ctrld.ClientInfo) -} - -// dnsmasqReadClientInfoFile populates mac table with client info reading from dnsmasq lease file. -func dnsmasqReadClientInfoFile(name string) error { - f, err := os.Open(name) - if err != nil { - return err - } - defer f.Close() - return dnsmasqReadClientInfoReader(f) - -} - -// dnsmasqReadClientInfoReader likes dnsmasqReadClientInfoFile, but reading from an io.Reader instead of file. -func dnsmasqReadClientInfoReader(reader io.Reader) error { - r := routerPlatform.Load() - return lineread.Reader(reader, func(line []byte) error { - fields := bytes.Fields(line) - if len(fields) < 4 { - return nil - } - mac := string(fields[1]) - if _, err := net.ParseMAC(mac); err != nil { - // The second field is not a mac, skip. - return nil - } - ip := normalizeIP(string(fields[2])) - if net.ParseIP(ip) == nil { - log.Printf("invalid ip address entry: %q", ip) - ip = "" - } - hostname := string(fields[3]) - r.mac.Store(mac, &ctrld.ClientInfo{Mac: mac, IP: ip, Hostname: hostname}) - return nil - }) -} - -// iscDHCPReadClientInfoFile populates mac table with client info reading from isc-dhcpd lease file. -func iscDHCPReadClientInfoFile(name string) error { - f, err := os.Open(name) - if err != nil { - return err - } - defer f.Close() - return iscDHCPReadClientInfoReader(f) -} - -// iscDHCPReadClientInfoReader likes iscDHCPReadClientInfoFile, but reading from an io.Reader instead of file. -func iscDHCPReadClientInfoReader(reader io.Reader) error { - r := routerPlatform.Load() - s := bufio.NewScanner(reader) - var ip, mac, hostname string - for s.Scan() { - line := s.Text() - if strings.HasPrefix(line, "}") { - if mac != "" { - r.mac.Store(mac, &ctrld.ClientInfo{Mac: mac, IP: ip, Hostname: hostname}) - ip, mac, hostname = "", "", "" - } - continue - } - fields := strings.Fields(line) - if len(fields) < 2 { - continue - } - switch fields[0] { - case "lease": - ip = normalizeIP(strings.ToLower(fields[1])) - if net.ParseIP(ip) == nil { - log.Printf("invalid ip address entry: %q", ip) - ip = "" - } - case "hardware": - if len(fields) >= 3 { - mac = strings.ToLower(strings.TrimRight(fields[2], ";")) - if _, err := net.ParseMAC(mac); err != nil { - // Invalid mac, skip. - mac = "" - } - } - case "client-hostname": - hostname = strings.Trim(fields[1], `";`) - } - } - return nil -} - -// normalizeIP normalizes the ip parsed from dnsmasq/dhcpd lease file. -func normalizeIP(in string) string { - // dnsmasq may put ip with interface index in lease file, strip it here. - ip, _, found := strings.Cut(in, "%") - if found { - return ip - } - return in -} diff --git a/internal/router/client_info_test.go b/internal/router/client_info_test.go deleted file mode 100644 index fac801c..0000000 --- a/internal/router/client_info_test.go +++ /dev/null @@ -1,107 +0,0 @@ -package router - -import ( - "io" - "strings" - "testing" - - "github.com/Control-D-Inc/ctrld" -) - -func Test_normalizeIP(t *testing.T) { - tests := []struct { - name string - in string - want string - }{ - {"v4", "127.0.0.1", "127.0.0.1"}, - {"v4 with index", "127.0.0.1%lo", "127.0.0.1"}, - {"v6", "fe80::1", "fe80::1"}, - {"v6 with index", "fe80::1%22002", "fe80::1"}, - } - for _, tc := range tests { - tc := tc - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - if got := normalizeIP(tc.in); got != tc.want { - t.Errorf("normalizeIP() = %v, want %v", got, tc.want) - } - }) - } -} - -func Test_readClientInfoReader(t *testing.T) { - tests := []struct { - name string - in string - readFunc func(r io.Reader) error - mac string - }{ - { - "good dnsmasq", - `1683329857 e6:20:59:b8:c1:6d 192.168.1.186 * 01:e6:20:59:b8:c1:6d -`, - dnsmasqReadClientInfoReader, - "e6:20:59:b8:c1:6d", - }, - { - "bad dnsmasq seen on UDMdream machine", - `1683329857 e6:20:59:b8:c1:6e 192.168.1.111 * 01:e6:20:59:b8:c1:6e -duid 00:01:00:01:2b:e4:2e:2c:52:52:14:26:dc:1c -1683322985 117442354 2600:4040:b0e6:b700::111 ASDASD 00:01:00:01:2a:d0:b9:81:00:07:32:4c:1c:07 -`, - dnsmasqReadClientInfoReader, - "e6:20:59:b8:c1:6e", - }, - { - "isc-dhcpd good", - `lease 192.168.1.1 { - hardware ethernet 00:00:00:00:00:01; - client-hostname "host-1"; -} -`, - iscDHCPReadClientInfoReader, - "00:00:00:00:00:01", - }, - { - "isc-dhcpd bad mac", - `lease 192.168.1.1 { - hardware ethernet invalid-mac; - client-hostname "host-1"; -} - -lease 192.168.1.2 { - hardware ethernet 00:00:00:00:00:02; - client-hostname "host-2"; -} -`, - iscDHCPReadClientInfoReader, - "00:00:00:00:00:02", - }, - { - "", - `1685794060 00:00:00:00:00:04 192.168.0.209 cuonglm-ThinkPad-X1-Carbon-Gen-9 00:00:00:00:00:04 9`, - dnsmasqReadClientInfoReader, - "00:00:00:00:00:04", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - r := routerPlatform.Load() - r.mac.Delete(tc.mac) - if err := tc.readFunc(strings.NewReader(tc.in)); err != nil { - t.Errorf("readClientInfoReader() error = %v", err) - } - info, existed := r.mac.Load(tc.mac) - if !existed { - t.Error("client info missing") - } - if ci, ok := info.(*ctrld.ClientInfo); ok && existed && ci.Mac != tc.mac { - t.Errorf("mac mismatched, got: %q, want: %q", ci.Mac, tc.mac) - } else { - t.Log(ci) - } - }) - } -} diff --git a/internal/router/ddwrt.go b/internal/router/ddwrt.go deleted file mode 100644 index 92318b1..0000000 --- a/internal/router/ddwrt.go +++ /dev/null @@ -1,72 +0,0 @@ -package router - -import ( - "errors" - "fmt" - "os/exec" -) - -const ( - nvramCtrldKeyPrefix = "ctrld_" - nvramCtrldSetupKey = "ctrld_setup" - nvramCtrldInstallKey = "ctrld_install" - nvramRCStartupKey = "rc_startup" -) - -//lint:ignore ST1005 This error is for human. -var errDdwrtJffs2NotEnabled = errors.New(`could not install service without jffs, follow this guide to enable: - -https://wiki.dd-wrt.com/wiki/index.php/Journalling_Flash_File_System -`) - -func setupDDWrt() error { - // Already setup. - if val, _ := nvram("get", nvramCtrldSetupKey); val == "1" { - return nil - } - - data, err := dnsMasqConf() - if err != nil { - return err - } - - nvramKvMap := nvramSetupKV() - nvramKvMap["dnsmasq_options"] = data - if err := nvramSetKV(nvramKvMap, nvramCtrldSetupKey); err != nil { - return err - } - - // Restart dnsmasq service. - if err := restartDNSMasq(); err != nil { - return err - } - return nil -} - -func cleanupDDWrt() error { - // Restore old configs. - if err := nvramRestore(nvramSetupKV(), nvramCtrldSetupKey); err != nil { - return err - } - // Restart dnsmasq service. - if err := restartDNSMasq(); err != nil { - return err - } - return nil -} - -func postInstallDDWrt() error { - return nil -} - -func ddwrtRestartDNSMasq() error { - if out, err := exec.Command("restart_dns").CombinedOutput(); err != nil { - return fmt.Errorf("restart_dns: %s, %w", string(out), err) - } - return nil -} - -func ddwrtJff2Enabled() bool { - out, _ := nvram("get", "enable_jffs2") - return out == "1" -} diff --git a/internal/router/ddwrt/ddwrt.go b/internal/router/ddwrt/ddwrt.go new file mode 100644 index 0000000..edd7e6b --- /dev/null +++ b/internal/router/ddwrt/ddwrt.go @@ -0,0 +1,117 @@ +package ddwrt + +import ( + "errors" + "fmt" + "os/exec" + + "github.com/kardianos/service" + + "github.com/Control-D-Inc/ctrld" + "github.com/Control-D-Inc/ctrld/internal/router/dnsmasq" + "github.com/Control-D-Inc/ctrld/internal/router/ntp" + "github.com/Control-D-Inc/ctrld/internal/router/nvram" +) + +const Name = "ddwrt" + +//lint:ignore ST1005 This error is for human. +var errDdwrtJffs2NotEnabled = errors.New(`could not install service without jffs, follow this guide to enable: + +https://wiki.dd-wrt.com/wiki/index.php/Journalling_Flash_File_System +`) + +var nvramKvMap = map[string]string{ + "dns_dnsmasq": "1", // Make dnsmasq running but disable DNS ability, ctrld will replace it. + "dnsmasq_options": "", // Configuration of dnsmasq set by ctrld, filled by setupDDWrt. + "dns_crypt": "0", // Disable DNSCrypt. + "dnssec": "0", // Disable DNSSEC. +} + +type Ddwrt struct { + cfg *ctrld.Config +} + +// New returns a router.Router for configuring/setup/run ctrld on ddwrt routers. +func New(cfg *ctrld.Config) *Ddwrt { + return &Ddwrt{cfg: cfg} +} + +func (d *Ddwrt) ConfigureService(config *service.Config) error { + if !ddwrtJff2Enabled() { + return errDdwrtJffs2NotEnabled + } + return nil +} + +func (d *Ddwrt) Install(_ *service.Config) error { + return nil +} + +func (d *Ddwrt) Uninstall(_ *service.Config) error { + return nil +} + +func (d *Ddwrt) PreRun() error { + _ = d.Cleanup() + return ntp.WaitNvram() +} + +func (d *Ddwrt) Setup() error { + if d.cfg.FirstListener().IsDirectDnsListener() { + return nil + } + // Already setup. + if val, _ := nvram.Run("get", nvram.CtrldSetupKey); val == "1" { + return nil + } + + data, err := dnsmasq.ConfTmpl(dnsmasq.ConfigContentTmpl, d.cfg) + if err != nil { + return err + } + + nvramKvMap["dnsmasq_options"] = data + if err := nvram.SetKV(nvramKvMap, nvram.CtrldSetupKey); err != nil { + return err + } + + // Restart dnsmasq service. + if err := restartDNSMasq(); err != nil { + return err + } + return nil +} + +func (d *Ddwrt) Cleanup() error { + if d.cfg.FirstListener().IsDirectDnsListener() { + return nil + } + if val, _ := nvram.Run("get", nvram.CtrldSetupKey); val != "1" { + return nil // was restored, nothing to do. + } + + nvramKvMap["dnsmasq_options"] = "" + // Restore old configs. + if err := nvram.Restore(nvramKvMap, nvram.CtrldSetupKey); err != nil { + return err + } + + // Restart dnsmasq service. + if err := restartDNSMasq(); err != nil { + return err + } + return nil +} + +func restartDNSMasq() error { + if out, err := exec.Command("restart_dns").CombinedOutput(); err != nil { + return fmt.Errorf("restart_dns: %s, %w", string(out), err) + } + return nil +} + +func ddwrtJff2Enabled() bool { + out, _ := nvram.Run("get", "enable_jffs2") + return out == "1" +} diff --git a/internal/router/dnsmasq.go b/internal/router/dnsmasq.go deleted file mode 100644 index 4d43d20..0000000 --- a/internal/router/dnsmasq.go +++ /dev/null @@ -1,87 +0,0 @@ -package router - -import ( - "strings" - "text/template" -) - -const dnsMasqConfigContentTmpl = `# GENERATED BY ctrld - DO NOT MODIFY -no-resolv -server=127.0.0.1#5354 -{{- if .SendClientInfo}} -add-mac -{{- end}} -` - -const merlinDNSMasqPostConfPath = "/jffs/scripts/dnsmasq.postconf" -const merlinDNSMasqPostConfMarker = `# GENERATED BY ctrld - EOF` - -const merlinDNSMasqPostConfTmpl = `# GENERATED BY ctrld - DO NOT MODIFY - -#!/bin/sh - -config_file="$1" -. /usr/sbin/helper.sh - -pid=$(cat /tmp/ctrld.pid 2>/dev/null) -if [ -n "$pid" ] && [ -f "/proc/${pid}/cmdline" ]; then - pc_delete "servers-file" "$config_file" # no WAN DNS settings - pc_append "no-resolv" "$config_file" # do not read /etc/resolv.conf - pc_append "server=127.0.0.1#5354" "$config_file" # use ctrld as upstream - {{- if .SendClientInfo}} - pc_append "add-mac" "$config_file" # add client mac - {{- end}} - pc_delete "dnssec" "$config_file" # disable DNSSEC - pc_delete "trust-anchor=" "$config_file" # disable DNSSEC - - # For John fork - pc_delete "resolv-file" "$config_file" # no WAN DNS settings - - # Change /etc/resolv.conf, which may be changed by WAN DNS setup - pc_delete "nameserver" /etc/resolv.conf - pc_append "nameserver 127.0.0.1" /etc/resolv.conf - - exit 0 -fi -` - -func dnsMasqConf() (string, error) { - var sb strings.Builder - var tmplText string - switch Name() { - case EdgeOS, DDWrt, OpenWrt, Ubios, Synology, Tomato: - tmplText = dnsMasqConfigContentTmpl - case Merlin: - tmplText = merlinDNSMasqPostConfTmpl - } - tmpl := template.Must(template.New("").Parse(tmplText)) - var to = &struct { - SendClientInfo bool - }{ - routerPlatform.Load().sendClientInfo, - } - if err := tmpl.Execute(&sb, to); err != nil { - return "", err - } - return sb.String(), nil -} - -func restartDNSMasq() error { - switch Name() { - case EdgeOS: - return edgeOSRestartDNSMasq() - case DDWrt: - return ddwrtRestartDNSMasq() - case Merlin: - return merlinRestartDNSMasq() - case OpenWrt: - return openwrtRestartDNSMasq() - case Ubios: - return ubiosRestartDNSMasq() - case Synology: - return synologyRestartDNSMasq() - case Tomato: - return tomatoRestartService(tomatoDNSMasqSvcName) - } - panic("not supported platform") -} diff --git a/internal/router/dnsmasq/conf.go b/internal/router/dnsmasq/conf.go new file mode 100644 index 0000000..b168042 --- /dev/null +++ b/internal/router/dnsmasq/conf.go @@ -0,0 +1,30 @@ +package dnsmasq + +import ( + "bufio" + "bytes" + "errors" + "io" + "os" + "strings" +) + +func InterfaceNameFromConfig(filename string) (string, error) { + buf, err := os.ReadFile(filename) + if err != nil { + return "", err + } + return interfaceNameFromReader(bytes.NewReader(buf)) +} + +func interfaceNameFromReader(r io.Reader) (string, error) { + scanner := bufio.NewScanner(r) + for scanner.Scan() { + line := scanner.Text() + after, found := strings.CutPrefix(line, "interface=") + if found { + return after, nil + } + } + return "", errors.New("not found") +} diff --git a/internal/router/dnsmasq/conf_test.go b/internal/router/dnsmasq/conf_test.go new file mode 100644 index 0000000..99a0710 --- /dev/null +++ b/internal/router/dnsmasq/conf_test.go @@ -0,0 +1,46 @@ +package dnsmasq + +import ( + "strings" + "testing" +) + +func Test_interfaceNameFromReader(t *testing.T) { + tests := []struct { + name string + in string + wantIface string + }{ + { + "good", + `interface=lo`, + "lo", + }, + { + "multiple", + `interface=lo +interface=eth0 +`, + "lo", + }, + { + "no iface", + `cache-size=100`, + "", + }, + } + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + ifaceName, err := interfaceNameFromReader(strings.NewReader(tc.in)) + if tc.wantIface != "" && err != nil { + t.Errorf("unexpected error: %v", err) + return + } + if tc.wantIface != ifaceName { + t.Errorf("mismatched, want: %q, got: %q", tc.wantIface, ifaceName) + } + }) + } +} diff --git a/internal/router/dnsmasq/dnsmasq.go b/internal/router/dnsmasq/dnsmasq.go new file mode 100644 index 0000000..a25f564 --- /dev/null +++ b/internal/router/dnsmasq/dnsmasq.go @@ -0,0 +1,131 @@ +package dnsmasq + +import ( + "errors" + "html/template" + "net" + "path/filepath" + "strings" + + "github.com/Control-D-Inc/ctrld" +) + +const ConfigContentTmpl = `# GENERATED BY ctrld - DO NOT MODIFY +no-resolv +{{- range .Upstreams}} +server={{ .IP }}#{{ .Port }} +{{- end}} +{{- if .SendClientInfo}} +add-mac +{{- end}} +` + +const MerlinPostConfPath = "/jffs/scripts/dnsmasq.postconf" +const MerlinPostConfMarker = `# GENERATED BY ctrld - EOF` +const MerlinPostConfTmpl = `# GENERATED BY ctrld - DO NOT MODIFY + +#!/bin/sh + +config_file="$1" +. /usr/sbin/helper.sh + +pid=$(cat /tmp/ctrld.pid 2>/dev/null) +if [ -n "$pid" ] && [ -f "/proc/${pid}/cmdline" ]; then + pc_delete "servers-file" "$config_file" # no WAN DNS settings + pc_append "no-resolv" "$config_file" # do not read /etc/resolv.conf + # use ctrld as upstream + pc_delete "server=" "$config_file" + {{- range .Upstreams}} + pc_append "server={{ .IP }}#{{ .Port }}" "$config_file" + {{- end}} + {{- if .SendClientInfo}} + pc_append "add-mac" "$config_file" # add client mac + {{- end}} + pc_delete "dnssec" "$config_file" # disable DNSSEC + pc_delete "trust-anchor=" "$config_file" # disable DNSSEC + + # For John fork + pc_delete "resolv-file" "$config_file" # no WAN DNS settings + + # Change /etc/resolv.conf, which may be changed by WAN DNS setup + pc_delete "nameserver" /etc/resolv.conf + pc_append "nameserver 127.0.0.1" /etc/resolv.conf + + exit 0 +fi +` + +type Upstream struct { + IP string + Port int +} + +func ConfTmpl(tmplText string, cfg *ctrld.Config) (string, error) { + listener := cfg.FirstListener() + if listener == nil { + return "", errors.New("missing listener") + } + ip := listener.IP + if ip == "0.0.0.0" || ip == "::" || ip == "" { + ip = "127.0.0.1" + } + upstreams := []Upstream{{IP: ip, Port: listener.Port}} + return confTmpl(tmplText, upstreams, cfg.HasUpstreamSendClientInfo()) +} + +func FirewallaConfTmpl(tmplText string, cfg *ctrld.Config) (string, error) { + if lc := cfg.FirstListener(); lc != nil && (lc.IP == "0.0.0.0" || lc.IP == "") { + return confTmpl(tmplText, firewallaUpstreams(lc.Port), cfg.HasUpstreamSendClientInfo()) + } + return ConfTmpl(tmplText, cfg) +} + +func confTmpl(tmplText string, upstreams []Upstream, sendClientInfo bool) (string, error) { + tmpl := template.Must(template.New("").Parse(tmplText)) + var to = &struct { + SendClientInfo bool + Upstreams []Upstream + }{ + SendClientInfo: sendClientInfo, + Upstreams: upstreams, + } + var sb strings.Builder + if err := tmpl.Execute(&sb, to); err != nil { + return "", err + } + return sb.String(), nil +} + +func firewallaUpstreams(port int) []Upstream { + ifaces := FirewallaSelfInterfaces() + upstreams := make([]Upstream, 0, len(ifaces)) + for _, netIface := range ifaces { + addrs, _ := netIface.Addrs() + for _, addr := range addrs { + if netIP, ok := addr.(*net.IPNet); ok && netIP.IP.To4() != nil { + upstreams = append(upstreams, Upstream{ + IP: netIP.IP.To4().String(), + Port: port, + }) + } + } + } + return upstreams +} + +// FirewallaSelfInterfaces returns list of interfaces that will be configured with default dnsmasq setup on Firewalla. +func FirewallaSelfInterfaces() []*net.Interface { + matches, err := filepath.Glob("/home/pi/firerouter/etc/dnsmasq.dns.*.conf") + if err != nil { + return nil + } + ifaces := make([]*net.Interface, 0, len(matches)) + for _, match := range matches { + // Trim prefix and suffix to get the iface name only. + ifaceName := strings.TrimSuffix(strings.TrimPrefix(match, "/home/pi/firerouter/etc/dnsmasq.dns."), ".conf") + if netIface, _ := net.InterfaceByName(ifaceName); netIface != nil { + ifaces = append(ifaces, netIface) + } + } + return ifaces +} diff --git a/internal/router/edgeos.go b/internal/router/edgeos.go deleted file mode 100644 index ccdb164..0000000 --- a/internal/router/edgeos.go +++ /dev/null @@ -1,56 +0,0 @@ -package router - -import ( - "fmt" - "os" - "os/exec" -) - -const edgeOSDNSMasqConfigPath = "/etc/dnsmasq.d/dnsmasq-zzz-ctrld.conf" - -func setupEdgeOS() error { - // Disable dnsmasq as DNS server. - dnsMasqConfigContent, err := dnsMasqConf() - if err != nil { - return err - } - if err := os.WriteFile(edgeOSDNSMasqConfigPath, []byte(dnsMasqConfigContent), 0600); err != nil { - return err - } - // Restart dnsmasq service. - if err := restartDNSMasq(); err != nil { - return err - } - return nil -} - -func cleanupEdgeOS() error { - // Remove the custom dnsmasq config - if err := os.Remove(edgeOSDNSMasqConfigPath); err != nil { - return err - } - // Restart dnsmasq service. - if err := restartDNSMasq(); err != nil { - return err - } - return nil -} - -func postInstallEdgeOS() error { - // If "Content Filtering" is enabled, UniFi OS will create firewall rules to intercept all DNS queries - // from outside, and route those queries to separated interfaces (e.g: dnsfilter-2@if79) created by UniFi OS. - // Thus, those queries will never reach ctrld listener. UniFi OS does not provide any mechanism to toggle this - // feature via command line, so there's nothing ctrld can do to disable this feature. For now, reporting an - // error and guiding users to disable the feature using UniFi OS web UI. - if contentFilteringEnabled() { - return errContentFilteringEnabled - } - return nil -} - -func edgeOSRestartDNSMasq() error { - if out, err := exec.Command("/etc/init.d/dnsmasq", "restart").CombinedOutput(); err != nil { - return fmt.Errorf("edgeosRestartDNSMasq: %s, %w", string(out), err) - } - return nil -} diff --git a/internal/router/edgeos/edgeos.go b/internal/router/edgeos/edgeos.go new file mode 100644 index 0000000..014a594 --- /dev/null +++ b/internal/router/edgeos/edgeos.go @@ -0,0 +1,182 @@ +package edgeos + +import ( + "bufio" + "bytes" + "fmt" + "os" + "os/exec" + "strings" + + "github.com/Control-D-Inc/ctrld/internal/router/dnsmasq" + + "github.com/Control-D-Inc/ctrld" + "github.com/kardianos/service" +) + +const ( + Name = "edgeos" + edgeOSDNSMasqConfigPath = "/etc/dnsmasq.d/dnsmasq-zzz-ctrld.conf" + usgDNSMasqConfigPath = "/etc/dnsmasq.conf" + usgDNSMasqBackupConfigPath = "/etc/dnsmasq.conf.bak" + toggleContentFilteringLink = "https://community.ui.com/questions/UDM-Pro-disable-enable-DNS-filtering/e2cc4060-e56a-4139-b200-62d7f773ff8f" +) + +var ErrContentFilteringEnabled = fmt.Errorf(`the "Content Filtering" feature" is enabled, which is conflicted with ctrld.\n +To disable it, folowing instruction here: %s`, toggleContentFilteringLink) + +type EdgeOS struct { + cfg *ctrld.Config + isUSG bool +} + +// New returns a router.Router for configuring/setup/run ctrld on EdgeOS routers. +func New(cfg *ctrld.Config) *EdgeOS { + e := &EdgeOS{cfg: cfg} + e.isUSG = checkUSG() + return e +} + +func (e *EdgeOS) ConfigureService(config *service.Config) error { + return nil +} + +func (e *EdgeOS) Install(_ *service.Config) error { + // If "Content Filtering" is enabled, UniFi OS will create firewall rules to intercept all DNS queries + // from outside, and route those queries to separated interfaces (e.g: dnsfilter-2@if79) created by UniFi OS. + // Thus, those queries will never reach ctrld listener. UniFi OS does not provide any mechanism to toggle this + // feature via command line, so there's nothing ctrld can do to disable this feature. For now, reporting an + // error and guiding users to disable the feature using UniFi OS web UI. + if ContentFilteringEnabled() { + return ErrContentFilteringEnabled + } + return nil +} + +func (e *EdgeOS) Uninstall(_ *service.Config) error { + return nil +} + +func (e *EdgeOS) PreRun() error { + return nil +} + +func (e *EdgeOS) Setup() error { + if e.cfg.FirstListener().IsDirectDnsListener() { + return nil + } + if e.isUSG { + return e.setupUSG() + } + return e.setupUDM() +} + +func (e *EdgeOS) Cleanup() error { + if e.cfg.FirstListener().IsDirectDnsListener() { + return nil + } + if e.isUSG { + return e.cleanupUSG() + } + return e.cleanupUDM() +} + +func (e *EdgeOS) setupUSG() error { + // On USG, dnsmasq is configured to forward queries to external provider by default. + // So instead of generating config in /etc/dnsmasq.d, we need to create a backup of + // the config, then modify it to forward queries to ctrld listener. + + // Creating a backup. + buf, err := os.ReadFile(usgDNSMasqConfigPath) + if err != nil { + return fmt.Errorf("setupUSG: reading current config: %w", err) + } + if err := os.WriteFile(usgDNSMasqBackupConfigPath, buf, 0600); err != nil { + return fmt.Errorf("setupUSG: backup current config: %w", err) + } + + // Removing all configured upstreams. + var sb strings.Builder + scanner := bufio.NewScanner(bytes.NewReader(buf)) + for scanner.Scan() { + line := scanner.Text() + if strings.HasPrefix(line, "server=") { + continue + } + if strings.HasPrefix(line, "all-servers") { + continue + } + sb.WriteString(line) + } + + data, err := dnsmasq.ConfTmpl(dnsmasq.ConfigContentTmpl, e.cfg) + if err != nil { + return err + } + sb.WriteString("\n") + sb.WriteString(data) + if err := os.WriteFile(usgDNSMasqConfigPath, []byte(sb.String()), 0644); err != nil { + return fmt.Errorf("setupUSG: writing dnsmasq config: %w", err) + } + + // Restart dnsmasq service. + if err := restartDNSMasq(); err != nil { + return fmt.Errorf("setupUSG: restartDNSMasq: %w", err) + } + return nil +} + +func (e *EdgeOS) setupUDM() error { + data, err := dnsmasq.ConfTmpl(dnsmasq.ConfigContentTmpl, e.cfg) + if err != nil { + return err + } + if err := os.WriteFile(edgeOSDNSMasqConfigPath, []byte(data), 0600); err != nil { + return fmt.Errorf("setupUDM: generating dnsmasq config: %w", err) + } + // Restart dnsmasq service. + if err := restartDNSMasq(); err != nil { + return fmt.Errorf("setupUDM: restartDNSMasq: %w", err) + } + return nil +} + +func (e *EdgeOS) cleanupUSG() error { + if err := os.Rename(usgDNSMasqBackupConfigPath, usgDNSMasqConfigPath); err != nil { + return fmt.Errorf("cleanupUSG: os.Rename: %w", err) + } + // Restart dnsmasq service. + if err := restartDNSMasq(); err != nil { + return fmt.Errorf("cleanupUSG: restartDNSMasq: %w", err) + } + return nil +} + +func (e *EdgeOS) cleanupUDM() error { + // Remove the custom dnsmasq config + if err := os.Remove(edgeOSDNSMasqConfigPath); err != nil { + return fmt.Errorf("cleanupUDM: os.Remove: %w", err) + } + // Restart dnsmasq service. + if err := restartDNSMasq(); err != nil { + return fmt.Errorf("cleanupUDM: restartDNSMasq: %w", err) + } + return nil +} + +func ContentFilteringEnabled() bool { + st, err := os.Stat("/run/dnsfilter/dnsfilter") + return err == nil && !st.IsDir() +} + +func checkUSG() bool { + out, _ := exec.Command("mca-cli-op", "info").Output() + return bytes.Contains(out, []byte("UniFi-Gateway-")) +} + +func restartDNSMasq() error { + if out, err := exec.Command("/etc/init.d/dnsmasq", "restart").CombinedOutput(); err != nil { + return fmt.Errorf("edgeosRestartDNSMasq: %s, %w", string(out), err) + } + return nil +} diff --git a/internal/router/firewalla/firewalla.go b/internal/router/firewalla/firewalla.go new file mode 100644 index 0000000..cdf6586 --- /dev/null +++ b/internal/router/firewalla/firewalla.go @@ -0,0 +1,110 @@ +package firewalla + +import ( + "fmt" + "os" + "os/exec" + "strings" + + "github.com/Control-D-Inc/ctrld/internal/router/dnsmasq" + + "github.com/Control-D-Inc/ctrld" + "github.com/kardianos/service" +) + +const ( + Name = "firewalla" + + firewallaDNSMasqConfigPath = "/home/pi/.firewalla/config/dnsmasq_local/ctrld" + firewallaConfigPostMainDir = "/home/pi/.firewalla/config/post_main.d" + firewallaCtrldInitScriptPath = "/home/pi/.firewalla/config/post_main.d/start_ctrld.sh" +) + +type Firewalla struct { + cfg *ctrld.Config +} + +// New returns a router.Router for configuring/setup/run ctrld on Firewalla routers. +func New(cfg *ctrld.Config) *Firewalla { + return &Firewalla{cfg: cfg} +} + +func (f *Firewalla) ConfigureService(_ *service.Config) error { + return nil +} + +func (f *Firewalla) Install(_ *service.Config) error { + // Writing startup script. + if err := writeFirewallStartupScript(); err != nil { + return fmt.Errorf("writing startup script: %w", err) + } + return nil +} + +func (f *Firewalla) Uninstall(_ *service.Config) error { + // Removing startup script. + if err := os.Remove(firewallaCtrldInitScriptPath); err != nil { + return fmt.Errorf("removing startup script: %w", err) + } + return nil +} + +func (f *Firewalla) PreRun() error { + return nil +} + +func (f *Firewalla) Setup() error { + if f.cfg.FirstListener().IsDirectDnsListener() { + return nil + } + data, err := dnsmasq.FirewallaConfTmpl(dnsmasq.ConfigContentTmpl, f.cfg) + if err != nil { + return fmt.Errorf("generating dnsmasq config: %w", err) + } + if err := os.WriteFile(firewallaDNSMasqConfigPath, []byte(data), 0600); err != nil { + return fmt.Errorf("writing ctrld config: %w", err) + } + + // Restart dnsmasq service. + if err := restartDNSMasq(); err != nil { + return fmt.Errorf("restartDNSMasq: %w", err) + } + + return nil +} + +func (f *Firewalla) Cleanup() error { + if f.cfg.FirstListener().IsDirectDnsListener() { + return nil + } + // Removing current config. + if err := os.Remove(firewallaDNSMasqConfigPath); err != nil { + return fmt.Errorf("removing ctrld config: %w", err) + } + + // Restart dnsmasq service. + if err := restartDNSMasq(); err != nil { + return fmt.Errorf("restartDNSMasq: %w", err) + } + + return nil +} + +func writeFirewallStartupScript() error { + if err := os.MkdirAll(firewallaConfigPostMainDir, 0775); err != nil { + return err + } + exe, err := os.Executable() + if err != nil { + return err + } + // This is called when "ctrld start ..." runs, so recording + // the same command line arguments to use in startup script. + argStr := strings.Join(os.Args[1:], " ") + script := fmt.Sprintf("#!/bin/bash\n\nsudo %q %s\n", exe, argStr) + return os.WriteFile(firewallaCtrldInitScriptPath, []byte(script), 0755) +} + +func restartDNSMasq() error { + return exec.Command("systemctl", "restart", "firerouter_dns").Run() +} diff --git a/internal/router/merlin.go b/internal/router/merlin.go deleted file mode 100644 index 8e20d68..0000000 --- a/internal/router/merlin.go +++ /dev/null @@ -1,89 +0,0 @@ -package router - -import ( - "bytes" - "fmt" - "os" - "os/exec" - "strings" - "unicode" -) - -func setupMerlin() error { - buf, err := os.ReadFile(merlinDNSMasqPostConfPath) - // Already setup. - if bytes.Contains(buf, []byte(merlinDNSMasqPostConfMarker)) { - return nil - } - if err != nil && !os.IsNotExist(err) { - return err - } - - merlinDNSMasqPostConf, err := dnsMasqConf() - if err != nil { - return err - } - data := strings.Join([]string{ - merlinDNSMasqPostConf, - "\n", - merlinDNSMasqPostConfMarker, - "\n", - string(buf), - }, "\n") - // Write dnsmasq post conf file. - if err := os.WriteFile(merlinDNSMasqPostConfPath, []byte(data), 0750); err != nil { - return err - } - // Restart dnsmasq service. - if err := restartDNSMasq(); err != nil { - return err - } - - if err := nvramSetKV(nvramSetupKV(), nvramCtrldSetupKey); err != nil { - return err - } - - return nil -} - -func cleanupMerlin() error { - // Restore old configs. - if err := nvramRestore(nvramSetupKV(), nvramCtrldSetupKey); err != nil { - return err - } - buf, err := os.ReadFile(merlinDNSMasqPostConfPath) - if err != nil && !os.IsNotExist(err) { - return err - } - // Restore dnsmasq post conf file. - if err := os.WriteFile(merlinDNSMasqPostConfPath, merlinParsePostConf(buf), 0750); err != nil { - return err - } - // Restart dnsmasq service. - if err := restartDNSMasq(); err != nil { - return err - } - return nil -} - -func postInstallMerlin() error { - return nil -} - -func merlinRestartDNSMasq() error { - if out, err := exec.Command("service", "restart_dnsmasq").CombinedOutput(); err != nil { - return fmt.Errorf("restart_dnsmasq: %s, %w", string(out), err) - } - return nil -} - -func merlinParsePostConf(buf []byte) []byte { - if len(buf) == 0 { - return nil - } - parts := bytes.Split(buf, []byte(merlinDNSMasqPostConfMarker)) - if len(parts) != 1 { - return bytes.TrimLeftFunc(parts[1], unicode.IsSpace) - } - return buf -} diff --git a/internal/router/merlin/merlin.go b/internal/router/merlin/merlin.go new file mode 100644 index 0000000..84ebd1c --- /dev/null +++ b/internal/router/merlin/merlin.go @@ -0,0 +1,142 @@ +package merlin + +import ( + "bytes" + "fmt" + "os" + "os/exec" + "strings" + "unicode" + + "github.com/kardianos/service" + + "github.com/Control-D-Inc/ctrld" + "github.com/Control-D-Inc/ctrld/internal/router/dnsmasq" + "github.com/Control-D-Inc/ctrld/internal/router/ntp" + "github.com/Control-D-Inc/ctrld/internal/router/nvram" +) + +const Name = "merlin" + +var nvramKvMap = map[string]string{ + "dnspriv_enable": "0", // Ensure Merlin native DoT disabled. +} + +type Merlin struct { + cfg *ctrld.Config +} + +// New returns a router.Router for configuring/setup/run ctrld on Merlin routers. +func New(cfg *ctrld.Config) *Merlin { + return &Merlin{cfg: cfg} +} + +func (m *Merlin) ConfigureService(config *service.Config) error { + return nil +} + +func (m *Merlin) Install(_ *service.Config) error { + return nil +} + +func (m *Merlin) Uninstall(_ *service.Config) error { + return nil +} + +func (m *Merlin) PreRun() error { + _ = m.Cleanup() + return ntp.WaitNvram() +} + +func (m *Merlin) Setup() error { + if m.cfg.FirstListener().IsDirectDnsListener() { + return nil + } + // Already setup. + if val, _ := nvram.Run("get", nvram.CtrldSetupKey); val == "1" { + return nil + } + if _, err := nvram.Run("set", nvram.CtrldSetupKey+"=1"); err != nil { + return err + } + 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. + if err := os.WriteFile(dnsmasq.MerlinPostConfPath, []byte(data), 0750); err != nil { + return err + } + // Restart dnsmasq service. + if err := restartDNSMasq(); err != nil { + return err + } + + if err := nvram.SetKV(nvramKvMap, nvram.CtrldSetupKey); err != nil { + return err + } + + return nil +} + +func (m *Merlin) Cleanup() error { + if m.cfg.FirstListener().IsDirectDnsListener() { + return nil + } + if val, _ := nvram.Run("get", nvram.CtrldSetupKey); val != "1" { + return nil // was restored, nothing to do. + } + + // Restore old configs. + if err := nvram.Restore(nvramKvMap, nvram.CtrldSetupKey); err != nil { + return err + } + + buf, err := os.ReadFile(dnsmasq.MerlinPostConfPath) + if err != nil && !os.IsNotExist(err) { + return err + } + // Restore dnsmasq post conf file. + if err := os.WriteFile(dnsmasq.MerlinPostConfPath, merlinParsePostConf(buf), 0750); err != nil { + return err + } + // Restart dnsmasq service. + if err := restartDNSMasq(); err != nil { + return err + } + return nil +} + +func restartDNSMasq() error { + if out, err := exec.Command("service", "restart_dnsmasq").CombinedOutput(); err != nil { + return fmt.Errorf("restart_dnsmasq: %s, %w", string(out), err) + } + return nil +} + +func merlinParsePostConf(buf []byte) []byte { + if len(buf) == 0 { + return nil + } + parts := bytes.Split(buf, []byte(dnsmasq.MerlinPostConfMarker)) + if len(parts) != 1 { + return bytes.TrimLeftFunc(parts[1], unicode.IsSpace) + } + return buf +} diff --git a/internal/router/merlin_test.go b/internal/router/merlin/merlin_test.go similarity index 83% rename from internal/router/merlin_test.go rename to internal/router/merlin/merlin_test.go index e1715af..057628c 100644 --- a/internal/router/merlin_test.go +++ b/internal/router/merlin/merlin_test.go @@ -1,17 +1,19 @@ -package router +package merlin import ( "bytes" "strings" "testing" + + "github.com/Control-D-Inc/ctrld/internal/router/dnsmasq" ) func Test_merlinParsePostConf(t *testing.T) { origContent := "# foo" data := strings.Join([]string{ - merlinDNSMasqPostConfTmpl, + dnsmasq.MerlinPostConfTmpl, "\n", - merlinDNSMasqPostConfMarker, + dnsmasq.MerlinPostConfMarker, "\n", }, "\n") diff --git a/internal/router/ntp/ntp.go b/internal/router/ntp/ntp.go new file mode 100644 index 0000000..5c04a36 --- /dev/null +++ b/internal/router/ntp/ntp.go @@ -0,0 +1,49 @@ +package ntp + +import ( + "bytes" + "context" + "errors" + "fmt" + "os/exec" + "time" + + "tailscale.com/logtail/backoff" + + "github.com/Control-D-Inc/ctrld/internal/router/nvram" +) + +// WaitNvram waits NTP synced by checking "ntp_ready" value using nvram. +func WaitNvram() error { + // Wait until `ntp_ready=1` set. + b := backoff.NewBackoff("ntp.Wait", func(format string, args ...any) {}, 10*time.Second) + for { + // ddwrt use "ntp_done": https://github.com/mirror/dd-wrt/blob/a08c693527ab3204bf7bebd408a7c9a83b6ede47/src/router/rc/ntp.c#L100 + for _, key := range []string{"ntp_ready", "ntp_done"} { + out, err := nvram.Run("get", key) + if err != nil { + return fmt.Errorf("PreStart: nvram: %w", err) + } + if out == "1" { + return nil + } + } + b.BackOff(context.Background(), errors.New("ntp not ready")) + } +} + +// WaitUpstart waits NTP synced by checking upstart task "ntpsync" is in "stop/waiting" state. +func WaitUpstart() error { + // Wait until `initctl status ntpsync` returns stop state. + b := backoff.NewBackoff("ntp.WaitUpstart", func(format string, args ...any) {}, 10*time.Second) + for { + out, err := exec.Command("initctl", "status", "ntpsync").CombinedOutput() + if err != nil { + return fmt.Errorf("exec.Command: %w", err) + } + if bytes.Contains(out, []byte("stop/waiting")) { + return nil + } + b.BackOff(context.Background(), errors.New("ntp not ready")) + } +} diff --git a/internal/router/nvram.go b/internal/router/nvram.go deleted file mode 100644 index de3400e..0000000 --- a/internal/router/nvram.go +++ /dev/null @@ -1,110 +0,0 @@ -package router - -import ( - "bytes" - "fmt" - "os/exec" - "strings" -) - -func nvram(args ...string) (string, error) { - cmd := exec.Command("nvram", args...) - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - if err := cmd.Run(); err != nil { - return "", fmt.Errorf("%s:%w", stderr.String(), err) - } - return strings.TrimSpace(stdout.String()), nil -} - -/* -NOTE: - - For Openwrt, DNSSEC is not included in default dnsmasq (require dnsmasq-full). - - For Merlin, DNSSEC is configured during postconf script (see merlinDNSMasqPostConfTmpl). - - For Ubios UDM Pro/Dream Machine, DNSSEC is not included in their dnsmasq package: - +https://community.ui.com/questions/Implement-DNSSEC-into-UniFi/951c72b0-4d88-4c86-9174-45417bd2f9ca - +https://community.ui.com/questions/Enable-DNSSEC-for-Unifi-Dream-Machine-FW-updates/e68e367c-d09b-4459-9444-18908f7c1ea1 -*/ -func nvramSetupKV() map[string]string { - switch Name() { - case DDWrt: - return map[string]string{ - "dns_dnsmasq": "1", // Make dnsmasq running but disable DNS ability, ctrld will replace it. - "dnsmasq_options": "", // Configuration of dnsmasq set by ctrld, filled by setupDDWrt. - "dns_crypt": "0", // Disable DNSCrypt. - "dnssec": "0", // Disable DNSSEC. - } - case Merlin: - return map[string]string{ - "dnspriv_enable": "0", // Ensure Merlin native DoT disabled. - } - case Tomato: - return map[string]string{ - "dnsmasq_custom": "", // Configuration of dnsmasq set by ctrld, filled by setupTomato. - "dnscrypt_proxy": "0", // Disable DNSCrypt. - "dnssec_enable": "0", // Disable DNSSEC. - "stubby_proxy": "0", // Disable Stubby - } - } - return nil -} - -func nvramInstallKV() map[string]string { - switch Name() { - case Tomato: - return map[string]string{ - tomatoNvramScriptWanupKey: "", // script to start ctrld, filled by tomatoSvc.Install method. - } - } - return nil -} - -func nvramSetKV(m map[string]string, setupKey string) error { - // Backup current value, store ctrld's configs. - for key, value := range m { - old, err := nvram("get", key) - if err != nil { - return fmt.Errorf("%s: %w", old, err) - } - if out, err := nvram("set", nvramCtrldKeyPrefix+key+"="+old); err != nil { - return fmt.Errorf("%s: %w", out, err) - } - if out, err := nvram("set", key+"="+value); err != nil { - return fmt.Errorf("%s: %w", out, err) - } - } - - if out, err := nvram("set", setupKey+"=1"); err != nil { - return fmt.Errorf("%s: %w", out, err) - } - // Commit. - if out, err := nvram("commit"); err != nil { - return fmt.Errorf("%s: %w", out, err) - } - return nil -} - -func nvramRestore(m map[string]string, setupKey string) error { - // Restore old configs. - for key := range m { - ctrldKey := nvramCtrldKeyPrefix + key - old, err := nvram("get", ctrldKey) - if err != nil { - return fmt.Errorf("%s: %w", old, err) - } - _, _ = nvram("unset", ctrldKey) - if out, err := nvram("set", key+"="+old); err != nil { - return fmt.Errorf("%s: %w", out, err) - } - } - - if out, err := nvram("unset", setupKey); err != nil { - return fmt.Errorf("%s: %w", out, err) - } - // Commit. - if out, err := nvram("commit"); err != nil { - return fmt.Errorf("%s: %w", out, err) - } - return nil -} diff --git a/internal/router/nvram/nvram.go b/internal/router/nvram/nvram.go new file mode 100644 index 0000000..e76c017 --- /dev/null +++ b/internal/router/nvram/nvram.go @@ -0,0 +1,89 @@ +package nvram + +import ( + "bytes" + "fmt" + "os/exec" + "strings" +) + +const ( + CtrldKeyPrefix = "ctrld_" + CtrldSetupKey = "ctrld_setup" + CtrldInstallKey = "ctrld_install" + RCStartupKey = "rc_startup" +) + +// Run runs the given nvram command. +func Run(args ...string) (string, error) { + cmd := exec.Command("nvram", args...) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return "", fmt.Errorf("%s:%w", stderr.String(), err) + } + return strings.TrimSpace(stdout.String()), nil +} + +/* +NOTE: + - For Openwrt, DNSSEC is not included in default dnsmasq (require dnsmasq-full). + - For Merlin, DNSSEC is configured during postconf script (see merlinDNSMasqPostConfTmpl). + - For Ubios UDM Pro/Dream Machine, DNSSEC is not included in their dnsmasq package: + +https://community.ui.com/questions/Implement-DNSSEC-into-UniFi/951c72b0-4d88-4c86-9174-45417bd2f9ca + +https://community.ui.com/questions/Enable-DNSSEC-for-Unifi-Dream-Machine-FW-updates/e68e367c-d09b-4459-9444-18908f7c1ea1 +*/ + +// SetKV writes the given key/value from map to nvram. +// The given setupKey is set to 1 to indicates key/value set. +func SetKV(m map[string]string, setupKey string) error { + // Backup current value, store ctrld's configs. + for key, value := range m { + old, err := Run("get", key) + if err != nil { + return fmt.Errorf("%s: %w", old, err) + } + if out, err := Run("set", CtrldKeyPrefix+key+"="+old); err != nil { + return fmt.Errorf("%s: %w", out, err) + } + if out, err := Run("set", key+"="+value); err != nil { + return fmt.Errorf("%s: %w", out, err) + } + } + + if out, err := Run("set", setupKey+"=1"); err != nil { + return fmt.Errorf("%s: %w", out, err) + } + // Commit. + if out, err := Run("commit"); err != nil { + return fmt.Errorf("%s: %w", out, err) + } + return nil +} + +// Restore restores the old value of given key from map m. +// The given setupKey is set to 0 to indicates key/value restored. +func Restore(m map[string]string, setupKey string) error { + // Restore old configs. + for key := range m { + ctrldKey := CtrldKeyPrefix + key + old, err := Run("get", ctrldKey) + if err != nil { + return fmt.Errorf("%s: %w", old, err) + } + _, _ = Run("unset", ctrldKey) + if out, err := Run("set", key+"="+old); err != nil { + return fmt.Errorf("%s: %w", out, err) + } + } + + if out, err := Run("unset", setupKey); err != nil { + return fmt.Errorf("%s: %w", out, err) + } + // Commit. + if out, err := Run("commit"); err != nil { + return fmt.Errorf("%s: %w", out, err) + } + return nil +} diff --git a/internal/router/openwrt.go b/internal/router/openwrt/openwrt.go similarity index 52% rename from internal/router/openwrt.go rename to internal/router/openwrt/openwrt.go index afc25ae..83ea884 100644 --- a/internal/router/openwrt.go +++ b/internal/router/openwrt/openwrt.go @@ -1,4 +1,4 @@ -package router +package openwrt import ( "bytes" @@ -7,42 +7,56 @@ import ( "os" "os/exec" "strings" + + "github.com/Control-D-Inc/ctrld/internal/router/dnsmasq" + + "github.com/kardianos/service" + + "github.com/Control-D-Inc/ctrld" +) + +const ( + Name = "openwrt" + openwrtDNSMasqConfigPath = "/tmp/dnsmasq.d/ctrld.conf" ) var errUCIEntryNotFound = errors.New("uci: Entry not found") -const openwrtDNSMasqConfigPath = "/tmp/dnsmasq.d/ctrld.conf" - -// IsGLiNet reports whether the router is an GL.iNet router. -func IsGLiNet() bool { - if Name() != OpenWrt { - return false - } - buf, _ := os.ReadFile("/proc/version") - // The output of /proc/version contains "(glinet@glinet)". - return bytes.Contains(buf, []byte(" (glinet")) +type Openwrt struct { + cfg *ctrld.Config } -// IsOldOpenwrt reports whether the router is an "old" version of Openwrt, -// aka versions which don't have "service" command. -func IsOldOpenwrt() bool { - if Name() != OpenWrt { - return false - } - cmd, _ := exec.LookPath("service") - return cmd == "" +// New returns a router.Router for configuring/setup/run ctrld on Openwrt routers. +func New(cfg *ctrld.Config) *Openwrt { + return &Openwrt{cfg: cfg} } -func setupOpenWrt() error { - // Delete dnsmasq port if set. - if _, err := uci("delete", "dhcp.@dnsmasq[0].port"); err != nil && !errors.Is(err, errUCIEntryNotFound) { - return err +func (o *Openwrt) ConfigureService(svc *service.Config) error { + svc.Option["SysvScript"] = openWrtScript + return nil +} + +func (o *Openwrt) Install(config *service.Config) error { + return exec.Command("/etc/init.d/ctrld", "enable").Run() +} + +func (o *Openwrt) Uninstall(config *service.Config) error { + return nil +} + +func (o *Openwrt) PreRun() error { + return nil +} + +func (o *Openwrt) Setup() error { + if o.cfg.FirstListener().IsDirectDnsListener() { + return nil } - dnsMasqConfigContent, err := dnsMasqConf() + data, err := dnsmasq.ConfTmpl(dnsmasq.ConfigContentTmpl, o.cfg) if err != nil { return err } - if err := os.WriteFile(openwrtDNSMasqConfigPath, []byte(dnsMasqConfigContent), 0600); err != nil { + if err := os.WriteFile(openwrtDNSMasqConfigPath, []byte(data), 0600); err != nil { return err } // Commit. @@ -56,7 +70,10 @@ func setupOpenWrt() error { return nil } -func cleanupOpenWrt() error { +func (o *Openwrt) Cleanup() error { + if o.cfg.FirstListener().IsDirectDnsListener() { + return nil + } // Remove the custom dnsmasq config if err := os.Remove(openwrtDNSMasqConfigPath); err != nil { return err @@ -68,8 +85,11 @@ func cleanupOpenWrt() error { return nil } -func postInstallOpenWrt() error { - return exec.Command("/etc/init.d/ctrld", "enable").Run() +func restartDNSMasq() error { + if out, err := exec.Command("/etc/init.d/dnsmasq", "restart").CombinedOutput(); err != nil { + return fmt.Errorf("%s: %w", string(out), err) + } + return nil } func uci(args ...string) (string, error) { @@ -85,10 +105,3 @@ func uci(args ...string) (string, error) { } return strings.TrimSpace(stdout.String()), nil } - -func openwrtRestartDNSMasq() error { - if out, err := exec.Command("/etc/init.d/dnsmasq", "restart").CombinedOutput(); err != nil { - return fmt.Errorf("%s: %w", string(out), err) - } - return nil -} diff --git a/internal/router/procd.go b/internal/router/openwrt/procd.go similarity index 97% rename from internal/router/procd.go rename to internal/router/openwrt/procd.go index d363f39..8e74461 100644 --- a/internal/router/procd.go +++ b/internal/router/openwrt/procd.go @@ -1,4 +1,4 @@ -package router +package openwrt const openWrtScript = `#!/bin/sh /etc/rc.common USE_PROCD=1 diff --git a/internal/router/os_freebsd.go b/internal/router/os_freebsd.go new file mode 100644 index 0000000..3b7c0a3 --- /dev/null +++ b/internal/router/os_freebsd.go @@ -0,0 +1,160 @@ +package router + +import ( + "bytes" + "fmt" + "net" + "os" + "os/exec" + "path/filepath" + "text/template" + + "github.com/kardianos/service" + + "github.com/Control-D-Inc/ctrld" +) + +const ( + osName = "freebsd" + rcPath = "/usr/local/etc/rc.d" + rcConfPath = "/etc/rc.conf.d/" + unboundRcPath = rcPath + "/unbound" + dnsmasqRcPath = rcPath + "/dnsmasq" +) + +func newOsRouter(cfg *ctrld.Config, cdMode bool) Router { + return &osRouter{cfg: cfg, cdMode: cdMode} +} + +type osRouter struct { + cfg *ctrld.Config + svcName string + // cdMode indicates whether the router will configure ctrld in cd mode (aka --cd=). + // When ctrld is running on freebsd-like routers, and there's process running on port 53 + // in cd mode, ctrld will attempt to kill the process and become direct listener. + // See details implemenation in osRouter.PreRun method. + cdMode bool +} + +func (or *osRouter) ConfigureService(svc *service.Config) error { + svc.Option["SysvScript"] = bsdInitScript + or.svcName = svc.Name + rcFile := filepath.Join(rcConfPath, or.svcName) + var to = &struct { + Name string + }{ + or.svcName, + } + + f, err := os.Create(rcFile) + if err != nil { + return fmt.Errorf("os.Create: %w", err) + } + defer f.Close() + if err := template.Must(template.New("").Parse(rcConfTmpl)).Execute(f, to); err != nil { + return err + } + return f.Close() +} + +func (or *osRouter) Install(_ *service.Config) error { + if isPfsense() { + // pfsense need ".sh" extension for script to be run at boot. + // See: https://docs.netgate.com/pfsense/en/latest/development/boot-commands.html#shell-script-option + oldname := filepath.Join(rcPath, or.svcName) + newname := filepath.Join(rcPath, or.svcName+".sh") + _ = os.Remove(newname) + if err := os.Symlink(oldname, newname); err != nil { + return fmt.Errorf("os.Symlink: %w", err) + } + } + return nil +} + +func (or *osRouter) Uninstall(_ *service.Config) error { + rcFiles := []string{filepath.Join(rcConfPath, or.svcName)} + if isPfsense() { + rcFiles = append(rcFiles, filepath.Join(rcPath, or.svcName+".sh")) + } + for _, filename := range rcFiles { + if err := os.Remove(filename); err != nil { + return fmt.Errorf("os.Remove: %w", err) + } + } + + return nil +} + +func (or *osRouter) PreRun() error { + if or.cdMode { + addr := "0.0.0.0:53" + udpLn, udpErr := net.ListenPacket("udp", addr) + if udpLn != nil { + udpLn.Close() + } + tcpLn, tcpErr := net.Listen("tcp", addr) + if tcpLn != nil { + tcpLn.Close() + } + // If we could not listen on :53 for any reason, try killing unbound/dnsmasq, become direct listener + if udpErr != nil || tcpErr != nil { + _ = exec.Command("killall", "unbound").Run() + _ = exec.Command("killall", "dnsmasq").Run() + } + } + return nil +} + +func (or *osRouter) Setup() error { + return nil +} + +func (or *osRouter) Cleanup() error { + if or.cdMode { + _ = exec.Command(unboundRcPath, "onerestart").Run() + _ = exec.Command(dnsmasqRcPath, "onerestart").Run() + } + return nil +} + +func isPfsense() bool { + b, err := os.ReadFile("/etc/platform") + return err == nil && bytes.HasPrefix(b, []byte("pfSense")) +} + +const bsdInitScript = `#!/bin/sh + +# PROVIDE: {{.Name}} +# REQUIRE: SERVERS +# REQUIRE: unbound dnsmasq securelevel +# KEYWORD: shutdown + +. /etc/rc.subr + +name="{{.Name}}" +rcvar="${name}_enable" +{{.Name}}_env="IS_DAEMON=1" +pidfile="/var/run/${name}.pid" +child_pidfile="/var/run/${name}_child.pid" +command="/usr/sbin/daemon" +daemon_args="-P ${pidfile} -p ${child_pidfile} -t \"${name}: daemon\"{{if .WorkingDirectory}} -c {{.WorkingDirectory}}{{end}}" +command_args="${daemon_args} {{.Path}}{{range .Arguments}} {{.}}{{end}}" + +stop_cmd="ctrld_stop" + +ctrld_stop() { + pid=$(cat ${pidfile}) + child_pid=$(cat ${child_pidfile}) + if [ -e "${child_pidfile}" ]; then + kill -s TERM "${child_pid}" + wait_for_pids "${child_pid}" "${pidfile}" + fi +} + +load_rc_config "${name}" +run_rc_command "$1" +` + +var rcConfTmpl = `# {{.Name}} +{{.Name}}_enable="YES" +` diff --git a/internal/router/os_others.go b/internal/router/os_others.go new file mode 100644 index 0000000..52b41e4 --- /dev/null +++ b/internal/router/os_others.go @@ -0,0 +1,41 @@ +//go:build !freebsd + +package router + +import ( + "github.com/kardianos/service" + + "github.com/Control-D-Inc/ctrld" +) + +const osName = "" + +func newOsRouter(cfg *ctrld.Config, cdMode bool) Router { + return &osRouter{} +} + +type osRouter struct{} + +func (d *osRouter) ConfigureService(_ *service.Config) error { + return nil +} + +func (d *osRouter) Install(_ *service.Config) error { + return nil +} + +func (d *osRouter) Uninstall(_ *service.Config) error { + return nil +} + +func (d *osRouter) PreRun() error { + return nil +} + +func (d *osRouter) Setup() error { + return nil +} + +func (d *osRouter) Cleanup() error { + return nil +} diff --git a/internal/router/pfsense.go b/internal/router/pfsense.go deleted file mode 100644 index 3818a58..0000000 --- a/internal/router/pfsense.go +++ /dev/null @@ -1,66 +0,0 @@ -package router - -import ( - "fmt" - "os" - "os/exec" - "path/filepath" - - "github.com/kardianos/service" -) - -const ( - rcPath = "/usr/local/etc/rc.d" - unboundRcPath = rcPath + "/unbound" - dnsmasqRcPath = rcPath + "/dnsmasq" -) - -func setupPfsense() error { - // If Pfsense is in DNS Resolver mode, ensure no unbound processes running. - _ = exec.Command("killall", "unbound").Run() - - // If Pfsense is in DNS Forwarder mode, ensure no dnsmasq processes running. - _ = exec.Command("killall", "dnsmasq").Run() - return nil -} - -func cleanupPfsense(svc *service.Config) error { - if err := os.Remove(filepath.Join(rcPath, svc.Name+".sh")); err != nil { - return fmt.Errorf("os.Remove: %w", err) - } - _ = exec.Command(unboundRcPath, "onerestart").Run() - _ = exec.Command(dnsmasqRcPath, "onerestart").Run() - - return nil -} - -func postInstallPfsense(svc *service.Config) error { - // pfsense need ".sh" extension for script to be run at boot. - // See: https://docs.netgate.com/pfsense/en/latest/development/boot-commands.html#shell-script-option - oldname := filepath.Join(rcPath, svc.Name) - newname := filepath.Join(rcPath, svc.Name+".sh") - _ = os.Remove(newname) - if err := os.Symlink(oldname, newname); err != nil { - return fmt.Errorf("os.Symlink: %w", err) - } - return nil -} - -const pfsenseInitScript = `#!/bin/sh - -# PROVIDE: {{.Name}} -# REQUIRE: SERVERS -# REQUIRE: unbound dnsmasq securelevel -# KEYWORD: shutdown - -. /etc/rc.subr - -name="{{.Name}}" -{{.Name}}_env="IS_DAEMON=1" -pidfile="/var/run/${name}.pid" -command="/usr/sbin/daemon" -daemon_args="-P ${pidfile} -r -t \"${name}: daemon\"{{if .WorkingDirectory}} -c {{.WorkingDirectory}}{{end}}" -command_args="${daemon_args} {{.Path}}{{range .Arguments}} {{.}}{{end}}" - -run_rc_command "$1" -` diff --git a/internal/router/router.go b/internal/router/router.go index f9b8be8..ad3c641 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -2,196 +2,98 @@ package router import ( "bytes" - "context" - "errors" - "fmt" + "crypto/x509" + "net" "os" "os/exec" - "sync" + "path/filepath" "sync/atomic" - "time" - "github.com/fsnotify/fsnotify" "github.com/kardianos/service" - "tailscale.com/logtail/backoff" "github.com/Control-D-Inc/ctrld" + "github.com/Control-D-Inc/ctrld/internal/certs" + "github.com/Control-D-Inc/ctrld/internal/router/ddwrt" + "github.com/Control-D-Inc/ctrld/internal/router/dnsmasq" + "github.com/Control-D-Inc/ctrld/internal/router/edgeos" + "github.com/Control-D-Inc/ctrld/internal/router/firewalla" + "github.com/Control-D-Inc/ctrld/internal/router/merlin" + "github.com/Control-D-Inc/ctrld/internal/router/openwrt" + "github.com/Control-D-Inc/ctrld/internal/router/synology" + "github.com/Control-D-Inc/ctrld/internal/router/tomato" + "github.com/Control-D-Inc/ctrld/internal/router/ubios" ) -const ( - OpenWrt = "openwrt" - DDWrt = "ddwrt" - Merlin = "merlin" - Ubios = "ubios" - Synology = "synology" - Tomato = "tomato" - EdgeOS = "edgeos" - Pfsense = "pfsense" -) +// Service is the interface to manage ctrld service on router. +type Service interface { + // ConfigureService performs works for installing ctrla as a service on router. + ConfigureService(*service.Config) error + // Install performs necessary works after service.Install done. + Install(*service.Config) error + // Uninstall performs necessary works after service.Uninstallation done. + Uninstall(*service.Config) error +} -// ErrNotSupported reports the current router is not supported error. -var ErrNotSupported = errors.New("unsupported platform") +// Router is the interface for managing ctrld running on router. +type Router interface { + Service + + // PreRun performs works need to be done before ctrld being run on router. + // Implementation should only return if the pre-condition was met (e.g: ntp synced). + PreRun() error + // Setup configures ctrld to be run on the router. + Setup() error + // Cleanup cleans up works setup on router by ctrld. + Cleanup() error +} + +// New returns new Router interface. +func New(cfg *ctrld.Config, cdMode bool) Router { + switch Name() { + case ddwrt.Name: + return ddwrt.New(cfg) + case merlin.Name: + return merlin.New(cfg) + case openwrt.Name: + return openwrt.New(cfg) + case edgeos.Name: + return edgeos.New(cfg) + case ubios.Name: + return ubios.New(cfg) + case synology.Name: + return synology.New(cfg) + case tomato.Name: + return tomato.New(cfg) + case firewalla.Name: + return firewalla.New(cfg) + } + return newOsRouter(cfg, cdMode) +} + +// IsGLiNet reports whether the router is an GL.iNet router. +func IsGLiNet() bool { + if Name() != openwrt.Name { + return false + } + buf, _ := os.ReadFile("/proc/version") + // The output of /proc/version contains "(glinet@glinet)". + return bytes.Contains(buf, []byte(" (glinet")) +} + +// IsOldOpenwrt reports whether the router is an "old" version of Openwrt, +// aka versions which don't have "service" command. +func IsOldOpenwrt() bool { + if Name() != openwrt.Name { + return false + } + cmd, _ := exec.LookPath("service") + return cmd == "" +} var routerPlatform atomic.Pointer[router] type router struct { - name string - sendClientInfo bool - mac sync.Map - watcher *fsnotify.Watcher -} - -// IsSupported reports whether the given platform is supported by ctrld. -func IsSupported(platform string) bool { - switch platform { - case EdgeOS, DDWrt, Merlin, OpenWrt, Pfsense, Synology, Tomato, Ubios: - return true - } - return false -} - -// SupportedPlatforms return all platforms that can be configured to run with ctrld. -func SupportedPlatforms() []string { - return []string{EdgeOS, DDWrt, Merlin, OpenWrt, Pfsense, Synology, Tomato, Ubios} -} - -var configureFunc = map[string]func() error{ - EdgeOS: setupEdgeOS, - DDWrt: setupDDWrt, - Merlin: setupMerlin, - OpenWrt: setupOpenWrt, - Pfsense: setupPfsense, - Synology: setupSynology, - Tomato: setupTomato, - Ubios: setupUbiOS, -} - -// Configure configures things for running ctrld on the router. -func Configure(c *ctrld.Config) error { - name := Name() - switch name { - case EdgeOS, DDWrt, Merlin, OpenWrt, Pfsense, Synology, Tomato, Ubios: - if c.HasUpstreamSendClientInfo() { - r := routerPlatform.Load() - r.sendClientInfo = true - watcher, err := fsnotify.NewWatcher() - if err != nil { - return err - } - r.watcher = watcher - go r.watchClientInfoTable() - for file, readClienInfoFunc := range clientInfoFiles { - _ = readClienInfoFunc(file) - _ = r.watcher.Add(file) - } - } - configure := configureFunc[name] - if err := configure(); err != nil { - return err - } - return nil - default: - return ErrNotSupported - } -} - -// ConfigureService performs necessary setup for running ctrld as a service on router. -func ConfigureService(sc *service.Config) error { - name := Name() - switch name { - case DDWrt: - if !ddwrtJff2Enabled() { - return errDdwrtJffs2NotEnabled - } - case OpenWrt: - sc.Option["SysvScript"] = openWrtScript - case Pfsense: - sc.Option["SysvScript"] = pfsenseInitScript - case EdgeOS, Merlin, Synology, Tomato, Ubios: - } - return nil -} - -// PreRun blocks until the router is ready for running ctrld. -func PreRun() (err error) { - // On some routers, NTP may out of sync, so waiting for it to be ready. - switch Name() { - case Merlin, Tomato: - // Wait until `ntp_ready=1` set. - b := backoff.NewBackoff("PreStart", func(format string, args ...any) {}, 10*time.Second) - for { - out, err := nvram("get", "ntp_ready") - if err != nil { - return fmt.Errorf("PreStart: nvram: %w", err) - } - if out == "1" { - return nil - } - b.BackOff(context.Background(), errors.New("ntp not ready")) - } - default: - return nil - } -} - -// PostInstall performs task after installing ctrld on router. -func PostInstall(svc *service.Config) error { - name := Name() - switch name { - case EdgeOS: - return postInstallEdgeOS() - case DDWrt: - return postInstallDDWrt() - case Merlin: - return postInstallMerlin() - case OpenWrt: - return postInstallOpenWrt() - case Pfsense: - return postInstallPfsense(svc) - case Synology: - return postInstallSynology() - case Tomato: - return postInstallTomato() - case Ubios: - return postInstallUbiOS() - } - return nil -} - -// Cleanup cleans ctrld setup on the router. -func Cleanup(svc *service.Config) error { - name := Name() - switch name { - case EdgeOS: - return cleanupEdgeOS() - case DDWrt: - return cleanupDDWrt() - case Merlin: - return cleanupMerlin() - case OpenWrt: - return cleanupOpenWrt() - case Pfsense: - return cleanupPfsense(svc) - case Synology: - return cleanupSynology() - case Tomato: - return cleanupTomato() - case Ubios: - return cleanupUbiOS() - } - return nil -} - -// ListenAddress returns the listener address of ctrld on router. -func ListenAddress() string { - name := Name() - switch name { - case EdgeOS, DDWrt, Merlin, OpenWrt, Synology, Tomato, Ubios: - return "127.0.0.1:5354" - case Pfsense: - // On pfsense, we run ctrld as DNS resolver. - } - return "" + name string } // Name returns name of the router platform. @@ -205,28 +107,118 @@ func Name() string { return r.name } +// DefaultInterfaceName returns the default interface name of the current router. +func DefaultInterfaceName() string { + switch Name() { + case ubios.Name: + return "lo" + } + return "" +} + +// LocalResolverIP returns the IP that could be used as nameserver in /etc/resolv.conf file. +func LocalResolverIP() string { + var iface string + switch Name() { + case edgeos.Name: + // On EdgeOS, dnsmasq is run with "--local-service", so we need to get + // the proper interface from dnsmasq config. + if name, _ := dnsmasq.InterfaceNameFromConfig("/etc/dnsmasq.conf"); name != "" { + iface = name + } + case firewalla.Name: + // On Firewalla, the lo interface is excluded in all dnsmasq settings of all interfaces. + // Thus, we use "br0" as the nameserver in /etc/resolv.conf file. + iface = "br0" + } + if netIface, _ := net.InterfaceByName(iface); netIface != nil { + addrs, _ := netIface.Addrs() + for _, addr := range addrs { + if netIP, ok := addr.(*net.IPNet); ok && netIP.IP.To4() != nil { + return netIP.IP.To4().String() + } + } + } + return "" +} + +// HomeDir returns the home directory of ctrld on current router. +func HomeDir() (string, error) { + switch Name() { + case ddwrt.Name, merlin.Name, tomato.Name: + exe, err := os.Executable() + if err != nil { + return "", err + } + return filepath.Dir(exe), nil + } + return "", nil +} + +// CertPool returns the system certificate pool of the current router. +func CertPool() *x509.CertPool { + if Name() == ddwrt.Name { + return certs.CACertPool() + } + return nil +} + +// CanListenLocalhost reports whether the ctrld can listen on localhost with current host. +func CanListenLocalhost() bool { + switch { + case Name() == firewalla.Name: + return false + default: + return true + } +} + +// ServiceDependencies returns list of dependencies that ctrld services needs on this router. +// See https://pkg.go.dev/github.com/kardianos/service#Config for list format. +func ServiceDependencies() []string { + if Name() == edgeos.Name { + // On EdeOS, ctrld needs to start after vyatta-dhcpd, so it can read leases file. + return []string{ + "Wants=vyatta-dhcpd.service", + "After=vyatta-dhcpd.service", + "Wants=dnsmasq.service", + } + } + return nil +} + +// SelfInterfaces return list of *net.Interface that will be source of requests from router itself. +func SelfInterfaces() []*net.Interface { + switch Name() { + case firewalla.Name: + return dnsmasq.FirewallaSelfInterfaces() + default: + return nil + } +} + func distroName() string { switch { case bytes.HasPrefix(unameO(), []byte("DD-WRT")): - return DDWrt + return ddwrt.Name case bytes.HasPrefix(unameO(), []byte("ASUSWRT-Merlin")): - return Merlin + return merlin.Name case haveFile("/etc/openwrt_version"): - return OpenWrt + return openwrt.Name case haveDir("/data/unifi"): - return Ubios + return ubios.Name case bytes.HasPrefix(unameU(), []byte("synology")): - return Synology + return synology.Name case bytes.HasPrefix(unameO(), []byte("Tomato")): - return Tomato + return tomato.Name case haveDir("/config/scripts/post-config.d"): - return EdgeOS + return edgeos.Name case haveFile("/etc/ubnt/init/vyatta-router"): - return EdgeOS // For 2.x - case isPfsense(): - return Pfsense + return edgeos.Name // For 2.x + case haveFile("/etc/firewalla_release"): + return firewalla.Name } - return "" + return osName } func haveFile(file string) bool { @@ -248,8 +240,3 @@ func unameU() []byte { out, _ := exec.Command("uname", "-u").Output() return out } - -func isPfsense() bool { - b, err := os.ReadFile("/etc/platform") - return err == nil && bytes.HasPrefix(b, []byte("pfSense")) -} diff --git a/internal/router/service.go b/internal/router/service.go index d9476e9..3333964 100644 --- a/internal/router/service.go +++ b/internal/router/service.go @@ -6,13 +6,18 @@ import ( "os/exec" "github.com/kardianos/service" + + "github.com/Control-D-Inc/ctrld/internal/router/ddwrt" + "github.com/Control-D-Inc/ctrld/internal/router/merlin" + "github.com/Control-D-Inc/ctrld/internal/router/tomato" + "github.com/Control-D-Inc/ctrld/internal/router/ubios" ) func init() { systems := []service.System{ &linuxSystemService{ name: "ddwrt", - detect: func() bool { return Name() == DDWrt }, + detect: func() bool { return Name() == ddwrt.Name }, interactive: func() bool { is, _ := isInteractive() return is @@ -21,7 +26,7 @@ func init() { }, &linuxSystemService{ name: "merlin", - detect: func() bool { return Name() == Merlin }, + detect: func() bool { return Name() == merlin.Name }, interactive: func() bool { is, _ := isInteractive() return is @@ -31,7 +36,7 @@ func init() { &linuxSystemService{ name: "ubios", detect: func() bool { - if Name() != Ubios { + if Name() != ubios.Name { return false } out, err := exec.Command("ubnt-device-info", "firmware").CombinedOutput() @@ -50,7 +55,7 @@ func init() { }, &linuxSystemService{ name: "tomato", - detect: func() bool { return Name() == Tomato }, + detect: func() bool { return Name() == tomato.Name }, interactive: func() bool { is, _ := isInteractive() return is diff --git a/internal/router/service_ddwrt.go b/internal/router/service_ddwrt.go index ac177f9..3217f8a 100644 --- a/internal/router/service_ddwrt.go +++ b/internal/router/service_ddwrt.go @@ -12,6 +12,8 @@ import ( "text/template" "github.com/kardianos/service" + + "github.com/Control-D-Inc/ctrld/internal/router/nvram" ) type ddwrtSvc struct { @@ -94,19 +96,19 @@ func (s *ddwrtSvc) Install() error { return err } s.rcStartup = sb.String() - curVal, err := nvram("get", nvramRCStartupKey) + curVal, err := nvram.Run("get", nvram.RCStartupKey) if err != nil { return err } - if _, err := nvram("set", nvramCtrldKeyPrefix+nvramRCStartupKey+"="+curVal); err != nil { + if _, err := nvram.Run("set", nvram.CtrldKeyPrefix+nvram.RCStartupKey+"="+curVal); err != nil { return err } val := strings.Join([]string{curVal, s.rcStartup + " &", fmt.Sprintf(`echo $! > "/tmp/%s.pid"`, s.Config.Name)}, "\n") - if _, err := nvram("set", nvramRCStartupKey+"="+val); err != nil { + if _, err := nvram.Run("set", nvram.RCStartupKey+"="+val); err != nil { return err } - if out, err := nvram("commit"); err != nil { + if out, err := nvram.Run("commit"); err != nil { return fmt.Errorf("%s: %w", out, err) } @@ -118,16 +120,16 @@ func (s *ddwrtSvc) Uninstall() error { return err } - ctrldStartupKey := nvramCtrldKeyPrefix + nvramRCStartupKey - rcStartup, err := nvram("get", ctrldStartupKey) + ctrldStartupKey := nvram.CtrldKeyPrefix + nvram.RCStartupKey + rcStartup, err := nvram.Run("get", ctrldStartupKey) if err != nil { return err } - _, _ = nvram("unset", ctrldStartupKey) - if _, err := nvram("set", nvramRCStartupKey+"="+rcStartup); err != nil { + _, _ = nvram.Run("unset", ctrldStartupKey) + if _, err := nvram.Run("set", nvram.RCStartupKey+"="+rcStartup); err != nil { return err } - if out, err := nvram("commit"); err != nil { + if out, err := nvram.Run("commit"); err != nil { return fmt.Errorf("%s: %w", out, err) } @@ -269,7 +271,7 @@ case "$1" in echo "failed to stop $name" exit 1 fi - exit 1 + exit 0 ;; restart) $0 stop diff --git a/internal/router/service_merlin.go b/internal/router/service_merlin.go index 3878c71..76ea938 100644 --- a/internal/router/service_merlin.go +++ b/internal/router/service_merlin.go @@ -13,6 +13,8 @@ import ( "text/template" "github.com/kardianos/service" + + "github.com/Control-D-Inc/ctrld/internal/router/nvram" ) const ( @@ -67,10 +69,10 @@ func (s *merlinSvc) Install() error { if !strings.HasPrefix(exePath, "/jffs/") { return errors.New("could not install service outside /jffs") } - if _, err := nvram("set", "jffs2_scripts=1"); err != nil { + if _, err := nvram.Run("set", "jffs2_scripts=1"); err != nil { return err } - if _, err := nvram("commit"); err != nil { + if _, err := nvram.Run("commit"); err != nil { return err } @@ -302,7 +304,7 @@ case "$1" in logger -c "failed to stop $name" exit 1 fi - exit 1 + exit 0 ;; restart) $0 stop diff --git a/internal/router/service_tomato.go b/internal/router/service_tomato.go index 8b7590c..1a7151a 100644 --- a/internal/router/service_tomato.go +++ b/internal/router/service_tomato.go @@ -12,6 +12,8 @@ import ( "text/template" "github.com/kardianos/service" + + "github.com/Control-D-Inc/ctrld/internal/router/nvram" ) const tomatoNvramScriptWanupKey = "script_wanup" @@ -63,10 +65,10 @@ func (s *tomatoSvc) Install() error { if !strings.HasPrefix(exePath, "/jffs/") { return errors.New("could not install service outside /jffs") } - if _, err := nvram("set", "jffs2_on=1"); err != nil { + if _, err := nvram.Run("set", "jffs2_on=1"); err != nil { return err } - if _, err := nvram("commit"); err != nil { + if _, err := nvram.Run("commit"); err != nil { return err } @@ -97,13 +99,15 @@ func (s *tomatoSvc) Install() error { return fmt.Errorf("os.Chmod: startup script: %w", err) } - nvramKvMap := nvramInstallKV() - old, err := nvram("get", tomatoNvramScriptWanupKey) + nvramKvMap := map[string]string{ + tomatoNvramScriptWanupKey: "", // script to start ctrld, filled by tomatoSvc.Install method. + } + old, err := nvram.Run("get", tomatoNvramScriptWanupKey) if err != nil { return fmt.Errorf("nvram: %w", err) } nvramKvMap[tomatoNvramScriptWanupKey] = strings.Join([]string{old, s.configPath() + " start"}, "\n") - if err := nvramSetKV(nvramKvMap, nvramCtrldInstallKey); err != nil { + if err := nvram.SetKV(nvramKvMap, nvram.CtrldInstallKey); err != nil { return err } return nil @@ -113,8 +117,11 @@ func (s *tomatoSvc) Uninstall() error { if err := os.Remove(s.configPath()); err != nil { return fmt.Errorf("os.Remove: %w", err) } + nvramKvMap := map[string]string{ + tomatoNvramScriptWanupKey: "", // script to start ctrld, filled by tomatoSvc.Install method. + } // Restore old configs. - if err := nvramRestore(nvramInstallKV(), nvramCtrldInstallKey); err != nil { + if err := nvram.Restore(nvramKvMap, nvram.CtrldInstallKey); err != nil { return err } return nil @@ -220,7 +227,7 @@ start() { stop() { if ! is_running; then elog "$NAME is not running." - exit 1 + exit 0 fi elog "Shutting down $NAME Services: " kill -SIGTERM "$(get_pid)" diff --git a/internal/router/service_ubios.go b/internal/router/service_ubios.go index 5c4d99d..0b49cd2 100644 --- a/internal/router/service_ubios.go +++ b/internal/router/service_ubios.go @@ -18,6 +18,9 @@ import ( // 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 diff --git a/internal/router/synology.go b/internal/router/synology.go deleted file mode 100644 index 8c1d1d6..0000000 --- a/internal/router/synology.go +++ /dev/null @@ -1,55 +0,0 @@ -package router - -import ( - "fmt" - "os" - "os/exec" -) - -const ( - synologyDNSMasqConfigPath = "/etc/dhcpd/dhcpd-zzz-ctrld.conf" - synologyDhcpdInfoPath = "/etc/dhcpd/dhcpd-zzz-ctrld.info" -) - -func setupSynology() error { - dnsMasqConfigContent, err := dnsMasqConf() - if err != nil { - return err - } - if err := os.WriteFile(synologyDNSMasqConfigPath, []byte(dnsMasqConfigContent), 0600); err != nil { - return err - } - if err := os.WriteFile(synologyDhcpdInfoPath, []byte(`enable="yes"`), 0600); err != nil { - return err - } - if err := restartDNSMasq(); err != nil { - return err - } - return nil -} - -func cleanupSynology() error { - // Remove the custom config files. - for _, f := range []string{synologyDNSMasqConfigPath, synologyDhcpdInfoPath} { - if err := os.Remove(f); err != nil { - return err - } - } - - // Restart dnsmasq service. - if err := restartDNSMasq(); err != nil { - return err - } - return nil -} - -func postInstallSynology() error { - return nil -} - -func synologyRestartDNSMasq() error { - if out, err := exec.Command("/etc/rc.network", "nat-restart-dhcp").CombinedOutput(); err != nil { - return fmt.Errorf("synologyRestartDNSMasq: %s - %w", string(out), err) - } - return nil -} diff --git a/internal/router/synology/synology.go b/internal/router/synology/synology.go new file mode 100644 index 0000000..7933943 --- /dev/null +++ b/internal/router/synology/synology.go @@ -0,0 +1,125 @@ +package synology + +import ( + "bytes" + "context" + "errors" + "fmt" + "os" + "os/exec" + "strings" + "time" + + "github.com/kardianos/service" + "tailscale.com/logtail/backoff" + + "github.com/Control-D-Inc/ctrld" + "github.com/Control-D-Inc/ctrld/internal/router/dnsmasq" + "github.com/Control-D-Inc/ctrld/internal/router/ntp" +) + +const ( + Name = "synology" + + synologyDNSMasqConfigPath = "/etc/dhcpd/dhcpd-zzz-ctrld.conf" + synologyDhcpdInfoPath = "/etc/dhcpd/dhcpd-zzz-ctrld.info" +) + +type Synology struct { + cfg *ctrld.Config + useUpstart bool +} + +// New returns a router.Router for configuring/setup/run ctrld on Ubios routers. +func New(cfg *ctrld.Config) *Synology { + return &Synology{ + cfg: cfg, + useUpstart: service.Platform() == "linux-upstart", + } +} + +func (s *Synology) ConfigureService(svc *service.Config) error { + svc.Option["LogOutput"] = true + return nil +} + +func (s *Synology) Install(_ *service.Config) error { + return nil +} + +func (s *Synology) Uninstall(_ *service.Config) error { + return nil +} + +func (s *Synology) PreRun() error { + if s.useUpstart { + if err := ntp.WaitUpstart(); err != nil { + return err + } + return waitDhcpServer() + } + return nil +} + +func (s *Synology) Setup() error { + if s.cfg.FirstListener().IsDirectDnsListener() { + return nil + } + data, err := dnsmasq.ConfTmpl(dnsmasq.ConfigContentTmpl, s.cfg) + if err != nil { + return err + } + if err := os.WriteFile(synologyDNSMasqConfigPath, []byte(data), 0600); err != nil { + return err + } + if err := os.WriteFile(synologyDhcpdInfoPath, []byte(`enable="yes"`), 0600); err != nil { + return err + } + if err := restartDNSMasq(); err != nil { + return err + } + return nil +} + +func (s *Synology) Cleanup() error { + if s.cfg.FirstListener().IsDirectDnsListener() { + return nil + } + // Remove the custom config files. + for _, f := range []string{synologyDNSMasqConfigPath, synologyDhcpdInfoPath} { + if err := os.Remove(f); err != nil { + return err + } + } + // Restart dnsmasq service. + if err := restartDNSMasq(); err != nil { + return err + } + return nil +} + +func restartDNSMasq() error { + if out, err := exec.Command("/etc/rc.network", "nat-restart-dhcp").CombinedOutput(); err != nil { + return fmt.Errorf("synologyRestartDNSMasq: %s - %w", string(out), err) + } + return nil +} + +func waitDhcpServer() error { + // Wait until `initctl status dhcpserver` returns running state. + b := backoff.NewBackoff("waitDhcpServer", func(format string, args ...any) {}, 10*time.Second) + for { + out, err := exec.Command("initctl", "status", "dhcpserver").CombinedOutput() + if err != nil { + if strings.Contains(err.Error(), "Unknown job") { + // dhcpserver service does not exist. + return nil + } + return fmt.Errorf("exec.Command: %w", err) + } + if bytes.Contains(out, []byte("start/running")) { + return nil + } + b.BackOff(context.Background(), errors.New("ntp not ready")) + } +} diff --git a/internal/router/tomato.go b/internal/router/tomato.go deleted file mode 100644 index 945e992..0000000 --- a/internal/router/tomato.go +++ /dev/null @@ -1,82 +0,0 @@ -package router - -import ( - "fmt" - "os/exec" -) - -const ( - tomatoDnsCryptProxySvcName = "dnscrypt-proxy" - tomatoStubbySvcName = "stubby" - tomatoDNSMasqSvcName = "dnsmasq" -) - -func setupTomato() error { - // Already setup. - if val, _ := nvram("get", nvramCtrldSetupKey); val == "1" { - return nil - } - - data, err := dnsMasqConf() - if err != nil { - return err - } - - nvramKvMap := nvramSetupKV() - nvramKvMap["dnsmasq_custom"] = data - if err := nvramSetKV(nvramKvMap, nvramCtrldSetupKey); err != nil { - return err - } - - // Restart dnscrypt-proxy service. - if err := tomatoRestartServiceWithKill(tomatoDnsCryptProxySvcName, true); err != nil { - return err - } - // Restart stubby service. - if err := tomatoRestartService(tomatoStubbySvcName); err != nil { - return err - } - // Restart dnsmasq service. - if err := restartDNSMasq(); err != nil { - return err - } - return nil -} - -func postInstallTomato() error { - return nil -} - -func cleanupTomato() error { - // Restore old configs. - if err := nvramRestore(nvramSetupKV(), nvramCtrldSetupKey); err != nil { - return err - } - // Restart dnscrypt-proxy service. - if err := tomatoRestartServiceWithKill(tomatoDnsCryptProxySvcName, true); err != nil { - return err - } - // Restart stubby service. - if err := tomatoRestartService(tomatoStubbySvcName); err != nil { - return err - } - // Restart dnsmasq service. - if err := restartDNSMasq(); err != nil { - return err - } - return nil -} - -func tomatoRestartService(name string) error { - return tomatoRestartServiceWithKill(name, false) -} - -func tomatoRestartServiceWithKill(name string, killBeforeRestart bool) error { - if killBeforeRestart { - _, _ = exec.Command("killall", name).CombinedOutput() - } - if out, err := exec.Command("service", name, "restart").CombinedOutput(); err != nil { - return fmt.Errorf("service restart %s: %s, %w", name, string(out), err) - } - return nil -} diff --git a/internal/router/tomato/tomato.go b/internal/router/tomato/tomato.go new file mode 100644 index 0000000..ee5f09b --- /dev/null +++ b/internal/router/tomato/tomato.go @@ -0,0 +1,133 @@ +package tomato + +import ( + "fmt" + "os/exec" + + "github.com/Control-D-Inc/ctrld" + "github.com/Control-D-Inc/ctrld/internal/router/dnsmasq" + "github.com/Control-D-Inc/ctrld/internal/router/ntp" + "github.com/Control-D-Inc/ctrld/internal/router/nvram" + "github.com/kardianos/service" +) + +const ( + Name = "freshtomato" + + tomatoDnsCryptProxySvcName = "dnscrypt-proxy" + tomatoStubbySvcName = "stubby" + tomatoDNSMasqSvcName = "dnsmasq" +) + +var nvramKvMap = map[string]string{ + "dnsmasq_custom": "", // Configuration of dnsmasq set by ctrld, filled by setupTomato. + "dnscrypt_proxy": "0", // Disable DNSCrypt. + "dnssec_enable": "0", // Disable DNSSEC. + "stubby_proxy": "0", // Disable Stubby +} + +type FreshTomato struct { + cfg *ctrld.Config +} + +// New returns a router.Router for configuring/setup/run ctrld on Ubios routers. +func New(cfg *ctrld.Config) *FreshTomato { + return &FreshTomato{cfg: cfg} +} + +func (f *FreshTomato) ConfigureService(config *service.Config) error { + return nil +} + +func (f *FreshTomato) Install(_ *service.Config) error { + return nil +} + +func (f *FreshTomato) Uninstall(_ *service.Config) error { + return nil +} + +func (f *FreshTomato) PreRun() error { + _ = f.Cleanup() + return ntp.WaitNvram() +} + +func (f *FreshTomato) Setup() error { + if f.cfg.FirstListener().IsDirectDnsListener() { + return nil + } + // Already setup. + if val, _ := nvram.Run("get", nvram.CtrldSetupKey); val == "1" { + return nil + } + + data, err := dnsmasq.ConfTmpl(dnsmasq.ConfigContentTmpl, f.cfg) + if err != nil { + return err + } + nvramKvMap["dnsmasq_custom"] = data + if err := nvram.SetKV(nvramKvMap, nvram.CtrldSetupKey); err != nil { + return err + } + + // Restart dnscrypt-proxy service. + if err := tomatoRestartServiceWithKill(tomatoDnsCryptProxySvcName, true); err != nil { + return err + } + // Restart stubby service. + if err := tomatoRestartService(tomatoStubbySvcName); err != nil { + return err + } + // Restart dnsmasq service. + if err := restartDNSMasq(); err != nil { + return err + } + return nil +} + +func (f *FreshTomato) Cleanup() error { + if f.cfg.FirstListener().IsDirectDnsListener() { + return nil + } + if val, _ := nvram.Run("get", nvram.CtrldSetupKey); val != "1" { + return nil // was restored, nothing to do. + } + + nvramKvMap["dnsmasq_custom"] = "" + // Restore old configs. + if err := nvram.Restore(nvramKvMap, nvram.CtrldSetupKey); err != nil { + return err + } + + // Restart dnscrypt-proxy service. + if err := tomatoRestartServiceWithKill(tomatoDnsCryptProxySvcName, true); err != nil { + return err + } + // Restart stubby service. + if err := tomatoRestartService(tomatoStubbySvcName); err != nil { + return err + } + // Restart dnsmasq service. + if err := restartDNSMasq(); err != nil { + return err + } + return nil +} + +func tomatoRestartService(name string) error { + return tomatoRestartServiceWithKill(name, false) +} + +func tomatoRestartServiceWithKill(name string, killBeforeRestart bool) error { + if killBeforeRestart { + _, _ = exec.Command("killall", name).CombinedOutput() + } + if out, err := exec.Command("service", name, "restart").CombinedOutput(); err != nil { + return fmt.Errorf("service restart %s: %s, %w", name, string(out), err) + } + return nil +} + +func restartDNSMasq() error { + return tomatoRestartService(tomatoDNSMasqSvcName) +} diff --git a/internal/router/ubios.go b/internal/router/ubios.go deleted file mode 100644 index 48e5d41..0000000 --- a/internal/router/ubios.go +++ /dev/null @@ -1,73 +0,0 @@ -package router - -import ( - "bytes" - "fmt" - "os" - "strconv" -) - -var errContentFilteringEnabled = fmt.Errorf(`the "Content Filtering" feature" is enabled, which is conflicted with ctrld.\n -To disable it, folowing instruction here: %s`, toggleContentFilteringLink) - -const ( - ubiosDNSMasqConfigPath = "/run/dnsmasq.conf.d/zzzctrld.conf" - toggleContentFilteringLink = "https://community.ui.com/questions/UDM-Pro-disable-enable-DNS-filtering/e2cc4060-e56a-4139-b200-62d7f773ff8f" -) - -func setupUbiOS() error { - // Disable dnsmasq as DNS server. - dnsMasqConfigContent, err := dnsMasqConf() - if err != nil { - return err - } - if err := os.WriteFile(ubiosDNSMasqConfigPath, []byte(dnsMasqConfigContent), 0600); err != nil { - return err - } - // Restart dnsmasq service. - if err := restartDNSMasq(); err != nil { - return err - } - return nil -} - -func cleanupUbiOS() error { - // Remove the custom dnsmasq config - if err := os.Remove(ubiosDNSMasqConfigPath); err != nil { - return err - } - // Restart dnsmasq service. - if err := restartDNSMasq(); err != nil { - return err - } - return nil -} - -func postInstallUbiOS() error { - // See comment in postInstallEdgeOS. - if contentFilteringEnabled() { - return errContentFilteringEnabled - } - return nil -} - -func ubiosRestartDNSMasq() error { - buf, err := os.ReadFile("/run/dnsmasq.pid") - if err != nil { - return err - } - pid, err := strconv.ParseUint(string(bytes.TrimSpace(buf)), 10, 64) - if err != nil { - return err - } - proc, err := os.FindProcess(int(pid)) - if err != nil { - return err - } - return proc.Kill() -} - -func contentFilteringEnabled() bool { - st, err := os.Stat("/run/dnsfilter/dnsfilter") - return err == nil && !st.IsDir() -} diff --git a/internal/router/ubios/ubios.go b/internal/router/ubios/ubios.go new file mode 100644 index 0000000..b0762db --- /dev/null +++ b/internal/router/ubios/ubios.go @@ -0,0 +1,96 @@ +package ubios + +import ( + "bytes" + "os" + "strconv" + + "github.com/Control-D-Inc/ctrld/internal/router/dnsmasq" + + "github.com/Control-D-Inc/ctrld" + "github.com/Control-D-Inc/ctrld/internal/router/edgeos" + "github.com/kardianos/service" +) + +const ( + Name = "ubios" + ubiosDNSMasqConfigPath = "/run/dnsmasq.conf.d/zzzctrld.conf" +) + +type Ubios struct { + cfg *ctrld.Config +} + +// New returns a router.Router for configuring/setup/run ctrld on Ubios routers. +func New(cfg *ctrld.Config) *Ubios { + return &Ubios{cfg: cfg} +} + +func (u *Ubios) ConfigureService(config *service.Config) error { + return nil +} + +func (u *Ubios) Install(config *service.Config) error { + // See comment in (*edgeos.EdgeOS).Install method. + if edgeos.ContentFilteringEnabled() { + return edgeos.ErrContentFilteringEnabled + } + return nil +} + +func (u *Ubios) Uninstall(_ *service.Config) error { + return nil +} + +func (u *Ubios) PreRun() error { + return nil +} + +func (u *Ubios) Setup() error { + if u.cfg.FirstListener().IsDirectDnsListener() { + return nil + } + data, err := dnsmasq.ConfTmpl(dnsmasq.ConfigContentTmpl, u.cfg) + if err != nil { + return err + } + if err := os.WriteFile(ubiosDNSMasqConfigPath, []byte(data), 0600); err != nil { + return err + } + // Restart dnsmasq service. + if err := restartDNSMasq(); err != nil { + return err + } + return nil +} + +func (u *Ubios) Cleanup() error { + if u.cfg.FirstListener().IsDirectDnsListener() { + return nil + } + // Remove the custom dnsmasq config + if err := os.Remove(ubiosDNSMasqConfigPath); err != nil { + return err + } + // Restart dnsmasq service. + if err := restartDNSMasq(); err != nil { + return err + } + return nil +} + +func restartDNSMasq() error { + buf, err := os.ReadFile("/run/dnsmasq.pid") + if err != nil { + return err + } + pid, err := strconv.ParseUint(string(bytes.TrimSpace(buf)), 10, 64) + if err != nil { + return err + } + proc, err := os.FindProcess(int(pid)) + if err != nil { + return err + } + return proc.Kill() +} diff --git a/log.go b/log.go index a4689b3..c521163 100644 --- a/log.go +++ b/log.go @@ -4,14 +4,24 @@ import ( "context" "fmt" "io" + "sync/atomic" "github.com/rs/zerolog" ) +func init() { + l := zerolog.New(io.Discard) + ProxyLogger.Store(&l) +} + // ProxyLog emits the log record for proxy operations. // The caller should set it only once. +// DEPRECATED: use ProxyLogger instead. var ProxyLog = zerolog.New(io.Discard) +// ProxyLogger emits the log record for proxy operations. +var ProxyLogger atomic.Pointer[zerolog.Logger] + // ReqIdCtxKey is the context.Context key for a request id. type ReqIdCtxKey struct{} diff --git a/resolver.go b/resolver.go index 3162d62..d2586ec 100644 --- a/resolver.go +++ b/resolver.go @@ -29,6 +29,13 @@ const ( var bootstrapDNS = "76.76.2.0" var or = &osResolver{nameservers: nameservers()} +func init() { + if len(or.nameservers) == 0 { + // Add bootstrap DNS in case we did not find any. + or.nameservers = []string{net.JoinHostPort(bootstrapDNS, "53")} + } +} + // Resolver is the interface that wraps the basic DNS operations. // // Resolve resolves the DNS query, return the result and the corresponding error. @@ -103,18 +110,6 @@ func (o *osResolver) Resolve(ctx context.Context, msg *dns.Msg) (*dns.Msg, error return nil, errors.Join(errs...) } -func newDialer(dnsAddress string) *net.Dialer { - return &net.Dialer{ - Resolver: &net.Resolver{ - PreferGo: true, - Dial: func(ctx context.Context, network, address string) (net.Conn, error) { - d := net.Dialer{} - return d.DialContext(ctx, network, dnsAddress) - }, - }, - } -} - type legacyResolver struct { uc *UpstreamConfig } @@ -142,6 +137,14 @@ func (r *legacyResolver) Resolve(ctx context.Context, msg *dns.Msg) (*dns.Msg, e return answer, err } +type dummyResolver struct{} + +func (d dummyResolver) Resolve(ctx context.Context, msg *dns.Msg) (*dns.Msg, error) { + ans := new(dns.Msg) + ans.SetReply(msg) + return ans, nil +} + // LookupIP looks up host using OS resolver. // It returns a slice of that host's IPv4 and IPv6 addresses. func LookupIP(domain string) []string { @@ -153,21 +156,33 @@ func lookupIP(domain string, timeout int, withBootstrapDNS bool) (ips []string) if withBootstrapDNS { resolver.nameservers = append([]string{net.JoinHostPort(bootstrapDNS, "53")}, resolver.nameservers...) } - ProxyLog.Debug().Msgf("Resolving %q using bootstrap DNS %q", domain, resolver.nameservers) + ProxyLogger.Load().Debug().Msgf("resolving %q using bootstrap DNS %q", domain, resolver.nameservers) timeoutMs := 2000 if timeout > 0 && timeout < timeoutMs { timeoutMs = timeout } questionDomain := dns.Fqdn(domain) - ipFromRecord := func(record dns.RR) string { + + // Getting the real target domain name from CNAME if presents. + targetDomain := func(answers []dns.RR) string { + for _, a := range answers { + switch ar := a.(type) { + case *dns.CNAME: + return ar.Target + } + } + return questionDomain + } + // Getting ip address from A or AAAA record. + ipFromRecord := func(record dns.RR, target string) string { switch ar := record.(type) { case *dns.A: - if ar.Hdr.Name != questionDomain { + if ar.Hdr.Name != target || len(ar.A) == 0 { return "" } return ar.A.String() case *dns.AAAA: - if ar.Hdr.Name != questionDomain { + if ar.Hdr.Name != target || len(ar.AAAA) == 0 { return "" } return ar.AAAA.String() @@ -184,19 +199,20 @@ func lookupIP(domain string, timeout int, withBootstrapDNS bool) (ips []string) r, err := resolver.Resolve(ctx, m) if err != nil { - ProxyLog.Error().Err(err).Msgf("could not lookup %q record for domain %q", dns.TypeToString[dnsType], domain) + ProxyLogger.Load().Error().Err(err).Msgf("could not lookup %q record for domain %q", dns.TypeToString[dnsType], domain) return } if r.Rcode != dns.RcodeSuccess { - ProxyLog.Error().Msgf("could not resolve domain %q, return code: %s", domain, dns.RcodeToString[r.Rcode]) + ProxyLogger.Load().Error().Msgf("could not resolve domain %q, return code: %s", domain, dns.RcodeToString[r.Rcode]) return } if len(r.Answer) == 0 { - ProxyLog.Error().Msg("no answer from OS resolver") + ProxyLogger.Load().Error().Msg("no answer from OS resolver") return } + target := targetDomain(r.Answer) for _, a := range r.Answer { - if ip := ipFromRecord(a); ip != "" { + if ip := ipFromRecord(a, target); ip != "" { ips = append(ips, ip) } } @@ -210,7 +226,6 @@ func lookupIP(domain string, timeout int, withBootstrapDNS bool) (ips []string) // NewBootstrapResolver returns an OS resolver, which use following nameservers: // -// - ControlD bootstrap DNS server. // - Gateway IP address (depends on OS). // - Input servers. func NewBootstrapResolver(servers ...string) Resolver { @@ -221,3 +236,36 @@ func NewBootstrapResolver(servers ...string) Resolver { } return resolver } + +// NewPrivateResolver returns an OS resolver, which includes only private DNS servers. +// This is useful for doing PTR lookup in LAN network. +func NewPrivateResolver() Resolver { + nss := nameservers() + n := 0 + for _, ns := range nss { + host, _, _ := net.SplitHostPort(ns) + ip := net.ParseIP(host) + if ip != nil && ip.IsPrivate() && !ip.IsLoopback() { + nss[n] = ns + n++ + } + } + nss = nss[:n] + if len(nss) == 0 { + return &dummyResolver{} + } + resolver := &osResolver{nameservers: nss} + return resolver +} + +func newDialer(dnsAddress string) *net.Dialer { + return &net.Dialer{ + Resolver: &net.Resolver{ + PreferGo: true, + Dial: func(ctx context.Context, network, address string) (net.Conn, error) { + d := net.Dialer{} + return d.DialContext(ctx, network, dnsAddress) + }, + }, + } +} diff --git a/testhelper/config.go b/testhelper/config.go index 0b739f0..5c2e5f4 100644 --- a/testhelper/config.go +++ b/testhelper/config.go @@ -19,6 +19,10 @@ func SampleConfig(t *testing.T) *ctrld.Config { return &cfg } +func SampleConfigStr(t *testing.T) string { + return sampleConfigContent +} + var sampleConfigContent = ` [service] log_level = "info"