diff --git a/.gitignore b/.gitignore index b4047c5b..9232c782 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ /AdGuardHome /AdGuardHome.exe /AdGuardHome.yaml +/AdGuardHome.log /data/ /build/ /dist/ 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 ccbb062d..d6379ae5 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,51 @@ 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. + +### Logging + +By default, the log is written to `stderr` when you run AdGuard Home as a console application. + +If you run it as a service/daemon, the log output depends on the platform: + +* Linux: the log is written to syslog. +* MacOS: the log is written to `/var/log/AdGuardHome.*.log` files. +* Windows: the log is written to the Windows event log. + +You can redefine this behavior in AdGuard Home configuration file (see below). + +### Command-line arguments + +Here is a list of all available command-line arguments. + +``` +$ ./AdGuardHome -h +Usage: + +./AdGuardHome [options] + +Options: + -c, --config path to config file + -o, --host host address to bind HTTP server on + -p, --port port to serve HTTP pages on + -v, --verbose enable verbose output + -s, --service service control action: status, install, uninstall, start, stop, restart + -l, --logfile path to the log file. If empty, writes to stdout, if 'syslog' -- system log + -h, --help print this help +``` + +Please note, that the command-line arguments override settings from the configuration file. + ### 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). @@ -139,6 +184,7 @@ Settings are stored in [YAML format](https://en.wikipedia.org/wiki/YAML), possib * `auth_name` — Web interface optional authorization username. * `auth_pass` — Web interface optional authorization password. * `dns` — DNS configuration section. + * `bind_host` - DNS interface IP address to listen on. * `port` — DNS server port to listen on. * `protection_enabled` — Whether any kind of filtering and protection should be done, when off it works as a plain dns forwarder. * `filtering_enabled` — Filtering of DNS requests based on filter lists. @@ -159,7 +205,17 @@ Settings are stored in [YAML format](https://en.wikipedia.org/wiki/YAML), possib * `name` — Name of the filter. If it's an adguard syntax filter it will get updated automatically, otherwise it stays unchanged. * `last_updated` — Time when the filter was last updated from server. * `ID` - filter ID (must be unique). + * `dhcp` - Built-in DHCP server configuration. + * `enabled` - DHCP server status. + * `interface_name` - network interface name (eth0, en0 and so on). + * `gateway_ip` - gateway IP address. + * `subnet_mask` - subnet mask. + * `range_start` - start IP address of the controlled range. + * `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 (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. @@ -239,6 +295,7 @@ This software wouldn't have been possible without: * [gcache](https://github.com/bluele/gcache) * [miekg's dns](https://github.com/miekg/dns) * [go-yaml](https://github.com/go-yaml/yaml) + * [service](https://godoc.org/github.com/kardianos/service) * [Node.js](https://nodejs.org/) and it's libraries: * [React.js](https://reactjs.org) * [Tabler](https://github.com/tabler/tabler) diff --git a/app.go b/app.go index 70462fee..2e72e1de 100644 --- a/app.go +++ b/app.go @@ -3,16 +3,19 @@ package main import ( "bufio" "fmt" + stdlog "log" "net" "net/http" "os" "os/signal" "path/filepath" + "runtime" "strconv" "syscall" "time" "github.com/gobuffalo/packr" + "github.com/hmage/golibs/log" "golang.org/x/crypto/ssh/terminal" ) @@ -20,59 +23,22 @@ import ( // VersionString will be set through ldflags, contains current version var VersionString = "undefined" +const ( + // 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() { - log.Printf("AdGuard Home web interface backend, version %s\n", VersionString) - box := packr.NewBox("build/static") - { - executable, err := os.Executable() - if err != nil { - panic(err) - } - - 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) - } - // 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 - loadOptions() + args := loadOptions() - // Load filters from the disk - // And if any filter has zero ID, assign a new one - for i := range config.Filters { - filter := &config.Filters[i] // otherwise we're operating on a copy - if filter.ID == 0 { - filter.ID = assignUniqueFilterID() - } - err := filter.load() - if err != nil { - // This is okay for the first start, the filter will be loaded later - log.Printf("Couldn't load filter %d contents due to %s", filter.ID, err) - // clear LastUpdated so it gets fetched right away - } - if len(filter.Rules) == 0 { - filter.LastUpdated = time.Time{} - } + if args.serviceControlAction != "" { + handleServiceControlAction(args.serviceControlAction) + return } - // Update filters we've just loaded right away, don't wait for periodic update timer - go func() { - refreshFiltersIfNecessary(false) - // Save the updated config - err := config.write() - if err != nil { - log.Fatal(err) - } - }() - signalChannel := make(chan os.Signal) signal.Notify(signalChannel, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP, syscall.SIGQUIT) go func() { @@ -81,109 +47,29 @@ func main() { os.Exit(0) }() - // Save the updated config - err := config.write() - if err != nil { - log.Fatal(err) - } - - address := net.JoinHostPort(config.BindHost, strconv.Itoa(config.BindPort)) - - go periodicallyRefreshFilters() - - http.Handle("/", optionalAuthHandler(http.FileServer(box))) - registerControlHandlers() - - err = startDNSServer() - if err != nil { - log.Fatal(err) - } - - err = startDHCPServer() - if err != nil { - log.Fatal(err) - } - - URL := fmt.Sprintf("http://%s", address) - log.Println("Go to " + URL) - log.Fatal(http.ListenAndServe(address, nil)) + // run the protection + run(args) } -func cleanup() { - err := stopDNSServer() - if err != nil { - log.Printf("Couldn't stop DNS server: %s", err) +// 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 } -} -func getInput() (string, error) { - scanner := bufio.NewScanner(os.Stdin) - scanner.Scan() - text := scanner.Text() - err := scanner.Err() - return text, err -} + // configure working dir and config path + initWorkingDir(args) -// loadOptions reads command line arguments and initializes configuration -func loadOptions() { - var printHelp func() - var configFilename *string - var bindHost *string - var bindPort *int - var opts = []struct { - longName string - shortName string - description string - callbackWithValue func(value string) - callbackNoValue func() - }{ - {"config", "c", "path to config file", func(value string) { configFilename = &value }, nil}, - {"host", "h", "host address to bind HTTP server on", func(value string) { bindHost = &value }, nil}, - {"port", "p", "port to serve HTTP pages on", func(value string) { - v, err := strconv.Atoi(value) - if err != nil { - panic("Got port that is not a number") - } - bindPort = &v - }, nil}, - {"verbose", "v", "enable verbose output", nil, func() { log.Verbose = true }}, - {"help", "h", "print this help", nil, func() { printHelp(); os.Exit(64) }}, - } - printHelp = func() { - fmt.Printf("Usage:\n\n") - fmt.Printf("%s [options]\n\n", os.Args[0]) - fmt.Printf("Options:\n") - for _, opt := range opts { - fmt.Printf(" -%s, %-30s %s\n", opt.shortName, "--"+opt.longName, opt.description) - } - } - for i := 1; i < len(os.Args); i++ { - v := os.Args[i] - knownParam := false - for _, opt := range opts { - if v == "--"+opt.longName || v == "-"+opt.shortName { - if opt.callbackWithValue != nil { - if i+1 > len(os.Args) { - log.Printf("ERROR: Got %s without argument\n", v) - os.Exit(64) - } - i++ - opt.callbackWithValue(os.Args[i]) - } else if opt.callbackNoValue != nil { - opt.callbackNoValue() - } - knownParam = true - break - } - } - if !knownParam { - log.Printf("ERROR: unknown option %v\n", v) - printHelp() - os.Exit(64) - } - } - if configFilename != nil { - config.ourConfigFilename = *configFilename + // 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() @@ -204,12 +90,229 @@ func loadOptions() { } // override bind host/port from the console - if bindHost != nil { - config.BindHost = *bindHost + if args.bindHost != "" { + config.BindHost = args.bindHost } - if bindPort != nil { - config.BindPort = *bindPort + if args.bindPort != 0 { + config.BindPort = args.bindPort } + + // Load filters from the disk + // And if any filter has zero ID, assign a new one + for i := range config.Filters { + filter := &config.Filters[i] // otherwise we're operating on a copy + if filter.ID == 0 { + filter.ID = assignUniqueFilterID() + } + err = filter.load() + if err != nil { + // This is okay for the first start, the filter will be loaded later + log.Printf("Couldn't load filter %d contents due to %s", filter.ID, err) + // clear LastUpdated so it gets fetched right away + } + if len(filter.Rules) == 0 { + filter.LastUpdated = time.Time{} + } + } + + // Save the updated config + err = config.write() + if err != nil { + log.Fatal(err) + } + + err = startDNSServer() + if err != nil { + log.Fatal(err) + } + + err = startDHCPServer() + if err != nil { + log.Fatal(err) + } + + // Update filters we've just loaded right away, don't wait for periodic update timer + go func() { + refreshFiltersIfNecessary(false) + // Save the updated config + err := config.write() + if err != nil { + log.Fatal(err) + } + }() + // 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(args options) { + exec, err := os.Executable() + if err != nil { + panic(err) + } + + if args.configFilename != "" { + // If there is a custom config file, use it's directory as our working dir + config.ourBinaryDir = filepath.Dir(args.configFilename) + } else { + config.ourBinaryDir = filepath.Dir(exec) + } +} + +// configureLogger configures logger level and output +func configureLogger(args options) { + ls := getLogSettings() + + // command-line arguments can override config settings + if args.verbose { + ls.Verbose = true + } + if args.logFile != "" { + ls.LogFile = args.logFile + } + + 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 + } + + 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) + } + } 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) + } +} + +func cleanup() { + log.Printf("Stopping AdGuard Home") + + err := stopDNSServer() + if err != nil { + log.Printf("Couldn't stop DNS server: %s", err) + } + err = stopDHCPServer() + if err != nil { + log.Printf("Couldn't stop DHCP server: %s", err) + } +} + +func getInput() (string, error) { + scanner := bufio.NewScanner(os.Stdin) + scanner.Scan() + text := scanner.Text() + err := scanner.Err() + return text, err +} + +// command-line arguments +type options struct { + verbose bool // is verbose logging enabled + configFilename string // path to the config file + bindHost string // 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 + + // 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 +func loadOptions() options { + o := options{} + + var printHelp func() + var opts = []struct { + longName string + shortName string + description string + callbackWithValue func(value string) + callbackNoValue func() + }{ + {"config", "c", "path to config file", func(value string) { o.configFilename = value }, nil}, + {"host", "o", "host address to bind HTTP server on", func(value string) { o.bindHost = value }, nil}, + {"port", "p", "port to serve HTTP pages on", func(value string) { + v, err := strconv.Atoi(value) + if err != nil { + panic("Got port that is not a number") + } + o.bindPort = v + }, nil}, + {"service", "s", "service control action: status, install, uninstall, start, stop, restart", func(value string) { + o.serviceControlAction = value + }, nil}, + {"logfile", "l", "path to the log file. If empty, writes to stdout, if 'syslog' -- system log", func(value string) { + o.logFile = value + }, nil}, + {"verbose", "v", "enable verbose output", nil, func() { o.verbose = true }}, + {"help", "h", "print this help", nil, func() { + printHelp() + os.Exit(64) + }}, + } + printHelp = func() { + fmt.Printf("Usage:\n\n") + fmt.Printf("%s [options]\n\n", os.Args[0]) + fmt.Printf("Options:\n") + for _, opt := range opts { + fmt.Printf(" -%s, %-30s %s\n", opt.shortName, "--"+opt.longName, opt.description) + } + } + for i := 1; i < len(os.Args); i++ { + v := os.Args[i] + knownParam := false + for _, opt := range opts { + if v == "--"+opt.longName || v == "-"+opt.shortName { + if opt.callbackWithValue != nil { + if i+1 >= len(os.Args) { + log.Printf("ERROR: Got %s without argument\n", v) + os.Exit(64) + } + i++ + opt.callbackWithValue(os.Args[i]) + } else if opt.callbackNoValue != nil { + opt.callbackNoValue() + } + knownParam = true + break + } + } + if !knownParam { + log.Printf("ERROR: unknown option %v\n", v) + printHelp() + os.Exit(64) + } + } + + return o } func promptAndGet(prompt string) (string, error) { @@ -244,11 +347,8 @@ func promptAndGetPassword(prompt string) (string, error) { } func askUsernamePasswordIfPossible() error { - configfile := config.ourConfigFilename - if !filepath.IsAbs(configfile) { - configfile = filepath.Join(config.ourBinaryDir, config.ourConfigFilename) - } - _, err := os.Stat(configfile) + configFile := config.getConfigFilename() + _, err := os.Stat(configFile) if !os.IsNotExist(err) { // do nothing, file exists return nil diff --git a/config.go b/config.go index ed96e874..33d717ef 100644 --- a/config.go +++ b/config.go @@ -18,6 +18,12 @@ const ( filterDir = "filters" // cache location for downloaded filters, it's under DataDir ) +// logSettings +type logSettings struct { + LogFile string `yaml:"log_file"` // Path to the log file. If empty, write to stdout. If "syslog", writes to syslog + Verbose bool `yaml:"verbose"` // If true, verbose logging is enabled +} + // configuration is loaded from YAML // field ordering is important -- yaml fields will mirror ordering from here type configuration struct { @@ -34,6 +40,8 @@ type configuration struct { UserRules []string `yaml:"user_rules"` DHCP dhcpd.ServerConfig `yaml:"dhcp"` + logSettings `yaml:",inline"` + sync.RWMutex `yaml:"-"` SchemaVersion int `yaml:"schema_version"` // keeping last so that users will be less tempted to change it -- used when upgrading between versions @@ -79,20 +87,43 @@ var config = configuration{ SchemaVersion: currentSchemaVersion, } -// Loads configuration from the YAML file -func parseConfig() error { - configFile := filepath.Join(config.ourBinaryDir, config.ourConfigFilename) - log.Printf("Reading YAML file: %s", configFile) - if _, err := os.Stat(configFile); os.IsNotExist(err) { - // do nothing, file doesn't exist - log.Printf("YAML file doesn't exist, skipping: %s", configFile) - return nil +// getConfigFilename returns path to the current config file +func (c *configuration) getConfigFilename() string { + configFile := config.ourConfigFilename + if !filepath.IsAbs(configFile) { + configFile = filepath.Join(config.ourBinaryDir, config.ourConfigFilename) } - yamlFile, err := ioutil.ReadFile(configFile) + return configFile +} + +// getLogSettings reads logging settings from the config file. +// we do it in a separate method in order to configure logger before the actual configuration is parsed and applied. +func getLogSettings() logSettings { + l := logSettings{} + yamlFile, err := readConfigFile() + if err != nil || yamlFile == nil { + return l + } + err = yaml.Unmarshal(yamlFile, &l) + if err != nil { + log.Printf("Couldn't get logging settings from the configuration: %s", err) + } + return l +} + +// parseConfig loads configuration from the YAML file +func parseConfig() error { + configFile := config.getConfigFilename() + log.Printf("Reading YAML file: %s", configFile) + yamlFile, err := readConfigFile() if err != nil { log.Printf("Couldn't read config file: %s", err) return err } + if yamlFile == nil { + log.Printf("YAML file doesn't exist, skipping it") + return nil + } err = yaml.Unmarshal(yamlFile, &config) if err != nil { log.Printf("Couldn't parse config file: %s", err) @@ -107,11 +138,21 @@ func parseConfig() error { return nil } +// readConfigFile reads config file contents if it exists +func readConfigFile() ([]byte, error) { + configFile := config.getConfigFilename() + if _, err := os.Stat(configFile); os.IsNotExist(err) { + // do nothing, file doesn't exist + return nil, nil + } + return ioutil.ReadFile(configFile) +} + // Saves configuration to the YAML file and also saves the user filter contents to a file func (c *configuration) write() error { c.Lock() defer c.Unlock() - configFile := filepath.Join(config.ourBinaryDir, config.ourConfigFilename) + configFile := config.getConfigFilename() log.Printf("Writing YAML file: %s", configFile) yamlText, err := yaml.Marshal(&config) if err != nil { diff --git a/dhcp.go b/dhcp.go index 90d6a88a..744e7a35 100644 --- a/dhcp.go +++ b/dhcp.go @@ -165,3 +165,20 @@ func startDHCPServer() error { } return nil } + +func stopDHCPServer() error { + if !config.DHCP.Enabled { + return nil + } + + if !dhcpServer.Enabled { + return nil + } + + err := dhcpServer.Stop() + if err != nil { + return errorx.Decorate(err, "Couldn't stop DHCP server") + } + + return nil +} diff --git a/go.mod b/go.mod index 24caab1a..cc8f3327 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,8 @@ 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 github.com/shirou/gopsutil v2.18.10+incompatible @@ -16,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 805c376a..29de0319 100644 --- a/go.sum +++ b/go.sum @@ -37,6 +37,10 @@ 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= github.com/krolaw/dhcp4 v0.0.0-20180925202202-7cead472c414/go.mod h1:0AqAH3ZogsCrvrtUpvc6EtVKbc3w6xwZhkvGLuqyi3o= github.com/markbates/oncer v0.0.0-20181014194634-05fccaae8fc4 h1:Mlji5gkcpzkqTROyE4ZxZ8hN7osunMb2RuGVrbvMvCc= diff --git a/service.go b/service.go new file mode 100644 index 00000000..fd233f4e --- /dev/null +++ b/service.go @@ -0,0 +1,180 @@ +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" + serviceName = "AdGuardHome" + serviceDisplayName = "AdGuard Home service" + serviceDescription = "AdGuard Home: Network-level blocker" +) + +// Represents the program that will be launched by a service or daemon +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{runningAsService: true} + go run(args) + return nil +} + +// Stop stops the program +func (p *program) Stop(s service.Service) error { + // Stop should not block. Return with a few seconds. + cleanup() + os.Exit(0) + return nil +} + +// handleServiceControlAction one of the possible control actions: +// install -- installs a service/daemon +// uninstall -- uninstalls it +// status -- prints the service status +// 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) + + pwd, err := os.Getwd() + if err != nil { + log.Fatal("Unable to find the path to the current directory") + } + svcConfig := &service.Config{ + Name: serviceName, + DisplayName: serviceDisplayName, + Description: serviceDescription, + WorkingDirectory: pwd, + Arguments: []string{"-s", "run"}, + } + configureService(svcConfig) + prg := &program{} + s, err := service.New(prg, svcConfig) + if err != nil { + log.Fatal(err) + } + + if action == "status" { + status, errSt := s.Status() + if errSt != nil { + log.Fatalf("failed to get service status: %s", errSt) + } + + switch status { + case service.StatusUnknown: + log.Printf("Service status is unknown") + case service.StatusStopped: + log.Printf("Service is stopped") + 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 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..1813bb99 --- /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, serviceName) + 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..e81b63ee --- /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" +) + +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 { + // Note that the eventlog src is the same as the service name + // Otherwise, we will get "the description for event id cannot be found" warning in every log record + + // 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(serviceName, 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(serviceName) + if err != nil { + return err + } + + log.SetOutput(&eventLogWriter{el: el}) + return nil +} diff --git a/upgrade.go b/upgrade.go index 4b142de6..3d79e744 100644 --- a/upgrade.go +++ b/upgrade.go @@ -15,7 +15,7 @@ const currentSchemaVersion = 2 // used for upgrading from old configs to new con // Performs necessary upgrade operations if needed func upgradeConfig() error { // read a config file into an interface map, so we can manipulate values without losing any - configFile := filepath.Join(config.ourBinaryDir, config.ourConfigFilename) + configFile := config.getConfigFilename() if _, err := os.Stat(configFile); os.IsNotExist(err) { log.Printf("config file %s does not exist, nothing to upgrade", configFile) return nil @@ -74,7 +74,7 @@ func upgradeConfigSchema(oldVersion int, diskConfig *map[string]interface{}) err return err } - configFile := filepath.Join(config.ourBinaryDir, config.ourConfigFilename) + configFile := config.getConfigFilename() body, err := yaml.Marshal(diskConfig) if err != nil { log.Printf("Couldn't generate YAML file: %s", err)