mirror of
https://github.com/Control-D-Inc/ctrld.git
synced 2026-09-04 13:36:35 +02:00
"ctrld status" reported the service manager's view and nothing else, so it printed "Service is running" and exited 0 for the incident's process: alive, registered as started, stuck in API preflight, with no control socket, no DNS listener and no policy applied. The one command an operator reaches for first confirmed the service was fine while the host had no working DNS. Probe the control server's /started endpoint before reporting success. That endpoint only answers once the onStarted hooks have completed, which is after the listeners are up, so a successful probe means the process is serving rather than merely alive. A service that is registered as running but cannot confirm startup is now reported as such, with a pointer to the log, and exits 3 - distinct from stopped (1) and unknown (2), because it needs a different response. A probe blocked by permissions is not evidence of a broken service: an unprivileged caller still gets "Service is running", with a note that startup was not verified. The probe is bounded by a short timeout so status stays fast. Document the exit codes in the command's help, and cover the probe (ready, not finished starting, no socket, timed out) and the classification, including that an unreadable socket is not reported as a failure. The not-ready verdict is only reported when the probe could have found the daemon's socket. socketDir() is caller-relative on unix - the system directory when writable, the caller's home otherwise - so an unprivileged "ctrld status" looks somewhere the root-owned daemon never listened and gets ENOENT, which is "wrong path", not "not ready". Since only darwin has an elevation PreRun and the root-level alias has none, that is the normal invocation; reporting exit 3 there would have told a monitoring check to restart healthy daemons. Such a caller now gets the service manager's view with startup reported as unverified. Windows and mobile resolve the same directory for every caller, so the verdict stays fully available on the platform the hung start was seen on. A successful probe is still conclusive whoever ran it.
319 lines
10 KiB
Go
319 lines
10 KiB
Go
package cli
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"runtime"
|
|
"strings"
|
|
|
|
"github.com/kardianos/service"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
// filterEmptyStrings removes empty strings from a slice
|
|
// This is used to clean up command line arguments and configuration values
|
|
func filterEmptyStrings(slice []string) []string {
|
|
var result []string
|
|
for _, s := range slice {
|
|
if s != "" {
|
|
result = append(result, s)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// ServiceCommand handles service-related operations
|
|
// This encapsulates all service management functionality for the CLI
|
|
type ServiceCommand struct {
|
|
serviceManager *ServiceManager
|
|
}
|
|
|
|
// initializeServiceManager creates a service manager with default configuration
|
|
// This sets up the basic service infrastructure needed for all service operations
|
|
func (sc *ServiceCommand) initializeServiceManager() (service.Service, *prog, error) {
|
|
svcConfig := sc.createServiceConfig()
|
|
return sc.initializeServiceManagerWithServiceConfig(svcConfig)
|
|
}
|
|
|
|
// initializeServiceManagerWithServiceConfig creates a service manager with the given configuration
|
|
// This allows for custom service configuration while maintaining the same initialization pattern
|
|
func (sc *ServiceCommand) initializeServiceManagerWithServiceConfig(svcConfig *service.Config) (service.Service, *prog, error) {
|
|
p := &prog{}
|
|
|
|
s, err := sc.newService(p, svcConfig)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("failed to create service: %w", err)
|
|
}
|
|
|
|
sc.serviceManager = &ServiceManager{prog: p, svc: s}
|
|
return s, p, nil
|
|
}
|
|
|
|
// newService creates a new service instance using the provided program and configuration.
|
|
// This abstracts the service creation process for different operating systems
|
|
func (sc *ServiceCommand) newService(p *prog, svcConfig *service.Config) (service.Service, error) {
|
|
s, err := newService(p, svcConfig)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create service: %w", err)
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
// NewServiceCommand creates a new service command handler
|
|
// This provides a clean factory method for creating service command instances
|
|
func NewServiceCommand() *ServiceCommand {
|
|
return &ServiceCommand{}
|
|
}
|
|
|
|
// createServiceConfig creates a properly initialized service configuration
|
|
// This ensures consistent service naming and description across all platforms
|
|
func (sc *ServiceCommand) createServiceConfig() *service.Config {
|
|
return &service.Config{
|
|
Name: ctrldServiceName,
|
|
DisplayName: "Control-D Helper Service",
|
|
Description: "A highly configurable, multi-protocol DNS forwarding proxy",
|
|
Option: service.KeyValue{},
|
|
}
|
|
}
|
|
|
|
// InitServiceCmd creates the service command with proper logic and aliases
|
|
// This sets up all service-related subcommands with appropriate permissions and flags
|
|
func InitServiceCmd(rootCmd *cobra.Command) *cobra.Command {
|
|
// Create service command handlers
|
|
sc := NewServiceCommand()
|
|
|
|
startCmd, startCmdAlias := createStartCommands(sc)
|
|
rootCmd.AddCommand(startCmdAlias)
|
|
|
|
// Stop command
|
|
stopCmd := &cobra.Command{
|
|
Use: "stop",
|
|
Short: "Stop the ctrld service",
|
|
Args: cobra.NoArgs,
|
|
PreRun: func(cmd *cobra.Command, args []string) {
|
|
checkHasElevatedPrivilege()
|
|
},
|
|
RunE: sc.Stop,
|
|
}
|
|
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")
|
|
|
|
// Restart command
|
|
restartCmd := &cobra.Command{
|
|
Use: "restart",
|
|
Short: "Restart the ctrld service",
|
|
Args: cobra.NoArgs,
|
|
PreRun: func(cmd *cobra.Command, args []string) {
|
|
checkHasElevatedPrivilege()
|
|
},
|
|
RunE: sc.Restart,
|
|
}
|
|
|
|
// Status command
|
|
statusCmd := &cobra.Command{
|
|
Use: "status",
|
|
Short: "Show status of the ctrld service",
|
|
Long: statusCmdLong,
|
|
Args: cobra.NoArgs,
|
|
RunE: sc.Status,
|
|
}
|
|
if runtime.GOOS == "darwin" {
|
|
// On darwin, running status command without privileges may return wrong information.
|
|
statusCmd.PreRun = func(cmd *cobra.Command, args []string) {
|
|
checkHasElevatedPrivilege()
|
|
}
|
|
}
|
|
|
|
// Reload command
|
|
reloadCmd := &cobra.Command{
|
|
Use: "reload",
|
|
Short: "Reload the ctrld service",
|
|
Args: cobra.NoArgs,
|
|
PreRun: func(cmd *cobra.Command, args []string) {
|
|
checkHasElevatedPrivilege()
|
|
},
|
|
RunE: sc.Reload,
|
|
}
|
|
|
|
// Uninstall command
|
|
uninstallCmd := &cobra.Command{
|
|
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,
|
|
PreRun: func(cmd *cobra.Command, args []string) {
|
|
checkHasElevatedPrivilege()
|
|
},
|
|
RunE: sc.Uninstall,
|
|
}
|
|
uninstallCmd.Flags().StringVarP(&iface, "iface", "", "", `Reset DNS setting for iface, "auto" means the default interface gateway`)
|
|
uninstallCmd.Flags().Int64VarP(&deactivationPin, "pin", "", defaultDeactivationPin, `Pin code for stopping ctrld`)
|
|
_ = uninstallCmd.Flags().MarkHidden("pin")
|
|
uninstallCmd.Flags().BoolVarP(&cleanup, "cleanup", "", false, `Removing ctrld binary and config files`)
|
|
|
|
// Interfaces command - use the existing InitInterfacesCmd function
|
|
interfacesCmd := InitInterfacesCmd(rootCmd)
|
|
|
|
stopCmdAlias := &cobra.Command{
|
|
PreRun: func(cmd *cobra.Command, args []string) {
|
|
checkHasElevatedPrivilege()
|
|
},
|
|
Use: "stop",
|
|
Short: "Quick stop service and remove DNS from interface",
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
if !cmd.Flags().Changed("iface") {
|
|
os.Args = append(os.Args, "--iface="+ifaceStartStop)
|
|
}
|
|
iface = ifaceStartStop
|
|
return stopCmd.RunE(cmd, args)
|
|
},
|
|
}
|
|
stopCmdAlias.Flags().StringVarP(&ifaceStartStop, "iface", "", "auto", `Reset DNS setting for iface, "auto" means the default interface gateway`)
|
|
stopCmdAlias.Flags().AddFlagSet(stopCmd.Flags())
|
|
rootCmd.AddCommand(stopCmdAlias)
|
|
|
|
// Create aliases for other service commands
|
|
restartCmdAlias := &cobra.Command{
|
|
PreRun: func(cmd *cobra.Command, args []string) {
|
|
checkHasElevatedPrivilege()
|
|
},
|
|
Use: "restart",
|
|
Short: "Restart the ctrld service",
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
return restartCmd.RunE(cmd, args)
|
|
},
|
|
}
|
|
rootCmd.AddCommand(restartCmdAlias)
|
|
|
|
reloadCmdAlias := &cobra.Command{
|
|
PreRun: func(cmd *cobra.Command, args []string) {
|
|
checkHasElevatedPrivilege()
|
|
},
|
|
Use: "reload",
|
|
Short: "Reload the ctrld service",
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
return reloadCmd.RunE(cmd, args)
|
|
},
|
|
}
|
|
rootCmd.AddCommand(reloadCmdAlias)
|
|
|
|
statusCmdAlias := &cobra.Command{
|
|
Use: "status",
|
|
Short: "Show status of the ctrld service",
|
|
Long: statusCmdLong,
|
|
Args: cobra.NoArgs,
|
|
RunE: statusCmd.RunE,
|
|
}
|
|
rootCmd.AddCommand(statusCmdAlias)
|
|
|
|
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.`,
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
if !cmd.Flags().Changed("iface") {
|
|
os.Args = append(os.Args, "--iface="+ifaceStartStop)
|
|
}
|
|
iface = ifaceStartStop
|
|
return uninstallCmd.RunE(cmd, args)
|
|
},
|
|
}
|
|
uninstallCmdAlias.Flags().StringVarP(&ifaceStartStop, "iface", "", "auto", `Reset DNS setting for iface, "auto" means the default interface gateway`)
|
|
uninstallCmdAlias.Flags().AddFlagSet(uninstallCmd.Flags())
|
|
rootCmd.AddCommand(uninstallCmdAlias)
|
|
|
|
// Create service command
|
|
serviceCmd := &cobra.Command{
|
|
Use: "service",
|
|
Short: "Manage ctrld service",
|
|
Args: cobra.OnlyValidArgs,
|
|
}
|
|
serviceCmd.ValidArgs = make([]string, 7)
|
|
serviceCmd.ValidArgs[0] = startCmd.Use
|
|
serviceCmd.ValidArgs[1] = stopCmd.Use
|
|
serviceCmd.ValidArgs[2] = restartCmd.Use
|
|
serviceCmd.ValidArgs[3] = reloadCmd.Use
|
|
serviceCmd.ValidArgs[4] = statusCmd.Use
|
|
serviceCmd.ValidArgs[5] = uninstallCmd.Use
|
|
serviceCmd.ValidArgs[6] = interfacesCmd.Use
|
|
|
|
serviceCmd.AddCommand(startCmd)
|
|
serviceCmd.AddCommand(stopCmd)
|
|
serviceCmd.AddCommand(restartCmd)
|
|
serviceCmd.AddCommand(reloadCmd)
|
|
serviceCmd.AddCommand(statusCmd)
|
|
serviceCmd.AddCommand(uninstallCmd)
|
|
serviceCmd.AddCommand(interfacesCmd)
|
|
|
|
rootCmd.AddCommand(serviceCmd)
|
|
|
|
return serviceCmd
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// validFirewallMode reports whether the given value is a recognized --firewall-mode.
|
|
func validFirewallMode(mode string) bool {
|
|
switch mode {
|
|
case "off", "on":
|
|
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
|
|
}
|