From a22f0579d55e6a9e74ecbaf351c7ea71d2b4b779 Mon Sep 17 00:00:00 2001 From: Cuong Manh Le Date: Tue, 29 Jul 2025 16:46:46 +0700 Subject: [PATCH] refactor: split ServiceCommand methods into dedicated files - Move ServiceCommand.Start to commands_service_start.go - Move ServiceCommand.Stop to commands_service_stop.go - Move ServiceCommand.Restart to commands_service_restart.go - Move ServiceCommand.Reload to commands_service_reload.go - Move ServiceCommand.Status to commands_service_status.go - Move ServiceCommand.Uninstall to commands_service_uninstall.go - Move createStartCommands to commands_service_start.go - Clean up imports in commands_service.go - Remove all method implementations from main service file This refactoring improves code organization by: - Separating concerns into focused files - Making navigation easier for developers - Reducing merge conflicts between different commands - Following consistent modular patterns - Reducing commands_service.go from ~650 lines to ~50 lines Each method is now co-located with its related functionality, making the codebase more maintainable and easier to understand. --- cmd/cli/commands_service.go | 615 -------------------------- cmd/cli/commands_service_reload.go | 53 +++ cmd/cli/commands_service_restart.go | 80 ++++ cmd/cli/commands_service_start.go | 379 ++++++++++++++++ cmd/cli/commands_service_status.go | 29 ++ cmd/cli/commands_service_stop.go | 43 ++ cmd/cli/commands_service_uninstall.go | 86 ++++ 7 files changed, 670 insertions(+), 615 deletions(-) create mode 100644 cmd/cli/commands_service_reload.go create mode 100644 cmd/cli/commands_service_restart.go create mode 100644 cmd/cli/commands_service_start.go create mode 100644 cmd/cli/commands_service_status.go create mode 100644 cmd/cli/commands_service_stop.go create mode 100644 cmd/cli/commands_service_uninstall.go diff --git a/cmd/cli/commands_service.go b/cmd/cli/commands_service.go index 6e57980..ad078cc 100644 --- a/cmd/cli/commands_service.go +++ b/cmd/cli/commands_service.go @@ -1,24 +1,12 @@ package cli import ( - "bytes" - "context" - "encoding/json" - "errors" "fmt" - "io" - "net" - "net/http" "os" - "path/filepath" "runtime" - "strings" - "time" "github.com/kardianos/service" "github.com/spf13/cobra" - - "github.com/Control-D-Inc/ctrld" ) // filterEmptyStrings removes empty strings from a slice @@ -59,609 +47,6 @@ func (sc *ServiceCommand) createServiceConfig() *service.Config { } } -// Start implements the logic from cmdStart.Run -func (sc *ServiceCommand) Start(cmd *cobra.Command, args []string) error { - s := sc.serviceManager.svc - p := sc.serviceManager.prog - checkStrFlagEmpty(cmd, cdUidFlagName) - checkStrFlagEmpty(cmd, cdOrgFlagName) - validateCdAndNextDNSFlags() - - svcConfig := sc.createServiceConfig() - osArgs := os.Args[2:] - osArgs = filterEmptyStrings(osArgs) - if os.Args[1] == "service" { - osArgs = os.Args[3:] - } - setDependencies(svcConfig) - svcConfig.Arguments = append([]string{"run"}, osArgs...) - - p.cfg = &cfg - p.preRun() - - status, err := s.Status() - isCtrldRunning := status == service.StatusRunning - isCtrldInstalled := !errors.Is(err, service.ErrNotInstalled) - - // Get current running iface, if any. - var currentIface *ifaceResponse - - // If pin code was set, do not allow running start command. - if isCtrldRunning { - if err := checkDeactivationPin(s, nil); isCheckDeactivationPinErr(err) { - os.Exit(deactivationPinInvalidExitCode) - } - currentIface = runningIface(s) - mainLog.Load().Debug().Msgf("current interface on start: %v", currentIface) - } - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - reportSetDnsOk := func(sockDir string) { - if cc := newSocketControlClient(ctx, s, sockDir); cc != nil { - if resp, _ := cc.post(ifacePath, nil); resp != nil && resp.StatusCode == http.StatusOK { - if iface == "auto" { - iface = defaultIfaceName() - } - res := &ifaceResponse{} - if err := json.NewDecoder(resp.Body).Decode(res); err != nil { - mainLog.Load().Warn().Err(err).Msg("failed to get iface info") - return - } - if res.OK { - name := res.Name - if iff, err := net.InterfaceByName(name); err == nil { - _, _ = patchNetIfaceName(iff) - name = iff.Name - } - logger := mainLog.Load().With().Str("iface", name) - logger.Debug().Msg("setting DNS successfully") - if res.All { - // Log that DNS is set for other interfaces. - withEachPhysicalInterfaces( - name, - "set DNS", - func(i *net.Interface) error { return nil }, - ) - } - } - } - } - } - - // No config path, generating config in HOME directory. - noConfigStart := isNoConfigStart(cmd) - writeDefaultConfig := !noConfigStart && configBase64 == "" - - logServerStarted := make(chan struct{}) - // A buffer channel to gather log output from runCmd and report - // to user in case self-check process failed. - runCmdLogCh := make(chan string, 256) - ud, err := userHomeDir() - sockDir := ud - if err != nil { - mainLog.Load().Warn().Msg("log server did not start") - close(logServerStarted) - } else { - setWorkingDirectory(svcConfig, ud) - if configPath == "" && writeDefaultConfig { - defaultConfigFile = filepath.Join(ud, defaultConfigFile) - } - svcConfig.Arguments = append(svcConfig.Arguments, "--homedir="+ud) - if d, err := socketDir(); err == nil { - sockDir = d - } - sockPath := filepath.Join(sockDir, ctrldLogUnixSock) - _ = os.Remove(sockPath) - go func() { - defer func() { - close(runCmdLogCh) - _ = os.Remove(sockPath) - }() - close(logServerStarted) - 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 - } - msg := string(buf[:n]) - if _, _, found := strings.Cut(msg, msgExit); found { - cancel() - } - runCmdLogCh <- msg - } - } - }() - } - <-logServerStarted - - if !startOnly { - startOnly = len(osArgs) == 0 - } - // If user run "ctrld start" and ctrld is already installed, starting existing service. - if startOnly && isCtrldInstalled { - tryReadingConfigWithNotice(false, true) - if err := v.Unmarshal(&cfg); err != nil { - mainLog.Load().Fatal().Msgf("failed to unmarshal config: %v", err) - } - - // if already running, dont restart - if isCtrldRunning { - mainLog.Load().Notice().Msg("service is already running") - return nil - } - - initInteractiveLogging() - tasks := []task{ - {func() error { - // Save current DNS so we can restore later. - withEachPhysicalInterfaces("", "saveCurrentStaticDNS", func(i *net.Interface) error { - if err := saveCurrentStaticDNS(i); !errors.Is(err, errSaveCurrentStaticDNSNotSupported) && err != nil { - return err - } - return nil - }) - return nil - }, false, "Save current DNS"}, - {func() error { - return ConfigureWindowsServiceFailureActions(ctrldServiceName) - }, false, "Configure service failure actions"}, - {s.Start, true, "Start"}, - {noticeWritingControlDConfig, false, "Notice writing ControlD config"}, - } - mainLog.Load().Notice().Msg("Starting existing ctrld service") - if doTasks(tasks) { - mainLog.Load().Notice().Msg("Service started") - sockDir, err := socketDir() - if err != nil { - mainLog.Load().Warn().Err(err).Msg("Failed to get socket directory") - os.Exit(1) - } - reportSetDnsOk(sockDir) - } else { - mainLog.Load().Error().Err(err).Msg("Failed to start existing ctrld service") - os.Exit(1) - } - return nil - } - - if cdUID != "" { - _ = doValidateCdRemoteConfig(cdUID, true) - } else if uid := cdUIDFromProvToken(); uid != "" { - cdUID = uid - mainLog.Load().Debug().Msg("using uid from provision token") - removeOrgFlagsFromArgs(svcConfig) - // Pass --cd flag to "ctrld run" command, so the provision token takes no effect. - svcConfig.Arguments = append(svcConfig.Arguments, "--cd="+cdUID) - } - if cdUID != "" { - validateCdUpstreamProtocol() - } - - if configPath != "" { - v.SetConfigFile(configPath) - } - - tryReadingConfigWithNotice(writeDefaultConfig, true) - - if err := v.Unmarshal(&cfg); err != nil { - mainLog.Load().Fatal().Msgf("failed to unmarshal config: %v", err) - } - - initInteractiveLogging() - - if nextdns != "" { - removeNextDNSFromArgs(svcConfig) - } - - // 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 == "" { - svcConfig.Arguments = append(svcConfig.Arguments, "--config="+defaultConfigFile) - } - - tasks := []task{ - {s.Stop, false, "Stop"}, - {func() error { return doGenerateNextDNSConfig(nextdns) }, true, "Checking config"}, - {func() error { return ensureUninstall(s) }, false, "Ensure uninstall"}, - //resetDnsTask(p, s, isCtrldInstalled, currentIface), - {func() error { - // Save current DNS so we can restore later. - withEachPhysicalInterfaces("", "saveCurrentStaticDNS", func(i *net.Interface) error { - if err := saveCurrentStaticDNS(i); !errors.Is(err, errSaveCurrentStaticDNSNotSupported) && err != nil { - return err - } - return nil - }) - return nil - }, false, "Save current DNS"}, - {s.Install, false, "Install"}, - {func() error { - return ConfigureWindowsServiceFailureActions(ctrldServiceName) - }, false, "Configure Windows service failure actions"}, - {s.Start, true, "Start"}, - // Note that startCmd do not actually write ControlD config, but the config file was - // generated after s.Start, so we notice users here for consistent with nextdns mode. - {noticeWritingControlDConfig, false, "Notice writing ControlD config"}, - } - mainLog.Load().Notice().Msg("Starting service") - if doTasks(tasks) { - // add a small delay to ensure the service is started and did not crash - time.Sleep(1 * time.Second) - - ok, status, err := selfCheckStatus(ctx, s, sockDir) - switch { - case ok && status == service.StatusRunning: - mainLog.Load().Notice().Msg("Service started") - default: - marker := bytes.Repeat([]byte("="), 32) - // If ctrld service is not running, emitting log obtained from ctrld process. - if status != service.StatusRunning || ctx.Err() != nil { - mainLog.Load().Error().Msg("ctrld service may not have started due to an error or misconfiguration, service log:") - _, _ = mainLog.Load().Write(marker) - haveLog := false - for msg := range runCmdLogCh { - _, _ = mainLog.Load().Write([]byte(strings.ReplaceAll(msg, msgExit, ""))) - haveLog = true - } - // If we're unable to get log from "ctrld run", notice users about it. - if !haveLog { - mainLog.Load().Write([]byte(`"`)) - } - } - // Report any error if occurred. - if err != nil { - _, _ = mainLog.Load().Write(marker) - msg := fmt.Sprintf("An error occurred while performing test query: %s", err) - mainLog.Load().Write([]byte(msg)) - } - // If ctrld service is running but selfCheckStatus failed, it could be related - // to user's system firewall configuration, notice users about it. - if status == service.StatusRunning && err == nil { - _, _ = mainLog.Load().Write(marker) - mainLog.Load().Write([]byte(`ctrld service was running, but a DNS query could not be sent to its listener`)) - mainLog.Load().Write([]byte(`Please check your system firewall if it is configured to block/intercept/redirect DNS queries`)) - } - - _, _ = mainLog.Load().Write(marker) - uninstall(p, s) - os.Exit(1) - } - reportSetDnsOk(sockDir) - } - - return nil -} - -// Stop implements the logic from cmdStop.Run -func (sc *ServiceCommand) Stop(cmd *cobra.Command, args []string) error { - s := sc.serviceManager.svc - p := sc.serviceManager.prog - readConfig(false) - v.Unmarshal(&cfg) - p.cfg = &cfg - p.preRun() - if ir := runningIface(s); ir != nil { - p.runningIface = ir.Name - p.requiredMultiNICsConfig = ir.All - } - - initInteractiveLogging() - - status, err := s.Status() - if errors.Is(err, service.ErrNotInstalled) { - mainLog.Load().Warn().Msg("service not installed") - return nil - } - if status == service.StatusStopped { - mainLog.Load().Warn().Msg("service is already stopped") - return nil - } - - if err := checkDeactivationPin(s, nil); isCheckDeactivationPinErr(err) { - os.Exit(deactivationPinInvalidExitCode) - } - if doTasks([]task{{s.Stop, true, "Stop"}}) { - mainLog.Load().Notice().Msg("Service stopped") - } - return nil -} - -// Restart implements the logic from cmdRestart.Run -func (sc *ServiceCommand) Restart(cmd *cobra.Command, args []string) error { - s := sc.serviceManager.svc - p := sc.serviceManager.prog - readConfig(false) - v.Unmarshal(&cfg) - cdUID = curCdUID() - cdMode := cdUID != "" - - p.cfg = &cfg - if iface == "" { - iface = "auto" - } - p.preRun() - if ir := runningIface(s); ir != nil { - p.runningIface = ir.Name - p.requiredMultiNICsConfig = ir.All - } - - initInteractiveLogging() - - var validateConfigErr error - if cdMode { - validateConfigErr = doValidateCdRemoteConfig(cdUID, false) - } - - if ir := runningIface(s); ir != nil { - iface = ir.Name - } - doRestart := func() bool { - tasks := []task{ - {s.Stop, true, "Stop"}, - {func() error { - // restore static DNS settings or DHCP - p.resetDNS(false, true) - return nil - }, false, "Cleanup"}, - {func() error { - time.Sleep(time.Second * 1) - return nil - }, false, "Waiting for service to stop"}, - } - if !doTasks(tasks) { - return false - } - tasks = []task{ - {s.Start, true, "Start"}, - } - return doTasks(tasks) - } - - if doRestart() { - if dir, err := socketDir(); err == nil { - timeout := dialSocketControlServerTimeout - if validateConfigErr != nil { - timeout = 5 * time.Second - } - if cc := newSocketControlClientWithTimeout(context.TODO(), s, dir, timeout); cc != nil { - _, _ = cc.post(ifacePath, nil) - } else { - mainLog.Load().Warn().Err(err).Msg("Service was restarted, but ctrld process may not be ready yet") - } - } else { - mainLog.Load().Warn().Err(err).Msg("Service was restarted, but could not ping the control server") - } - mainLog.Load().Notice().Msg("Service restarted") - } else { - mainLog.Load().Error().Msg("Service restart failed") - } - return nil -} - -// Reload implements the logic from cmdReload.Run -func (sc *ServiceCommand) Reload(cmd *cobra.Command, args []string) error { - status, err := sc.serviceManager.svc.Status() - if errors.Is(err, service.ErrNotInstalled) { - mainLog.Load().Warn().Msg("service not installed") - return nil - } - if status == service.StatusStopped { - mainLog.Load().Warn().Msg("service is not running") - return nil - } - dir, err := socketDir() - 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(reloadPath, nil) - if err != nil { - mainLog.Load().Fatal().Err(err).Msg("failed to send reload signal to ctrld") - } - defer resp.Body.Close() - switch resp.StatusCode { - case http.StatusOK: - mainLog.Load().Notice().Msg("Service reloaded") - case http.StatusCreated: - mainLog.Load().Warn().Msg("Service was reloaded, but new config requires service restart.") - mainLog.Load().Warn().Msg("Restarting service") - if _, err := sc.serviceManager.svc.Status(); errors.Is(err, service.ErrNotInstalled) { - mainLog.Load().Warn().Msg("Service not installed") - return nil - } - return sc.Restart(cmd, args) - default: - buf, err := io.ReadAll(resp.Body) - if err != nil { - mainLog.Load().Fatal().Err(err).Msg("could not read response from control server") - } - mainLog.Load().Error().Err(err).Msgf("failed to reload ctrld: %s", string(buf)) - } - return nil -} - -// Status implements the logic from cmdStatus.Run -func (sc *ServiceCommand) Status(cmd *cobra.Command, args []string) error { - status, err := sc.serviceManager.svc.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) - } - return nil -} - -// Uninstall implements the logic from cmdUninstall.Run -func (sc *ServiceCommand) Uninstall(cmd *cobra.Command, args []string) error { - s := sc.serviceManager.svc - p := sc.serviceManager.prog - readConfig(false) - v.Unmarshal(&cfg) - p.cfg = &cfg - if iface == "" { - iface = "auto" - } - p.preRun() - if ir := runningIface(s); ir != nil { - p.runningIface = ir.Name - p.requiredMultiNICsConfig = ir.All - } - if err := checkDeactivationPin(s, nil); isCheckDeactivationPinErr(err) { - os.Exit(deactivationPinInvalidExitCode) - } - uninstall(p, s) - if cleanup { - var files []string - // Config file. - files = append(files, v.ConfigFileUsed()) - // Log file and backup log file. - // For safety, only process if log file path is absolute. - if logFile := normalizeLogFilePath(cfg.Service.LogPath); filepath.IsAbs(logFile) { - files = append(files, logFile) - oldLogFile := logFile + oldLogSuffix - if _, err := os.Stat(oldLogFile); err == nil { - files = append(files, oldLogFile) - } - } - // Socket files. - if dir, _ := socketDir(); dir != "" { - files = append(files, filepath.Join(dir, ctrldControlUnixSock)) - files = append(files, filepath.Join(dir, ctrldLogUnixSock)) - } - // Static DNS settings files. - withEachPhysicalInterfaces("", "", func(i *net.Interface) error { - file := ctrld.SavedStaticDnsSettingsFilePath(i) - files = append(files, file) - return nil - }) - bin, err := os.Executable() - if err != nil { - mainLog.Load().Warn().Err(err).Msg("failed to get executable path") - } - if bin != "" && supportedSelfDelete { - files = append(files, bin) - } - // Backup file after upgrading. - oldBin := bin + oldBinSuffix - if _, err := os.Stat(oldBin); err == nil { - files = append(files, oldBin) - } - for _, file := range files { - if file == "" { - continue - } - if err := os.Remove(file); err == nil { - mainLog.Load().Notice().Msgf("removed %s", file) - } - } - // Self-delete the ctrld binary if supported - if err := selfDeleteExe(); err != nil { - mainLog.Load().Warn().Err(err).Msg("failed to delete ctrld binary") - } else { - if !supportedSelfDelete { - mainLog.Load().Debug().Msgf("file removed: %s", bin) - } - } - } - return nil -} - -// createStartCommands creates the start command and its alias -func createStartCommands(sc *ServiceCommand) (*cobra.Command, *cobra.Command) { - // Start command - startCmd := &cobra.Command{ - Use: "start", - Short: "Install and start the ctrld service", - Long: `Install and start the ctrld service - -NOTE: running "ctrld start" without any arguments will start already installed ctrld service.`, - Args: func(cmd *cobra.Command, args []string) error { - args = filterEmptyStrings(args) - if len(args) > 0 { - return fmt.Errorf("'ctrld start' doesn't accept positional arguments\n" + - "Use flags instead (e.g. --cd, --iface) or see 'ctrld start --help' for all options") - } - return nil - }, - PreRun: func(cmd *cobra.Command, args []string) { - checkHasElevatedPrivilege() - }, - RunE: sc.Start, - } - // Keep these flags in sync with runCmd above, except for "-d"/"--nextdns". - 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, cdUidFlagName, "", "", "Control D resolver uid") - startCmd.Flags().StringVarP(&cdOrg, cdOrgFlagName, "", "", "Control D provision token") - startCmd.Flags().StringVarP(&customHostname, customHostnameFlagName, "", "", "Custom hostname passed to ControlD API") - 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().StringVarP(&nextdns, nextdnsFlagName, "", "", "NextDNS resolver id") - startCmd.Flags().StringVarP(&cdUpstreamProto, "proto", "", ctrld.ResolverTypeDOH, `Control D upstream type, either "doh" or "doh3"`) - startCmd.Flags().BoolVarP(&skipSelfChecks, "skip_self_checks", "", false, `Skip self checks after installing ctrld service`) - startCmd.Flags().BoolVarP(&startOnly, "start_only", "", false, "Do not install new service") - _ = startCmd.Flags().MarkHidden("start_only") - - // Start command alias - startCmdAlias := &cobra.Command{ - PreRun: func(cmd *cobra.Command, args []string) { - checkHasElevatedPrivilege() - }, - Use: "start", - Short: "Quick start service and configure DNS on interface", - Long: `Quick start service and configure DNS on interface - -NOTE: running "ctrld start" without any arguments will start already installed ctrld service.`, - Args: func(cmd *cobra.Command, args []string) error { - args = filterEmptyStrings(args) - if len(args) > 0 { - return fmt.Errorf("'ctrld start' doesn't accept positional arguments\n" + - "Use flags instead (e.g. --cd, --iface) or see 'ctrld start --help' for all options") - } - return nil - }, - RunE: func(cmd *cobra.Command, args []string) error { - if len(os.Args) == 2 { - startOnly = true - } - if !cmd.Flags().Changed("iface") { - os.Args = append(os.Args, "--iface="+ifaceStartStop) - } - iface = ifaceStartStop - return startCmd.RunE(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) - - return startCmd, startCmdAlias -} - // InitServiceCmd creates the service command with proper logic and aliases func InitServiceCmd() *cobra.Command { // Create service command handlers diff --git a/cmd/cli/commands_service_reload.go b/cmd/cli/commands_service_reload.go new file mode 100644 index 0000000..9e19068 --- /dev/null +++ b/cmd/cli/commands_service_reload.go @@ -0,0 +1,53 @@ +package cli + +import ( + "errors" + "io" + "net/http" + "path/filepath" + + "github.com/kardianos/service" + "github.com/spf13/cobra" +) + +// Reload implements the logic from cmdReload.Run +func (sc *ServiceCommand) Reload(cmd *cobra.Command, args []string) error { + status, err := sc.serviceManager.svc.Status() + if errors.Is(err, service.ErrNotInstalled) { + mainLog.Load().Warn().Msg("service not installed") + return nil + } + if status == service.StatusStopped { + mainLog.Load().Warn().Msg("service is not running") + return nil + } + dir, err := socketDir() + 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(reloadPath, nil) + if err != nil { + mainLog.Load().Fatal().Err(err).Msg("failed to send reload signal to ctrld") + } + defer resp.Body.Close() + switch resp.StatusCode { + case http.StatusOK: + mainLog.Load().Notice().Msg("Service reloaded") + case http.StatusCreated: + mainLog.Load().Warn().Msg("Service was reloaded, but new config requires service restart.") + mainLog.Load().Warn().Msg("Restarting service") + if _, err := sc.serviceManager.svc.Status(); errors.Is(err, service.ErrNotInstalled) { + mainLog.Load().Warn().Msg("Service not installed") + return nil + } + return sc.Restart(cmd, args) + default: + buf, err := io.ReadAll(resp.Body) + if err != nil { + mainLog.Load().Fatal().Err(err).Msg("could not read response from control server") + } + mainLog.Load().Error().Err(err).Msgf("failed to reload ctrld: %s", string(buf)) + } + return nil +} diff --git a/cmd/cli/commands_service_restart.go b/cmd/cli/commands_service_restart.go new file mode 100644 index 0000000..5303ea4 --- /dev/null +++ b/cmd/cli/commands_service_restart.go @@ -0,0 +1,80 @@ +package cli + +import ( + "context" + "time" + + "github.com/spf13/cobra" +) + +// Restart implements the logic from cmdRestart.Run +func (sc *ServiceCommand) Restart(cmd *cobra.Command, args []string) error { + s := sc.serviceManager.svc + p := sc.serviceManager.prog + readConfig(false) + v.Unmarshal(&cfg) + cdUID = curCdUID() + cdMode := cdUID != "" + + p.cfg = &cfg + if iface == "" { + iface = "auto" + } + p.preRun() + if ir := runningIface(s); ir != nil { + p.runningIface = ir.Name + p.requiredMultiNICsConfig = ir.All + } + + initInteractiveLogging() + + var validateConfigErr error + if cdMode { + validateConfigErr = doValidateCdRemoteConfig(cdUID, false) + } + + if ir := runningIface(s); ir != nil { + iface = ir.Name + } + doRestart := func() bool { + tasks := []task{ + {s.Stop, true, "Stop"}, + {func() error { + // restore static DNS settings or DHCP + p.resetDNS(false, true) + return nil + }, false, "Cleanup"}, + {func() error { + time.Sleep(time.Second * 1) + return nil + }, false, "Waiting for service to stop"}, + } + if !doTasks(tasks) { + return false + } + tasks = []task{ + {s.Start, true, "Start"}, + } + return doTasks(tasks) + } + + if doRestart() { + if dir, err := socketDir(); err == nil { + timeout := dialSocketControlServerTimeout + if validateConfigErr != nil { + timeout = 5 * time.Second + } + if cc := newSocketControlClientWithTimeout(context.TODO(), s, dir, timeout); cc != nil { + _, _ = cc.post(ifacePath, nil) + } else { + mainLog.Load().Warn().Err(err).Msg("Service was restarted, but ctrld process may not be ready yet") + } + } else { + mainLog.Load().Warn().Err(err).Msg("Service was restarted, but could not ping the control server") + } + mainLog.Load().Notice().Msg("Service restarted") + } else { + mainLog.Load().Error().Msg("Service restart failed") + } + return nil +} diff --git a/cmd/cli/commands_service_start.go b/cmd/cli/commands_service_start.go new file mode 100644 index 0000000..b1c301b --- /dev/null +++ b/cmd/cli/commands_service_start.go @@ -0,0 +1,379 @@ +package cli + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "net" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "github.com/kardianos/service" + "github.com/spf13/cobra" + + "github.com/Control-D-Inc/ctrld" +) + +// Start implements the logic from cmdStart.Run +func (sc *ServiceCommand) Start(cmd *cobra.Command, args []string) error { + s := sc.serviceManager.svc + p := sc.serviceManager.prog + checkStrFlagEmpty(cmd, cdUidFlagName) + checkStrFlagEmpty(cmd, cdOrgFlagName) + validateCdAndNextDNSFlags() + + svcConfig := sc.createServiceConfig() + osArgs := os.Args[2:] + osArgs = filterEmptyStrings(osArgs) + if os.Args[1] == "service" { + osArgs = os.Args[3:] + } + setDependencies(svcConfig) + svcConfig.Arguments = append([]string{"run"}, osArgs...) + + p.cfg = &cfg + p.preRun() + + status, err := s.Status() + isCtrldRunning := status == service.StatusRunning + isCtrldInstalled := !errors.Is(err, service.ErrNotInstalled) + + // Get current running iface, if any. + var currentIface *ifaceResponse + + // If pin code was set, do not allow running start command. + if isCtrldRunning { + if err := checkDeactivationPin(s, nil); isCheckDeactivationPinErr(err) { + os.Exit(deactivationPinInvalidExitCode) + } + currentIface = runningIface(s) + mainLog.Load().Debug().Msgf("current interface on start: %v", currentIface) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + reportSetDnsOk := func(sockDir string) { + if cc := newSocketControlClient(ctx, s, sockDir); cc != nil { + if resp, _ := cc.post(ifacePath, nil); resp != nil && resp.StatusCode == http.StatusOK { + if iface == "auto" { + iface = defaultIfaceName() + } + res := &ifaceResponse{} + if err := json.NewDecoder(resp.Body).Decode(res); err != nil { + mainLog.Load().Warn().Err(err).Msg("failed to get iface info") + return + } + if res.OK { + name := res.Name + if iff, err := net.InterfaceByName(name); err == nil { + _, _ = patchNetIfaceName(iff) + name = iff.Name + } + logger := mainLog.Load().With().Str("iface", name) + logger.Debug().Msg("setting DNS successfully") + if res.All { + // Log that DNS is set for other interfaces. + withEachPhysicalInterfaces( + name, + "set DNS", + func(i *net.Interface) error { return nil }, + ) + } + } + } + } + } + + // No config path, generating config in HOME directory. + noConfigStart := isNoConfigStart(cmd) + writeDefaultConfig := !noConfigStart && configBase64 == "" + + logServerStarted := make(chan struct{}) + // A buffer channel to gather log output from runCmd and report + // to user in case self-check process failed. + runCmdLogCh := make(chan string, 256) + ud, err := userHomeDir() + sockDir := ud + if err != nil { + mainLog.Load().Warn().Msg("log server did not start") + close(logServerStarted) + } else { + setWorkingDirectory(svcConfig, ud) + if configPath == "" && writeDefaultConfig { + defaultConfigFile = filepath.Join(ud, defaultConfigFile) + } + svcConfig.Arguments = append(svcConfig.Arguments, "--homedir="+ud) + if d, err := socketDir(); err == nil { + sockDir = d + } + sockPath := filepath.Join(sockDir, ctrldLogUnixSock) + _ = os.Remove(sockPath) + go func() { + defer func() { + close(runCmdLogCh) + _ = os.Remove(sockPath) + }() + close(logServerStarted) + 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 + } + msg := string(buf[:n]) + if _, _, found := strings.Cut(msg, msgExit); found { + cancel() + } + runCmdLogCh <- msg + } + } + }() + } + <-logServerStarted + + if !startOnly { + startOnly = len(osArgs) == 0 + } + // If user run "ctrld start" and ctrld is already installed, starting existing service. + if startOnly && isCtrldInstalled { + tryReadingConfigWithNotice(false, true) + if err := v.Unmarshal(&cfg); err != nil { + mainLog.Load().Fatal().Msgf("failed to unmarshal config: %v", err) + } + + // if already running, dont restart + if isCtrldRunning { + mainLog.Load().Notice().Msg("service is already running") + return nil + } + + initInteractiveLogging() + tasks := []task{ + {func() error { + // Save current DNS so we can restore later. + withEachPhysicalInterfaces("", "saveCurrentStaticDNS", func(i *net.Interface) error { + if err := saveCurrentStaticDNS(i); !errors.Is(err, errSaveCurrentStaticDNSNotSupported) && err != nil { + return err + } + return nil + }) + return nil + }, false, "Save current DNS"}, + {func() error { + return ConfigureWindowsServiceFailureActions(ctrldServiceName) + }, false, "Configure service failure actions"}, + {s.Start, true, "Start"}, + {noticeWritingControlDConfig, false, "Notice writing ControlD config"}, + } + mainLog.Load().Notice().Msg("Starting existing ctrld service") + if doTasks(tasks) { + mainLog.Load().Notice().Msg("Service started") + sockDir, err := socketDir() + if err != nil { + mainLog.Load().Warn().Err(err).Msg("Failed to get socket directory") + os.Exit(1) + } + reportSetDnsOk(sockDir) + } else { + mainLog.Load().Error().Err(err).Msg("Failed to start existing ctrld service") + os.Exit(1) + } + return nil + } + + if cdUID != "" { + _ = doValidateCdRemoteConfig(cdUID, true) + } else if uid := cdUIDFromProvToken(); uid != "" { + cdUID = uid + mainLog.Load().Debug().Msg("using uid from provision token") + removeOrgFlagsFromArgs(svcConfig) + // Pass --cd flag to "ctrld run" command, so the provision token takes no effect. + svcConfig.Arguments = append(svcConfig.Arguments, "--cd="+cdUID) + } + if cdUID != "" { + validateCdUpstreamProtocol() + } + + if configPath != "" { + v.SetConfigFile(configPath) + } + + tryReadingConfigWithNotice(writeDefaultConfig, true) + + if err := v.Unmarshal(&cfg); err != nil { + mainLog.Load().Fatal().Msgf("failed to unmarshal config: %v", err) + } + + initInteractiveLogging() + + if nextdns != "" { + removeNextDNSFromArgs(svcConfig) + } + + // 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 == "" { + svcConfig.Arguments = append(svcConfig.Arguments, "--config="+defaultConfigFile) + } + + tasks := []task{ + {s.Stop, false, "Stop"}, + {func() error { return doGenerateNextDNSConfig(nextdns) }, true, "Checking config"}, + {func() error { return ensureUninstall(s) }, false, "Ensure uninstall"}, + //resetDnsTask(p, s, isCtrldInstalled, currentIface), + {func() error { + // Save current DNS so we can restore later. + withEachPhysicalInterfaces("", "saveCurrentStaticDNS", func(i *net.Interface) error { + if err := saveCurrentStaticDNS(i); !errors.Is(err, errSaveCurrentStaticDNSNotSupported) && err != nil { + return err + } + return nil + }) + return nil + }, false, "Save current DNS"}, + {s.Install, false, "Install"}, + {func() error { + return ConfigureWindowsServiceFailureActions(ctrldServiceName) + }, false, "Configure Windows service failure actions"}, + {s.Start, true, "Start"}, + // Note that startCmd do not actually write ControlD config, but the config file was + // generated after s.Start, so we notice users here for consistent with nextdns mode. + {noticeWritingControlDConfig, false, "Notice writing ControlD config"}, + } + mainLog.Load().Notice().Msg("Starting service") + if doTasks(tasks) { + // add a small delay to ensure the service is started and did not crash + time.Sleep(1 * time.Second) + + ok, status, err := selfCheckStatus(ctx, s, sockDir) + switch { + case ok && status == service.StatusRunning: + mainLog.Load().Notice().Msg("Service started") + default: + marker := bytes.Repeat([]byte("="), 32) + // If ctrld service is not running, emitting log obtained from ctrld process. + if status != service.StatusRunning || ctx.Err() != nil { + mainLog.Load().Error().Msg("ctrld service may not have started due to an error or misconfiguration, service log:") + _, _ = mainLog.Load().Write(marker) + haveLog := false + for msg := range runCmdLogCh { + _, _ = mainLog.Load().Write([]byte(strings.ReplaceAll(msg, msgExit, ""))) + haveLog = true + } + // If we're unable to get log from "ctrld run", notice users about it. + if !haveLog { + mainLog.Load().Write([]byte(`"`)) + } + } + // Report any error if occurred. + if err != nil { + _, _ = mainLog.Load().Write(marker) + msg := fmt.Sprintf("An error occurred while performing test query: %s", err) + mainLog.Load().Write([]byte(msg)) + } + // If ctrld service is running but selfCheckStatus failed, it could be related + // to user's system firewall configuration, notice users about it. + if status == service.StatusRunning && err == nil { + _, _ = mainLog.Load().Write(marker) + mainLog.Load().Write([]byte(`ctrld service was running, but a DNS query could not be sent to its listener`)) + mainLog.Load().Write([]byte(`Please check your system firewall if it is configured to block/intercept/redirect DNS queries`)) + } + + _, _ = mainLog.Load().Write(marker) + uninstall(p, s) + os.Exit(1) + } + reportSetDnsOk(sockDir) + } + + return nil +} + +// createStartCommands creates the start command and its alias +func createStartCommands(sc *ServiceCommand) (*cobra.Command, *cobra.Command) { + // Start command + startCmd := &cobra.Command{ + Use: "start", + Short: "Install and start the ctrld service", + Long: `Install and start the ctrld service + +NOTE: running "ctrld start" without any arguments will start already installed ctrld service.`, + Args: func(cmd *cobra.Command, args []string) error { + args = filterEmptyStrings(args) + if len(args) > 0 { + return fmt.Errorf("'ctrld start' doesn't accept positional arguments\n" + + "Use flags instead (e.g. --cd, --iface) or see 'ctrld start --help' for all options") + } + return nil + }, + PreRun: func(cmd *cobra.Command, args []string) { + checkHasElevatedPrivilege() + }, + RunE: sc.Start, + } + // Keep these flags in sync with runCmd above, except for "-d"/"--nextdns". + 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, cdUidFlagName, "", "", "Control D resolver uid") + startCmd.Flags().StringVarP(&cdOrg, cdOrgFlagName, "", "", "Control D provision token") + startCmd.Flags().StringVarP(&customHostname, customHostnameFlagName, "", "", "Custom hostname passed to ControlD API") + 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().StringVarP(&nextdns, nextdnsFlagName, "", "", "NextDNS resolver id") + startCmd.Flags().StringVarP(&cdUpstreamProto, "proto", "", ctrld.ResolverTypeDOH, `Control D upstream type, either "doh" or "doh3"`) + startCmd.Flags().BoolVarP(&skipSelfChecks, "skip_self_checks", "", false, `Skip self checks after installing ctrld service`) + startCmd.Flags().BoolVarP(&startOnly, "start_only", "", false, "Do not install new service") + _ = startCmd.Flags().MarkHidden("start_only") + + // Start command alias + startCmdAlias := &cobra.Command{ + PreRun: func(cmd *cobra.Command, args []string) { + checkHasElevatedPrivilege() + }, + Use: "start", + Short: "Quick start service and configure DNS on interface", + Long: `Quick start service and configure DNS on interface + +NOTE: running "ctrld start" without any arguments will start already installed ctrld service.`, + Args: func(cmd *cobra.Command, args []string) error { + args = filterEmptyStrings(args) + if len(args) > 0 { + return fmt.Errorf("'ctrld start' doesn't accept positional arguments\n" + + "Use flags instead (e.g. --cd, --iface) or see 'ctrld start --help' for all options") + } + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + if len(os.Args) == 2 { + startOnly = true + } + if !cmd.Flags().Changed("iface") { + os.Args = append(os.Args, "--iface="+ifaceStartStop) + } + iface = ifaceStartStop + return startCmd.RunE(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) + + return startCmd, startCmdAlias +} diff --git a/cmd/cli/commands_service_status.go b/cmd/cli/commands_service_status.go new file mode 100644 index 0000000..190d66d --- /dev/null +++ b/cmd/cli/commands_service_status.go @@ -0,0 +1,29 @@ +package cli + +import ( + "os" + + "github.com/kardianos/service" + "github.com/spf13/cobra" +) + +// Status implements the logic from cmdStatus.Run +func (sc *ServiceCommand) Status(cmd *cobra.Command, args []string) error { + status, err := sc.serviceManager.svc.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) + } + return nil +} diff --git a/cmd/cli/commands_service_stop.go b/cmd/cli/commands_service_stop.go new file mode 100644 index 0000000..daec9ab --- /dev/null +++ b/cmd/cli/commands_service_stop.go @@ -0,0 +1,43 @@ +package cli + +import ( + "errors" + "os" + + "github.com/kardianos/service" + "github.com/spf13/cobra" +) + +// Stop implements the logic from cmdStop.Run +func (sc *ServiceCommand) Stop(cmd *cobra.Command, args []string) error { + s := sc.serviceManager.svc + p := sc.serviceManager.prog + readConfig(false) + v.Unmarshal(&cfg) + p.cfg = &cfg + p.preRun() + if ir := runningIface(s); ir != nil { + p.runningIface = ir.Name + p.requiredMultiNICsConfig = ir.All + } + + initInteractiveLogging() + + status, err := s.Status() + if errors.Is(err, service.ErrNotInstalled) { + mainLog.Load().Warn().Msg("service not installed") + return nil + } + if status == service.StatusStopped { + mainLog.Load().Warn().Msg("service is already stopped") + return nil + } + + if err := checkDeactivationPin(s, nil); isCheckDeactivationPinErr(err) { + os.Exit(deactivationPinInvalidExitCode) + } + if doTasks([]task{{s.Stop, true, "Stop"}}) { + mainLog.Load().Notice().Msg("Service stopped") + } + return nil +} diff --git a/cmd/cli/commands_service_uninstall.go b/cmd/cli/commands_service_uninstall.go new file mode 100644 index 0000000..a14cac3 --- /dev/null +++ b/cmd/cli/commands_service_uninstall.go @@ -0,0 +1,86 @@ +package cli + +import ( + "net" + "os" + "path/filepath" + + "github.com/spf13/cobra" + + "github.com/Control-D-Inc/ctrld" +) + +// Uninstall implements the logic from cmdUninstall.Run +func (sc *ServiceCommand) Uninstall(cmd *cobra.Command, args []string) error { + s := sc.serviceManager.svc + p := sc.serviceManager.prog + readConfig(false) + v.Unmarshal(&cfg) + p.cfg = &cfg + if iface == "" { + iface = "auto" + } + p.preRun() + if ir := runningIface(s); ir != nil { + p.runningIface = ir.Name + p.requiredMultiNICsConfig = ir.All + } + if err := checkDeactivationPin(s, nil); isCheckDeactivationPinErr(err) { + os.Exit(deactivationPinInvalidExitCode) + } + uninstall(p, s) + if cleanup { + var files []string + // Config file. + files = append(files, v.ConfigFileUsed()) + // Log file and backup log file. + // For safety, only process if log file path is absolute. + if logFile := normalizeLogFilePath(cfg.Service.LogPath); filepath.IsAbs(logFile) { + files = append(files, logFile) + oldLogFile := logFile + oldLogSuffix + if _, err := os.Stat(oldLogFile); err == nil { + files = append(files, oldLogFile) + } + } + // Socket files. + if dir, _ := socketDir(); dir != "" { + files = append(files, filepath.Join(dir, ctrldControlUnixSock)) + files = append(files, filepath.Join(dir, ctrldLogUnixSock)) + } + // Static DNS settings files. + withEachPhysicalInterfaces("", "", func(i *net.Interface) error { + file := ctrld.SavedStaticDnsSettingsFilePath(i) + files = append(files, file) + return nil + }) + bin, err := os.Executable() + if err != nil { + mainLog.Load().Warn().Err(err).Msg("failed to get executable path") + } + if bin != "" && supportedSelfDelete { + files = append(files, bin) + } + // Backup file after upgrading. + oldBin := bin + oldBinSuffix + if _, err := os.Stat(oldBin); err == nil { + files = append(files, oldBin) + } + for _, file := range files { + if file == "" { + continue + } + if err := os.Remove(file); err == nil { + mainLog.Load().Notice().Msgf("removed %s", file) + } + } + // Self-delete the ctrld binary if supported + if err := selfDeleteExe(); err != nil { + mainLog.Load().Warn().Err(err).Msg("failed to delete ctrld binary") + } else { + if !supportedSelfDelete { + mainLog.Load().Debug().Msgf("file removed: %s", bin) + } + } + } + return nil +}