diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c1138c8..581a3167 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,7 +27,8 @@ and this project adheres to - Settable timeouts for querying the upstream servers ([#2280]). - Configuration file parameters to change group and user ID on startup on Unix ([#2763]). -- Experimental OpenBSD support for AMD64 and 64-bit ARM CPUs ([#2439], [#3225]). +- Experimental OpenBSD support for AMD64 and 64-bit ARM CPUs ([#2439], [#3225], + [#3226]). - Support for custom port in DNS-over-HTTPS profiles for Apple's devices ([#3172]). - `darwin/arm64` support ([#2443]). @@ -118,6 +119,7 @@ and this project adheres to [#3198]: https://github.com/AdguardTeam/AdGuardHome/issues/3198 [#3217]: https://github.com/AdguardTeam/AdGuardHome/issues/3217 [#3225]: https://github.com/AdguardTeam/AdGuardHome/issues/3225 +[#3226]: https://github.com/AdguardTeam/AdGuardHome/issues/3226 [#3256]: https://github.com/AdguardTeam/AdGuardHome/issues/3256 [#3257]: https://github.com/AdguardTeam/AdGuardHome/issues/3257 [#3289]: https://github.com/AdguardTeam/AdGuardHome/issues/3289 diff --git a/internal/aghos/os.go b/internal/aghos/os.go index 7eb0b3cf..501f7d66 100644 --- a/internal/aghos/os.go +++ b/internal/aghos/os.go @@ -13,6 +13,7 @@ import ( "strconv" "strings" + "github.com/AdguardTeam/golibs/errors" "github.com/AdguardTeam/golibs/log" ) @@ -59,8 +60,11 @@ func RunCommand(command string, arguments ...string) (int, string, error) { if len(out) > MaxCmdOutputSize { out = out[:MaxCmdOutputSize] } - if err != nil { - return 1, "", fmt.Errorf("exec.Command(%s) failed: %v: %s", command, err, string(out)) + + if errors.As(err, new(*exec.ExitError)) { + return cmd.ProcessState.ExitCode(), string(out), nil + } else if err != nil { + return 1, "", fmt.Errorf("exec.Command(%s) failed: %w: %s", command, err, string(out)) } return cmd.ProcessState.ExitCode(), string(out), nil diff --git a/internal/home/home.go b/internal/home/home.go index e99eee65..0d7a37d9 100644 --- a/internal/home/home.go +++ b/internal/home/home.go @@ -116,13 +116,6 @@ func Main(clientBuildFS fs.FS) { }() if args.serviceControlAction != "" { - // TODO(a.garipov): github.com/kardianos/service doesn't seem to - // support OpenBSD currently. Either patch it to do so or make - // our own implementation of the service.System interface. - if runtime.GOOS == "openbsd" { - log.Fatal("service actions are not supported on openbsd, see issue 3226") - } - handleServiceControlAction(args, clientBuildFS) return diff --git a/internal/home/service.go b/internal/home/service.go index 3b2216d0..2ba8c114 100644 --- a/internal/home/service.go +++ b/internal/home/service.go @@ -8,8 +8,10 @@ import ( "strconv" "strings" "syscall" + "time" "github.com/AdguardTeam/AdGuardHome/internal/aghos" + "github.com/AdguardTeam/AdGuardHome/internal/version" "github.com/AdguardTeam/golibs/errors" "github.com/AdguardTeam/golibs/log" "github.com/kardianos/service" @@ -26,33 +28,37 @@ const ( serviceDescription = "AdGuard Home: Network-level blocker" ) -// Represents the program that will be launched by a service or daemon +// program represents the program that will be launched by as a service or a +// daemon. type program struct { clientBuildFS fs.FS opts options } -// Start should quickly start the program -func (p *program) Start(s service.Service) error { - // Start should not block. Do the actual work async. +// Start implements service.Interface interface for *program. +func (p *program) Start(_ service.Service) (err error) { + // Start should not block. Do the actual work async. args := p.opts args.runningAsService = true + go run(args, p.clientBuildFS) return nil } -// Stop stops the program -func (p *program) Stop(s service.Service) error { - // Stop should not block. Return with a few seconds. +// Stop implements service.Interface interface for *program. +func (p *program) Stop(_ service.Service) error { + // Stop should not block. Return with a few seconds. if Context.appSignalChannel == nil { os.Exit(0) } + Context.appSignalChannel <- syscall.SIGINT + return nil } -// svcStatus check the service's status. +// svcStatus returns the service's status. // // On OpenWrt, the service utility may not exist. We use our service script // directly in this case. @@ -87,8 +93,8 @@ func svcAction(s service.Service, action string) (err error) { return err } -// Send SIGHUP to a process with ID taken from our pid-file -// If pid-file doesn't exist, find our PID using 'ps' command +// Send SIGHUP to a process with PID taken from our .pid file. If it doesn't +// exist, find our PID using 'ps' command. func sendSigReload() { if runtime.GOOS == "windows" { log.Error("not implemented on windows") @@ -152,11 +158,16 @@ func sendSigReload() { // 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(opts options, clientBuildFS fs.FS) { + // Call chooseSystem expicitly to introduce OpenBSD support for service + // package. It's a noop for other GOOS values. + chooseSystem() + action := opts.serviceControlAction log.Printf("Service control action: %s", action) if action == "reload" { sendSigReload() + return } @@ -164,6 +175,7 @@ func handleServiceControlAction(opts options, clientBuildFS fs.FS) { if err != nil { log.Fatal("Unable to find the path to the current directory") } + runOpts := opts runOpts.serviceControlAction = "run" svcConfig := &service.Config{ @@ -174,39 +186,39 @@ func handleServiceControlAction(opts options, clientBuildFS fs.FS) { Arguments: serialize(runOpts), } configureService(svcConfig) + prg := &program{ clientBuildFS: clientBuildFS, opts: runOpts, } - s, err := service.New(prg, svcConfig) - if err != nil { + var s service.Service + if s, err = service.New(prg, svcConfig); err != nil { log.Fatal(err) } - if action == "status" { + switch action { + case "status": handleServiceStatusCommand(s) - } else if action == "run" { - err = s.Run() - if err != nil { + case "run": + if err = s.Run(); err != nil { log.Fatalf("Failed to run service: %s", err) } - } else if action == "install" { + case "install": initConfigFilename(opts) initWorkingDir(opts) handleServiceInstallCommand(s) - } else if action == "uninstall" { + case "uninstall": handleServiceUninstallCommand(s) - } else { - err = svcAction(s, action) - if err != nil { + default: + if err = svcAction(s, action); err != nil { log.Fatal(err) } } - log.Printf("Action %s has been done successfully on %s", action, service.ChosenSystem().String()) + log.Printf("action %s has been done successfully on %s", action, service.ChosenSystem()) } -// handleServiceStatusCommand handles service "status" command +// handleServiceStatusCommand handles service "status" command. func handleServiceStatusCommand(s service.Service) { status, errSt := svcStatus(s) if errSt != nil { @@ -231,15 +243,16 @@ func handleServiceInstallCommand(s service.Service) { } if aghos.IsOpenWrt() { - // On OpenWrt it is important to run enable after the service installation - // Otherwise, the service won't start on the system startup + // On OpenWrt it is important to run enable after the service + // installation Otherwise, the service won't start on the system + // startup. _, err = runInitdCommand("enable") if err != nil { log.Fatal(err) } } - // Start automatically after install + // Start automatically after install. err = svcAction(s, "start") if err != nil { log.Fatalf("Failed to start the service: %s", err) @@ -267,14 +280,13 @@ func handleServiceUninstallCommand(s service.Service) { } } - err := svcAction(s, "uninstall") - if err != nil { + if err := svcAction(s, "uninstall"); err != nil { log.Fatal(err) } if runtime.GOOS == "darwin" { // Remove log files on cleanup and log errors. - err = os.Remove(launchdStdoutPath) + err := os.Remove(launchdStdoutPath) if err != nil && !errors.Is(err, os.ErrNotExist) { log.Printf("removing stdout file: %s", err) } @@ -313,6 +325,9 @@ func configureService(c *service.Config) { } else if runtime.GOOS == "freebsd" { c.Option["SysvScript"] = freeBSDScript } + + c.Option["RunComScript"] = openBSDScript + c.Option["SvcInfo"] = fmt.Sprintf("%s %s", version.Full(), time.Now()) } // runInitdCommand runs init.d service command @@ -551,3 +566,17 @@ command="/usr/sbin/daemon" command_args="-p ${pidfile} -f -r {{.WorkingDirectory}}/{{.Name}}" run_rc_command "$1" ` + +const openBSDScript = `#!/bin/sh +# +# $OpenBSD: {{ .SvcInfo }} + +daemon="{{.Path}}" +daemon_flags={{ .Arguments | args }} + +. /etc/rc.d/rc.subr + +rc_bg=YES + +rc_cmd $1 +` diff --git a/internal/home/service_openbsd.go b/internal/home/service_openbsd.go new file mode 100644 index 00000000..be715edf --- /dev/null +++ b/internal/home/service_openbsd.go @@ -0,0 +1,432 @@ +//go:build openbsd +// +build openbsd + +package home + +import ( + "fmt" + "os" + "os/signal" + "path/filepath" + "strings" + "syscall" + "text/template" + + "github.com/AdguardTeam/AdGuardHome/internal/aghos" + "github.com/AdguardTeam/golibs/errors" + "github.com/AdguardTeam/golibs/log" + "github.com/AdguardTeam/golibs/stringutil" + "github.com/kardianos/service" +) + +// OpenBSD Service Implementation +// +// The file contains OpenBSD implementations for service.System and +// service.Service interfaces. It uses the default approach for RunCom-based +// services systems, e.g. rc.d script. It's written as if it was in a separate +// package and has only one internal dependency. +// +// TODO(e.burkov): Perhaps, file a PR to github.com/kardianos/service. + +// sysVersion is the version of local service.System interface +// implementation. +const sysVersion = "openbsd-runcom" + +func chooseSystem() { + service.ChooseSystem(openbsdSystem{}) +} + +// openbsdSystem is the service.System to be used on the OpenBSD. +type openbsdSystem struct{} + +// String implements service.System interface for openbsdSystem. +func (openbsdSystem) String() string { + return sysVersion +} + +// Detect implements service.System interface for openbsdSystem. +func (openbsdSystem) Detect() (ok bool) { + return true +} + +// Interactive implements service.System interface for openbsdSystem. +func (openbsdSystem) Interactive() (ok bool) { + return os.Getppid() != 1 +} + +// New implements service.System interface for openbsdSystem. +func (openbsdSystem) New(i service.Interface, c *service.Config) (s service.Service, err error) { + return &openbsdRunComService{ + i: i, + cfg: c, + }, nil +} + +// openbsdRunComService is the RunCom-based service.Service to be used on the +// OpenBSD. +type openbsdRunComService struct { + i service.Interface + cfg *service.Config +} + +// Platform implements service.Service interface for *openbsdRunComService. +func (*openbsdRunComService) Platform() (p string) { + return "openbsd" +} + +// String implements service.Service interface for *openbsdRunComService. +func (s *openbsdRunComService) String() string { + return stringutil.Coalesce(s.cfg.DisplayName, s.cfg.Name) +} + +// getBool returns the value of the given name from kv, assuming the value is a +// boolean. If the value isn't found or is not of the type, the defaultValue is +// returned. +func getBool(kv service.KeyValue, name string, defaultValue bool) (val bool) { + var ok bool + if val, ok = kv[name].(bool); ok { + return val + } + + return defaultValue +} + +// getString returns the value of the given name from kv, assuming the value is +// a string. If the value isn't found or is not of the type, the defaultValue +// is returned. +func getString(kv service.KeyValue, name, defaultValue string) (val string) { + var ok bool + if val, ok = kv[name].(string); ok { + return val + } + + return defaultValue +} + +// getFuncNiladic returns the value of the given name from kv, assuming the +// value is a func(). If the value isn't found or is not of the type, the +// defaultValue is returned. +func getFuncNiladic(kv service.KeyValue, name string, defaultValue func()) (val func()) { + var ok bool + if val, ok = kv[name].(func()); ok { + return val + } + + return defaultValue +} + +const ( + // optionUserService is the UserService option name. + optionUserService = "UserService" + + // optionUserServiceDefault is the UserService option default value. + optionUserServiceDefault = false + + // errNoUserServiceRunCom is returned when the service uses some custom + // path to script. + errNoUserServiceRunCom errors.Error = "user services are not supported on " + sysVersion +) + +// scriptPath returns the absolute path to the script. It's commonly used to +// send commands to the service. +func (s *openbsdRunComService) scriptPath() (cp string, err error) { + if getBool(s.cfg.Option, optionUserService, optionUserServiceDefault) { + return "", errNoUserServiceRunCom + } + + const scriptPathPref = "/etc/rc.d" + + return filepath.Join(scriptPathPref, s.cfg.Name), nil +} + +const ( + // optionRunComScript is the RunCom script option name. + optionRunComScript = "RunComScript" + + // runComScript is the default RunCom script. + runComScript = `#!/bin/sh +# +# $OpenBSD: {{ .SvcInfo }} + +daemon="{{.Path}}" +daemon_flags={{ .Arguments | args }} + +. /etc/rc.d/rc.subr + +rc_bg=YES + +rc_cmd $1 +` +) + +// template returns the script template to put into rc.d. +func (s *openbsdRunComService) template() (t *template.Template) { + tf := map[string]interface{}{ + "args": func(sl []string) string { + return `"` + strings.Join(sl, " ") + `"` + }, + } + + return template.Must(template.New("").Funcs(tf).Parse(getString( + s.cfg.Option, + optionRunComScript, + runComScript, + ))) +} + +// execPath returns the absolute path to the excutable to be run as a service. +func (s *openbsdRunComService) execPath() (path string, err error) { + if c := s.cfg; c != nil && len(c.Executable) != 0 { + return filepath.Abs(c.Executable) + } + + if path, err = os.Executable(); err != nil { + return "", err + } + + return filepath.Abs(path) +} + +// annotate wraps errors.Annotate applying a common error format. +func (s *openbsdRunComService) annotate(action string, err error) (annotated error) { + return errors.Annotate(err, "%s %s %s service: %w", action, sysVersion, s.cfg.Name) +} + +// Install implements service.Service interface for *openbsdRunComService. +func (s *openbsdRunComService) Install() (err error) { + defer func() { err = s.annotate("installing", err) }() + + if err = s.writeScript(); err != nil { + return err + } + + return s.configureSysStartup(true) +} + +// configureSysStartup adds s into the group of packages started with system. +func (s *openbsdRunComService) configureSysStartup(enable bool) (err error) { + cmd := "enable" + if !enable { + cmd = "disable" + } + + var code int + code, _, err = aghos.RunCommand("rcctl", cmd, s.cfg.Name) + if err != nil { + return err + } else if code != 0 { + return fmt.Errorf("rcctl finished with code %d", code) + } + + return nil +} + +// writeScript tries to write the script for the service. +func (s *openbsdRunComService) writeScript() (err error) { + var scriptPath string + if scriptPath, err = s.scriptPath(); err != nil { + return err + } + + if _, err = os.Stat(scriptPath); !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("script already exists at %s", scriptPath) + } + + var execPath string + if execPath, err = s.execPath(); err != nil { + return err + } + + t := s.template() + f, err := os.Create(scriptPath) + if err != nil { + return fmt.Errorf("creating rc.d script file: %w", err) + } + defer f.Close() + + err = t.Execute(f, &struct { + *service.Config + Path string + SvcInfo string + }{ + Config: s.cfg, + Path: execPath, + SvcInfo: getString(s.cfg.Option, "SvcInfo", s.String()), + }) + if err != nil { + return err + } + + return errors.Annotate( + os.Chmod(scriptPath, 0o755), + "changing rc.d script file permissions: %w", + ) +} + +// Uninstall implements service.Service interface for *openbsdRunComService. +func (s *openbsdRunComService) Uninstall() (err error) { + defer func() { err = s.annotate("uninstalling", err) }() + + if err = s.configureSysStartup(false); err != nil { + return err + } + + var scriptPath string + if scriptPath, err = s.scriptPath(); err != nil { + return err + } + + if err = os.Remove(scriptPath); errors.Is(err, os.ErrNotExist) { + return service.ErrNotInstalled + } + + return errors.Annotate(err, "removing rc.d script: %w") +} + +// optionRunWait is the name of the option associated with function which waits +// for the service to be stopped. +const optionRunWait = "RunWait" + +// runWait is the default function to wait for service to be stopped. +func runWait() { + sigChan := make(chan os.Signal, 3) + signal.Notify(sigChan, syscall.SIGTERM, os.Interrupt) + <-sigChan +} + +// Run implements service.Service interface for *openbsdRunComService. +func (s *openbsdRunComService) Run() (err error) { + if err = s.i.Start(s); err != nil { + return err + } + + getFuncNiladic(s.cfg.Option, optionRunWait, runWait)() + + return s.i.Stop(s) +} + +// runCom calls the script with the specified cmd. +func (s *openbsdRunComService) runCom(cmd string) (out string, err error) { + var scriptPath string + if scriptPath, err = s.scriptPath(); err != nil { + return "", err + } + + // TODO(e.burkov): It's possible that os.ErrNotExist is caused by + // something different than the service script's non-existence. Keep it + // in mind, when replace the aghos.RunCommand. + _, out, err = aghos.RunCommand(scriptPath, cmd) + if errors.Is(err, os.ErrNotExist) { + return "", service.ErrNotInstalled + } + + return out, err +} + +// Status implements service.Service interface for *openbsdRunComService. +func (s *openbsdRunComService) Status() (status service.Status, err error) { + defer func() { err = s.annotate("getting status of", err) }() + + var out string + if out, err = s.runCom("check"); err != nil { + return service.StatusUnknown, err + } + + name := s.cfg.Name + switch out { + case fmt.Sprintf("%s(ok)\n", name): + return service.StatusRunning, nil + case fmt.Sprintf("%s(failed)\n", name): + return service.StatusStopped, nil + default: + return service.StatusUnknown, service.ErrNotInstalled + } +} + +// Start implements service.Service interface for *openbsdRunComService. +func (s *openbsdRunComService) Start() (err error) { + _, err = s.runCom("start") + + return s.annotate("starting", err) +} + +// Stop implements service.Service interface for *openbsdRunComService. +func (s *openbsdRunComService) Stop() (err error) { + _, err = s.runCom("stop") + + return s.annotate("stopping", err) +} + +// Restart implements service.Service interface for *openbsdRunComService. +func (s *openbsdRunComService) Restart() (err error) { + if err = s.Stop(); err != nil { + return err + } + + return s.Start() +} + +// Logger implements service.Service interface for *openbsdRunComService. +func (s *openbsdRunComService) Logger(errs chan<- error) (l service.Logger, err error) { + if service.ChosenSystem().Interactive() { + return service.ConsoleLogger, nil + } + + return s.SystemLogger(errs) +} + +// SystemLogger implements service.Service interface for *openbsdRunComService. +func (s *openbsdRunComService) SystemLogger(errs chan<- error) (l service.Logger, err error) { + return newSysLogger(s.cfg.Name, errs) +} + +// newSysLogger returns a stub service.Logger implementation. +func newSysLogger(_ string, _ chan<- error) (service.Logger, error) { + return sysLogger{}, nil +} + +// sysLogger wraps calls of the logging functions understandable for service +// interfaces. +type sysLogger struct{} + +// Error implements service.Logger interface for sysLogger. +func (sysLogger) Error(v ...interface{}) error { + log.Error(fmt.Sprint(v...)) + + return nil +} + +// Warning implements service.Logger interface for sysLogger. +func (sysLogger) Warning(v ...interface{}) error { + log.Info("warning: %s", fmt.Sprint(v...)) + + return nil +} + +// Info implements service.Logger interface for sysLogger. +func (sysLogger) Info(v ...interface{}) error { + log.Info(fmt.Sprint(v...)) + + return nil +} + +// Errorf implements service.Logger interface for sysLogger. +func (sysLogger) Errorf(format string, a ...interface{}) error { + log.Error(format, a...) + + return nil +} + +// Warningf implements service.Logger interface for sysLogger. +func (sysLogger) Warningf(format string, a ...interface{}) error { + log.Info("warning: %s", fmt.Sprintf(format, a...)) + + return nil +} + +// Infof implements service.Logger interface for sysLogger. +func (sysLogger) Infof(format string, a ...interface{}) error { + log.Info(format, a...) + + return nil +} diff --git a/internal/home/service_others.go b/internal/home/service_others.go new file mode 100644 index 00000000..83aa63ea --- /dev/null +++ b/internal/home/service_others.go @@ -0,0 +1,6 @@ +//go:build !openbsd +// +build !openbsd + +package home + +func chooseSystem() {}