diff --git a/README.md b/README.md index 0fad82f..a5e3e34 100644 --- a/README.md +++ b/README.md @@ -96,31 +96,31 @@ go build ./cmd/gpu-turnstile ### Run natively on Windows (current primary deployment) -Download `gpu-turnstile.exe` from a release, put a `gpu-turnstile.env` -next to it, and run it — or install it as a Windows service from an -elevated shell: +Download `gpu-turnstile.exe` from a release and install it as a Windows +service from an elevated shell — the installer creates the canonical +layout, copies the binary and (if none exists yet) your +`gpu-turnstile.env` into it, and registers the copy: ```sh -gpu-turnstile.exe --install-service # auto-start service, recovery = restart +gpu-turnstile.exe --install-service # installs into Program Files, auto-start +gpu-turnstile.exe --install-service --no-copy # register in place instead gpu-turnstile.exe --remove-service ``` -The service uses the config file (services have no convenient -environment); set `LOG_FILE` in it since there is no console. - -Suggested layout: `C:\Program Files\gpu-turnstile\` for the exe and -`gpu-turnstile.env`, logs under `C:\ProgramData\gpu-turnstile\` via -`LOG_FILE`. The service always runs as the virtual account -`NT SERVICE\gpu-turnstile` (low-privilege, per-service, no password); the -installer automatically grants it write access to the install and log -directories — nothing else to do. +Layout: `C:\Program Files\gpu-turnstile\` holds the exe and +`gpu-turnstile.env`, logs go to `C:\ProgramData\gpu-turnstile\` (set +`LOG_FILE` in the env file — there is no console). The service always runs +as the virtual account `NT SERVICE\gpu-turnstile` (low-privilege, +per-service, no password); the installer automatically grants it write +access to the install and data directories — nothing else to do. ### Run natively on Linux (systemd) The same binary works on Linux. Install it as a systemd service as root: ```sh -gpu-turnstile --install-service # writes + enables + starts the unit +gpu-turnstile --install-service # installs into /var/lib/gpu-turnstile, enables + starts +gpu-turnstile --install-service --no-copy # register in place instead gpu-turnstile --remove-service ``` diff --git a/SPEC.md b/SPEC.md index e561765..0e709d6 100644 --- a/SPEC.md +++ b/SPEC.md @@ -169,29 +169,36 @@ The binary runs natively on Windows (the current primary deployment) and on Linux with systemd (the future GPU server), as well as in Docker. Service management is the same on both platforms: -`gpu-turnstile --install-service [-config path]` registers and starts an -auto-start service; `--remove-service` stops and unregisters it (both need -an elevated/root shell). The legacy form `gpu-turnstile service +`gpu-turnstile --install-service [-config path]` installs, registers and +starts an auto-start service; `--remove-service` stops and uninstalls it +(both need an elevated/root shell). The legacy form `gpu-turnstile service install|remove` does the same thing. +By default install creates the canonical layout and copies the binary into +it (Windows: `%ProgramFiles%\gpu-turnstile\`, plus +`%ProgramData%\gpu-turnstile\` for logs; Linux: `/var/lib/gpu-turnstile/` +with the config at `/etc/gpu-turnstile.env`). An existing config in the +target location is never overwritten. `--no-copy` registers the current +executable location as-is instead. + ### Windows -- `--install-service` registers a Windows service; recovery actions restart - it 5 s after any failure. -- **Layout**: install to `C:\Program Files\gpu-turnstile\` (exe plus - `gpu-turnstile.env`); logs belong in `C:\ProgramData\gpu-turnstile\` via - `LOG_FILE`. +- `--install-service` creates `%ProgramFiles%\gpu-turnstile\` and + `%ProgramData%\gpu-turnstile\`, copies the exe and (if none exists there + yet) the `gpu-turnstile.env` into the Program Files directory, and + registers that copy as a Windows service; recovery actions restart it + 5 s after any failure. Logs go to the ProgramData directory via + `LOG_FILE` since there is no console. - **Account**: the service always runs as the virtual account `NT SERVICE\gpu-turnstile` — a per-service low-privilege identity the SCM manages (no password, automatic logon-as-a-service right, no admin rights, gone when the service is removed). The installer grants it - modify access to the install directory (self-updates rewrite the exe) - and the `LOG_FILE` directory (created if missing), plus read access to - the config file when it lives elsewhere. The grants happen after service - registration because the virtual account's SID only exists from that - point on; if a grant fails the service registration is rolled back. -- Use a config file (above) for the service — Windows services have no - convenient environment. Logs go to `LOG_FILE` since there is no console. + modify access to the install and data directories (self-updates rewrite + the exe) and the `LOG_FILE` directory (created if missing), plus read + access to the config file when it lives elsewhere. The grants happen + after service registration because the virtual account's SID only exists + from that point on; if a grant fails the service registration is rolled + back. ### Linux (systemd) @@ -199,7 +206,12 @@ install|remove` does the same thing. copies the config to `/etc/gpu-turnstile.env` if none exists there yet, writes `/etc/systemd/system/gpu-turnstile.service`, then runs `systemctl daemon-reload` and `enable --now`. `--remove-service` removes the unit - and the installed binary; the `/etc` config stays. + and the installed binary; the `/etc` config stays. The binary does not + go to `/usr/local/sbin` on purpose: replacing a running binary needs + write access to its *directory*, and granting the sandboxed service + write access to a shared system directory would let a compromised + service overwrite other binaries — `/var/lib/gpu-turnstile` is + exclusively ours. - **Sandboxing** mirrors the Windows virtual account: the unit runs with `DynamicUser=yes` — a transient per-service UID with no login, no home and no password, managed entirely by systemd. `ProtectSystem=strict` diff --git a/cmd/gpu-turnstile/main.go b/cmd/gpu-turnstile/main.go index 634671f..3a72fb2 100644 --- a/cmd/gpu-turnstile/main.go +++ b/cmd/gpu-turnstile/main.go @@ -35,21 +35,28 @@ var version = "dev" const exitCodeUpdate = 3 func main() { - configPath, install, remove, args := parseFlags(os.Args[1:]) + configPath, install, remove, noCopy, args := parseFlags(os.Args[1:]) + if len(args) > 0 && args[0] == "service" { + // Legacy subcommand form: gpu-turnstile service install|remove. + if len(args) != 2 || (args[1] != "install" && args[1] != "remove") { + fmt.Fprintf(os.Stderr, "usage: gpu-turnstile service install|remove [-config path]\n") + os.Exit(2) + } + install = args[1] == "install" + remove = !install + args = nil + } switch { case install && remove: fmt.Fprintf(os.Stderr, "gpu-turnstile: --install-service and --remove-service are mutually exclusive\n") os.Exit(2) case install: - os.Exit(serviceCommand(configPath, []string{"install"})) + os.Exit(serviceCommand(configPath, true, noCopy)) case remove: - os.Exit(serviceCommand(configPath, []string{"remove"})) - } - if len(args) > 0 && args[0] == "service" { - os.Exit(serviceCommand(configPath, args[1:])) + os.Exit(serviceCommand(configPath, false, noCopy)) } if len(args) > 0 { - fmt.Fprintf(os.Stderr, "usage: gpu-turnstile [-config path] [--install-service | --remove-service]\n") + fmt.Fprintf(os.Stderr, "usage: gpu-turnstile [-config path] [--install-service [--no-copy] | --remove-service]\n") fmt.Fprintf(os.Stderr, " gpu-turnstile service install|remove [-config path]\n") os.Exit(2) } @@ -81,9 +88,9 @@ func main() { } } -// parseFlags extracts -config (or -config=) and the -// --install-service / --remove-service switches from args. -func parseFlags(args []string) (configPath string, install, remove bool, rest []string) { +// parseFlags extracts -config (or -config=), the +// --install-service / --remove-service switches and --no-copy from args. +func parseFlags(args []string) (configPath string, install, remove, noCopy bool, rest []string) { rest = args[:0] for i := 0; i < len(args); i++ { switch { @@ -96,11 +103,13 @@ func parseFlags(args []string) (configPath string, install, remove bool, rest [] install = true case args[i] == "--remove-service" || args[i] == "-remove-service": remove = true + case args[i] == "--no-copy" || args[i] == "-no-copy": + noCopy = true default: rest = append(rest, args[i]) } } - return configPath, install, remove, rest + return configPath, install, remove, noCopy, rest } // defaultConfigPath returns gpu-turnstile.env next to the executable. @@ -172,26 +181,26 @@ func newLogger(cfg config.Config) (*slog.Logger, io.Writer, io.Closer) { return log, out, closer } -func serviceCommand(configPath string, args []string) int { - if len(args) != 1 || (args[0] != "install" && args[0] != "remove") { - fmt.Fprintf(os.Stderr, "usage: gpu-turnstile service install|remove [-config path]\n") - return 2 - } +// serviceCommand installs (copyBin = register the canonical-layout copy) +// or removes the service and reports the result. +func serviceCommand(configPath string, install, noCopy bool) int { + verb := "remove" var err error - if args[0] == "install" { + if install { + verb = "install" path := resolveConfigPath(configPath) if abs, absErr := filepath.Abs(path); absErr == nil { path = abs } - err = service.Install(path) + err = service.Install(path, !noCopy) } else { err = service.Remove() } if err != nil { - fmt.Fprintf(os.Stderr, "gpu-turnstile service %s: %v\n", args[0], err) + fmt.Fprintf(os.Stderr, "gpu-turnstile service %s: %v\n", verb, err) return 1 } - fmt.Printf("service %s: %sd\n", service.Name, args[0]) + fmt.Printf("service %s: %sd\n", service.Name, verb) return 0 } diff --git a/internal/service/service_linux.go b/internal/service/service_linux.go index 11b4a76..943e2dc 100644 --- a/internal/service/service_linux.go +++ b/internal/service/service_linux.go @@ -113,9 +113,16 @@ func copyFile(src, dst string, mode os.FileMode) error { // Install copies the current executable into /var/lib/gpu-turnstile, makes // sure /etc/gpu-turnstile.env exists (copied from the given config file if -// provided), writes the hardened unit, then enables and starts it. Needs -// root. -func Install(configPath string) error { +// provided), writes the hardened unit, then enables and starts it. With +// copyBin=false the current executable location and config path are +// registered as-is instead. Needs root. +// +// The binary lives in the StateDirectory rather than /usr/local/sbin on +// purpose: replacing a running binary needs write access to its +// *directory*, and granting the sandboxed service user write access to a +// shared system directory would let a compromised service overwrite other +// binaries. /var/lib/gpu-turnstile is exclusively ours. +func Install(configPath string, copyBin bool) error { exe, err := os.Executable() if err != nil { return err @@ -123,21 +130,29 @@ func Install(configPath string) error { if abs, absErr := filepath.Abs(exe); absErr == nil { exe = abs } - if err := os.MkdirAll(stateDir, 0o755); err != nil { - return fmt.Errorf("create %s (run as root): %w", stateDir, err) - } - installedExe := filepath.Join(stateDir, Name) - if exe != installedExe { - if err := copyFile(exe, installedExe, 0o755); err != nil { - return fmt.Errorf("install binary to %s: %w", installedExe, err) + cfg := etcConfig + if copyBin { + if err := os.MkdirAll(stateDir, 0o755); err != nil { + return fmt.Errorf("create %s (run as root): %w", stateDir, err) + } + installedExe := filepath.Join(stateDir, Name) + if exe != installedExe { + if err := copyFile(exe, installedExe, 0o755); err != nil { + return fmt.Errorf("install binary to %s: %w", installedExe, err) + } + } + exe = installedExe + if _, err := os.Stat(etcConfig); os.IsNotExist(err) && configPath != "" { + // Missing config is not fatal: the service fails fast with a + // clear "no consumer URL" error until the user writes one. + copyFile(configPath, etcConfig, 0o644) //nolint:errcheck // best effort + } + } else if configPath != "" { + if abs, absErr := filepath.Abs(configPath); absErr == nil { + cfg = abs } } - if _, err := os.Stat(etcConfig); os.IsNotExist(err) && configPath != "" { - // Missing config is not fatal: the service fails fast with a clear - // "no consumer URL" error until the user writes one. - copyFile(configPath, etcConfig, 0o644) //nolint:errcheck // best effort - } - if err := os.WriteFile(unitPath, []byte(renderUnit(installedExe, etcConfig)), 0o644); err != nil { + if err := os.WriteFile(unitPath, []byte(renderUnit(exe, cfg)), 0o644); err != nil { return fmt.Errorf("write %s (run as root): %w", unitPath, err) } if out, err := exec.Command("systemctl", "daemon-reload").CombinedOutput(); err != nil { diff --git a/internal/service/service_other.go b/internal/service/service_other.go index e2dccb1..05e4bdf 100644 --- a/internal/service/service_other.go +++ b/internal/service/service_other.go @@ -28,8 +28,8 @@ func Run(run func(ctx context.Context) error) error { return run(ctx) } -// Install is unsupported on non-Windows platforms. -func Install(string) error { return errUnsupported } +// Install is unsupported on non-Windows, non-Linux platforms. +func Install(string, bool) error { return errUnsupported } // Remove is unsupported on non-Windows platforms. func Remove() error { return errUnsupported } diff --git a/internal/service/service_windows.go b/internal/service/service_windows.go index bfe8fce..8046707 100644 --- a/internal/service/service_windows.go +++ b/internal/service/service_windows.go @@ -10,6 +10,7 @@ package service import ( "context" "fmt" + "io" "os" "os/exec" "path/filepath" @@ -30,6 +31,20 @@ const Name = "gpu-turnstile" // when the service is removed. const virtualAccount = `NT SERVICE\` + Name +// installDirs returns the canonical install (Program Files) and data +// (ProgramData) directories. +func installDirs() (install, data string) { + pf := os.Getenv("ProgramFiles") + if pf == "" { + pf = `C:\Program Files` + } + pd := os.Getenv("ProgramData") + if pd == "" { + pd = `C:\ProgramData` + } + return filepath.Join(pf, Name), filepath.Join(pd, Name) +} + // IsService reports whether the process is running as a Windows service. func IsService() bool { isSvc, err := svc.IsWindowsService() @@ -78,24 +93,51 @@ func (h *handler) Execute(_ []string, requests <-chan svc.ChangeRequest, status // Install registers gpu-turnstile as an auto-start Windows service running // as the NT SERVICE\gpu-turnstile virtual account, whose binPath loads the -// given config file. Recovery actions restart the service after 5s on -// failure — this is also what brings up a staged update after the updater -// exits with a non-zero code. After registering, the virtual account is -// granted modify access to the install directory (self-updates rewrite the -// exe) and to the LOG_FILE directory, and read access to the config file -// if it lives elsewhere. The grants must come after CreateService: the -// virtual account's SID only exists once the service is registered. -func Install(configPath string) error { +// given config file. With copyBin it first creates the canonical layout — +// the binary is copied into %ProgramFiles%\gpu-turnstile and the config +// next to it (an existing config there is kept), %ProgramData%\gpu-turnstile +// is created for logs — and registers that copy; with copyBin=false the +// current executable location is registered as-is. Recovery actions restart +// the service after 5s on failure — this is also what brings up a staged +// update after the updater exits with a non-zero code. After registering, +// the virtual account is granted modify access to the install and data +// directories (self-updates rewrite the exe), and read access to the +// config file if it lives elsewhere. The grants must come after +// CreateService: the virtual account's SID only exists once the service is +// registered. +func Install(configPath string, copyBin bool) error { exe, err := os.Executable() if err != nil { return err } + if abs, absErr := filepath.Abs(exe); absErr == nil { + exe = abs + } if configPath != "" { if abs, absErr := filepath.Abs(configPath); absErr == nil { configPath = abs } } + installDir, _ := installDirs() + if copyBin && !strings.EqualFold(filepath.Dir(exe), installDir) { + if err := os.MkdirAll(installDir, 0o755); err != nil { + return fmt.Errorf("create %s: %w", installDir, err) + } + installedExe := filepath.Join(installDir, "gpu-turnstile.exe") + if err := copyFile(exe, installedExe); err != nil { + return fmt.Errorf("copy binary to %s: %w", installedExe, err) + } + exe = installedExe + targetCfg := filepath.Join(installDir, "gpu-turnstile.env") + if configPath != "" && !strings.EqualFold(configPath, targetCfg) { + if _, statErr := os.Stat(targetCfg); os.IsNotExist(statErr) { + copyFile(configPath, targetCfg) //nolint:errcheck // best effort + } + configPath = targetCfg + } + } + m, err := mgr.Connect() if err != nil { return fmt.Errorf("connect to service manager (run as administrator): %w", err) @@ -129,8 +171,36 @@ func Install(configPath string) error { return nil } -// grantAll gives the virtual account every ACL the service needs. +// copyFile copies src to dst (0755 on the new file). +func copyFile(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + out, err := os.OpenFile(dst, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o755) + if err != nil { + return err + } + if _, err := io.Copy(out, in); err != nil { + out.Close() + return err + } + return out.Close() +} + +// grantAll gives the virtual account every ACL the service needs: modify +// on the install and ProgramData directories and the LOG_FILE directory +// (if configured elsewhere), read on a config file outside the install +// directory. func grantAll(exe, configPath string) error { + _, dataDir := installDirs() + if err := os.MkdirAll(dataDir, 0o755); err != nil { + return fmt.Errorf("create %s: %w", dataDir, err) + } + if err := grantAccess(dataDir, "(OI)(CI)(M)"); err != nil { + return err + } exeDir := filepath.Dir(exe) if err := grantAccess(exeDir, "(OI)(CI)(M)"); err != nil { return err