diff --git a/cmd/ctrld/cli.go b/cmd/ctrld/cli.go index f510aa6..6b452bf 100644 --- a/cmd/ctrld/cli.go +++ b/cmd/ctrld/cli.go @@ -200,7 +200,7 @@ func initCLI() { os.Exit(0) } - if runtime.GOOS == "linux" && onRouter { + if router.Name() != "" { mainLog.Debug().Msg("Router setup") err := router.Configure(&cfg) if errors.Is(err, router.ErrNotSupported) { @@ -230,8 +230,6 @@ func initCLI() { _ = 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(&onRouter, "router", "", false, `Configure onRouter for running ctrld`) - _ = runCmd.Flags().MarkHidden("router") rootCmd.AddCommand(runCmd) @@ -249,6 +247,7 @@ func initCLI() { } setDependencies(sc) sc.Arguments = append([]string{"run"}, osArgs...) + router.ConfigureService(sc) // No config path, generating config in HOME directory. noConfigStart := isNoConfigStart(cmd) @@ -276,12 +275,12 @@ func initCLI() { cfg.Service.LogPath = logPath processCDFlags() - // On Windows, the service will be run as SYSTEM, so if ctrld start as Admin, - // the user home dir is different, so pass specific arguments that relevant here. - if runtime.GOOS == "windows" { - if configPath == "" { - sc.Arguments = append(sc.Arguments, "--config="+defaultConfigFile) - } + + // 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{} @@ -297,7 +296,11 @@ func initCLI() { {s.Start, true}, } if doTasks(tasks) { - status, err := s.Status() + if err := router.PostInstall(); 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 @@ -329,8 +332,6 @@ func initCLI() { startCmd.Flags().IntVarP(&cacheSize, "cache_size", "", 0, "Enable cache with size items") startCmd.Flags().StringVarP(&cdUID, "cd", "", "", "Control D resolver uid") startCmd.Flags().StringVarP(&iface, "iface", "", "", `Update DNS setting for iface, "auto" means the default interface gateway`) - startCmd.Flags().BoolVarP(&onRouter, "router", "", false, `Configure onRouter for running ctrld`) - _ = startCmd.Flags().MarkHidden("router") stopCmd := &cobra.Command{ PreRun: checkHasElevatedPrivilege, @@ -381,7 +382,7 @@ func initCLI() { stderrMsg(err.Error()) return } - status, err := s.Status() + status, err := serviceStatus(s) if err != nil { stderrMsg(err.Error()) os.Exit(1) @@ -429,6 +430,9 @@ NOTE: Uninstalling will set DNS to values provided by DHCP.`, iface = "auto" } prog.resetDNS() + if err := router.Cleanup(); err != nil { + mainLog.Warn().Err(err).Msg("could not cleanup router") + } mainLog.Info().Msg("Service uninstalled") return } @@ -815,5 +819,5 @@ func selfCheckStatus(status service.Status) service.Status { } func unsupportedPlatformHelp(cmd *cobra.Command) { - cmd.PrintErrln("Unsupported or incorrectly chosen onRouter platform. Please open an issue and provide all relevant information: https://github.com/Control-D-Inc/ctrld/issues/new") + cmd.PrintErrln("Unsupported or incorrectly chosen router platform. Please open an issue and provide all relevant information: https://github.com/Control-D-Inc/ctrld/issues/new") } diff --git a/cmd/ctrld/cli_router_linux.go b/cmd/ctrld/cli_router_linux.go index b1dc4b4..c927010 100644 --- a/cmd/ctrld/cli_router_linux.go +++ b/cmd/ctrld/cli_router_linux.go @@ -14,7 +14,7 @@ import ( func initRouterCLI() { validArgs := append(router.SupportedPlatforms(), "auto") var b strings.Builder - b.WriteString("Auto-setup Control D on a onRouter.\n\nSupported platforms:\n\n") + b.WriteString("Auto-setup Control D on a router.\n\nSupported platforms:\n\n") for _, arg := range validArgs { b.WriteString(" ₒ ") b.WriteString(arg) @@ -52,8 +52,7 @@ func initRouterCLI() { } cmdArgs := []string{"start"} - cmdArgs = append(cmdArgs, os.Args[3:]...) - cmdArgs = append(cmdArgs, "--router=true") + cmdArgs = append(cmdArgs, osArgs(platform)...) command := exec.Command(exe, cmdArgs...) command.Stdout = os.Stdout command.Stderr = os.Stderr @@ -63,8 +62,32 @@ func initRouterCLI() { } }, } + // 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().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/dns_proxy.go b/cmd/ctrld/dns_proxy.go index ee95cb4..0821813 100644 --- a/cmd/ctrld/dns_proxy.go +++ b/cmd/ctrld/dns_proxy.go @@ -17,10 +17,17 @@ import ( "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 staleTTL = 60 * time.Second +var osUpstreamConfig = &ctrld.UpstreamConfig{ + Name: "OS resolver", + Type: ctrld.ResolverTypeOS, + Timeout: 2000, +} + func (p *prog) serveDNS(listenerNum string) error { listenerConfig := p.cfg.Listener[listenerNum] // make sure ip is allocated @@ -61,7 +68,7 @@ func (p *prog) serveDNS(listenerNum string) error { proto := proto // On Windows, there's no easy way for disabling/removing IPv6 DNS resolver, so we check whether we can // listen on ::1, then spawn a listener for receiving DNS requests. - if runtime.GOOS == "windows" && ctrldnet.SupportsIPv6ListenLocal() { + if needLocalIPv6Listener() { g.Go(func() error { s := &dns.Server{ Addr: net.JoinHostPort("::1", strconv.Itoa(listenerConfig.Port)), @@ -80,7 +87,7 @@ func (p *prog) serveDNS(listenerNum string) error { } g.Go(func() error { s := &dns.Server{ - Addr: net.JoinHostPort(listenerConfig.IP, strconv.Itoa(listenerConfig.Port)), + Addr: dnsListenAddress(listenerConfig), Net: proto, Handler: handler, } @@ -353,8 +360,13 @@ func ttlFromMsg(msg *dns.Msg) uint32 { return 0 } -var osUpstreamConfig = &ctrld.UpstreamConfig{ - Name: "OS resolver", - Type: ctrld.ResolverTypeOS, - Timeout: 2000, +func needLocalIPv6Listener() bool { + return ctrldnet.SupportsIPv6ListenLocal() && runtime.GOOS == "windows" +} + +func dnsListenAddress(lc *ctrld.ListenerConfig) string { + if addr := router.ListenAddress(); addr != "" { + return addr + } + return net.JoinHostPort(lc.IP, strconv.Itoa(lc.Port)) } diff --git a/cmd/ctrld/main.go b/cmd/ctrld/main.go index 078c5db..656e2e8 100644 --- a/cmd/ctrld/main.go +++ b/cmd/ctrld/main.go @@ -29,7 +29,6 @@ var ( cdUID string iface string ifaceStartStop string - onRouter bool mainLog = zerolog.New(io.Discard) ) diff --git a/cmd/ctrld/prog.go b/cmd/ctrld/prog.go index 0206402..5075630 100644 --- a/cmd/ctrld/prog.go +++ b/cmd/ctrld/prog.go @@ -25,6 +25,7 @@ var errWindowsAddrInUse = syscall.Errno(0x2740) var svcConfig = &service.Config{ Name: "ctrld", DisplayName: "Control-D Helper Service", + Option: service.KeyValue{}, } type prog struct { diff --git a/cmd/ctrld/service.go b/cmd/ctrld/service.go index 14834c6..0da893f 100644 --- a/cmd/ctrld/service.go +++ b/cmd/ctrld/service.go @@ -1,9 +1,12 @@ package main import ( + "bytes" "fmt" "os" + "os/exec" + "github.com/kardianos/service" "github.com/spf13/cobra" ) @@ -43,3 +46,24 @@ func checkHasElevatedPrivilege(cmd *cobra.Command, args []string) { 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/internal/router/openwrt.go b/internal/router/openwrt.go new file mode 100644 index 0000000..be1316b --- /dev/null +++ b/internal/router/openwrt.go @@ -0,0 +1,74 @@ +package router + +import ( + "bytes" + "errors" + "fmt" + "os" + "os/exec" + "strings" +) + +var errUCIEntryNotFound = errors.New("uci: Entry not found") + +const openwrtDNSMasqConfigPath = "/tmp/dnsmasq.d/ctrld.conf" +const openwrtDNSMasqConfigContent = `# GENERATED BY ctrld - DO NOT MODIFY +port=0 +` + +func setupOpenWrt() error { + // Delete dnsmasq port if set. + if _, err := uci("delete", "dhcp.@dnsmasq[0].port"); err != nil && !errors.Is(err, errUCIEntryNotFound) { + return err + } + // Disable dnsmasq as DNS server. + if err := os.WriteFile(openwrtDNSMasqConfigPath, []byte(openwrtDNSMasqConfigContent), 0600); err != nil { + return err + } + // Commit. + if _, err := uci("commit"); err != nil { + return err + } + // Restart dnsmasq service. + if err := restartDNSMasq(); err != nil { + return err + } + return nil +} + +func cleanupOpenWrt() error { + // Remove the custom dnsmasq config + if err := os.Remove(openwrtDNSMasqConfigPath); err != nil { + return err + } + // Restart dnsmasq service. + if err := restartDNSMasq(); err != nil { + return err + } + return nil +} + +func postInstallOpenWrt() error { + return exec.Command("/etc/init.d/ctrld", "enable").Run() +} + +func uci(args ...string) (string, error) { + cmd := exec.Command("uci", args...) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + if strings.HasPrefix(stderr.String(), errUCIEntryNotFound.Error()) { + return "", errUCIEntryNotFound + } + return "", fmt.Errorf("%s:%w", stderr.String(), err) + } + return strings.TrimSpace(stdout.String()), nil +} + +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 +} diff --git a/internal/router/procd.go b/internal/router/procd.go new file mode 100644 index 0000000..d363f39 --- /dev/null +++ b/internal/router/procd.go @@ -0,0 +1,24 @@ +package router + +const openWrtScript = `#!/bin/sh /etc/rc.common +USE_PROCD=1 +# After network starts +START=21 +# Before network stops +STOP=89 +cmd="{{.Path}}{{range .Arguments}} {{.|cmd}}{{end}}" +name="{{.Name}}" +pid_file="/var/run/${name}.pid" + +start_service() { + echo "Starting ${name}" + procd_open_instance + procd_set_param command ${cmd} + procd_set_param respawn # respawn automatically if something died + procd_set_param stdout 1 # forward stdout of the command to logd + procd_set_param stderr 1 # same for stderr + procd_set_param pidfile ${pid_file} # write a pid file on instance start and remove it on stop + procd_close_instance + echo "${name} has been started" +} +` diff --git a/internal/router/router.go b/internal/router/router.go index cb8b2b2..3fc5af5 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -8,6 +8,8 @@ import ( "os/exec" "sync/atomic" + "github.com/kardianos/service" + "github.com/Control-D-Inc/ctrld" ) @@ -21,7 +23,7 @@ const ( // ErrNotSupported reports the current router is not supported error. var ErrNotSupported = errors.New("unsupported platform") -var routerAtomic atomic.Pointer[router] +var routerPlatform atomic.Pointer[router] type router struct { name string @@ -32,11 +34,13 @@ func SupportedPlatforms() []string { return []string{DDWrt, Merlin, OpenWrt, Ubios} } -// Configure change the given *ctrld.Config for running on the router. +// Configure configures things for running ctrld on the router. func Configure(c *ctrld.Config) error { name := Name() switch name { - case DDWrt, Merlin, OpenWrt, Ubios: + case OpenWrt: + return setupOpenWrt() + case DDWrt, Merlin, Ubios: default: return ErrNotSupported } @@ -45,14 +49,57 @@ func Configure(c *ctrld.Config) error { return nil } +// ConfigureService performs necessary setup for running ctrld as a service on router. +func ConfigureService(sc *service.Config) { + name := Name() + switch name { + case OpenWrt: + sc.Option["SysvScript"] = openWrtScript + case DDWrt, Merlin, Ubios: + } +} + +// PostInstall performs task after installing ctrld on router. +func PostInstall() error { + name := Name() + switch name { + case OpenWrt: + return postInstallOpenWrt() + case DDWrt, Merlin, Ubios: + } + return nil +} + +// Cleanup cleans ctrld setup on the router. +func Cleanup() error { + name := Name() + switch name { + case OpenWrt: + return cleanupOpenWrt() + case DDWrt, Merlin, Ubios: + } + return nil +} + +// ListenAddress returns the listener address of ctrld on router. +func ListenAddress() string { + name := Name() + switch name { + case OpenWrt: + return ":53" + case DDWrt, Merlin, Ubios: + } + return "" +} + // Name returns name of the router platform. func Name() string { - if r := routerAtomic.Load(); r != nil { + if r := routerPlatform.Load(); r != nil { return r.name } r := &router{} r.name = distroName() - routerAtomic.Store(r) + routerPlatform.Store(r) return r.name }