Files
ctrld/cmd/cli/commands.go
T
Cuong Manh Le bf7c264464 cli: default to dns intercept mode for new desktop installations
When no intercept mode is selected by flag or config, a new
installation on macOS or a Windows desktop edition now uses "dns".
Windows Server, iOS and other platforms keep interface DNS mode unless
intercept is selected explicitly, and so does a config that cannot run
in intercept mode.

Existing Standard installations are not switched on upgrade. ctrld
start marks the services it installs fresh, and the service applies
the default below service.intercept_mode against the config it
fetches, so a service with neither a mode nor the marker predates the
default and keeps interface DNS mode, whichever path upgrades it; the
macOS package also pins such a service to "off" explicitly. An
explicit "off" stays final and is persisted.
2026-10-20 17:23:47 +07:00

1833 lines
61 KiB
Go

package cli
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"os"
"os/exec"
"os/signal"
"path/filepath"
"runtime"
"slices"
"sort"
"strconv"
"strings"
"syscall"
"time"
"github.com/docker/go-units"
"github.com/kardianos/service"
"github.com/minio/selfupdate"
"github.com/olekukonko/tablewriter"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"github.com/Control-D-Inc/ctrld"
"github.com/Control-D-Inc/ctrld/internal/clientinfo"
"github.com/Control-D-Inc/ctrld/internal/router"
)
// dialSocketControlServerTimeout is the default timeout to wait when ping control server.
const dialSocketControlServerTimeout = 30 * time.Second
// logRequestPath adds the query that makes the control server read every log
// file, not only the newest debug bytes.
func logRequestPath(path string, full bool) string {
if !full {
return path
}
return path + "?full=1"
}
// removeLogFiles deletes the log files of this installation and returns what
// it could not remove. A missing file is not an error.
func removeLogFiles(logPath string, backups int, internalPaths []string) []error {
for _, path := range internalPaths {
pruneNumberedBackups(path, 0)
}
var errs []error
for _, path := range logFilesToRemove(logPath, backups, internalPaths) {
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
errs = append(errs, fmt.Errorf("remove %s: %w", path, err))
}
}
return errs
}
// logFilesToRemove names the log files of this installation. The log_path
// backups come from the configured count, because log_path can name a file in
// a directory that holds the files of other programs, and a scan of that
// directory would take their files too. An empty logPath leaves the internal
// files alone.
func logFilesToRemove(logPath string, backups int, internalPaths []string) []string {
var paths []string
if logPath != "" {
paths = append(paths, logPath)
for index := 1; index <= backups; index++ {
paths = append(paths, fmt.Sprintf("%s.%d", logPath, index))
}
}
return append(paths, internalPaths...)
}
func initLogCmd() *cobra.Command {
warnRuntimeLoggingNotEnabled := func() {
mainLog.Load().Warn().Msg("runtime debug logging is not enabled")
mainLog.Load().Warn().Msg(`ctrld may be running without "--cd" flag or logging is already enabled`)
}
var sendFullLogs bool
logSendCmd := &cobra.Command{
Use: "send",
Short: "Send runtime debug logs to ControlD",
Long: "Send runtime debug logs to ControlD. On macOS, an administrator can enable service.allow_unprivileged_log_send and restart ctrld to allow standard users to send bounded diagnostics. --full still requires elevation.",
Args: cobra.NoArgs,
PreRunE: func(cmd *cobra.Command, args []string) error {
if delegatedLogSendCLI() {
if sendFullLogs {
return errors.New("log send --full requires administrator privileges")
}
return nil
}
checkHasElevatedPrivilege()
return nil
},
Run: func(cmd *cobra.Command, args []string) {
// The standard-user macOS route must precede service.Status and
// socketDir: both depend on the calling user's launchd/home context.
if delegatedLogSendCLI() {
runDelegatedLogSend(cmd.Context(), sendFullLogs)
return
}
p := &prog{router: router.New(&cfg, false)}
s, _ := newService(p, svcConfig)
status, err := s.Status()
if errors.Is(err, service.ErrNotInstalled) {
mainLog.Load().Warn().Msg("service not installed")
return
}
if status == service.StatusStopped {
mainLog.Load().Warn().Msg("service is not running")
return
}
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(logRequestPath(sendLogsPath, sendFullLogs), nil)
if err != nil {
mainLog.Load().Fatal().Err(err).Msg("failed to send logs")
}
defer resp.Body.Close()
switch resp.StatusCode {
case http.StatusServiceUnavailable:
mainLog.Load().Warn().Msg("runtime logs could only be sent once per minute")
return
case http.StatusMovedPermanently:
warnRuntimeLoggingNotEnabled()
return
}
var logs logSentResponse
if err := json.NewDecoder(resp.Body).Decode(&logs); err != nil {
mainLog.Load().Fatal().Err(err).Msg("failed to decode sent logs result")
}
size := units.BytesSize(float64(logs.Size))
if logs.Error == "" {
mainLog.Load().Notice().Msgf("runtime logs sent successfully (%s)", size)
} else {
mainLog.Load().Error().Msgf("failed to send logs (%s)", size)
mainLog.Load().Error().Msg(logs.Error)
}
},
}
logSendCmd.Flags().BoolVar(&sendFullLogs, "full", false, "Send every log file, not only the newest 10 MB of debug")
var viewFullLogs bool
logViewCmd := &cobra.Command{
Use: "view",
Short: "View current runtime debug logs",
Args: cobra.NoArgs,
PreRun: func(cmd *cobra.Command, args []string) {
checkHasElevatedPrivilege()
},
Run: func(cmd *cobra.Command, args []string) {
p := &prog{router: router.New(&cfg, false)}
s, _ := newService(p, svcConfig)
status, err := s.Status()
if errors.Is(err, service.ErrNotInstalled) {
mainLog.Load().Warn().Msg("service not installed")
return
}
if status == service.StatusStopped {
mainLog.Load().Warn().Msg("service is not running")
return
}
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(logRequestPath(viewLogsPath, viewFullLogs), nil)
if err != nil {
mainLog.Load().Fatal().Err(err).Msg("failed to get logs")
}
defer resp.Body.Close()
switch resp.StatusCode {
case http.StatusMovedPermanently:
warnRuntimeLoggingNotEnabled()
return
case http.StatusBadRequest:
mainLog.Load().Warn().Msg("runtime debugs log is not available")
buf, err := io.ReadAll(resp.Body)
if err != nil {
mainLog.Load().Fatal().Err(err).Msg("failed to read response body")
}
mainLog.Load().Warn().Msgf("ctrld process response:\n\n%s\n", string(buf))
return
case http.StatusOK:
}
var logs logViewResponse
if err := json.NewDecoder(resp.Body).Decode(&logs); err != nil {
mainLog.Load().Fatal().Err(err).Msg("failed to decode view logs result")
}
fmt.Println(logs.Data)
},
}
logViewCmd.Flags().BoolVar(&viewFullLogs, "full", false, "Show every log file, not only the newest 10 MB of debug")
var tailLines int
logTailCmd := &cobra.Command{
Use: "tail",
Short: "Tail live runtime debug logs",
Long: "Stream live runtime debug logs to the terminal, similar to tail -f. Press Ctrl+C to stop.",
Args: cobra.NoArgs,
PreRun: func(cmd *cobra.Command, args []string) {
checkHasElevatedPrivilege()
},
Run: func(cmd *cobra.Command, args []string) {
p := &prog{router: router.New(&cfg, false)}
s, _ := newService(p, svcConfig)
status, err := s.Status()
if errors.Is(err, service.ErrNotInstalled) {
mainLog.Load().Warn().Msg("service not installed")
return
}
if status == service.StatusStopped {
mainLog.Load().Warn().Msg("service is not running")
return
}
dir, err := socketDir()
if err != nil {
mainLog.Load().Fatal().Err(err).Msg("failed to find ctrld home dir")
}
cc := newControlClient(filepath.Join(dir, ctrldControlUnixSock))
tailPath := fmt.Sprintf("%s?lines=%d", tailLogsPath, tailLines)
resp, err := cc.postStream(tailPath, nil)
if err != nil {
mainLog.Load().Fatal().Err(err).Msg("failed to connect for log tailing")
}
defer resp.Body.Close()
switch resp.StatusCode {
case http.StatusMovedPermanently:
warnRuntimeLoggingNotEnabled()
return
case http.StatusOK:
default:
mainLog.Load().Fatal().Msgf("unexpected response status: %d", resp.StatusCode)
return
}
// Set up signal handling for clean shutdown.
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
done := make(chan struct{})
go func() {
defer close(done)
// Stream output to stdout.
buf := make([]byte, 4096)
for {
n, readErr := resp.Body.Read(buf)
if n > 0 {
os.Stdout.Write(buf[:n])
}
if readErr != nil {
if readErr != io.EOF {
mainLog.Load().Error().Err(readErr).Msg("error reading log stream")
}
return
}
}
}()
select {
case <-ctx.Done():
if errors.Is(ctx.Err(), context.Canceled) {
msg := fmt.Sprintf("\nexiting: %s\n", context.Cause(ctx).Error())
os.Stdout.WriteString(msg)
}
case <-done:
}
},
}
logTailCmd.Flags().IntVarP(&tailLines, "lines", "n", 10, "Number of historical lines to show on connect")
logCmd := &cobra.Command{
Use: "log",
Short: "Manage runtime debug logs",
Args: cobra.OnlyValidArgs,
ValidArgs: []string{
logSendCmd.Use,
},
}
logCmd.AddCommand(logSendCmd)
logCmd.AddCommand(logViewCmd)
logCmd.AddCommand(logTailCmd)
rootCmd.AddCommand(logCmd)
return logCmd
}
func initRunCmd() *cobra.Command {
runCmd := &cobra.Command{
Use: "run",
Short: "Run the DNS proxy server",
Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) {
RunCobraCommand(cmd)
},
}
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, cdUidFlagName, "", "", "Control D resolver uid")
runCmd.Flags().StringVarP(&cdOrg, cdOrgFlagName, "", "", "Control D provision token")
runCmd.Flags().StringVarP(&customHostname, customHostnameFlagName, "", "", "Custom hostname passed to ControlD API")
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().StringVarP(&cdUpstreamProto, "proto", "", ctrld.ResolverTypeDOH, `Control D upstream type, either "doh" or "doh3"`)
runCmd.Flags().BoolVarP(&rfc1918, "rfc1918", "", false, "Listen on RFC1918 addresses when 127.0.0.1 is the only listener")
runCmd.Flags().StringVarP(&interceptMode, "intercept-mode", "", "", "OS-level DNS interception mode: 'dns' (with VPN split routing), 'hard' (all DNS through ctrld, no VPN split routing), or 'off' (interface DNS mode, persisted as intercept_mode = \"off\"). Unset uses service.intercept_mode, then the platform default: 'dns' on macOS and Windows desktop editions, interface DNS mode elsewhere (Windows Server included)")
// Set by "ctrld start" on a service it installs with no intercept mode
// selected; not for users. See interceptModeDefaultFlag.
runCmd.Flags().BoolVar(&interceptModeDefault, strings.TrimPrefix(interceptModeDefaultFlag, "--"), false, "Apply the platform default intercept mode (set by ctrld start)")
_ = runCmd.Flags().MarkHidden(strings.TrimPrefix(interceptModeDefaultFlag, "--"))
runCmd.FParseErrWhitelist = cobra.FParseErrWhitelist{UnknownFlags: true}
rootCmd.AddCommand(runCmd)
return runCmd
}
// serviceStageFailureCode maps an aborted service-manager task to its
// provisioning code. Other abortOnError tasks (like config validation) keep
// their own error paths.
func serviceStageFailureCode(taskName string) (provisionFailureCode, bool) {
switch taskName {
case "Install":
return provisionCodeServiceInstall, true
case "Start":
return provisionCodeServiceStartFailed, true
default:
return "", false
}
}
// serviceTaskErrorSummary describes which service-manager task failed and why,
// for use as a provisioning result message.
func serviceTaskErrorSummary(taskName string, err error) string {
return fmt.Sprintf("%s failed: %v", taskName, err)
}
// resultStalenessTolerance absorbs clock granularity between "ctrld start"
// recording its start time and the daemon writing its result file.
const resultStalenessTolerance = 2 * time.Second
// reportStartFailure reports why "ctrld start" failed after install/start
// looked fine. A result file the daemon wrote during this attempt names the
// failure better than a generic self-check code, so it wins.
func reportStartFailure(startedAt time.Time, fallbackMsg string) {
if r, err := readProvisionResult(); err == nil && provisionResultTrusted(r) {
if ts, err := time.Parse(time.RFC3339, r.Timestamp); err == nil {
if !ts.Before(startedAt.Add(-resultStalenessTolerance)) {
mainLog.Load().Error().Msg(r.failureLine())
provisionExit(r.ExitCode)
return
}
}
}
failProvision(newProvisionResult(provisionCodeServiceSelfCheck, fallbackMsg, nil, provisionSecrets()...), nil)
}
func initStartCmd() *cobra.Command {
startCmd := &cobra.Command{
PreRun: func(cmd *cobra.Command, args []string) {
checkHasElevatedPrivilege()
},
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
},
Run: func(cmd *cobra.Command, args []string) {
// Clear before any check runs, not just before doTasksE: a result from a
// previous attempt must never survive to mislead diag/postinstall on this
// one, even if this attempt fails before reaching doTasksE.
clearProvisionResult()
if !checkStrFlagEmpty(cmd, cdUidFlagName) {
return
}
if !checkStrFlagEmpty(cmd, cdOrgFlagName) {
return
}
if !validateCdAndNextDNSFlags() {
return
}
sc := &service.Config{}
*sc = *svcConfig
osArgs := os.Args[2:]
osArgs = filterEmptyStrings(osArgs)
if os.Args[1] == "service" {
osArgs = os.Args[3:]
}
setDependencies(sc)
sc.Arguments = append([]string{"run"}, osArgs...)
// Validate --intercept-mode early, before installing the service.
// Without this, a typo like "--intercept-mode fds" would install the service,
// the child process would Fatal() on the invalid value, and the parent would
// then uninstall — confusing and destructive.
if !validateInterceptModeFlag(interceptMode) {
return
}
p := &prog{
router: router.New(&cfg, cdUID != ""),
cfg: &cfg,
}
s, err := newService(p, sc)
if err != nil {
mainLog.Load().Error().Msg(err.Error())
// A bare return would exit 0 with no result file, so support
// could not tell this failure from a start that never ran.
failProvisionUnclassified("initialize service: "+err.Error(), nil)
return
}
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
// Handle "ctrld start --intercept-mode dns|hard" on an existing
// service BEFORE the pin check. Adding intercept mode is an enhancement, not
// deactivation, so it doesn't require the deactivation pin. We modify the
// plist/registry directly and restart the service via the OS service manager.
osArgsEarly := os.Args[2:]
if os.Args[1] == "service" {
osArgsEarly = os.Args[3:]
}
osArgsEarly = filterEmptyStrings(osArgsEarly)
interceptOnly := onlyInterceptFlags(osArgsEarly)
svcExists := serviceConfigFileExists()
mainLog.Load().Debug().Msg(interceptUpgradeCheckLine(osArgsEarly, interceptOnly, svcExists, interceptMode))
if interceptOnly && svcExists {
// An explicit "off" argument must override a previously persisted config
// value while the service clears that value on startup.
if err := removeServiceFlag("--intercept-mode"); err != nil {
failRunUnclassified(mainLog.Load().Error().Err(err), fmt.Sprintf("failed to remove existing intercept mode from service arguments: %v", err), nil)
return
}
if interceptMode == "off" {
mainLog.Load().Notice().Msg("Existing service detected — disabling intercept mode")
} else {
mainLog.Load().Notice().Msgf("Existing service detected — appending --intercept-mode %s to service arguments", interceptMode)
}
if err := appendServiceFlag("--intercept-mode"); err != nil {
failRunUnclassified(mainLog.Load().Error().Err(err), fmt.Sprintf("failed to append intercept flag to service arguments: %v", err), nil)
return
}
if err := appendServiceFlag(interceptMode); err != nil {
failRunUnclassified(mainLog.Load().Error().Err(err), fmt.Sprintf("failed to append intercept mode value to service arguments: %v", err), nil)
return
}
// Stop the service if running (bypasses ctrld pin — this is an
// enhancement, not deactivation). Then fall through to the normal
// startOnly path which handles start, self-check, and reporting.
if isCtrldRunning {
mainLog.Load().Notice().Msg("Stopping service for intercept mode upgrade")
_ = s.Stop()
isCtrldRunning = false
}
startOnly = true
isCtrldInstalled = true
// Fall through to startOnly path below.
}
// 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 == autoIface {
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 {
// In intercept mode, show intercept-specific status instead of
// per-interface DNS messages (which are irrelevant).
if res.InterceptMode != "" {
switch res.InterceptMode {
case "hard":
mainLog.Load().Notice().Msg("DNS hard intercept mode active — all DNS traffic intercepted, no VPN split routing")
default:
mainLog.Load().Notice().Msg("DNS intercept mode active — all DNS traffic intercepted via OS packet filter")
}
if res.InterceptModeSource == interceptModeSourceDefault {
mainLog.Load().Notice().Msg("Intercept mode is the default on this platform; run \"ctrld start --intercept-mode off\" to use interface DNS mode instead")
}
} else {
name := res.Name
if iff, err := net.InterfaceByName(name); err == nil {
_, _ = patchNetIfaceName(iff)
name = iff.Name
}
logger := mainLog.Load().With().Str("iface", name).Logger()
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(sc, ud)
if configPath == "" && writeDefaultConfig {
defaultConfigFile = filepath.Join(ud, defaultConfigFile)
}
sc.Arguments = append(sc.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 {
failRunUnclassified(mainLog.Load().Error(), fmt.Sprintf("failed to unmarshal config: %v", err), nil)
return
}
// if already running, dont restart
if isCtrldRunning {
mainLog.Load().Notice().Msg("service is already running")
return
}
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"},
}
startAttemptAt := time.Now()
mainLog.Load().Notice().Msg("Starting existing ctrld service")
failedTask, taskErr := doTasksE(tasks)
if taskErr != nil {
if code, ok := serviceStageFailureCode(failedTask); ok {
failProvision(newProvisionResult(code, serviceTaskErrorSummary(failedTask, taskErr), nil, provisionSecrets()...), nil)
return
}
// Not a service-stage task. doTasksE already logged the cause; classify
// UNCLASSIFIED instead of the old silent fall-through that exited 1.
failProvisionUnclassified(serviceTaskErrorSummary(failedTask, taskErr), nil)
return
}
sockDir, err := socketDir()
if err != nil {
failRunUnclassified(mainLog.Load().Error(), fmt.Sprintf("failed to get socket directory: %v", err), nil)
return
}
// The daemon can start and still fail provisioning (for example a
// listener bind conflict). Self-check like a fresh install so this
// path reports the daemon's failure code instead of a false
// "Service started" — but never uninstall an existing service.
time.Sleep(1 * time.Second)
ok, status, err := selfCheckStatus(ctx, s, sockDir)
if !ok || status != service.StatusRunning {
fallbackMsg := "ctrld service did not pass its post-start self-check"
if err != nil {
fallbackMsg = fmt.Sprintf("An error occurred while performing test query: %s", err)
mainLog.Load().Error().Msg(fallbackMsg)
}
if status == service.StatusRunning && err == nil {
fallbackMsg = "ctrld service was running, but a DNS query could not be sent to its listener; check firewall rules blocking/intercepting/redirecting DNS queries"
mainLog.Load().Error().Msg(fallbackMsg)
}
reportStartFailure(startAttemptAt, fallbackMsg)
return
}
mainLog.Load().Notice().Msg("Service started")
clearProvisionResult()
reportSetDnsOk(sockDir)
// Verify service registration after successful start.
if err := verifyServiceRegistration(); err != nil {
mainLog.Load().Warn().Err(err).Msg("Service registry verification failed")
}
return
}
if cdUID != "" {
// Skip doValidateCdRemoteConfig() here - run command will handle
// validation and config fetch via processCDFlags().
} else if uid := cdUIDFromProvToken(); uid != "" {
cdUID = uid
mainLog.Load().Debug().Msg("using uid from provision token")
removeOrgFlagsFromArgs(sc)
// Pass --cd flag to "ctrld run" command, so the provision token takes no effect.
sc.Arguments = append(sc.Arguments, "--cd="+cdUID)
}
if cdUID != "" {
if !validateCdUpstreamProtocol(nil) {
return
}
}
if err := p.router.ConfigureService(sc); err != nil {
failRunUnclassified(mainLog.Load().Error().Err(err), fmt.Sprintf("failed to configure service on router: %v", err), nil)
return
}
if configPath != "" {
v.SetConfigFile(configPath)
}
tryReadingConfigWithNotice(writeDefaultConfig, true)
if err := v.Unmarshal(&cfg); err != nil {
failRunUnclassified(mainLog.Load().Error(), fmt.Sprintf("failed to unmarshal config: %v", err), nil)
return
}
initInteractiveLogging()
// Record the intercept mode in the service's arguments, so the
// installed service can tell a fresh install from one that predates
// the dns default (#589). See installInterceptMode.
var existingArgs []string
if isCtrldInstalled && interceptMode == "" {
args, err := installedServiceArgs()
if err != nil {
mainLog.Load().Debug().Err(err).Msg("could not read the arguments of the existing service")
}
existingArgs = args
}
mode, markDefault := installInterceptMode(interceptMode, &cfg, isCtrldInstalled, existingArgs)
switch {
case mode != "":
if _, hadMode := interceptModeFromArgs(existingArgs); !hadMode {
mainLog.Load().Notice().Msg("Existing service selects no intercept mode; keeping interface DNS mode (--intercept-mode off)")
}
sc.Arguments = append(sc.Arguments, "--intercept-mode", mode)
interceptMode = mode
case markDefault:
// One argument with an inline value: see interceptModeDefaultFlag.
sc.Arguments = append(sc.Arguments, interceptModeDefaultFlag+"=true")
}
if nextdns != "" {
removeNextDNSFromArgs(sc)
}
// 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)
}
if router.Name() != "" && iface != "" {
mainLog.Load().Debug().Msg("cleaning up router before installing")
_ = p.router.Cleanup()
}
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, true, "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"},
}
startAttemptAt := time.Now()
mainLog.Load().Notice().Msg("Starting service")
failedTask, taskErr := doTasksE(tasks)
if taskErr != nil {
if code, ok := serviceStageFailureCode(failedTask); ok {
failProvision(newProvisionResult(code, serviceTaskErrorSummary(failedTask, taskErr), nil, provisionSecrets()...), nil)
return
}
// Not a service-stage task. doTasksE already logged the cause; classify
// UNCLASSIFIED instead of the old silent fall-through that exited 0.
failProvisionUnclassified(serviceTaskErrorSummary(failedTask, taskErr), nil)
return
}
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
}
// 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")
clearProvisionResult()
default:
marker := bytes.Repeat([]byte("="), 32)
fallbackMsg := "ctrld service did not pass its post-start self-check"
// 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(`<no log output is obtained from ctrld process>"`))
}
}
// 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))
fallbackMsg = 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`))
fallbackMsg = "ctrld service was running, but a DNS query could not be sent to its listener; check firewall rules blocking/intercepting/redirecting DNS queries"
}
_, _ = mainLog.Load().Write(marker)
uninstall(p, s)
reportStartFailure(startAttemptAt, fallbackMsg)
return
}
reportSetDnsOk(sockDir)
// Verify service registration after successful start.
if err := verifyServiceRegistration(); err != nil {
mainLog.Load().Warn().Err(err).Msg("Service registry verification failed")
}
},
}
// 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")
startCmd.Flags().BoolVarP(&rfc1918, "rfc1918", "", false, "Listen on RFC1918 addresses when 127.0.0.1 is the only listener")
startCmd.Flags().StringVarP(&interceptMode, "intercept-mode", "", "", "OS-level DNS interception mode: 'dns' (with VPN split routing), 'hard' (all DNS through ctrld, no VPN split routing), or 'off' (interface DNS mode, persisted as intercept_mode = \"off\"). Unset uses service.intercept_mode, then the platform default: 'dns' on macOS and Windows desktop editions, interface DNS mode elsewhere (Windows Server included)")
routerCmd := &cobra.Command{
Use: "setup",
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)
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
},
Run: func(cmd *cobra.Command, args []string) {
if len(os.Args) == 2 {
startOnly = true
}
if !cmd.Flags().Changed("iface") {
os.Args = append(os.Args, "--iface="+ifaceStartStop)
}
iface = ifaceStartStop
startCmd.Run(cmd, args)
},
}
startCmdAlias.Flags().StringVarP(&ifaceStartStop, "iface", "", autoIface, `Update DNS setting for iface, "auto" means the default interface gateway`)
startCmdAlias.Flags().AddFlagSet(startCmd.Flags())
rootCmd.AddCommand(startCmdAlias)
return startCmd
}
func initStopCmd() *cobra.Command {
stopCmd := &cobra.Command{
PreRun: func(cmd *cobra.Command, args []string) {
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, runInCdMode())}
s, err := newService(p, svcConfig)
if err != nil {
mainLog.Load().Error().Msg(err.Error())
return
}
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
}
if status == service.StatusStopped {
mainLog.Load().Warn().Msg("service is already stopped")
return
}
if err := checkDeactivationPin(s, nil); isCheckDeactivationPinErr(err) {
os.Exit(deactivationPinInvalidExitCode)
}
if doTasks([]task{{s.Stop, true, "Stop"}}) {
if router.WaitProcessExited() {
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
defer cancel()
for {
select {
case <-ctx.Done():
mainLog.Load().Error().Msg("timeout while waiting for service to stop")
return
default:
}
time.Sleep(time.Second)
if status, _ := s.Status(); status == service.StatusStopped {
break
}
}
}
mainLog.Load().Notice().Msg("Service stopped")
}
},
}
stopCmd.Flags().StringVarP(&iface, "iface", "", "", `Reset DNS setting for iface, "auto" means the default interface gateway`)
stopCmd.Flags().Int64VarP(&deactivationPin, "pin", "", defaultDeactivationPin, `Pin code for stopping ctrld`)
_ = stopCmd.Flags().MarkHidden("pin")
stopCmdAlias := &cobra.Command{
PreRun: func(cmd *cobra.Command, args []string) {
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", "", autoIface, `Reset DNS setting for iface, "auto" means the default interface gateway`)
stopCmdAlias.Flags().AddFlagSet(stopCmd.Flags())
rootCmd.AddCommand(stopCmdAlias)
return stopCmd
}
func initRestartCmd() *cobra.Command {
restartCmd := &cobra.Command{
PreRun: func(cmd *cobra.Command, args []string) {
checkHasElevatedPrivilege()
},
Use: "restart",
Short: "Restart the ctrld service",
Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) {
readConfig(false)
v.Unmarshal(&cfg)
cdUID = curCdUID()
cdMode := cdUID != ""
p := &prog{router: router.New(&cfg, cdMode)}
s, err := newService(p, svcConfig)
if err != nil {
mainLog.Load().Error().Msg(err.Error())
return
}
if _, err := s.Status(); errors.Is(err, service.ErrNotInstalled) {
mainLog.Load().Warn().Msg("service not installed")
return
}
if iface == "" {
iface = autoIface
}
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 {
p.router.Cleanup()
// 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) {
if router.WaitProcessExited() {
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
defer cancel()
loop:
for {
select {
case <-ctx.Done():
mainLog.Load().Error().Msg("timeout while waiting for service to stop")
break loop
default:
}
time.Sleep(time.Second)
if status, _ := s.Status(); status == service.StatusStopped {
break
}
}
}
} else {
return false
}
tasks = []task{
{s.Start, true, "Start"},
}
return doTasks(tasks)
}
if doRestart() {
if dir, err := socketDir(); err == nil {
timeout := dialSocketControlServerTimeout
// If we failed to validate remote config above, it's likely that
// we are having problem with network connection. So using a shorter
// timeout than default one for better UX.
if validateConfigErr != nil {
timeout = 5 * time.Second
}
// Only a ready service counts. Restart replaces nothing, so
// there is nothing to roll back, but a service that came
// back without finishing its start must not be reported as
// restarted cleanly.
if cc := newReadySocketControlClientWithTimeout(context.TODO(), s, dir, timeout); cc != nil {
_, _ = cc.post(ifacePath, nil)
} else {
mainLog.Load().Warn().Msgf("Service was restarted, but ctrld did not report ready within %s; check `ctrld status`", timeout)
return
}
} else {
mainLog.Load().Warn().Err(err).Msg("Service was restarted, but could not ping the control server")
return
}
mainLog.Load().Notice().Msg("Service restarted")
} else {
mainLog.Load().Error().Msg("Service restart failed")
}
},
}
restartCmdAlias := &cobra.Command{
PreRun: func(cmd *cobra.Command, args []string) {
checkHasElevatedPrivilege()
},
Use: "restart",
Short: "Restart the ctrld service",
Run: func(cmd *cobra.Command, args []string) {
restartCmd.Run(cmd, args)
},
}
rootCmd.AddCommand(restartCmdAlias)
return restartCmd
}
func initReloadCmd(restartCmd *cobra.Command) *cobra.Command {
reloadCmd := &cobra.Command{
PreRun: func(cmd *cobra.Command, args []string) {
checkHasElevatedPrivilege()
},
Use: "reload",
Short: "Reload the ctrld service",
Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) {
p := &prog{router: router.New(&cfg, false)}
s, _ := newService(p, svcConfig)
status, err := s.Status()
if errors.Is(err, service.ErrNotInstalled) {
mainLog.Load().Warn().Msg("service not installed")
return
}
if status == service.StatusStopped {
mainLog.Load().Warn().Msg("service is not running")
return
}
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:
s, err := newService(&prog{}, svcConfig)
if err != nil {
mainLog.Load().Error().Msg(err.Error())
return
}
mainLog.Load().Warn().Msg("Service was reloaded, but new config requires service restart.")
mainLog.Load().Warn().Msg("Restarting service")
if _, err := s.Status(); errors.Is(err, service.ErrNotInstalled) {
mainLog.Load().Warn().Msg("Service not installed")
return
}
restartCmd.Run(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))
}
},
}
reloadCmdAlias := &cobra.Command{
PreRun: func(cmd *cobra.Command, args []string) {
checkHasElevatedPrivilege()
},
Use: "reload",
Short: "Reload the ctrld service",
Run: func(cmd *cobra.Command, args []string) {
reloadCmd.Run(cmd, args)
},
}
rootCmd.AddCommand(reloadCmdAlias)
return reloadCmd
}
func initStatusCmd() *cobra.Command {
statusCmd := &cobra.Command{
Use: "status",
Short: "Show status of the ctrld service",
Long: statusCmdLong,
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
}
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(statusExitUnknown)
case service.StatusRunning:
// The service manager only knows a process was created. It reports a
// service as running even when the process is still in startup, with
// no control socket, no DNS listener and no policy applied - so
// "Service is running" can describe a host with no working DNS.
// Probe readiness before claiming it.
ready, probeErr := serviceReady()
if probeErr != nil {
mainLog.Load().Debug().Err(probeErr).Msg("Readiness probe did not confirm startup")
}
r := classifyReadiness(ready, probeErr, readinessVerifiable())
for _, msg := range r.messages {
mainLog.Load().Notice().Msg(msg)
}
os.Exit(r.exitCode)
case service.StatusStopped:
mainLog.Load().Notice().Msg("Service is stopped")
os.Exit(statusExitStopped)
}
},
}
if runtime.GOOS == "darwin" {
// On darwin, running status command without privileges may return wrong information.
statusCmd.PreRun = func(cmd *cobra.Command, args []string) {
checkHasElevatedPrivilege()
}
}
statusCmdAlias := &cobra.Command{
Use: "status",
Short: "Show status of the ctrld service",
Long: statusCmdLong,
Args: cobra.NoArgs,
Run: statusCmd.Run,
}
rootCmd.AddCommand(statusCmdAlias)
return statusCmd
}
func initUninstallCmd() *cobra.Command {
uninstallCmd := &cobra.Command{
PreRun: func(cmd *cobra.Command, args []string) {
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, runInCdMode())}
s, err := newService(p, svcConfig)
if err != nil {
mainLog.Load().Error().Msg(err.Error())
return
}
if iface == "" {
iface = autoIface
}
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 files. For safety, only remove the log_path chain if
// that path is absolute.
logFile := normalizeLogFilePath(cfg.Service.LogPath)
if !filepath.IsAbs(logFile) {
logFile = ""
}
backups := debugLogBudget(&cfg.Service, router.Name() != "").backups
internalLogs := []string{absHomeDir(logFileName), absHomeDir(journalLogFileName)}
for _, err := range removeLogFiles(logFile, backups, internalLogs) {
mainLog.Load().Warn().Err(err).Msg("failed to remove log file")
}
// 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 := savedStaticDnsSettingsFilePath(i)
if _, err := os.Stat(file); err == nil {
files = append(files, file)
}
return nil
})
// Windows forwarders file.
if hasLocalDnsServerRunning() {
files = append(files, absHomeDir(windowsForwardersFilename))
}
// Binary itself.
bin, _ := os.Executable()
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 {
if os.IsNotExist(err) {
continue
}
mainLog.Load().Warn().Err(err).Msgf("failed to remove file: %s", file)
} else {
mainLog.Load().Debug().Msgf("file removed: %s", file)
}
}
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)
}
}
}
},
}
uninstallCmd.Flags().StringVarP(&iface, "iface", "", "", `Reset DNS setting for iface, use "auto" for the default gateway interface`)
uninstallCmd.Flags().Int64VarP(&deactivationPin, "pin", "", defaultDeactivationPin, `Pin code for uninstalling ctrld`)
_ = uninstallCmd.Flags().MarkHidden("pin")
uninstallCmd.Flags().BoolVarP(&cleanup, "cleanup", "", false, `Removing ctrld binary and config files`)
uninstallCmdAlias := &cobra.Command{
PreRun: func(cmd *cobra.Command, args []string) {
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", "", autoIface, `Reset DNS setting for iface, "auto" means the default interface gateway`)
uninstallCmdAlias.Flags().AddFlagSet(uninstallCmd.Flags())
rootCmd.AddCommand(uninstallCmdAlias)
return uninstallCmd
}
func initInterfacesCmd() *cobra.Command {
listIfacesCmd := &cobra.Command{
Use: "list",
Short: "List network interfaces of the host",
Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) {
withEachPhysicalInterfaces("", "Interface list", func(i *net.Interface) error {
fmt.Printf("Index : %d\n", i.Index)
fmt.Printf("Name : %s\n", i.Name)
var status string
if i.Flags&net.FlagUp != 0 {
status = "Up"
} else {
status = "Down"
}
fmt.Printf("Status: %s\n", status)
addrs, _ := i.Addrs()
for i, ipaddr := range addrs {
if i == 0 {
fmt.Printf("Addrs : %v\n", ipaddr)
continue
}
fmt.Printf(" %v\n", ipaddr)
}
nss, err := currentStaticDNS(i)
if err != nil {
mainLog.Load().Warn().Err(err).Msg("failed to get DNS")
}
if len(nss) == 0 {
nss = currentDNS(i)
}
for i, dns := range nss {
if i == 0 {
fmt.Printf("DNS : %s\n", dns)
continue
}
fmt.Printf(" : %s\n", dns)
}
println()
return nil
})
},
}
interfacesCmd := &cobra.Command{
Use: "interfaces",
Short: "Manage network interfaces",
Args: cobra.OnlyValidArgs,
ValidArgs: []string{
listIfacesCmd.Use,
},
}
interfacesCmd.AddCommand(listIfacesCmd)
return interfacesCmd
}
func initClientsCmd() *cobra.Command {
listClientsCmd := &cobra.Command{
Use: "list",
Short: "List clients that ctrld discovered",
Args: cobra.NoArgs,
PreRun: func(cmd *cobra.Command, args []string) {
checkHasElevatedPrivilege()
},
Run: func(cmd *cobra.Command, args []string) {
p := &prog{router: router.New(&cfg, false)}
s, _ := newService(p, svcConfig)
status, err := s.Status()
if errors.Is(err, service.ErrNotInstalled) {
mainLog.Load().Warn().Msg("service not installed")
return
}
if status == service.StatusStopped {
mainLog.Load().Warn().Msg("service is not running")
return
}
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(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 {
if k == "" { // skip empty source from output.
continue
}
s = append(s, k)
}
sort.Strings(s)
return s
}
// If metrics is enabled, server set this for all clients, so we can check only the first one.
// Ideally, we may have a field in response to indicate that query count should be shown, but
// it would break earlier version of ctrld, which only look list of clients in response.
withQueryCount := len(clients) > 0 && clients[0].IncludeQueryCount
data := make([][]string, len(clients))
for i, c := range clients {
row := []string{
c.IP.String(),
c.Hostname,
c.Mac,
strings.Join(map2Slice(c.Source), ","),
}
if withQueryCount {
row = append(row, strconv.FormatInt(c.QueryCount, 10))
}
data[i] = row
}
table := tablewriter.NewWriter(os.Stdout)
headers := []string{"IP", "Hostname", "Mac", "Discovered"}
if withQueryCount {
headers = append(headers, "Queries")
}
table.SetHeader(headers)
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)
return clientsCmd
}
func initUpgradeCmd() *cobra.Command {
const (
upgradeChannelDev = "dev"
upgradeChannelProd = "prod"
upgradeChannelDefault = "default"
)
upgradeChannel := map[string]string{
upgradeChannelDefault: "https://dl.controld.dev",
upgradeChannelDev: "https://dl.controld.dev",
upgradeChannelProd: "https://dl.controld.com",
}
if isStableVersion(curVersion()) {
upgradeChannel[upgradeChannelDefault] = upgradeChannel[upgradeChannelProd]
}
upgradeCmd := &cobra.Command{
Use: "upgrade",
Short: "Upgrading ctrld to latest version",
ValidArgs: []string{upgradeChannelDev, upgradeChannelProd},
Args: cobra.MaximumNArgs(1),
PreRun: func(cmd *cobra.Command, args []string) {
checkHasElevatedPrivilege()
},
Run: func(cmd *cobra.Command, args []string) {
bin, err := os.Executable()
if err != nil {
mainLog.Load().Fatal().Err(err).Msg("failed to get current ctrld binary path")
}
sc := &service.Config{}
*sc = *svcConfig
sc.Executable = bin
readConfig(false)
v.Unmarshal(&cfg)
p := &prog{router: router.New(&cfg, runInCdMode())}
s, err := newService(p, sc)
if err != nil {
mainLog.Load().Error().Msg(err.Error())
return
}
if iface == "" {
iface = autoIface
}
p.preRun()
if ir := runningIface(s); ir != nil {
p.runningIface = ir.Name
p.requiredMultiNICsConfig = ir.All
}
svcInstalled := true
if _, err := s.Status(); errors.Is(err, service.ErrNotInstalled) {
svcInstalled = false
}
oldBin := bin + oldBinSuffix
baseUrl := upgradeChannel[upgradeChannelDefault]
if len(args) > 0 {
channel := args[0]
switch channel {
case upgradeChannelProd, upgradeChannelDev: // ok
default:
mainLog.Load().Fatal().Msgf("uprade argument must be either %q or %q", upgradeChannelProd, upgradeChannelDev)
}
baseUrl = upgradeChannel[channel]
}
dlUrl := upgradeUrl(baseUrl)
mainLog.Load().Debug().Msgf("Downloading binary: %s", dlUrl)
resp, err := getWithRetry(dlUrl, downloadServerIp)
if err != nil {
mainLog.Load().Fatal().Err(err).Msg("failed to download binary")
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
mainLog.Load().Fatal().Msgf("could not download binary: %s", http.StatusText(resp.StatusCode))
}
mainLog.Load().Debug().Msg("Updating current binary")
if err := selfupdate.Apply(resp.Body, selfupdate.Options{OldSavePath: oldBin}); err != nil {
if rerr := selfupdate.RollbackError(err); rerr != nil {
mainLog.Load().Error().Err(rerr).Msg("could not rollback old binary")
}
mainLog.Load().Fatal().Err(err).Msg("failed to update current binary")
}
doRestart := func() bool {
if !svcInstalled {
return true
}
tasks := []task{
{s.Stop, true, "Stop"},
{func() error {
p.router.Cleanup()
// 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) {
if router.WaitProcessExited() {
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
defer cancel()
loop:
for {
select {
case <-ctx.Done():
mainLog.Load().Error().Msg("timeout while waiting for service to stop")
break loop
default:
}
time.Sleep(time.Second)
if status, _ := s.Status(); status == service.StatusStopped {
break
}
}
}
}
tasks = []task{
{s.Start, true, "Start"},
}
if doTasks(tasks) {
if dir, err := socketDir(); err == nil {
// Only a ready service counts: a build that starts but
// never becomes ready must be rolled back.
if cc := newReadySocketControlClient(context.TODO(), s, dir); cc != nil {
_, _ = cc.post(ifacePath, nil)
return true
}
}
}
return false
}
if svcInstalled {
mainLog.Load().Debug().Msg("Restarting ctrld service using new binary")
}
if doRestart() {
_ = os.Remove(oldBin)
_ = os.Chmod(bin, 0755)
ver, err := binaryVersion(bin)
if err != nil {
mainLog.Load().Warn().Err(err).Msg("Failed to get new binary version")
ver = "unknown version"
}
mainLog.Load().Notice().Msgf("Upgrade successful - %s", ver)
return
}
mainLog.Load().Warn().Msg("Upgrade failed: the new binary did not become ready")
stop := func() error {
if !svcInstalled {
return nil
}
if err := stopServiceAndWait(s, upgradeStopTimeout); err != nil {
return err
}
// Mirror the Cleanup task in doRestart: leave DNS settings as the OS
// had them, not as a half-started ctrld left them.
p.router.Cleanup()
p.resetDNS(false, true)
return nil
}
if err := rollbackToPreviousBinary(bin, oldBin, stop, doRestart); err != nil {
mainLog.Load().Error().Err(err).Msg("Rollback did not complete")
}
},
}
rootCmd.AddCommand(upgradeCmd)
return upgradeCmd
}
func initServicesCmd(commands ...*cobra.Command) *cobra.Command {
serviceCmd := &cobra.Command{
Use: "service",
Short: "Manage ctrld service",
Args: cobra.OnlyValidArgs,
}
serviceCmd.ValidArgs = make([]string, len(commands))
for i, cmd := range commands {
serviceCmd.ValidArgs[i] = cmd.Use
serviceCmd.AddCommand(cmd)
}
rootCmd.AddCommand(serviceCmd)
return serviceCmd
}
// filterEmptyStrings removes empty strings from a slice of strings.
// It returns a new slice containing only non-empty strings.
func filterEmptyStrings(slice []string) []string {
return slices.DeleteFunc(slice, func(s string) bool {
return s == ""
})
}
// validInterceptMode reports whether the given value is a recognized --intercept-mode.
// This is the single source of truth for mode validation — used by the early start
// command check, the runtime validation in prog.go, and onlyInterceptFlags below.
// Add new modes here to have them recognized everywhere.
func validInterceptMode(mode string) bool {
switch mode {
case "off", "dns", "hard":
return true
}
return false
}
// onlyInterceptFlags reports whether args contain only intercept mode
// flags (--intercept-mode <value>) and flags that are auto-added by the
// start command alias (--iface). This is used to detect "ctrld start --intercept-mode dns"
// (or "off" to disable) on an existing installation, where the intent is to modify the
// intercept flag on the existing service without replacing other arguments.
//
// Note: the startCmdAlias appends "--iface=auto" to os.Args when --iface isn't
// explicitly provided, so we must allow it here.
func onlyInterceptFlags(args []string) bool {
hasIntercept := false
for i := 0; i < len(args); i++ {
arg := args[i]
switch {
case arg == "--intercept-mode":
// Next arg must be a valid mode value.
if i+1 < len(args) && validInterceptMode(args[i+1]) {
hasIntercept = true
i++ // skip the value
} else {
return false
}
case strings.HasPrefix(arg, "--intercept-mode="):
val := strings.TrimPrefix(arg, "--intercept-mode=")
if validInterceptMode(val) {
hasIntercept = true
} else {
return false
}
case arg == "--iface="+autoIface || arg == "--iface" || arg == autoIface:
// Auto-added by startCmdAlias or its value; safe to ignore.
continue
default:
return false
}
}
return hasIntercept
}