diff --git a/internal/dhcpd/http_unix.go b/internal/dhcpd/http_unix.go index ab3ce318..e6b1f8fc 100644 --- a/internal/dhcpd/http_unix.go +++ b/internal/dhcpd/http_unix.go @@ -235,22 +235,7 @@ func (s *server) handleDHCPSetConfig(w http.ResponseWriter, r *http.Request) { return } - if conf.Enabled != aghalg.NBNull { - s.conf.Enabled = conf.Enabled == aghalg.NBTrue - } - - if conf.InterfaceName != "" { - s.conf.InterfaceName = conf.InterfaceName - } - - if srv4 != nil { - s.srv4 = srv4 - } - - if srv6 != nil { - s.srv6 = srv6 - } - + s.setConfFromJSON(conf, srv4, srv6) s.conf.ConfigModified() err = s.dbLoad() @@ -269,6 +254,26 @@ func (s *server) handleDHCPSetConfig(w http.ResponseWriter, r *http.Request) { } } +// setConfFromJSON sets configuration parameters in s from the new configuration +// decoded from JSON. +func (s *server) setConfFromJSON(conf *dhcpServerConfigJSON, srv4, srv6 DHCPServer) { + if conf.Enabled != aghalg.NBNull { + s.conf.Enabled = conf.Enabled == aghalg.NBTrue + } + + if conf.InterfaceName != "" { + s.conf.InterfaceName = conf.InterfaceName + } + + if srv4 != nil { + s.srv4 = srv4 + } + + if srv6 != nil { + s.srv6 = srv6 + } +} + type netInterfaceJSON struct { Name string `json:"name"` HardwareAddr string `json:"hardware_address"` diff --git a/internal/home/auth_test.go b/internal/home/auth_test.go index 1bf38753..46767f7d 100644 --- a/internal/home/auth_test.go +++ b/internal/home/auth_test.go @@ -12,16 +12,11 @@ import ( "testing" "time" - "github.com/AdguardTeam/AdGuardHome/internal/aghtest" "github.com/AdguardTeam/golibs/testutil" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func TestMain(m *testing.M) { - aghtest.DiscardLogOutput(m) -} - func TestNewSessionToken(t *testing.T) { // Successful case. token, err := newSessionToken() diff --git a/internal/home/home.go b/internal/home/home.go index 42c44249..289c1c64 100644 --- a/internal/home/home.go +++ b/internal/home/home.go @@ -97,9 +97,15 @@ var Context homeContext // Main is the entry point func Main(clientBuildFS fs.FS) { - // config can be specified, which reads options from there, but other command line flags have to override config values - // therefore, we must do it manually instead of using a lib - args := loadOptions() + initCmdLineOpts() + + // The configuration file path can be overridden, but other command-line + // options have to override config values. Therefore, do it manually + // instead of using package flag. + // + // TODO(a.garipov): The comment above is most likely false. Replace with + // package flag. + opts := loadCmdLineOpts() Context.appSignalChannel = make(chan os.Signal) signal.Notify(Context.appSignalChannel, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP, syscall.SIGQUIT) @@ -120,26 +126,18 @@ func Main(clientBuildFS fs.FS) { } }() - if args.serviceControlAction != "" { - handleServiceControlAction(args, clientBuildFS) + if opts.serviceControlAction != "" { + handleServiceControlAction(opts, clientBuildFS) return } // run the protection - run(args, clientBuildFS) + run(opts, clientBuildFS) } -func setupContext(args options) { - Context.runningAsService = args.runningAsService - Context.disableUpdate = args.disableUpdate || - version.Channel() == version.ChannelDevelopment - - Context.firstRun = detectFirstRun() - if Context.firstRun { - log.Info("This is the first time AdGuard Home is launched") - checkPermissions() - } +func setupContext(opts options) { + setupContextFlags(opts) switch version.Channel() { case version.ChannelEdge, version.ChannelDevelopment: @@ -174,13 +172,13 @@ func setupContext(args options) { os.Exit(1) } - if args.checkConfig { + if opts.checkConfig { log.Info("configuration file is ok") os.Exit(0) } - if !args.noEtcHosts && config.Clients.Sources.HostsFile { + if !opts.noEtcHosts && config.Clients.Sources.HostsFile { err = setupHostsContainer() fatalOnError(err) } @@ -189,6 +187,24 @@ func setupContext(args options) { Context.mux = http.NewServeMux() } +// setupContextFlags sets global flags and prints their status to the log. +func setupContextFlags(opts options) { + Context.firstRun = detectFirstRun() + if Context.firstRun { + log.Info("This is the first time AdGuard Home is launched") + checkPermissions() + } + + Context.runningAsService = opts.runningAsService + // Don't print the runningAsService flag, since that has already been done + // in [run]. + + Context.disableUpdate = opts.disableUpdate || version.Channel() == version.ChannelDevelopment + if Context.disableUpdate { + log.Info("AdGuard Home updates are disabled") + } +} + // logIfUnsupported logs a formatted warning if the error is one of the // unsupported errors and returns nil. If err is nil, logIfUnsupported returns // nil. Otherwise, it returns err. @@ -270,7 +286,7 @@ func setupHostsContainer() (err error) { return nil } -func setupConfig(args options) (err error) { +func setupConfig(opts options) (err error) { config.DNS.DnsfilterConf.EtcHosts = Context.etcHosts config.DNS.DnsfilterConf.ConfigModified = onConfigModified config.DNS.DnsfilterConf.HTTPRegister = httpRegister @@ -312,9 +328,9 @@ func setupConfig(args options) (err error) { Context.clients.Init(config.Clients.Persistent, Context.dhcpServer, Context.etcHosts, arpdb) - if args.bindPort != 0 { + if opts.bindPort != 0 { tcpPorts := aghalg.UniqChecker[tcpPort]{} - addPorts(tcpPorts, tcpPort(args.bindPort), tcpPort(config.BetaBindPort)) + addPorts(tcpPorts, tcpPort(opts.bindPort), tcpPort(config.BetaBindPort)) udpPorts := aghalg.UniqChecker[udpPort]{} addPorts(udpPorts, udpPort(config.DNS.Port)) @@ -336,23 +352,23 @@ func setupConfig(args options) (err error) { return fmt.Errorf("validating udp ports: %w", err) } - config.BindPort = args.bindPort + config.BindPort = opts.bindPort } // override bind host/port from the console - if args.bindHost != nil { - config.BindHost = args.bindHost + if opts.bindHost != nil { + config.BindHost = opts.bindHost } - if len(args.pidFile) != 0 && writePIDFile(args.pidFile) { - Context.pidFileName = args.pidFile + if len(opts.pidFile) != 0 && writePIDFile(opts.pidFile) { + Context.pidFileName = opts.pidFile } return nil } -func initWeb(args options, clientBuildFS fs.FS) (web *Web, err error) { +func initWeb(opts options, clientBuildFS fs.FS) (web *Web, err error) { var clientFS, clientBetaFS fs.FS - if args.localFrontend { + if opts.localFrontend { log.Info("warning: using local frontend files") clientFS = os.DirFS("build/static") @@ -400,24 +416,24 @@ func fatalOnError(err error) { } // run configures and starts AdGuard Home. -func run(args options, clientBuildFS fs.FS) { +func run(opts options, clientBuildFS fs.FS) { // configure config filename - initConfigFilename(args) + initConfigFilename(opts) // configure working dir and config path - initWorkingDir(args) + initWorkingDir(opts) // configure log level and output - configureLogger(args) + configureLogger(opts) // Print the first message after logger is configured. log.Info(version.Full()) log.Debug("current working directory is %s", Context.workDir) - if args.runningAsService { + if opts.runningAsService { log.Info("AdGuard Home is running as a service") } - setupContext(args) + setupContext(opts) err := configureOS(config) fatalOnError(err) @@ -427,7 +443,7 @@ func run(args options, clientBuildFS fs.FS) { // but also avoid relying on automatic Go init() function filtering.InitModule() - err = setupConfig(args) + err = setupConfig(opts) fatalOnError(err) if !Context.firstRun { @@ -456,7 +472,7 @@ func run(args options, clientBuildFS fs.FS) { } sessFilename := filepath.Join(Context.getDataDir(), "sessions.db") - GLMode = args.glinetMode + GLMode = opts.glinetMode var rateLimiter *authRateLimiter if config.AuthAttempts > 0 && config.AuthBlockMin > 0 { rateLimiter = newAuthRateLimiter( @@ -483,7 +499,7 @@ func run(args options, clientBuildFS fs.FS) { log.Fatalf("Can't initialize TLS module") } - Context.web, err = initWeb(args, clientBuildFS) + Context.web, err = initWeb(opts, clientBuildFS) fatalOnError(err) if !Context.firstRun { @@ -575,10 +591,10 @@ func writePIDFile(fn string) bool { return true } -func initConfigFilename(args options) { +func initConfigFilename(opts options) { // config file path can be overridden by command-line arguments: - if args.configFilename != "" { - Context.configFilename = args.configFilename + if opts.confFilename != "" { + Context.configFilename = opts.confFilename } else { // Default config file name Context.configFilename = "AdGuardHome.yaml" @@ -587,15 +603,15 @@ func initConfigFilename(args options) { // initWorkingDir initializes the workDir // if no command-line arguments specified, we use the directory where our binary file is located -func initWorkingDir(args options) { +func initWorkingDir(opts options) { execPath, err := os.Executable() if err != nil { panic(err) } - if args.workDir != "" { + if opts.workDir != "" { // If there is a custom config file, use it's directory as our working dir - Context.workDir = args.workDir + Context.workDir = opts.workDir } else { Context.workDir = filepath.Dir(execPath) } @@ -609,15 +625,15 @@ func initWorkingDir(args options) { } // configureLogger configures logger level and output -func configureLogger(args options) { +func configureLogger(opts options) { ls := getLogSettings() // command-line arguments can override config settings - if args.verbose || config.Verbose { + if opts.verbose || config.Verbose { ls.Verbose = true } - if args.logFile != "" { - ls.File = args.logFile + if opts.logFile != "" { + ls.File = opts.logFile } else if config.File != "" { ls.File = config.File } @@ -638,7 +654,7 @@ func configureLogger(args options) { // happen pretty quickly. log.SetFlags(log.LstdFlags | log.Lmicroseconds) - if args.runningAsService && ls.File == "" && runtime.GOOS == "windows" { + if opts.runningAsService && ls.File == "" && runtime.GOOS == "windows" { // When running as a Windows service, use eventlog by default if nothing // else is configured. Otherwise, we'll simply lose the log output. ls.File = configSyslog @@ -728,25 +744,29 @@ func exitWithError() { os.Exit(64) } -// loadOptions reads command line arguments and initializes configuration -func loadOptions() options { - o, f, err := parse(os.Args[0], os.Args[1:]) - +// loadCmdLineOpts reads command line arguments and initializes configuration +// from them. If there is an error or an effect, loadCmdLineOpts processes them +// and exits. +func loadCmdLineOpts() (opts options) { + opts, eff, err := parseCmdOpts(os.Args[0], os.Args[1:]) if err != nil { log.Error(err.Error()) - _ = printHelp(os.Args[0]) + printHelp(os.Args[0]) + exitWithError() - } else if f != nil { - err = f() + } + + if eff != nil { + err = eff() if err != nil { log.Error(err.Error()) exitWithError() - } else { - os.Exit(0) } + + os.Exit(0) } - return o + return opts } // printWebAddrs prints addresses built from proto, addr, and an appropriate diff --git a/internal/home/home_test.go b/internal/home/home_test.go new file mode 100644 index 00000000..1a611588 --- /dev/null +++ b/internal/home/home_test.go @@ -0,0 +1,12 @@ +package home + +import ( + "testing" + + "github.com/AdguardTeam/AdGuardHome/internal/aghtest" +) + +func TestMain(m *testing.M) { + aghtest.DiscardLogOutput(m) + initCmdLineOpts() +} diff --git a/internal/home/options.go b/internal/home/options.go index 6f5a4d8d..531a0fd4 100644 --- a/internal/home/options.go +++ b/internal/home/options.go @@ -5,30 +5,60 @@ import ( "net" "os" "strconv" + "strings" "github.com/AdguardTeam/AdGuardHome/internal/version" "github.com/AdguardTeam/golibs/log" + "github.com/AdguardTeam/golibs/stringutil" ) -// options passed from command-line arguments -type options struct { - verbose bool // is verbose logging enabled - configFilename string // path to the config file - workDir string // path to the working directory where we will store the filters data and the querylog - bindHost net.IP // host address to bind HTTP server on - bindPort int // port to serve HTTP pages on - logFile string // Path to the log file. If empty, write to stdout. If "syslog", writes to syslog - pidFile string // File name to save PID to - checkConfig bool // Check configuration and exit - disableUpdate bool // If set, don't check for updates +// TODO(a.garipov): Replace with package flag. - // service control action (see service.ControlAction array + "status" command) +// options represents the command-line options. +type options struct { + // confFilename is the path to the configuration file. + confFilename string + + // workDir is the path to the working directory where AdGuard Home stores + // filter data, the query log, and other data. + workDir string + + // logFile is the path to the log file. If empty, AdGuard Home writes to + // stdout; if "syslog", to syslog. + logFile string + + // pidFile is the file name for the file to which the PID is saved. + pidFile string + + // serviceControlAction is the service action to perform. See + // [service.ControlAction] and [handleServiceControlAction]. serviceControlAction string - // runningAsService flag is set to true when options are passed from the service runner + // bindHost is the address on which to serve the HTTP UI. + bindHost net.IP + + // bindPort is the port on which to serve the HTTP UI. + bindPort int + + // checkConfig is true if the current invocation is only required to check + // the configuration file and exit. + checkConfig bool + + // disableUpdate, if set, makes AdGuard Home not check for updates. + disableUpdate bool + + // verbose shows if verbose logging is enabled. + verbose bool + + // runningAsService flag is set to true when options are passed from the + // service runner + // + // TODO(a.garipov): Perhaps this could be determined by a non-empty + // serviceControlAction? runningAsService bool - glinetMode bool // Activate GL-Inet compatibility mode + // glinetMode shows if the GL-Inet compatibility mode is enabled. + glinetMode bool // noEtcHosts flag should be provided when /etc/hosts file shouldn't be // used. @@ -39,88 +69,85 @@ type options struct { localFrontend bool } -// functions used for their side-effects -type effect func() error - -type arg struct { - description string // a short, English description of the argument - longName string // the name of the argument used after '--' - shortName string // the name of the argument used after '-' - - // only one of updateWithValue, updateNoValue, and effect should be present - - updateWithValue func(o options, v string) (options, error) // the mutator for arguments with parameters - updateNoValue func(o options) (options, error) // the mutator for arguments without parameters - effect func(o options, exec string) (f effect, err error) // the side-effect closure generator - - serialize func(o options) []string // the re-serialization function back to arguments (return nil for omit) +// initCmdLineOpts completes initialization of the global command-line option +// slice. It must only be called once. +func initCmdLineOpts() { + // The --help option cannot be put directly into cmdLineOpts, because that + // causes initialization cycle due to printHelp referencing cmdLineOpts. + cmdLineOpts = append(cmdLineOpts, cmdLineOpt{ + updateWithValue: nil, + updateNoValue: nil, + effect: func(o options, exec string) (effect, error) { + return func() error { printHelp(exec); exitWithError(); return nil }, nil + }, + serialize: func(o options) (val string, ok bool) { return "", false }, + description: "Print this help.", + longName: "help", + shortName: "", + }) } -// {type}SliceOrNil functions check their parameter of type {type} -// against its zero value and return nil if the parameter value is -// zero otherwise they return a string slice of the parameter +// effect is the type for functions used for their side-effects. +type effect func() (err error) -func ipSliceOrNil(ip net.IP) []string { - if ip == nil { - return nil - } +// cmdLineOpt contains the data for a single command-line option. Only one of +// updateWithValue, updateNoValue, and effect must be present. +type cmdLineOpt struct { + updateWithValue func(o options, v string) (updated options, err error) + updateNoValue func(o options) (updated options, err error) + effect func(o options, exec string) (eff effect, err error) - return []string{ip.String()} + // serialize is a function that encodes the option into a slice of + // command-line arguments, if necessary. If ok is false, this option should + // be skipped. + serialize func(o options) (val string, ok bool) + + description string + longName string + shortName string } -func stringSliceOrNil(s string) []string { - if s == "" { - return nil - } +// cmdLineOpts are all command-line options of AdGuard Home. +var cmdLineOpts = []cmdLineOpt{{ + updateWithValue: func(o options, v string) (options, error) { + o.confFilename = v + return o, nil + }, + updateNoValue: nil, + effect: nil, + serialize: func(o options) (val string, ok bool) { + return o.confFilename, o.confFilename != "" + }, + description: "Path to the config file.", + longName: "config", + shortName: "c", +}, { + updateWithValue: func(o options, v string) (options, error) { o.workDir = v; return o, nil }, + updateNoValue: nil, + effect: nil, + serialize: func(o options) (val string, ok bool) { return o.workDir, o.workDir != "" }, + description: "Path to the working directory.", + longName: "work-dir", + shortName: "w", +}, { + updateWithValue: func(o options, v string) (options, error) { + o.bindHost = net.ParseIP(v) + return o, nil + }, + updateNoValue: nil, + effect: nil, + serialize: func(o options) (val string, ok bool) { + if o.bindHost == nil { + return "", false + } - return []string{s} -} - -func intSliceOrNil(i int) []string { - if i == 0 { - return nil - } - - return []string{strconv.Itoa(i)} -} - -func boolSliceOrNil(b bool) []string { - if b { - return []string{} - } - - return nil -} - -var args []arg - -var configArg = arg{ - "Path to the config file.", - "config", "c", - func(o options, v string) (options, error) { o.configFilename = v; return o, nil }, - nil, - nil, - func(o options) []string { return stringSliceOrNil(o.configFilename) }, -} - -var workDirArg = arg{ - "Path to the working directory.", - "work-dir", "w", - func(o options, v string) (options, error) { o.workDir = v; return o, nil }, nil, nil, - func(o options) []string { return stringSliceOrNil(o.workDir) }, -} - -var hostArg = arg{ - "Host address to bind HTTP server on.", - "host", "h", - func(o options, v string) (options, error) { o.bindHost = net.ParseIP(v); return o, nil }, nil, nil, - func(o options) []string { return ipSliceOrNil(o.bindHost) }, -} - -var portArg = arg{ - "Port to serve HTTP pages on.", - "port", "p", - func(o options, v string) (options, error) { + return o.bindHost.String(), true + }, + description: "Host address to bind HTTP server on.", + longName: "host", + shortName: "h", +}, { + updateWithValue: func(o options, v string) (options, error) { var err error var p int minPort, maxPort := 0, 1<<16-1 @@ -131,108 +158,81 @@ var portArg = arg{ } else { o.bindPort = p } - return o, err - }, nil, nil, - func(o options) []string { return intSliceOrNil(o.bindPort) }, -} -var serviceArg = arg{ - "Service control action: status, install, uninstall, start, stop, restart, reload (configuration).", - "service", "s", - func(o options, v string) (options, error) { + return o, err + }, + updateNoValue: nil, + effect: nil, + serialize: func(o options) (val string, ok bool) { + if o.bindPort == 0 { + return "", false + } + + return strconv.Itoa(o.bindPort), true + }, + description: "Port to serve HTTP pages on.", + longName: "port", + shortName: "p", +}, { + updateWithValue: func(o options, v string) (options, error) { o.serviceControlAction = v return o, nil - }, nil, nil, - func(o options) []string { return stringSliceOrNil(o.serviceControlAction) }, -} - -var logfileArg = arg{ - "Path to log file. If empty: write to stdout; if 'syslog': write to system log.", - "logfile", "l", - func(o options, v string) (options, error) { o.logFile = v; return o, nil }, nil, nil, - func(o options) []string { return stringSliceOrNil(o.logFile) }, -} - -var pidfileArg = arg{ - "Path to a file where PID is stored.", - "pidfile", "", - func(o options, v string) (options, error) { o.pidFile = v; return o, nil }, nil, nil, - func(o options) []string { return stringSliceOrNil(o.pidFile) }, -} - -var checkConfigArg = arg{ - "Check configuration and exit.", - "check-config", "", - nil, func(o options) (options, error) { o.checkConfig = true; return o, nil }, nil, - func(o options) []string { return boolSliceOrNil(o.checkConfig) }, -} - -var noCheckUpdateArg = arg{ - "Don't check for updates.", - "no-check-update", "", - nil, func(o options) (options, error) { o.disableUpdate = true; return o, nil }, nil, - func(o options) []string { return boolSliceOrNil(o.disableUpdate) }, -} - -var disableMemoryOptimizationArg = arg{ - "Deprecated. Disable memory optimization.", - "no-mem-optimization", "", - nil, nil, func(_ options, _ string) (f effect, err error) { + }, + updateNoValue: nil, + effect: nil, + serialize: func(o options) (val string, ok bool) { + return o.serviceControlAction, o.serviceControlAction != "" + }, + description: `Service control action: status, install (as a service), ` + + `uninstall (as a service), start, stop, restart, reload (configuration).`, + longName: "service", + shortName: "s", +}, { + updateWithValue: func(o options, v string) (options, error) { o.logFile = v; return o, nil }, + updateNoValue: nil, + effect: nil, + serialize: func(o options) (val string, ok bool) { return o.logFile, o.logFile != "" }, + description: `Path to log file. If empty, write to stdout; ` + + `if "syslog", write to system log.`, + longName: "logfile", + shortName: "l", +}, { + updateWithValue: func(o options, v string) (options, error) { o.pidFile = v; return o, nil }, + updateNoValue: nil, + effect: nil, + serialize: func(o options) (val string, ok bool) { return o.pidFile, o.pidFile != "" }, + description: "Path to a file where PID is stored.", + longName: "pidfile", + shortName: "", +}, { + updateWithValue: nil, + updateNoValue: func(o options) (options, error) { o.checkConfig = true; return o, nil }, + effect: nil, + serialize: func(o options) (val string, ok bool) { return "", o.checkConfig }, + description: "Check configuration and exit.", + longName: "check-config", + shortName: "", +}, { + updateWithValue: nil, + updateNoValue: func(o options) (options, error) { o.disableUpdate = true; return o, nil }, + effect: nil, + serialize: func(o options) (val string, ok bool) { return "", o.disableUpdate }, + description: "Don't check for updates.", + longName: "no-check-update", + shortName: "", +}, { + updateWithValue: nil, + updateNoValue: nil, + effect: func(_ options, _ string) (f effect, err error) { log.Info("warning: using --no-mem-optimization flag has no effect and is deprecated") return nil, nil }, - func(o options) []string { return nil }, -} - -var verboseArg = arg{ - "Enable verbose output.", - "verbose", "v", - nil, func(o options) (options, error) { o.verbose = true; return o, nil }, nil, - func(o options) []string { return boolSliceOrNil(o.verbose) }, -} - -var glinetArg = arg{ - "Run in GL-Inet compatibility mode.", - "glinet", "", - nil, func(o options) (options, error) { o.glinetMode = true; return o, nil }, nil, - func(o options) []string { return boolSliceOrNil(o.glinetMode) }, -} - -var versionArg = arg{ - description: "Show the version and exit. Show more detailed version description with -v.", - longName: "version", - shortName: "", - updateWithValue: nil, - updateNoValue: nil, - effect: func(o options, exec string) (effect, error) { - return func() error { - if o.verbose { - fmt.Println(version.Verbose()) - } else { - fmt.Println(version.Full()) - } - os.Exit(0) - - return nil - }, nil - }, - serialize: func(o options) []string { return nil }, -} - -var helpArg = arg{ - "Print this help.", - "help", "", - nil, nil, func(o options, exec string) (effect, error) { - return func() error { _ = printHelp(exec); os.Exit(64); return nil }, nil - }, - func(o options) []string { return nil }, -} - -var noEtcHostsArg = arg{ - description: "Deprecated. Do not use the OS-provided hosts.", - longName: "no-etc-hosts", - shortName: "", + serialize: func(o options) (val string, ok bool) { return "", false }, + description: "Deprecated. Disable memory optimization.", + longName: "no-mem-optimization", + shortName: "", +}, { updateWithValue: nil, updateNoValue: func(o options) (options, error) { o.noEtcHosts = true; return o, nil }, effect: func(_ options, _ string) (f effect, err error) { @@ -242,146 +242,216 @@ var noEtcHostsArg = arg{ return nil, nil }, - serialize: func(o options) []string { return boolSliceOrNil(o.noEtcHosts) }, -} - -var localFrontendArg = arg{ - description: "Use local frontend directories.", - longName: "local-frontend", - shortName: "", + serialize: func(o options) (val string, ok bool) { return "", o.noEtcHosts }, + description: "Deprecated. Do not use the OS-provided hosts.", + longName: "no-etc-hosts", + shortName: "", +}, { updateWithValue: nil, updateNoValue: func(o options) (options, error) { o.localFrontend = true; return o, nil }, effect: nil, - serialize: func(o options) []string { return boolSliceOrNil(o.localFrontend) }, -} + serialize: func(o options) (val string, ok bool) { return "", o.localFrontend }, + description: "Use local frontend directories.", + longName: "local-frontend", + shortName: "", +}, { + updateWithValue: nil, + updateNoValue: func(o options) (options, error) { o.verbose = true; return o, nil }, + effect: nil, + serialize: func(o options) (val string, ok bool) { return "", o.verbose }, + description: "Enable verbose output.", + longName: "verbose", + shortName: "v", +}, { + updateWithValue: nil, + updateNoValue: func(o options) (options, error) { o.glinetMode = true; return o, nil }, + effect: nil, + serialize: func(o options) (val string, ok bool) { return "", o.glinetMode }, + description: "Run in GL-Inet compatibility mode.", + longName: "glinet", + shortName: "", +}, { + updateWithValue: nil, + updateNoValue: nil, + effect: func(o options, exec string) (effect, error) { + return func() error { + if o.verbose { + fmt.Println(version.Verbose()) + } else { + fmt.Println(version.Full()) + } -func init() { - args = []arg{ - configArg, - workDirArg, - hostArg, - portArg, - serviceArg, - logfileArg, - pidfileArg, - checkConfigArg, - noCheckUpdateArg, - disableMemoryOptimizationArg, - noEtcHostsArg, - localFrontendArg, - verboseArg, - glinetArg, - versionArg, - helpArg, - } -} + os.Exit(0) -func getUsageLines(exec string, args []arg) []string { - usage := []string{ - "Usage:", - "", - fmt.Sprintf("%s [options]", exec), - "", - "Options:", - } - for _, arg := range args { + return nil + }, nil + }, + serialize: func(o options) (val string, ok bool) { return "", false }, + description: "Show the version and exit. Show more detailed version description with -v.", + longName: "version", + shortName: "", +}} + +// printHelp prints the entire help message. It exits with an error code if +// there are any I/O errors. +func printHelp(exec string) { + b := &strings.Builder{} + + stringutil.WriteToBuilder( + b, + "Usage:\n\n", + fmt.Sprintf("%s [options]\n\n", exec), + "Options:\n", + ) + + var err error + for _, opt := range cmdLineOpts { val := "" - if arg.updateWithValue != nil { + if opt.updateWithValue != nil { val = " VALUE" } - if arg.shortName != "" { - usage = append(usage, fmt.Sprintf(" -%s, %-30s %s", - arg.shortName, - "--"+arg.longName+val, - arg.description)) + + longDesc := opt.longName + val + if opt.shortName != "" { + _, err = fmt.Fprintf(b, " -%s, --%-28s %s\n", opt.shortName, longDesc, opt.description) } else { - usage = append(usage, fmt.Sprintf(" %-34s %s", - "--"+arg.longName+val, - arg.description)) + _, err = fmt.Fprintf(b, " --%-32s %s\n", longDesc, opt.description) + } + + if err != nil { + // The only error here can be from incorrect Fprintf usage, which is + // a programmer error. + panic(err) } } - return usage + + _, err = fmt.Print(b) + if err != nil { + // Exit immediately, since not being able to print out a help message + // essentially means that the I/O is very broken at the moment. + exitWithError() + } } -func printHelp(exec string) error { - for _, line := range getUsageLines(exec, args) { - _, err := fmt.Println(line) +// parseCmdOpts parses the command-line arguments into options and effects. +func parseCmdOpts(cmdName string, args []string) (o options, eff effect, err error) { + // Don't use range since the loop changes the loop variable. + argsLen := len(args) + for i := 0; i < len(args); i++ { + arg := args[i] + isKnown := false + for _, opt := range cmdLineOpts { + isKnown = argMatches(opt, arg) + if !isKnown { + continue + } + + if opt.updateWithValue != nil { + i++ + if i >= argsLen { + return o, eff, fmt.Errorf("got %s without argument", arg) + } + + o, err = opt.updateWithValue(o, args[i]) + } else { + o, eff, err = updateOptsNoValue(o, eff, opt, cmdName) + } + + if err != nil { + return o, eff, fmt.Errorf("applying option %s: %w", arg, err) + } + + break + } + + if !isKnown { + return o, eff, fmt.Errorf("unknown option %s", arg) + } + } + + return o, eff, err +} + +// argMatches returns true if arg matches command-line option opt. +func argMatches(opt cmdLineOpt, arg string) (ok bool) { + if arg == "" || arg[0] != '-' { + return false + } + + arg = arg[1:] + if arg == "" { + return false + } + + return (opt.shortName != "" && arg == opt.shortName) || + (arg[0] == '-' && arg[1:] == opt.longName) +} + +// updateOptsNoValue sets values or effects from opt into o or prev. +func updateOptsNoValue( + o options, + prev effect, + opt cmdLineOpt, + cmdName string, +) (updated options, chained effect, err error) { + if opt.updateNoValue != nil { + o, err = opt.updateNoValue(o) + if err != nil { + return o, prev, err + } + + return o, prev, nil + } + + next, err := opt.effect(o, cmdName) + if err != nil { + return o, prev, err + } + + chained = chainEffect(prev, next) + + return o, chained, nil +} + +// chainEffect chans the next effect after the prev one. If prev is nil, eff +// only calls next. If next is nil, eff is prev; if prev is nil, eff is next. +func chainEffect(prev, next effect) (eff effect) { + if prev == nil { + return next + } else if next == nil { + return prev + } + + eff = func() (err error) { + err = prev() if err != nil { return err } + + return next() } - return nil + + return eff } -func argMatches(a arg, v string) bool { - return v == "--"+a.longName || (a.shortName != "" && v == "-"+a.shortName) -} - -func parse(exec string, ss []string) (o options, f effect, err error) { - for i := 0; i < len(ss); i++ { - v := ss[i] - knownParam := false - for _, arg := range args { - if argMatches(arg, v) { - if arg.updateWithValue != nil { - if i+1 >= len(ss) { - return o, f, fmt.Errorf("got %s without argument", v) - } - i++ - o, err = arg.updateWithValue(o, ss[i]) - if err != nil { - return - } - } else if arg.updateNoValue != nil { - o, err = arg.updateNoValue(o) - if err != nil { - return - } - } else if arg.effect != nil { - var eff effect - eff, err = arg.effect(o, exec) - if err != nil { - return - } - if eff != nil { - prevf := f - f = func() (ferr error) { - if prevf != nil { - ferr = prevf() - } - if ferr == nil { - ferr = eff() - } - return ferr - } - } - } - knownParam = true - break - } +// optsToArgs converts command line options into a list of arguments. +func optsToArgs(o options) (args []string) { + for _, opt := range cmdLineOpts { + val, ok := opt.serialize(o) + if !ok { + continue } - if !knownParam { - return o, f, fmt.Errorf("unknown option %v", v) + + if opt.shortName != "" { + args = append(args, "-"+opt.shortName) + } else { + args = append(args, "--"+opt.longName) + } + + if val != "" { + args = append(args, val) } } - return -} - -func shortestFlag(a arg) string { - if a.shortName != "" { - return "-" + a.shortName - } - return "--" + a.longName -} - -func serialize(o options) []string { - ss := []string{} - for _, arg := range args { - s := arg.serialize(o) - if s != nil { - ss = append(ss, append([]string{shortestFlag(arg)}, s...)...) - } - } - return ss + return args } diff --git a/internal/home/options_test.go b/internal/home/options_test.go index 21972b0a..7954c0e4 100644 --- a/internal/home/options_test.go +++ b/internal/home/options_test.go @@ -12,7 +12,7 @@ import ( func testParseOK(t *testing.T, ss ...string) options { t.Helper() - o, _, err := parse("", ss) + o, _, err := parseCmdOpts("", ss) require.NoError(t, err) return o @@ -21,7 +21,7 @@ func testParseOK(t *testing.T, ss ...string) options { func testParseErr(t *testing.T, descr string, ss ...string) { t.Helper() - _, _, err := parse("", ss) + _, _, err := parseCmdOpts("", ss) require.Error(t, err) } @@ -38,11 +38,11 @@ func TestParseVerbose(t *testing.T) { } func TestParseConfigFilename(t *testing.T) { - assert.Equal(t, "", testParseOK(t).configFilename, "empty is no config filename") - assert.Equal(t, "path", testParseOK(t, "-c", "path").configFilename, "-c is config filename") + assert.Equal(t, "", testParseOK(t).confFilename, "empty is no config filename") + assert.Equal(t, "path", testParseOK(t, "-c", "path").confFilename, "-c is config filename") testParseParamMissing(t, "-c") - assert.Equal(t, "path", testParseOK(t, "--config", "path").configFilename, "--config is config filename") + assert.Equal(t, "path", testParseOK(t, "--config", "path").confFilename, "--config is config filename") testParseParamMissing(t, "--config") } @@ -103,7 +103,7 @@ func TestParseDisableUpdate(t *testing.T) { // TODO(e.burkov): Remove after v0.108.0. func TestParseDisableMemoryOptimization(t *testing.T) { - o, eff, err := parse("", []string{"--no-mem-optimization"}) + o, eff, err := parseCmdOpts("", []string{"--no-mem-optimization"}) require.NoError(t, err) assert.Nil(t, eff) @@ -130,73 +130,73 @@ func TestParseUnknown(t *testing.T) { testParseErr(t, "unknown dash", "-") } -func TestSerialize(t *testing.T) { +func TestOptsToArgs(t *testing.T) { testCases := []struct { name string + args []string opts options - ss []string }{{ name: "empty", + args: []string{}, opts: options{}, - ss: []string{}, }, { name: "config_filename", - opts: options{configFilename: "path"}, - ss: []string{"-c", "path"}, + args: []string{"-c", "path"}, + opts: options{confFilename: "path"}, }, { name: "work_dir", + args: []string{"-w", "path"}, opts: options{workDir: "path"}, - ss: []string{"-w", "path"}, }, { name: "bind_host", + args: []string{"-h", "1.2.3.4"}, opts: options{bindHost: net.IP{1, 2, 3, 4}}, - ss: []string{"-h", "1.2.3.4"}, }, { name: "bind_port", + args: []string{"-p", "666"}, opts: options{bindPort: 666}, - ss: []string{"-p", "666"}, }, { name: "log_file", + args: []string{"-l", "path"}, opts: options{logFile: "path"}, - ss: []string{"-l", "path"}, }, { name: "pid_file", + args: []string{"--pidfile", "path"}, opts: options{pidFile: "path"}, - ss: []string{"--pidfile", "path"}, }, { name: "disable_update", + args: []string{"--no-check-update"}, opts: options{disableUpdate: true}, - ss: []string{"--no-check-update"}, }, { name: "control_action", + args: []string{"-s", "run"}, opts: options{serviceControlAction: "run"}, - ss: []string{"-s", "run"}, }, { name: "glinet_mode", + args: []string{"--glinet"}, opts: options{glinetMode: true}, - ss: []string{"--glinet"}, }, { name: "multiple", - opts: options{ - serviceControlAction: "run", - configFilename: "config", - workDir: "work", - pidFile: "pid", - disableUpdate: true, - }, - ss: []string{ + args: []string{ "-c", "config", "-w", "work", "-s", "run", "--pidfile", "pid", "--no-check-update", }, + opts: options{ + serviceControlAction: "run", + confFilename: "config", + workDir: "work", + pidFile: "pid", + disableUpdate: true, + }, }} for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - result := serialize(tc.opts) - assert.ElementsMatch(t, tc.ss, result) + result := optsToArgs(tc.opts) + assert.ElementsMatch(t, tc.args, result) }) } } diff --git a/internal/home/service.go b/internal/home/service.go index e52f9799..3aece1f2 100644 --- a/internal/home/service.go +++ b/internal/home/service.go @@ -197,7 +197,7 @@ func handleServiceControlAction(opts options, clientBuildFS fs.FS) { DisplayName: serviceDisplayName, Description: serviceDescription, WorkingDirectory: pwd, - Arguments: serialize(runOpts), + Arguments: optsToArgs(runOpts), } configureService(svcConfig) diff --git a/scripts/make/go-lint.sh b/scripts/make/go-lint.sh index e04af725..8c462d5b 100644 --- a/scripts/make/go-lint.sh +++ b/scripts/make/go-lint.sh @@ -223,8 +223,7 @@ govulncheck ./... # Apply more lax standards to the code we haven't properly refactored yet. gocyclo --over 17 ./internal/querylog/ -gocyclo --over 15 ./internal/home/ ./internal/dhcpd -gocyclo --over 13 ./internal/filtering/ +gocyclo --over 13 ./internal/dhcpd ./internal/filtering/ ./internal/home/ # Apply stricter standards to new or somewhat refactored code. gocyclo --over 10 ./internal/aghio/ ./internal/aghnet/ ./internal/aghos/\