From 5d3b6a58e9d2739760b962cb12e1f825edec8bd9 Mon Sep 17 00:00:00 2001 From: Dominic Della Valle Date: Tue, 11 Jul 2023 11:19:38 -0400 Subject: [PATCH] commands: add system service commands --- go.mod | 3 + go.sum | 3 + internal/commands/daemon.go | 60 ++-- internal/commands/service.go | 325 ++++++++++++++++++++++ internal/commands/service_darwin.go | 72 +++++ internal/commands/service_illumos.go | 18 ++ internal/commands/service_linux.go | 25 ++ internal/commands/service_maddrs_other.go | 13 + internal/commands/service_posix.go | 91 ++++++ internal/commands/service_windows.go | 187 +++++++++++++ 10 files changed, 774 insertions(+), 23 deletions(-) create mode 100644 internal/commands/service.go create mode 100644 internal/commands/service_darwin.go create mode 100644 internal/commands/service_illumos.go create mode 100644 internal/commands/service_linux.go create mode 100644 internal/commands/service_maddrs_other.go create mode 100644 internal/commands/service_posix.go create mode 100644 internal/commands/service_windows.go diff --git a/go.mod b/go.mod index f959a1e4..de57581d 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ require ( github.com/ipfs/go-ipld-format v0.5.0 github.com/ipfs/kubo v0.21.0 github.com/jaevor/go-nanoid v1.3.0 + github.com/kardianos/service v1.2.2 github.com/mattn/go-colorable v0.1.4 github.com/muesli/termenv v0.15.1 github.com/multiformats/go-multiaddr v0.9.0 @@ -97,3 +98,5 @@ require ( gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect lukechampine.com/blake3 v1.2.1 // indirect ) + +replace github.com/kardianos/service => github.com/djdv/service v1.2.1-0.20230705182121-36b49552050b diff --git a/go.sum b/go.sum index 8f440922..91785587 100644 --- a/go.sum +++ b/go.sum @@ -30,6 +30,8 @@ github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 h1:8UrgZ3GkP4i/CLijOJx79Yu+etly github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0= github.com/djdv/p9 v0.2.1-0.20230601152255-7d74b92b80b0 h1:TmRbQZzEz+AbtudHs+4OtcggEd6mgbcf1UA3DdUMg/M= github.com/djdv/p9 v0.2.1-0.20230601152255-7d74b92b80b0/go.mod h1:TGzUXNk2SONYuJnhbmn6w308jdHeBqWwQUqr3yng5XQ= +github.com/djdv/service v1.2.1-0.20230705182121-36b49552050b h1:yGeAxK6k9g1N0T33iw2yTlVNel7otQonAAyft46Z59U= +github.com/djdv/service v1.2.1-0.20230705182121-36b49552050b/go.mod h1:CIMRFEJVL+0DS1a3Nx06NaMn4Dz63Ng6O7dl0qH0zVM= github.com/dlclark/regexp2 v1.4.0 h1:F1rxgk7p4uKjwIQxBs9oAXe5CqrXlCduYEJvrF4u93E= github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= github.com/facebookgo/atomicfile v0.0.0-20151019160806-2de1f203e7d5 h1:BBso6MBKW8ncyZLv37o+KNyy0HrrHgfnOaGQC2qvN+A= @@ -342,6 +344,7 @@ golang.org/x/sys v0.0.0-20190219092855-153ac476189d/go.mod h1:STP8DvDyc/dI5b8T5h golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201015000850-e3ed0017c211/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210303074136-134d130e1a04/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= diff --git a/internal/commands/daemon.go b/internal/commands/daemon.go index 9123e66e..62cd1e39 100644 --- a/internal/commands/daemon.go +++ b/internal/commands/daemon.go @@ -96,6 +96,21 @@ const ( errShutdownDisposition = generic.ConstError("invalid shutdown disposition") ) +// Daemon constructs the command which +// hosts the file system service server. +func Daemon() command.Command { + const ( + name = daemonCommandName + synopsis = "Host system services." + ) + usage := header("File system service daemon.") + + "\n\n" + synopsis + return command.MakeVariadicCommand[daemonOptions]( + name, synopsis, usage, daemonExecute, + command.WithSubcommands(Service()), + ) +} + func (do *daemonOptions) BindFlags(flagSet *flag.FlagSet) { const ( verboseName = "verbose" @@ -112,9 +127,12 @@ func (do *daemonOptions) BindFlags(flagSet *flag.FlagSet) { } return nil }) +} + +func bindDaemonFlags(flagSet *flag.FlagSet, options *daemonOptions) { const serverUsage = "listening socket `maddr`" + "\ncan be specified multiple times and/or comma separated" - flagSetFunc(flagSet, serverFlagName, serverUsage, do, + flagSetFunc(flagSet, serverFlagName, serverUsage, options, func(value []multiaddr.Multiaddr, settings *daemonSettings) error { settings.serverMaddrs = append(settings.serverMaddrs, value...) return nil @@ -129,7 +147,7 @@ func (do *daemonOptions) BindFlags(flagSet *flag.FlagSet) { exitName = exitAfterFlagName exitUsage = "check every `interval` (e.g. \"30s\") and shutdown the daemon if its idle" ) - flagSetFunc(flagSet, exitName, exitUsage, do, + flagSetFunc(flagSet, exitName, exitUsage, options, func(value time.Duration, settings *daemonSettings) error { settings.exitInterval = value return nil @@ -138,7 +156,7 @@ func (do *daemonOptions) BindFlags(flagSet *flag.FlagSet) { uidName = apiFlagPrefix + "uid" uidUsage = "file owner's `uid`" ) - flagSetFunc(flagSet, uidName, uidUsage, do, + flagSetFunc(flagSet, uidName, uidUsage, options, func(value p9.UID, settings *daemonSettings) error { settings.nineIDs.uid = value return nil @@ -149,7 +167,7 @@ func (do *daemonOptions) BindFlags(flagSet *flag.FlagSet) { gidName = apiFlagPrefix + "gid" gidUsage = "file owner's `gid`" ) - flagSetFunc(flagSet, gidName, gidUsage, do, + flagSetFunc(flagSet, gidName, gidUsage, options, func(value p9.GID, settings *daemonSettings) error { settings.nineIDs.gid = value return nil @@ -161,7 +179,7 @@ func (do *daemonOptions) BindFlags(flagSet *flag.FlagSet) { permissionsUsage = "`permissions` to use when creating service files" ) apiPermissions := fs.FileMode(apiPermissionsDefault) - flagSetFunc(flagSet, permissionsName, permissionsUsage, do, + flagSetFunc(flagSet, permissionsName, permissionsUsage, options, func(value string, settings *daemonSettings) error { permissions, err := parsePOSIXPermissions(apiPermissions, value) if err != nil { @@ -176,13 +194,7 @@ func (do *daemonOptions) BindFlags(flagSet *flag.FlagSet) { } func (do daemonOptions) make() (daemonSettings, error) { - settings := daemonSettings{ - nineIDs: nineIDs{ - uid: apiUIDDefault, - gid: apiGIDDefault, - }, - permissions: apiPermissionsDefault, - } + settings := makeDaemonSettings() if err := generic.ApplyOptions(&settings, do...); err != nil { return daemonSettings{}, err } @@ -199,16 +211,14 @@ func (do daemonOptions) make() (daemonSettings, error) { return settings, nil } -// Daemon constructs the command which -// hosts the file system service server. -func Daemon() command.Command { - const ( - name = daemonCommandName - synopsis = "Host system services." - ) - usage := header("File system service daemon.") + - "\n\n" + synopsis - return command.MakeVariadicCommand[daemonOptions](name, synopsis, usage, daemonExecute) +func makeDaemonSettings() daemonSettings { + return daemonSettings{ + nineIDs: nineIDs{ + uid: apiUIDDefault, + gid: apiGIDDefault, + }, + permissions: apiPermissionsDefault, + } } func daemonExecute(ctx context.Context, options ...daemonOption) error { @@ -216,9 +226,13 @@ func daemonExecute(ctx context.Context, options ...daemonOption) error { if err != nil { return err } + return daemonRun(ctx, &settings) +} + +func daemonRun(ctx context.Context, settings *daemonSettings) error { dCtx, cancel := context.WithCancel(ctx) defer cancel() - system, err := newSystem(dCtx, &settings) + system, err := newSystem(dCtx, settings) if err != nil { return err } diff --git a/internal/commands/service.go b/internal/commands/service.go new file mode 100644 index 00000000..1e54fd2d --- /dev/null +++ b/internal/commands/service.go @@ -0,0 +1,325 @@ +package commands + +import ( + "context" + "errors" + "flag" + "fmt" + "strings" + + "github.com/djdv/go-filesystem-utils/internal/command" + "github.com/djdv/go-filesystem-utils/internal/generic" + "github.com/kardianos/service" + "github.com/multiformats/go-multiaddr" + "github.com/u-root/uio/ulog" +) + +type ( + cleanupFunc func() error + serviceSettings struct { + controlSettings + daemonWrapper + } + serviceOption func(*serviceSettings) error + serviceOptions []serviceOption + daemonWrapper struct { + ctx context.Context + dbgSysLog service.Logger + cleanupFn cleanupFunc + runErrs <-chan error + daemonSettings + maddrSetExplicitly bool + } + serviceLog struct{ service.Logger } + controlSettings struct { + service.Config + } + controlOption func(*controlSettings) error + controlOptions []controlOption +) + +const serviceFlagPrefix = "service-" + +func Service() command.Command { + const ( + name = "service" + synopsis = "Daemon as a system service." + ) + usage := header("File system service daemon.") + + "\n\n" + "Host system services." + return command.MakeVariadicCommand[serviceOptions]( + name, synopsis, usage, serviceExecute, + command.WithSubcommands(makeControllerCommands()...), + ) +} + +func (so *serviceOptions) BindFlags(flagSet *flag.FlagSet) { + var ( + daemonOptions daemonOptions + controlOptions controlOptions + ) + bindDaemonFlags(flagSet, &daemonOptions) + (&controlOptions).BindFlags(flagSet) + // TODO: can we set this default easily + // or does cross plat make it annoying? + // flagSet.Lookup(serverFlagName). + // DefValue = userMaddrs[0].String() + *so = append(*so, func(settings *serviceSettings) error { + subset := makeDaemonSettings() + if err := generic.ApplyOptions(&subset, daemonOptions...); err != nil { + return err + } + control, err := controlOptions.make() + if err != nil { + return err + } + if subset.systemLog == nil { + subset.systemLog = ulog.Null + } + settings.daemonSettings = subset + settings.controlSettings = control + return nil + }) +} + +func (so serviceOptions) make() (serviceSettings, error) { + control, err := controlOptions.make(nil) + if err != nil { + return serviceSettings{}, err + } + settings := serviceSettings{ + controlSettings: control, + } + return settings, generic.ApplyOptions(&settings, so...) +} + +func serviceExecute(ctx context.Context, options ...serviceOption) error { + if service.Interactive() { + return command.UsageError{ + Err: generic.ConstError( + "this version of the daemon is intended for use by the host service manager" + + " use the `daemon` command for interactive use", + ), + } + } + settings, err := serviceOptions(options).make() + if err != nil { + return err + } + svc := &settings.daemonWrapper + svc.ctx = ctx + controller, err := service.New( + svc, + &settings.Config, + ) + if err != nil { + return err + } + sysLog, err := controller.SystemLogger(nil) + if err != nil { + return err + } + svc.daemonSettings.systemLog = serviceLog{sysLog} + svc.dbgSysLog = sysLog + // return controller.Run() + if err := controller.Run(); err != nil { + sysLog.Error("run:", err) + return err + } + return nil +} + +func (svc *daemonWrapper) Start(svcIntf service.Service) error { + var ( + cleanup cleanupFunc + settings = &svc.daemonSettings + ) + if !svc.maddrSetExplicitly { + svc.dbgSysLog.Warning("maddrs empty (expected)") + var ( + serviceMaddrs []multiaddr.Multiaddr + err error + ) + if serviceMaddrs, cleanup, err = createServiceMaddrs(); err != nil { + return err + } + settings.serverMaddrs = serviceMaddrs + svc.cleanupFn = func() error { + settings.serverMaddrs = nil + return cleanup() + } + } + var ( + dCtx, dCancel = context.WithCancel(svc.ctx) + errs = make(chan error, 1) + ) + go func() { + defer dCancel() + errs <- daemonRun(dCtx, settings) + }() + select { + default: // Fail-fast check. + case err := <-errs: + if cleanup != nil { + if cErr := cleanup(); cErr != nil { + err = errors.Join(err, cErr) + } + } + return err + } + svc.runErrs = errs + svc.cleanupFn = cleanup + return nil +} + +func (svc *daemonWrapper) Stop(svcIntf service.Service) error { + serviceMaddr := svc.serverMaddrs[0] + if err := shutdownExecute( + svc.ctx, + func(settings *shutdownSettings) error { + settings.serviceMaddr = serviceMaddr + settings.disposition = immediateShutdown + return nil + }, + ); err != nil { + return err + } + err := <-svc.runErrs + if cleanup := svc.cleanupFn; cleanup != nil { + svc.cleanupFn = nil + if cErr := cleanup(); cErr != nil { + err = errors.Join(err, cErr) + } + } + return err +} + +func (co *controlOptions) BindFlags(flagSet *flag.FlagSet) { + const ( + serviceName = serviceFlagPrefix + "name" + serviceUsage = "service name associated with service manager" + ) + flagSetFunc(flagSet, serviceName, serviceUsage, co, + func(value string, settings *controlSettings) error { + settings.Config.Name = value + return nil + }) + const ( // TODO: we don't need this for run? (only install) + displayName = serviceFlagPrefix + "display-name" + displayUsage = "service display name associate with the service manager" + ) + flagSetFunc(flagSet, displayName, displayUsage, co, + func(value string, settings *controlSettings) error { + settings.Config.DisplayName = value + return nil + }) + const ( + descriptionName = serviceFlagPrefix + "description" + descriptionUsage = "description to associate with the service manager" + ) + flagSetFunc(flagSet, descriptionName, descriptionUsage, co, + func(value string, settings *controlSettings) error { + settings.Config.Description = value + return nil + }) + const ( + userNameName = serviceFlagPrefix + "username" + userNameUsage = "username to use when interfacing with the service manager" + ) + flagSetFunc(flagSet, userNameName, userNameUsage, co, + func(value string, settings *controlSettings) error { + settings.Config.UserName = value + return nil + }) + const ( + argumentsName = serviceFlagPrefix + "arguments" + argumentsUsage = "arguments passed to the service command when started (space separated string)" + ) + flagSetFunc(flagSet, argumentsName, argumentsUsage, co, + func(value string, settings *controlSettings) error { + settings.Config.Arguments = append( + settings.Config.Arguments, + splitArgString(value)..., + ) + return nil + }) + bindServiceControlFlags(flagSet, co) +} + +func splitArgString(input string) []string { + const ( + quote = '"' + space = ' ' + ) + var ( + enclosed bool + head = 0 + tail int + estimate = strings.Count(input, " ") + list = make([]string, 0, estimate) + ) + for i, r := range input { + tail = i + switch { + case r == space && !enclosed: + list = append(list, input[head:tail]) + head = i + 1 + case r == quote: + enclosed = !enclosed + } + } + return append(list, input[head:]) +} + +func (co controlOptions) make() (controlSettings, error) { + settings := controlSettings{ + Config: service.Config{ + Name: "go-filesystem", + DisplayName: "Go File system service", + Description: "Manages Go file system instances.", + Arguments: []string{ + "daemon", // TODO: consts + "service", + }, + }, + } + return settings, generic.ApplyOptions(&settings, co...) +} + +func makeControllerCommands() []command.Command { + var ( + actions = service.ControlAction + commands = make([]command.Command, len(actions)) + ) + for i, action := range actions { + var ( + synopsis = fmt.Sprintf( + "%s the service.", strings.Title(action), + ) + usage = synopsis + ) + action := action // Closed over. + commands[i] = command.MakeVariadicCommand[controlOptions]( + action, synopsis, usage, + func(_ context.Context, options ...controlOption) error { + settings, err := controlOptions(options).make() + if err != nil { + return err + } + serviceClient, err := service.New( + (service.Interface)(nil), + &settings.Config, + ) + if err != nil { + return err + } + return service.Control(serviceClient, action) + }) + } + return commands +} + +var _ ulog.Logger = (*serviceLog)(nil) + +func (sl serviceLog) Printf(format string, v ...any) { sl.Infof(format, v...) } +func (sl serviceLog) Print(v ...any) { sl.Info(v...) } diff --git a/internal/commands/service_darwin.go b/internal/commands/service_darwin.go new file mode 100644 index 00000000..5acda4a1 --- /dev/null +++ b/internal/commands/service_darwin.go @@ -0,0 +1,72 @@ +package commands + +import "flag" + +func bindServiceControlFlags(flagSet *flag.FlagSet, options *controlOptions) { + // NOTE: Key+value types defined on + // [service.KeyValue] documentation. + bindServiceControlFlagsPOSIX(flagSet, options) + const ( + launchdConfigName = serviceFlagPrefix + "launchd-config" + launchdConfigUsage = "use custom launchd config" + ) + flagSetFunc(flagSet, launchdConfigName, launchdConfigUsage, options, + func(value string, settings *controlSettings) error { + settings.Config.Option["LaunchdConfig"] = value + return nil + }) + const ( + keepAliveName = serviceFlagPrefix + "keep-alive" + keepAliveUsage = "prevent the system from stopping the service automatically" + ) + flagSetFunc(flagSet, keepAliveName, keepAliveUsage, options, + func(value bool, settings *controlSettings) error { + settings.Config.Option["KeepAlive"] = value + return nil + }) + const ( + runAtLoadName = serviceFlagPrefix + "run-at-load" + runAtLoadUsage = "run the service after its job has been loaded" + ) + flagSetFunc(flagSet, runAtLoadName, runAtLoadUsage, options, + func(value bool, settings *controlSettings) error { + settings.Config.Option["RunAtLoad"] = value + return nil + }) + const ( + sessionCreateName = serviceFlagPrefix + "session-create" + sessionCreateUsage = "create a full user session" + ) + flagSetFunc(flagSet, sessionCreateName, sessionCreateUsage, options, + func(value bool, settings *controlSettings) error { + settings.Config.Option["SessionCreate"] = value + return nil + }) + const ( + socketsTypeName = serviceFlagPrefix + "sock-type" + socketsTypeUsage = `what type of socket to create ("stream", "dgram", "seqpacket")` + ) + flagSetFunc(flagSet, socketsTypeName, socketsTypeUsage, options, + func(value string, settings *controlSettings) error { + settings.Config.Option["SockType"] = value + return nil + }) + const ( + socketsPathNameName = serviceFlagPrefix + "sockets-path-name" + socketsPathNameUsage = "Unix domain socket path" + ) + flagSetFunc(flagSet, socketsPathNameName, socketsPathNameUsage, options, + func(value string, settings *controlSettings) error { + settings.Config.Option["SockPathName"] = value + return nil + }) + const ( + socketsPathModeName = serviceFlagPrefix + "sockets-path-mode" + socketsPathModeUsage = "socket file mode bits (must be decimal; not octal)" + ) + flagSetFunc(flagSet, socketsPathModeName, socketsPathModeUsage, options, + func(value int, settings *controlSettings) error { + settings.Config.Option["SockPathMode"] = value + return nil + }) +} diff --git a/internal/commands/service_illumos.go b/internal/commands/service_illumos.go new file mode 100644 index 00000000..f0ab8177 --- /dev/null +++ b/internal/commands/service_illumos.go @@ -0,0 +1,18 @@ +package commands + +import "flag" + +func bindServiceControlFlags(flagSet *flag.FlagSet, options *controlOptions) { + // NOTE: Key+value types defined on + // [service.KeyValue] documentation. + bindServiceControlFlagsPOSIX(flagSet, options) + const ( + prefixName = serviceFlagPrefix + "prefix" + prefixUsage = "service FMRI prefix" + ) + flagSetFunc(flagSet, prefixName, prefixUsage, options, + func(value string, settings *controlSettings) error { + settings.Config.Option["Prefix"] = value + return nil + }) +} diff --git a/internal/commands/service_linux.go b/internal/commands/service_linux.go new file mode 100644 index 00000000..9c55f781 --- /dev/null +++ b/internal/commands/service_linux.go @@ -0,0 +1,25 @@ +package commands + +import "flag" + +func bindServiceControlFlags(flagSet *flag.FlagSet, options *controlOptions) { + bindServiceControlFlagsPOSIX(flagSet, options) + const ( + openRCScriptName = serviceFlagPrefix + "openrc-script" + openRCScriptUsage = "use custom OpenRC script" + ) + flagSetFunc(flagSet, openRCScriptName, openRCScriptUsage, options, + func(value string, settings *controlSettings) error { + settings.Config.Option["OpenRCScript"] = value + return nil + }) + const ( + limitNOFILEName = serviceFlagPrefix + "limit-nofile" + limitNOFILEUsage = "maximum open files" + ) + flagSetFunc(flagSet, limitNOFILEName, limitNOFILEUsage, options, + func(value int, settings *controlSettings) error { + settings.Config.Option["LimitNOFILE"] = value + return nil + }) +} diff --git a/internal/commands/service_maddrs_other.go b/internal/commands/service_maddrs_other.go new file mode 100644 index 00000000..23c5c5b9 --- /dev/null +++ b/internal/commands/service_maddrs_other.go @@ -0,0 +1,13 @@ +//go:build !windows + +package commands + +import "github.com/multiformats/go-multiaddr" + +func createServiceMaddrs() ([]multiaddr.Multiaddr, cleanupFunc, error) { + serviceMaddrs, err := hostServiceMaddrs() + if err != nil { + return nil, nil, err + } + return serviceMaddrs[:1], nil, nil +} diff --git a/internal/commands/service_posix.go b/internal/commands/service_posix.go new file mode 100644 index 00000000..4e679647 --- /dev/null +++ b/internal/commands/service_posix.go @@ -0,0 +1,91 @@ +//go:build !windows + +package commands + +import "flag" + +func bindServiceControlFlagsPOSIX(flagSet *flag.FlagSet, options *controlOptions) { + // NOTE: Key+value types defined on + // [service.KeyValue] documentation. + const ( + userServiceName = serviceFlagPrefix + "user-service" + userServiceUsage = "install as a current user service" + ) + flagSetFunc(flagSet, userServiceName, userServiceUsage, options, + func(value bool, settings *controlSettings) error { + settings.Config.Option["UserService"] = value + return nil + }) + const ( + systemdScriptName = serviceFlagPrefix + "systemd-script" + systemdScriptUsage = "use custom systemd script" + ) + flagSetFunc(flagSet, systemdScriptName, systemdScriptUsage, options, + func(value string, settings *controlSettings) error { + settings.Config.Option["SystemdScript"] = value + return nil + }) + const ( + upstartScriptName = serviceFlagPrefix + "upstart-script" + upstartScriptUsage = "use custom upstart script" + ) + flagSetFunc(flagSet, upstartScriptName, upstartScriptUsage, options, + func(value string, settings *controlSettings) error { + settings.Config.Option["UpstartScript"] = value + return nil + }) + const ( + sysvScriptName = serviceFlagPrefix + "sysv-script" + sysvScriptUsage = "use custom sysv script" + ) + flagSetFunc(flagSet, sysvScriptName, sysvScriptUsage, options, + func(value string, settings *controlSettings) error { + settings.Config.Option["SysvScript"] = value + return nil + }) + const ( + reloadSignalName = serviceFlagPrefix + "reload-signal" + reloadSignalUsage = "signal to send on reload" + ) + flagSetFunc(flagSet, reloadSignalName, reloadSignalUsage, options, + func(value string, settings *controlSettings) error { + settings.Config.Option["ReloadSignal"] = value + return nil + }) + const ( + pidFileName = serviceFlagPrefix + "pid-file" + pidFileUsage = "location of the PID file" + ) + flagSetFunc(flagSet, pidFileName, pidFileUsage, options, + func(value string, settings *controlSettings) error { + settings.Config.Option["PIDFile"] = value + return nil + }) + const ( + logOutputName = serviceFlagPrefix + "log-output" + logOutputUsage = "redirect StdErr & StandardOutPath to files" + ) + flagSetFunc(flagSet, logOutputName, logOutputUsage, options, + func(value bool, settings *controlSettings) error { + settings.Config.Option["LogOutput"] = value + return nil + }) + const ( + restartName = serviceFlagPrefix + "restart" + restartUsage = "service restart keyword (e.g. \"always\")" + ) + flagSetFunc(flagSet, restartName, restartUsage, options, + func(value string, settings *controlSettings) error { + settings.Config.Option["Restart"] = value + return nil + }) + const ( + successExitStatusName = serviceFlagPrefix + "success-exit-status" + successExitStatusUsage = "the list of exit status that shall be considered as successful, in addition to the default ones" + ) + flagSetFunc(flagSet, successExitStatusName, successExitStatusUsage, options, + func(value string, settings *controlSettings) error { + settings.Config.Option["SuccessExitStatus"] = value + return nil + }) +} diff --git a/internal/commands/service_windows.go b/internal/commands/service_windows.go new file mode 100644 index 00000000..6453a95c --- /dev/null +++ b/internal/commands/service_windows.go @@ -0,0 +1,187 @@ +package commands + +import ( + "errors" + "flag" + "io/fs" + "os" + "path/filepath" + "unsafe" + + "github.com/adrg/xdg" + "github.com/multiformats/go-multiaddr" + "golang.org/x/sys/windows" +) + +func bindServiceControlFlags(flagSet *flag.FlagSet, options *controlOptions) { + // NOTE: Key+value types defined on + // [service.KeyValue] documentation. + const ( + passwordName = serviceFlagPrefix + "password" + passwordUsage = "password to use when interfacing with the service manager" + ) + flagSetFunc(flagSet, passwordName, passwordUsage, options, + func(value string, settings *controlSettings) error { + settings.Config.Option["Password"] = value + return nil + }) + const ( + delayedName = serviceFlagPrefix + "delayed-auto-start" + delayedUsage = "delay the service from starting (immediately after boot)" + ) + flagSetFunc(flagSet, delayedName, delayedUsage, options, + func(value bool, settings *controlSettings) error { + settings.Config.Option["DelayedAutoStart"] = value + return nil + }) +} + +func createServiceMaddrs() ([]multiaddr.Multiaddr, cleanupFunc, error) { + serviceDir := filepath.Join( + xdg.ConfigDirs[0], + serverRootName, + ) + cleanup, err := createSystemServiceDirectory(serviceDir) + if err != nil { + return nil, nil, err + } + serviceMaddr, err := multiaddr.NewMultiaddr( + "/unix/" + filepath.Join(serviceDir, serverName), + ) + if err == nil { + return []multiaddr.Multiaddr{serviceMaddr}, cleanup, nil + } + if cleanup != nil { + if cErr := cleanup(); cErr != nil { + err = errors.Join(err, cErr) + } + } + return nil, nil, err +} + +// NOTE: On permissions. +// 1) [MSDN] +// "If an application requires normal Users to have write access to an application +// specific subdirectory of CSIDL_COMMON_APPDATA, +// then the application must explicitly modify the security +// on that sub-directory during application setup." +// +// 2) +// WSA currently (v19043.928) requires +// read, write, and delete for `connect` to succeed with unix domain sockets. +// We allow to be able to do that, so we allow that access +// on files underneath the service directory. + +func createSystemServiceDirectory(serviceDir string) (cleanupFunc, error) { + var systemSid, adminSid, usersSid *windows.SID + { + sids := []**windows.SID{&systemSid, &adminSid, &usersSid} + for i, sid := range []windows.WELL_KNOWN_SID_TYPE{ + windows.WinLocalSystemSid, + windows.WinBuiltinAdministratorsSid, + windows.WinBuiltinUsersSid, + } { + var err error + if *(sids[i]), err = windows.CreateWellKnownSid(sid); err != nil { + return nil, err + } + } + } + dacl, err := makeServiceACL(systemSid, usersSid) + if err != nil { + return nil, err + } + securityAttributes, err := makeServiceSecurityAttributes(systemSid, adminSid, dacl) + if err != nil { + return nil, err + } + pszServiceDir, err := windows.UTF16PtrFromString(serviceDir) + if err != nil { + return nil, err + } + // NOTE: Regardless of the state of the file system; + // we're about to own (and later destroy) this directory. + // The directory shouldn't exist, but if the caller fails + // to call cleanup, it could on a subsequent run. + // Most users will not have delete access, so instead of failing + // we make an exception here and just take ownership. + // This allows the calling code to be patched. Afterwards + // the service can be restarted, and the (erroneously) leftover + // directory will be clobbered. + // TODO: reconsider this, we've bulletproofed the daemon somewhat. + // service should fail if this exists. + if err = windows.CreateDirectory(pszServiceDir, securityAttributes); err != nil { + if !errors.Is(err, fs.ErrExist) { + return nil, err + } + // Don't remake the service directory, + // but set its security to the permissions we need. + if err = windows.SetNamedSecurityInfo(serviceDir, windows.SE_FILE_OBJECT, + windows.DACL_SECURITY_INFORMATION, + systemSid, systemSid, + dacl, nil); err != nil { + return nil, err + } + } + // Allow the service directory to be deleted + // when the caller is done with it. + cleanup := func() error { return os.Remove(serviceDir) } + return cleanup, nil +} + +func makeServiceACL(ownerSid, clientSid *windows.SID) (*windows.ACL, error) { + aces := []windows.EXPLICIT_ACCESS{ + { // Service level 0+ (/**) + // Grant ALL ... + AccessPermissions: windows.GENERIC_ALL, + AccessMode: windows.GRANT_ACCESS, + Inheritance: windows.SUB_CONTAINERS_AND_OBJECTS_INHERIT, // recursively ... + Trustee: windows.TRUSTEE{ // ... to the service owner. + TrusteeForm: windows.TRUSTEE_IS_SID, + TrusteeType: windows.TRUSTEE_IS_USER, + TrusteeValue: windows.TrusteeValueFromSID(ownerSid), + }, + }, + { // Level 1 - (/*) + // Grant permissions required to operate Unix socket objects ... + AccessPermissions: windows.GENERIC_READ | + windows.GENERIC_WRITE | + windows.DELETE, + AccessMode: windows.GRANT_ACCESS, + Inheritance: windows.INHERIT_ONLY_ACE | // but not in our container (Level 0) ... + windows.OBJECT_INHERIT_ACE | // and only to objects (files in Level 1) ... + windows.INHERIT_NO_PROPAGATE, // and not in levels 2+ ... + Trustee: windows.TRUSTEE{ // ... to clients of this scope. + TrusteeForm: windows.TRUSTEE_IS_SID, + TrusteeType: windows.TRUSTEE_IS_GROUP, + TrusteeValue: windows.TrusteeValueFromSID(clientSid), + }, + }, + } + return windows.ACLFromEntries(aces, nil) +} + +func makeServiceSecurityAttributes(ownerSid, groupSid *windows.SID, + dacl *windows.ACL, +) (*windows.SecurityAttributes, error) { + securityDesc, err := windows.NewSecurityDescriptor() + if err != nil { + return nil, err + } + + if err := securityDesc.SetDACL(dacl, true, false); err != nil { + return nil, err + } + if err := securityDesc.SetOwner(ownerSid, false); err != nil { + return nil, err + } + if err := securityDesc.SetGroup(groupSid, false); err != nil { + return nil, err + } + + securityAttributes := new(windows.SecurityAttributes) + securityAttributes.Length = uint32(unsafe.Sizeof(*securityAttributes)) + securityAttributes.SecurityDescriptor = securityDesc + + return securityAttributes, nil +}