From 277415124e99961964a6d12667e4d6d753710425 Mon Sep 17 00:00:00 2001 From: Andrey Meshkov Date: Mon, 4 Feb 2019 13:54:53 +0300 Subject: [PATCH] AdGuard Home as a system service 1. Reworked working with command-line arguments 2. Added service control actions: install/uninstall/start/stop/status 3. Added log settings to the configuration file 4. Updated the README file --- .gitignore | 1 + README.md | 40 ++++++ app.go | 371 ++++++++++++++++++++++++++++++++--------------------- config.go | 46 ++++++- dhcp.go | 17 +++ go.mod | 1 + go.sum | 2 + service.go | 76 +++++++++++ 8 files changed, 402 insertions(+), 152 deletions(-) create mode 100644 service.go 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/README.md b/README.md index ccbb062d..412d0942 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,35 @@ sudo ./AdGuardHome Now open the browser and navigate to http://localhost:3000/ to control your AdGuard Home service. +### 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 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). @@ -139,6 +168,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 +189,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. + * `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 70462fee..94cb9348 100644 --- a/app.go +++ b/app.go @@ -3,6 +3,8 @@ package main import ( "bufio" "fmt" + stdlog "log" + "log/syslog" "net" "net/http" "os" @@ -20,58 +22,19 @@ import ( // VersionString will be set through ldflags, contains current version var VersionString = "undefined" +// 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) - } - }() + // run the protection + run(args) signalChannel := make(chan os.Signal) signal.Notify(signalChannel, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP, syscall.SIGQUIT) @@ -81,110 +44,23 @@ 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)) } -func cleanup() { - err := stopDNSServer() - if err != nil { - log.Printf("Couldn't stop DNS server: %s", err) +// run initializes configuration and runs the AdGuard Home +func run(args options) { + 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 log level and output + configureLogger(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 - } + // print the first message after logger is configured + log.Printf("AdGuard Home, version %s\n", VersionString) err := askUsernamePasswordIfPossible() if err != nil { @@ -204,12 +80,217 @@ 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) + } + + 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) + } + + 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() +} + +// 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 ls.LogFile == "" { + return + } + + // TODO: add windows eventlog support + if ls.LogFile == "syslog" { + w, err := syslog.New(syslog.LOG_INFO, "AdGuard Home") + if err != nil { + log.Fatalf("cannot initialize syslog: %s", err) + } + stdlog.SetOutput(w) + } + + 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 +} + +// 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) { diff --git a/config.go b/config.go index ed96e874..ca9f52e5 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,34 @@ var config = configuration{ SchemaVersion: currentSchemaVersion, } -// Loads configuration from the YAML file +// 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 := 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 - } - yamlFile, err := ioutil.ReadFile(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,6 +129,16 @@ func parseConfig() error { return nil } +// readConfigFile reads config file contents if it exists +func readConfigFile() ([]byte, error) { + configFile := filepath.Join(config.ourBinaryDir, config.ourConfigFilename) + 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() 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..a0dbd5d0 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/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 diff --git a/go.sum b/go.sum index 805c376a..fef136a4 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/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..dd6e4403 --- /dev/null +++ b/service.go @@ -0,0 +1,76 @@ +package main + +import ( + "os" + + "github.com/hmage/golibs/log" + "github.com/kardianos/service" +) + +// 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{} + 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() + 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 +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: "AdGuardHome", + DisplayName: "AdGuard Home service", + Description: "AdGuard Home: Network-level blocker", + WorkingDirectory: pwd, + } + 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 { + err = service.Control(s, action) + if err != nil { + log.Fatal(err) + } + log.Printf("Action %s has been done successfully", action) + } +}