Pull request: refactor-opts

Updates #2893.

Squashed commit of the following:

commit c7027abd1088e27569367f3450e9225ff605b43d
Author: Ainar Garipov <A.Garipov@AdGuard.COM>
Date:   Wed Oct 5 16:54:23 2022 +0300

    home: imp docs

commit 86a5b0aca916a7db608eba8263ecdc6ca79c8043
Author: Ainar Garipov <A.Garipov@AdGuard.COM>
Date:   Wed Oct 5 16:50:44 2022 +0300

    home: refactor opts more

commit 74c5989d1edf8d007dec847f4aaa0d7a0d24dc38
Author: Ainar Garipov <A.Garipov@AdGuard.COM>
Date:   Wed Oct 5 15:17:26 2022 +0300

    home: refactor option parsing
This commit is contained in:
Ainar Garipov 2022-10-05 17:07:08 +03:00
parent f557339ca0
commit 2e0f6e5468
8 changed files with 515 additions and 414 deletions

View file

@ -235,22 +235,7 @@ func (s *server) handleDHCPSetConfig(w http.ResponseWriter, r *http.Request) {
return return
} }
if conf.Enabled != aghalg.NBNull { s.setConfFromJSON(conf, srv4, srv6)
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.conf.ConfigModified() s.conf.ConfigModified()
err = s.dbLoad() 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 { type netInterfaceJSON struct {
Name string `json:"name"` Name string `json:"name"`
HardwareAddr string `json:"hardware_address"` HardwareAddr string `json:"hardware_address"`

View file

@ -12,16 +12,11 @@ import (
"testing" "testing"
"time" "time"
"github.com/AdguardTeam/AdGuardHome/internal/aghtest"
"github.com/AdguardTeam/golibs/testutil" "github.com/AdguardTeam/golibs/testutil"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
func TestMain(m *testing.M) {
aghtest.DiscardLogOutput(m)
}
func TestNewSessionToken(t *testing.T) { func TestNewSessionToken(t *testing.T) {
// Successful case. // Successful case.
token, err := newSessionToken() token, err := newSessionToken()

View file

@ -97,9 +97,15 @@ var Context homeContext
// Main is the entry point // Main is the entry point
func Main(clientBuildFS fs.FS) { func Main(clientBuildFS fs.FS) {
// config can be specified, which reads options from there, but other command line flags have to override config values initCmdLineOpts()
// therefore, we must do it manually instead of using a lib
args := loadOptions() // 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) Context.appSignalChannel = make(chan os.Signal)
signal.Notify(Context.appSignalChannel, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP, syscall.SIGQUIT) signal.Notify(Context.appSignalChannel, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP, syscall.SIGQUIT)
@ -120,26 +126,18 @@ func Main(clientBuildFS fs.FS) {
} }
}() }()
if args.serviceControlAction != "" { if opts.serviceControlAction != "" {
handleServiceControlAction(args, clientBuildFS) handleServiceControlAction(opts, clientBuildFS)
return return
} }
// run the protection // run the protection
run(args, clientBuildFS) run(opts, clientBuildFS)
} }
func setupContext(args options) { func setupContext(opts options) {
Context.runningAsService = args.runningAsService setupContextFlags(opts)
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()
}
switch version.Channel() { switch version.Channel() {
case version.ChannelEdge, version.ChannelDevelopment: case version.ChannelEdge, version.ChannelDevelopment:
@ -174,13 +172,13 @@ func setupContext(args options) {
os.Exit(1) os.Exit(1)
} }
if args.checkConfig { if opts.checkConfig {
log.Info("configuration file is ok") log.Info("configuration file is ok")
os.Exit(0) os.Exit(0)
} }
if !args.noEtcHosts && config.Clients.Sources.HostsFile { if !opts.noEtcHosts && config.Clients.Sources.HostsFile {
err = setupHostsContainer() err = setupHostsContainer()
fatalOnError(err) fatalOnError(err)
} }
@ -189,6 +187,24 @@ func setupContext(args options) {
Context.mux = http.NewServeMux() 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 // logIfUnsupported logs a formatted warning if the error is one of the
// unsupported errors and returns nil. If err is nil, logIfUnsupported returns // unsupported errors and returns nil. If err is nil, logIfUnsupported returns
// nil. Otherwise, it returns err. // nil. Otherwise, it returns err.
@ -270,7 +286,7 @@ func setupHostsContainer() (err error) {
return nil return nil
} }
func setupConfig(args options) (err error) { func setupConfig(opts options) (err error) {
config.DNS.DnsfilterConf.EtcHosts = Context.etcHosts config.DNS.DnsfilterConf.EtcHosts = Context.etcHosts
config.DNS.DnsfilterConf.ConfigModified = onConfigModified config.DNS.DnsfilterConf.ConfigModified = onConfigModified
config.DNS.DnsfilterConf.HTTPRegister = httpRegister 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) Context.clients.Init(config.Clients.Persistent, Context.dhcpServer, Context.etcHosts, arpdb)
if args.bindPort != 0 { if opts.bindPort != 0 {
tcpPorts := aghalg.UniqChecker[tcpPort]{} tcpPorts := aghalg.UniqChecker[tcpPort]{}
addPorts(tcpPorts, tcpPort(args.bindPort), tcpPort(config.BetaBindPort)) addPorts(tcpPorts, tcpPort(opts.bindPort), tcpPort(config.BetaBindPort))
udpPorts := aghalg.UniqChecker[udpPort]{} udpPorts := aghalg.UniqChecker[udpPort]{}
addPorts(udpPorts, udpPort(config.DNS.Port)) addPorts(udpPorts, udpPort(config.DNS.Port))
@ -336,23 +352,23 @@ func setupConfig(args options) (err error) {
return fmt.Errorf("validating udp ports: %w", err) return fmt.Errorf("validating udp ports: %w", err)
} }
config.BindPort = args.bindPort config.BindPort = opts.bindPort
} }
// override bind host/port from the console // override bind host/port from the console
if args.bindHost != nil { if opts.bindHost != nil {
config.BindHost = args.bindHost config.BindHost = opts.bindHost
} }
if len(args.pidFile) != 0 && writePIDFile(args.pidFile) { if len(opts.pidFile) != 0 && writePIDFile(opts.pidFile) {
Context.pidFileName = args.pidFile Context.pidFileName = opts.pidFile
} }
return nil 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 var clientFS, clientBetaFS fs.FS
if args.localFrontend { if opts.localFrontend {
log.Info("warning: using local frontend files") log.Info("warning: using local frontend files")
clientFS = os.DirFS("build/static") clientFS = os.DirFS("build/static")
@ -400,24 +416,24 @@ func fatalOnError(err error) {
} }
// run configures and starts AdGuard Home. // run configures and starts AdGuard Home.
func run(args options, clientBuildFS fs.FS) { func run(opts options, clientBuildFS fs.FS) {
// configure config filename // configure config filename
initConfigFilename(args) initConfigFilename(opts)
// configure working dir and config path // configure working dir and config path
initWorkingDir(args) initWorkingDir(opts)
// configure log level and output // configure log level and output
configureLogger(args) configureLogger(opts)
// Print the first message after logger is configured. // Print the first message after logger is configured.
log.Info(version.Full()) log.Info(version.Full())
log.Debug("current working directory is %s", Context.workDir) log.Debug("current working directory is %s", Context.workDir)
if args.runningAsService { if opts.runningAsService {
log.Info("AdGuard Home is running as a service") log.Info("AdGuard Home is running as a service")
} }
setupContext(args) setupContext(opts)
err := configureOS(config) err := configureOS(config)
fatalOnError(err) fatalOnError(err)
@ -427,7 +443,7 @@ func run(args options, clientBuildFS fs.FS) {
// but also avoid relying on automatic Go init() function // but also avoid relying on automatic Go init() function
filtering.InitModule() filtering.InitModule()
err = setupConfig(args) err = setupConfig(opts)
fatalOnError(err) fatalOnError(err)
if !Context.firstRun { if !Context.firstRun {
@ -456,7 +472,7 @@ func run(args options, clientBuildFS fs.FS) {
} }
sessFilename := filepath.Join(Context.getDataDir(), "sessions.db") sessFilename := filepath.Join(Context.getDataDir(), "sessions.db")
GLMode = args.glinetMode GLMode = opts.glinetMode
var rateLimiter *authRateLimiter var rateLimiter *authRateLimiter
if config.AuthAttempts > 0 && config.AuthBlockMin > 0 { if config.AuthAttempts > 0 && config.AuthBlockMin > 0 {
rateLimiter = newAuthRateLimiter( rateLimiter = newAuthRateLimiter(
@ -483,7 +499,7 @@ func run(args options, clientBuildFS fs.FS) {
log.Fatalf("Can't initialize TLS module") log.Fatalf("Can't initialize TLS module")
} }
Context.web, err = initWeb(args, clientBuildFS) Context.web, err = initWeb(opts, clientBuildFS)
fatalOnError(err) fatalOnError(err)
if !Context.firstRun { if !Context.firstRun {
@ -575,10 +591,10 @@ func writePIDFile(fn string) bool {
return true return true
} }
func initConfigFilename(args options) { func initConfigFilename(opts options) {
// config file path can be overridden by command-line arguments: // config file path can be overridden by command-line arguments:
if args.configFilename != "" { if opts.confFilename != "" {
Context.configFilename = args.configFilename Context.configFilename = opts.confFilename
} else { } else {
// Default config file name // Default config file name
Context.configFilename = "AdGuardHome.yaml" Context.configFilename = "AdGuardHome.yaml"
@ -587,15 +603,15 @@ func initConfigFilename(args options) {
// initWorkingDir initializes the workDir // initWorkingDir initializes the workDir
// if no command-line arguments specified, we use the directory where our binary file is located // 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() execPath, err := os.Executable()
if err != nil { if err != nil {
panic(err) panic(err)
} }
if args.workDir != "" { if opts.workDir != "" {
// If there is a custom config file, use it's directory as our working dir // If there is a custom config file, use it's directory as our working dir
Context.workDir = args.workDir Context.workDir = opts.workDir
} else { } else {
Context.workDir = filepath.Dir(execPath) Context.workDir = filepath.Dir(execPath)
} }
@ -609,15 +625,15 @@ func initWorkingDir(args options) {
} }
// configureLogger configures logger level and output // configureLogger configures logger level and output
func configureLogger(args options) { func configureLogger(opts options) {
ls := getLogSettings() ls := getLogSettings()
// command-line arguments can override config settings // command-line arguments can override config settings
if args.verbose || config.Verbose { if opts.verbose || config.Verbose {
ls.Verbose = true ls.Verbose = true
} }
if args.logFile != "" { if opts.logFile != "" {
ls.File = args.logFile ls.File = opts.logFile
} else if config.File != "" { } else if config.File != "" {
ls.File = config.File ls.File = config.File
} }
@ -638,7 +654,7 @@ func configureLogger(args options) {
// happen pretty quickly. // happen pretty quickly.
log.SetFlags(log.LstdFlags | log.Lmicroseconds) 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 // When running as a Windows service, use eventlog by default if nothing
// else is configured. Otherwise, we'll simply lose the log output. // else is configured. Otherwise, we'll simply lose the log output.
ls.File = configSyslog ls.File = configSyslog
@ -728,25 +744,29 @@ func exitWithError() {
os.Exit(64) os.Exit(64)
} }
// loadOptions reads command line arguments and initializes configuration // loadCmdLineOpts reads command line arguments and initializes configuration
func loadOptions() options { // from them. If there is an error or an effect, loadCmdLineOpts processes them
o, f, err := parse(os.Args[0], os.Args[1:]) // 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])
if err != nil {
log.Error(err.Error())
_ = printHelp(os.Args[0])
exitWithError() exitWithError()
} else if f != nil { }
err = f()
if eff != nil {
err = eff()
if err != nil { if err != nil {
log.Error(err.Error()) log.Error(err.Error())
exitWithError() exitWithError()
} else { }
os.Exit(0) os.Exit(0)
} }
}
return o return opts
} }
// printWebAddrs prints addresses built from proto, addr, and an appropriate // printWebAddrs prints addresses built from proto, addr, and an appropriate

View file

@ -0,0 +1,12 @@
package home
import (
"testing"
"github.com/AdguardTeam/AdGuardHome/internal/aghtest"
)
func TestMain(m *testing.M) {
aghtest.DiscardLogOutput(m)
initCmdLineOpts()
}

View file

@ -5,30 +5,60 @@ import (
"net" "net"
"os" "os"
"strconv" "strconv"
"strings"
"github.com/AdguardTeam/AdGuardHome/internal/version" "github.com/AdguardTeam/AdGuardHome/internal/version"
"github.com/AdguardTeam/golibs/log" "github.com/AdguardTeam/golibs/log"
"github.com/AdguardTeam/golibs/stringutil"
) )
// options passed from command-line arguments // TODO(a.garipov): Replace with package flag.
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
// 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 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 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 // noEtcHosts flag should be provided when /etc/hosts file shouldn't be
// used. // used.
@ -39,88 +69,85 @@ type options struct {
localFrontend bool localFrontend bool
} }
// functions used for their side-effects // initCmdLineOpts completes initialization of the global command-line option
type effect func() error // slice. It must only be called once.
func initCmdLineOpts() {
type arg struct { // The --help option cannot be put directly into cmdLineOpts, because that
description string // a short, English description of the argument // causes initialization cycle due to printHelp referencing cmdLineOpts.
longName string // the name of the argument used after '--' cmdLineOpts = append(cmdLineOpts, cmdLineOpt{
shortName string // the name of the argument used after '-' updateWithValue: nil,
updateNoValue: nil,
// only one of updateWithValue, updateNoValue, and effect should be present effect: func(o options, exec string) (effect, error) {
return func() error { printHelp(exec); exitWithError(); return nil }, nil
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 serialize: func(o options) (val string, ok bool) { return "", false },
effect func(o options, exec string) (f effect, err error) // the side-effect closure generator description: "Print this help.",
longName: "help",
serialize func(o options) []string // the re-serialization function back to arguments (return nil for omit) shortName: "",
})
} }
// {type}SliceOrNil functions check their parameter of type {type} // effect is the type for functions used for their side-effects.
// against its zero value and return nil if the parameter value is type effect func() (err error)
// zero otherwise they return a string slice of the parameter
func ipSliceOrNil(ip net.IP) []string { // cmdLineOpt contains the data for a single command-line option. Only one of
if ip == nil { // updateWithValue, updateNoValue, and effect must be present.
return nil 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)
// 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
}
// 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{ip.String()} return o.bindHost.String(), true
} },
description: "Host address to bind HTTP server on.",
func stringSliceOrNil(s string) []string { longName: "host",
if s == "" { shortName: "h",
return nil }, {
} updateWithValue: func(o options, v string) (options, error) {
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) {
var err error var err error
var p int var p int
minPort, maxPort := 0, 1<<16-1 minPort, maxPort := 0, 1<<16-1
@ -131,108 +158,81 @@ var portArg = arg{
} else { } else {
o.bindPort = p o.bindPort = p
} }
return o, err
}, nil, nil,
func(o options) []string { return intSliceOrNil(o.bindPort) },
}
var serviceArg = arg{ return o, err
"Service control action: status, install, uninstall, start, stop, restart, reload (configuration).", },
"service", "s", updateNoValue: nil,
func(o options, v string) (options, error) { 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 o.serviceControlAction = v
return o, nil return o, nil
}, nil, nil, },
func(o options) []string { return stringSliceOrNil(o.serviceControlAction) }, updateNoValue: nil,
} effect: nil,
serialize: func(o options) (val string, ok bool) {
var logfileArg = arg{ return o.serviceControlAction, o.serviceControlAction != ""
"Path to log file. If empty: write to stdout; if 'syslog': write to system log.", },
"logfile", "l", description: `Service control action: status, install (as a service), ` +
func(o options, v string) (options, error) { o.logFile = v; return o, nil }, nil, nil, `uninstall (as a service), start, stop, restart, reload (configuration).`,
func(o options) []string { return stringSliceOrNil(o.logFile) }, longName: "service",
} shortName: "s",
}, {
var pidfileArg = arg{ updateWithValue: func(o options, v string) (options, error) { o.logFile = v; return o, nil },
"Path to a file where PID is stored.", updateNoValue: nil,
"pidfile", "", effect: nil,
func(o options, v string) (options, error) { o.pidFile = v; return o, nil }, nil, nil, serialize: func(o options) (val string, ok bool) { return o.logFile, o.logFile != "" },
func(o options) []string { return stringSliceOrNil(o.pidFile) }, description: `Path to log file. If empty, write to stdout; ` +
} `if "syslog", write to system log.`,
longName: "logfile",
var checkConfigArg = arg{ shortName: "l",
"Check configuration and exit.", }, {
"check-config", "", updateWithValue: func(o options, v string) (options, error) { o.pidFile = v; return o, nil },
nil, func(o options) (options, error) { o.checkConfig = true; return o, nil }, nil, updateNoValue: nil,
func(o options) []string { return boolSliceOrNil(o.checkConfig) }, effect: nil,
} serialize: func(o options) (val string, ok bool) { return o.pidFile, o.pidFile != "" },
description: "Path to a file where PID is stored.",
var noCheckUpdateArg = arg{ longName: "pidfile",
"Don't check for updates.", shortName: "",
"no-check-update", "", }, {
nil, func(o options) (options, error) { o.disableUpdate = true; return o, nil }, nil, updateWithValue: nil,
func(o options) []string { return boolSliceOrNil(o.disableUpdate) }, 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 },
var disableMemoryOptimizationArg = arg{ description: "Check configuration and exit.",
"Deprecated. Disable memory optimization.", longName: "check-config",
"no-mem-optimization", "", shortName: "",
nil, nil, func(_ options, _ string) (f effect, err error) { }, {
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") log.Info("warning: using --no-mem-optimization flag has no effect and is deprecated")
return nil, nil return nil, nil
}, },
func(o options) []string { return nil }, serialize: func(o options) (val string, ok bool) { return "", false },
} description: "Deprecated. Disable memory optimization.",
longName: "no-mem-optimization",
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: "", shortName: "",
}, {
updateWithValue: nil, updateWithValue: nil,
updateNoValue: func(o options) (options, error) { o.noEtcHosts = true; return o, nil }, updateNoValue: func(o options) (options, error) { o.noEtcHosts = true; return o, nil },
effect: func(_ options, _ string) (f effect, err error) { effect: func(_ options, _ string) (f effect, err error) {
@ -242,146 +242,216 @@ var noEtcHostsArg = arg{
return nil, nil return nil, nil
}, },
serialize: func(o options) []string { return boolSliceOrNil(o.noEtcHosts) }, serialize: func(o options) (val string, ok bool) { return "", o.noEtcHosts },
} description: "Deprecated. Do not use the OS-provided hosts.",
longName: "no-etc-hosts",
var localFrontendArg = arg{
description: "Use local frontend directories.",
longName: "local-frontend",
shortName: "", shortName: "",
}, {
updateWithValue: nil, updateWithValue: nil,
updateNoValue: func(o options) (options, error) { o.localFrontend = true; return o, nil }, updateNoValue: func(o options) (options, error) { o.localFrontend = true; return o, nil },
effect: 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",
func init() { shortName: "",
args = []arg{ }, {
configArg, updateWithValue: nil,
workDirArg, updateNoValue: func(o options) (options, error) { o.verbose = true; return o, nil },
hostArg, effect: nil,
portArg, serialize: func(o options) (val string, ok bool) { return "", o.verbose },
serviceArg, description: "Enable verbose output.",
logfileArg, longName: "verbose",
pidfileArg, shortName: "v",
checkConfigArg, }, {
noCheckUpdateArg, updateWithValue: nil,
disableMemoryOptimizationArg, updateNoValue: func(o options) (options, error) { o.glinetMode = true; return o, nil },
noEtcHostsArg, effect: nil,
localFrontendArg, serialize: func(o options) (val string, ok bool) { return "", o.glinetMode },
verboseArg, description: "Run in GL-Inet compatibility mode.",
glinetArg, longName: "glinet",
versionArg, shortName: "",
helpArg, }, {
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 getUsageLines(exec string, args []arg) []string { os.Exit(0)
usage := []string{
"Usage:", return nil
"", }, nil
fmt.Sprintf("%s [options]", exec), },
"", serialize: func(o options) (val string, ok bool) { return "", false },
"Options:", description: "Show the version and exit. Show more detailed version description with -v.",
} longName: "version",
for _, arg := range args { 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 := "" val := ""
if arg.updateWithValue != nil { if opt.updateWithValue != nil {
val = " VALUE" val = " VALUE"
} }
if arg.shortName != "" {
usage = append(usage, fmt.Sprintf(" -%s, %-30s %s", longDesc := opt.longName + val
arg.shortName, if opt.shortName != "" {
"--"+arg.longName+val, _, err = fmt.Fprintf(b, " -%s, --%-28s %s\n", opt.shortName, longDesc, opt.description)
arg.description))
} else { } else {
usage = append(usage, fmt.Sprintf(" %-34s %s", _, err = fmt.Fprintf(b, " --%-32s %s\n", longDesc, opt.description)
"--"+arg.longName+val, }
arg.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 { // parseCmdOpts parses the command-line arguments into options and effects.
for _, line := range getUsageLines(exec, args) { func parseCmdOpts(cmdName string, args []string) (o options, eff effect, err error) {
_, err := fmt.Println(line) // 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 { if err != nil {
return err return err
} }
return next()
} }
return nil
return eff
} }
func argMatches(a arg, v string) bool { // optsToArgs converts command line options into a list of arguments.
return v == "--"+a.longName || (a.shortName != "" && v == "-"+a.shortName) func optsToArgs(o options) (args []string) {
} for _, opt := range cmdLineOpts {
val, ok := opt.serialize(o)
if !ok {
continue
}
func parse(exec string, ss []string) (o options, f effect, err error) { if opt.shortName != "" {
for i := 0; i < len(ss); i++ { args = append(args, "-"+opt.shortName)
v := ss[i] } else {
knownParam := false args = append(args, "--"+opt.longName)
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 val != "" {
if err != nil { args = append(args, val)
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
}
}
if !knownParam {
return o, f, fmt.Errorf("unknown option %v", v)
} }
} }
return return args
}
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
} }

View file

@ -12,7 +12,7 @@ import (
func testParseOK(t *testing.T, ss ...string) options { func testParseOK(t *testing.T, ss ...string) options {
t.Helper() t.Helper()
o, _, err := parse("", ss) o, _, err := parseCmdOpts("", ss)
require.NoError(t, err) require.NoError(t, err)
return o return o
@ -21,7 +21,7 @@ func testParseOK(t *testing.T, ss ...string) options {
func testParseErr(t *testing.T, descr string, ss ...string) { func testParseErr(t *testing.T, descr string, ss ...string) {
t.Helper() t.Helper()
_, _, err := parse("", ss) _, _, err := parseCmdOpts("", ss)
require.Error(t, err) require.Error(t, err)
} }
@ -38,11 +38,11 @@ func TestParseVerbose(t *testing.T) {
} }
func TestParseConfigFilename(t *testing.T) { func TestParseConfigFilename(t *testing.T) {
assert.Equal(t, "", testParseOK(t).configFilename, "empty is no config filename") assert.Equal(t, "", testParseOK(t).confFilename, "empty is no config filename")
assert.Equal(t, "path", testParseOK(t, "-c", "path").configFilename, "-c is config filename") assert.Equal(t, "path", testParseOK(t, "-c", "path").confFilename, "-c is config filename")
testParseParamMissing(t, "-c") 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") testParseParamMissing(t, "--config")
} }
@ -103,7 +103,7 @@ func TestParseDisableUpdate(t *testing.T) {
// TODO(e.burkov): Remove after v0.108.0. // TODO(e.burkov): Remove after v0.108.0.
func TestParseDisableMemoryOptimization(t *testing.T) { 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) require.NoError(t, err)
assert.Nil(t, eff) assert.Nil(t, eff)
@ -130,73 +130,73 @@ func TestParseUnknown(t *testing.T) {
testParseErr(t, "unknown dash", "-") testParseErr(t, "unknown dash", "-")
} }
func TestSerialize(t *testing.T) { func TestOptsToArgs(t *testing.T) {
testCases := []struct { testCases := []struct {
name string name string
args []string
opts options opts options
ss []string
}{{ }{{
name: "empty", name: "empty",
args: []string{},
opts: options{}, opts: options{},
ss: []string{},
}, { }, {
name: "config_filename", name: "config_filename",
opts: options{configFilename: "path"}, args: []string{"-c", "path"},
ss: []string{"-c", "path"}, opts: options{confFilename: "path"},
}, { }, {
name: "work_dir", name: "work_dir",
args: []string{"-w", "path"},
opts: options{workDir: "path"}, opts: options{workDir: "path"},
ss: []string{"-w", "path"},
}, { }, {
name: "bind_host", name: "bind_host",
args: []string{"-h", "1.2.3.4"},
opts: options{bindHost: net.IP{1, 2, 3, 4}}, opts: options{bindHost: net.IP{1, 2, 3, 4}},
ss: []string{"-h", "1.2.3.4"},
}, { }, {
name: "bind_port", name: "bind_port",
args: []string{"-p", "666"},
opts: options{bindPort: 666}, opts: options{bindPort: 666},
ss: []string{"-p", "666"},
}, { }, {
name: "log_file", name: "log_file",
args: []string{"-l", "path"},
opts: options{logFile: "path"}, opts: options{logFile: "path"},
ss: []string{"-l", "path"},
}, { }, {
name: "pid_file", name: "pid_file",
args: []string{"--pidfile", "path"},
opts: options{pidFile: "path"}, opts: options{pidFile: "path"},
ss: []string{"--pidfile", "path"},
}, { }, {
name: "disable_update", name: "disable_update",
args: []string{"--no-check-update"},
opts: options{disableUpdate: true}, opts: options{disableUpdate: true},
ss: []string{"--no-check-update"},
}, { }, {
name: "control_action", name: "control_action",
args: []string{"-s", "run"},
opts: options{serviceControlAction: "run"}, opts: options{serviceControlAction: "run"},
ss: []string{"-s", "run"},
}, { }, {
name: "glinet_mode", name: "glinet_mode",
args: []string{"--glinet"},
opts: options{glinetMode: true}, opts: options{glinetMode: true},
ss: []string{"--glinet"},
}, { }, {
name: "multiple", name: "multiple",
opts: options{ args: []string{
serviceControlAction: "run",
configFilename: "config",
workDir: "work",
pidFile: "pid",
disableUpdate: true,
},
ss: []string{
"-c", "config", "-c", "config",
"-w", "work", "-w", "work",
"-s", "run", "-s", "run",
"--pidfile", "pid", "--pidfile", "pid",
"--no-check-update", "--no-check-update",
}, },
opts: options{
serviceControlAction: "run",
confFilename: "config",
workDir: "work",
pidFile: "pid",
disableUpdate: true,
},
}} }}
for _, tc := range testCases { for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
result := serialize(tc.opts) result := optsToArgs(tc.opts)
assert.ElementsMatch(t, tc.ss, result) assert.ElementsMatch(t, tc.args, result)
}) })
} }
} }

View file

@ -197,7 +197,7 @@ func handleServiceControlAction(opts options, clientBuildFS fs.FS) {
DisplayName: serviceDisplayName, DisplayName: serviceDisplayName,
Description: serviceDescription, Description: serviceDescription,
WorkingDirectory: pwd, WorkingDirectory: pwd,
Arguments: serialize(runOpts), Arguments: optsToArgs(runOpts),
} }
configureService(svcConfig) configureService(svcConfig)

View file

@ -223,8 +223,7 @@ govulncheck ./...
# Apply more lax standards to the code we haven't properly refactored yet. # Apply more lax standards to the code we haven't properly refactored yet.
gocyclo --over 17 ./internal/querylog/ gocyclo --over 17 ./internal/querylog/
gocyclo --over 15 ./internal/home/ ./internal/dhcpd gocyclo --over 13 ./internal/dhcpd ./internal/filtering/ ./internal/home/
gocyclo --over 13 ./internal/filtering/
# Apply stricter standards to new or somewhat refactored code. # Apply stricter standards to new or somewhat refactored code.
gocyclo --over 10 ./internal/aghio/ ./internal/aghnet/ ./internal/aghos/\ gocyclo --over 10 ./internal/aghio/ ./internal/aghnet/ ./internal/aghos/\