diff --git a/.gometalinter.json b/.gometalinter.json index e420bd51..2c3f557e 100644 --- a/.gometalinter.json +++ b/.gometalinter.json @@ -17,6 +17,11 @@ "WarnUnmatchedDirective": true, "EnableAll": true, + "DisableAll": false, + "Disable": [ + "maligned", + "goconst" + ], "Cyclo": 20, "LineLength": 200 diff --git a/README.md b/README.md index bfbb2165..6523c26e 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,17 @@ sudo ./AdGuardHome Now open the browser and navigate to http://localhost:3000/ to control your AdGuard Home service. +### Running as a service + +You can register AdGuard Home as a system service on Windows, Linux/(systemd | Upstart | SysV), and OSX/Launchd. + +* `AdGuardHome -s install` - install AdGuard Home as a system service. +* `AdGuardHome -s uninstall` - uninstalls the AdGuard Home service. +* `AdGuardHome -s start` - starts the service. +* `AdGuardHome -s stop` - stops the service. +* `AdGuardHome -s restart` - restarts the service. +* `AdGuardHome -s status` - shows the current service status. + ### Command-line arguments Here is a list of all available command-line arguments. @@ -119,15 +130,6 @@ Options: -h, --help print this help ``` -Please note, that you can register AdGuard Home as a system service on Windows, Linux/(systemd | Upstart | SysV), and OSX/Launchd. - -* `AdGuardHome -s install` - install as a system service. -* `AdGuardHome -s uninstall` - uninstall's AdGuard Home service. -* `AdGuardHome -s start` - starts the service. -* `AdGuardHome -s stop` - stops the service. -* `AdGuardHome -s restart` - restarts the service. -* `AdGuardHome -s status` - shows the current service status. - ### Running without superuser You can run AdGuard Home without superuser privileges, but you need to either grant the binary a capability (on Linux) or instruct it to use a different port (all platforms). @@ -198,7 +200,7 @@ Settings are stored in [YAML format](https://en.wikipedia.org/wiki/YAML), possib * `range_end` - end IP address of the controlled range. * `lease_duration` - lease duration in seconds. If 0, using default duration (2 hours). * `user_rules` — User-specified filtering rules. - * `log_file` — Path to the log file. If empty, writes to stdout, if 'syslog' -- system log. + * `log_file` — Path to the log file. If empty, writes to stdout, if `syslog` -- system log (or eventlog on Windows). * `verbose` — Enable our disables debug verbose output. Removing an entry from settings file will reset it to the default value. Deleting the file will reset all settings to the default values. diff --git a/app.go b/app.go index 94cb9348..1c74e14d 100644 --- a/app.go +++ b/app.go @@ -4,17 +4,20 @@ import ( "bufio" "fmt" stdlog "log" - "log/syslog" "net" "net/http" "os" "os/signal" + "path" "path/filepath" + "runtime" "strconv" + "strings" "syscall" "time" "github.com/gobuffalo/packr" + "github.com/hmage/golibs/log" "golang.org/x/crypto/ssh/terminal" ) @@ -22,6 +25,14 @@ import ( // VersionString will be set through ldflags, contains current version var VersionString = "undefined" +const ( + // We use it to detect the working dir + executableName = "AdGuardHome" + + // Used in config to indicate that syslog or eventlog (win) should be used for logger output + configSyslog = "syslog" +) + // main is the entry point func main() { // config can be specified, which reads options from there, but other command line flags have to override config values @@ -33,9 +44,6 @@ func main() { return } - // run the protection - run(args) - signalChannel := make(chan os.Signal) signal.Notify(signalChannel, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP, syscall.SIGQUIT) go func() { @@ -44,23 +52,30 @@ func main() { os.Exit(0) }() - address := net.JoinHostPort(config.BindHost, strconv.Itoa(config.BindPort)) - URL := fmt.Sprintf("http://%s", address) - log.Println("Go to " + URL) - log.Fatal(http.ListenAndServe(address, nil)) + // run the protection + run(args) } // run initializes configuration and runs the AdGuard Home +// run is a blocking method and it won't exit until the service is stopped! func run(args options) { + // config file path can be overridden by command-line arguments: if args.configFilename != "" { config.ourConfigFilename = args.configFilename } + // configure working dir and config path + initWorkingDir() + // configure log level and output configureLogger(args) // print the first message after logger is configured log.Printf("AdGuard Home, version %s\n", VersionString) + log.Printf("Current working directory is %s", config.ourBinaryDir) + if args.runningAsService { + log.Printf("AdGuard Home is running as a service") + } err := askUsernamePasswordIfPossible() if err != nil { @@ -111,27 +126,6 @@ func run(args options) { log.Fatal(err) } - box := packr.NewBox("build/static") - { - executable, osErr := os.Executable() - if osErr != nil { - panic(osErr) - } - - executableName := filepath.Base(executable) - if executableName == "AdGuardHome" { - // Binary build - config.ourBinaryDir = filepath.Dir(executable) - } else { - // Most likely we're debugging -- using current working directory in this case - workDir, _ := os.Getwd() - config.ourBinaryDir = workDir - } - log.Printf("Current working directory is %s", config.ourBinaryDir) - } - http.Handle("/", optionalAuthHandler(http.FileServer(box))) - registerControlHandlers() - err = startDNSServer() if err != nil { log.Fatal(err) @@ -153,6 +147,35 @@ func run(args options) { }() // Schedule automatic filters updates go periodicallyRefreshFilters() + + // Initialize and run the admin Web interface + box := packr.NewBox("build/static") + http.Handle("/", optionalAuthHandler(http.FileServer(box))) + registerControlHandlers() + + address := net.JoinHostPort(config.BindHost, strconv.Itoa(config.BindPort)) + URL := fmt.Sprintf("http://%s", address) + log.Println("Go to " + URL) + log.Fatal(http.ListenAndServe(address, nil)) +} + +// initWorkingDir initializes the ourBinaryDir (basically, we use it as a working dir) +func initWorkingDir() { + exec, err := os.Executable() + if err != nil { + panic(err) + } + + currentExecutableName := filepath.Base(exec) + currentExecutableName = strings.TrimSuffix(currentExecutableName, path.Ext(currentExecutableName)) + if currentExecutableName == executableName { + // Binary build + config.ourBinaryDir = filepath.Dir(exec) + } else { + // Most likely we're debugging -- using current working directory in this case + workDir, _ := os.Getwd() + config.ourBinaryDir = workDir + } } // configureLogger configures logger level and output @@ -169,25 +192,30 @@ func configureLogger(args options) { log.Verbose = ls.Verbose + if args.runningAsService && ls.LogFile == "" && runtime.GOOS == "windows" { + // When running as a Windows service, use eventlog by default if nothing else is configured + // Otherwise, we'll simply loose the log output + ls.LogFile = configSyslog + } + if ls.LogFile == "" { return } - // TODO: add windows eventlog support - if ls.LogFile == "syslog" { - w, err := syslog.New(syslog.LOG_INFO, "AdGuard Home") + if ls.LogFile == configSyslog { + // Use syslog where it is possible and eventlog on Windows + err := configureSyslog() if err != nil { log.Fatalf("cannot initialize syslog: %s", err) } - stdlog.SetOutput(w) + } else { + logFilePath := filepath.Join(config.ourBinaryDir, ls.LogFile) + file, err := os.OpenFile(logFilePath, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0755) + if err != nil { + log.Fatalf("cannot create a log file: %s", err) + } + stdlog.SetOutput(file) } - - logFilePath := filepath.Join(config.ourBinaryDir, ls.LogFile) - file, err := os.OpenFile(logFilePath, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0755) - if err != nil { - log.Fatalf("cannot create a log file: %s", err) - } - stdlog.SetOutput(file) } func cleanup() { @@ -221,6 +249,9 @@ type options struct { // service control action (see service.ControlAction array + "status" command) serviceControlAction string + + // runningAsService flag is set to true when options are passed from the service runner + runningAsService bool } // loadOptions reads command line arguments and initializes configuration diff --git a/go.mod b/go.mod index a0dbd5d0..cc8f3327 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/gobuffalo/packr v1.19.0 github.com/hmage/golibs v0.0.0-20181229160906-c8491df0bfc4 github.com/joomcode/errorx v0.1.0 + github.com/kardianos/osext v0.0.0-20170510131534-ae77be60afb1 // indirect github.com/kardianos/service v0.0.0-20181115005516-4c239ee84e7b github.com/krolaw/dhcp4 v0.0.0-20180925202202-7cead472c414 github.com/miekg/dns v1.1.1 @@ -17,6 +18,7 @@ require ( go.uber.org/goleak v0.10.0 golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9 golang.org/x/net v0.0.0-20181220203305-927f97764cc3 + golang.org/x/sys v0.0.0-20181228144115-9a3f9b0469bb gopkg.in/asaskevich/govalidator.v4 v4.0.0-20160518190739-766470278477 gopkg.in/yaml.v2 v2.2.1 ) diff --git a/go.sum b/go.sum index fef136a4..29de0319 100644 --- a/go.sum +++ b/go.sum @@ -37,6 +37,8 @@ github.com/joho/godotenv v1.3.0 h1:Zjp+RcGpHhGlrMbJzXTrZZPrWj+1vfm90La1wgB6Bhc= github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg= github.com/joomcode/errorx v0.1.0 h1:QmJMiI1DE1UFje2aI1ZWO/VMT5a32qBoXUclGOt8vsc= github.com/joomcode/errorx v0.1.0/go.mod h1:kgco15ekB6cs+4Xjzo7SPeXzx38PbJzBwbnu9qfVNHQ= +github.com/kardianos/osext v0.0.0-20170510131534-ae77be60afb1 h1:PJPDf8OUfOK1bb/NeTKd4f1QXZItOX389VN3B6qC8ro= +github.com/kardianos/osext v0.0.0-20170510131534-ae77be60afb1/go.mod h1:1NbS8ALrpOvjt0rHPNLyCIeMtbizbir8U//inJ+zuB8= github.com/kardianos/service v0.0.0-20181115005516-4c239ee84e7b h1:vfiqKno48aUndBMjTeWFpCExNnTf2Xnd6d228L4EfTQ= github.com/kardianos/service v0.0.0-20181115005516-4c239ee84e7b/go.mod h1:10UU/bEkzh2iEN6aYzbevY7J6p03KO5siTxQWXMEerg= github.com/krolaw/dhcp4 v0.0.0-20180925202202-7cead472c414 h1:6wnYc2S/lVM7BvR32BM74ph7bPgqMztWopMYKgVyEho= diff --git a/service.go b/service.go index dd6e4403..f7ffb8da 100644 --- a/service.go +++ b/service.go @@ -2,11 +2,17 @@ package main import ( "os" + "runtime" "github.com/hmage/golibs/log" "github.com/kardianos/service" ) +const ( + launchdStdoutPath = "/var/log/AdGuardHome.stdout.log" + launchdStderrPath = "/var/log/AdGuardHome.stderr.log" +) + // Represents the program that will be launched by a service or daemon type program struct { } @@ -14,7 +20,7 @@ type program struct { // Start should quickly start the program func (p *program) Start(s service.Service) error { // Start should not block. Do the actual work async. - args := options{} + args := options{runningAsService: true} go run(args) return nil } @@ -23,6 +29,7 @@ func (p *program) Start(s service.Service) error { func (p *program) Stop(s service.Service) error { // Stop should not block. Return with a few seconds. cleanup() + os.Exit(0) return nil } @@ -33,6 +40,9 @@ func (p *program) Stop(s service.Service) error { // start -- starts the previously installed service // stop -- stops the previously installed service // restart - restarts the previously installed service +// run - this is a special command that is not supposed to be used directly +// it is specified when we register a service, and it indicates to the app +// that it is being run as a service/daemon. func handleServiceControlAction(action string) { log.Printf("Service control action: %s", action) @@ -45,7 +55,9 @@ func handleServiceControlAction(action string) { DisplayName: "AdGuard Home service", Description: "AdGuard Home: Network-level blocker", WorkingDirectory: pwd, + Arguments: []string{"-s", "run"}, } + configureService(svcConfig) prg := &program{} s, err := service.New(prg, svcConfig) if err != nil { @@ -66,11 +78,100 @@ func handleServiceControlAction(action string) { case service.StatusRunning: log.Printf("Service is running") } + } else if action == "run" { + err = s.Run() + if err != nil { + log.Fatalf("Failed to run service: %s", err) + } } else { + if action == "uninstall" { + // In case of Windows and Linux when a running service is being uninstalled, + // it is just marked for deletion but not stopped + // So we explicitly stop it here + _ = s.Stop() + } + err = service.Control(s, action) if err != nil { log.Fatal(err) } - log.Printf("Action %s has been done successfully", action) + log.Printf("Action %s has been done successfully on %s", action, service.ChosenSystem().String()) + + if action == "install" { + // Start automatically after install + err = service.Control(s, "start") + if err != nil { + log.Fatalf("Failed to start the service: %s", err) + } + log.Printf("Service has been started") + } else if action == "uninstall" { + cleanupService() + } } } + +// configureService defines additional settings of the service +func configureService(c *service.Config) { + c.Option = service.KeyValue{} + + // OS X + // Redefines the launchd config file template + // The purpose is to enable stdout/stderr redirect by default + c.Option["LaunchdConfig"] = launchdConfig + // This key is used to start the job as soon as it has been loaded. For daemons this means execution at boot time, for agents execution at login. + c.Option["RunAtLoad"] = true + + // POSIX + // Redirect StdErr & StdOut to files. + c.Option["LogOutput"] = true + + // Windows + if runtime.GOOS == "windows" { + c.UserName = "NT AUTHORITY\\NetworkService" + } +} + +// cleanupService called on the service uninstall, cleans up additional files if needed +func cleanupService() { + if runtime.GOOS == "darwin" { + // Removing log files on cleanup and ignore errors + err := os.Remove(launchdStdoutPath) + if err != nil && !os.IsNotExist(err) { + log.Printf("cannot remove %s", launchdStdoutPath) + } + err = os.Remove(launchdStderrPath) + if err != nil && !os.IsNotExist(err) { + log.Printf("cannot remove %s", launchdStderrPath) + } + } +} + +// Basically the same template as the one defined in github.com/kardianos/service +// but with two additional keys - StandardOutPath and StandardErrorPath +var launchdConfig = ` + + + +Label{{html .Name}} +ProgramArguments + + {{html .Path}} +{{range .Config.Arguments}} + {{html .}} +{{end}} + +{{if .UserName}}UserName{{html .UserName}}{{end}} +{{if .ChRoot}}RootDirectory{{html .ChRoot}}{{end}} +{{if .WorkingDirectory}}WorkingDirectory{{html .WorkingDirectory}}{{end}} +SessionCreate<{{bool .SessionCreate}}/> +KeepAlive<{{bool .KeepAlive}}/> +RunAtLoad<{{bool .RunAtLoad}}/> +Disabled +StandardOutPath +` + launchdStdoutPath + ` +StandardErrorPath +` + launchdStderrPath + ` + + +` diff --git a/syslog_others.go b/syslog_others.go new file mode 100644 index 00000000..629d170d --- /dev/null +++ b/syslog_others.go @@ -0,0 +1,18 @@ +// +build !windows,!nacl,!plan9 + +package main + +import ( + "log" + "log/syslog" +) + +// configureSyslog reroutes standard logger output to syslog +func configureSyslog() error { + w, err := syslog.New(syslog.LOG_NOTICE|syslog.LOG_USER, "AdGuard Home") + if err != nil { + return err + } + log.SetOutput(w) + return nil +} diff --git a/syslog_windows.go b/syslog_windows.go new file mode 100644 index 00000000..b6f1d885 --- /dev/null +++ b/syslog_windows.go @@ -0,0 +1,39 @@ +package main + +import ( + "log" + "strings" + + "golang.org/x/sys/windows" + "golang.org/x/sys/windows/svc/eventlog" +) + +// should be the same as the service name! +const eventLogSrc = "AdGuardHome" + +type eventLogWriter struct { + el *eventlog.Log +} + +// Write sends a log message to the Event Log. +func (w *eventLogWriter) Write(b []byte) (int, error) { + return len(b), w.el.Info(1, string(b)) +} + +func configureSyslog() error { + // Continue if we receive "registry key already exists" or if we get + // ERROR_ACCESS_DENIED so that we can log without administrative permissions + // for pre-existing eventlog sources. + if err := eventlog.InstallAsEventCreate(eventLogSrc, eventlog.Info|eventlog.Warning|eventlog.Error); err != nil { + if !strings.Contains(err.Error(), "registry key already exists") && err != windows.ERROR_ACCESS_DENIED { + return err + } + } + el, err := eventlog.Open(eventLogSrc) + if err != nil { + return err + } + + log.SetOutput(&eventLogWriter{el: el}) + return nil +}