diff --git a/README.md b/README.md index 09c5008..1715c6e 100644 --- a/README.md +++ b/README.md @@ -110,10 +110,10 @@ 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 runs as `LocalSystem` by default, which can write -the install directory for self-updates. For least privilege, run it as the -virtual account `NT SERVICE\gpu-turnstile` and grant write access to just -those two directories. +`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. ### Run natively on Linux (systemd) diff --git a/SPEC.md b/SPEC.md index e5208d6..3eb5bf3 100644 --- a/SPEC.md +++ b/SPEC.md @@ -180,13 +180,16 @@ install|remove` does the same thing. 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`. The service must be able to write its install directory for - self-updates — Program Files is writable by LocalSystem and admins, which - is why running as the default `LocalSystem` account is the simple choice. -- **Account**: the default `LocalSystem` works out of the box. For least - privilege, create the service with the virtual account - `NT SERVICE\gpu-turnstile` and grant it write access to the install and - log directories only (no network logon, no user profile). + `LOG_FILE`. +- **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. diff --git a/internal/service/service_windows.go b/internal/service/service_windows.go index 6760762..bfe8fce 100644 --- a/internal/service/service_windows.go +++ b/internal/service/service_windows.go @@ -2,22 +2,34 @@ // Package service integrates gpu-turnstile with the Windows Service // Control Manager: running as a service with graceful stop, plus -// install/remove helpers. +// install/remove helpers. Installed services always run as the virtual +// account NT SERVICE\gpu-turnstile — a per-service low-privilege identity +// managed by the SCM, with no password and no admin rights. package service import ( "context" "fmt" "os" + "os/exec" + "path/filepath" + "strings" "time" "golang.org/x/sys/windows/svc" "golang.org/x/sys/windows/svc/mgr" + + "gpu-turnstile/internal/config" ) // Name is the Windows service name. const Name = "gpu-turnstile" +// virtualAccount is the per-service identity the service runs as. The SCM +// manages it: no password, automatic "log on as a service" right, gone +// when the service is removed. +const virtualAccount = `NT SERVICE\` + Name + // IsService reports whether the process is running as a Windows service. func IsService() bool { isSvc, err := svc.IsWindowsService() @@ -64,15 +76,26 @@ func (h *handler) Execute(_ []string, requests <-chan svc.ChangeRequest, status } } -// Install registers gpu-turnstile as an auto-start Windows service 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. +// 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 { exe, err := os.Executable() if err != nil { return err } + if configPath != "" { + if abs, absErr := filepath.Abs(configPath); absErr == nil { + configPath = abs + } + } + m, err := mgr.Connect() if err != nil { return fmt.Errorf("connect to service manager (run as administrator): %w", err) @@ -81,9 +104,10 @@ func Install(configPath string) error { binPath := fmt.Sprintf(`"%s" -config "%s"`, exe, configPath) s, err := m.CreateService(Name, binPath, mgr.Config{ - StartType: mgr.StartAutomatic, - DisplayName: "gpu-turnstile", - Description: "GPU arbitration proxy for Ollama and ComfyUI", + StartType: mgr.StartAutomatic, + DisplayName: "gpu-turnstile", + Description: "GPU arbitration proxy for Ollama and ComfyUI", + ServiceStartName: virtualAccount, }) if err != nil { return fmt.Errorf("create service: %w", err) @@ -97,10 +121,39 @@ func Install(configPath string) error { if err := s.SetRecoveryActionsOnNonCrashFailures(true); err != nil { return fmt.Errorf("set failure actions flag: %w", err) } + + if err := grantAll(exe, configPath); err != nil { + s.Delete() // roll back so a retry starts clean + return err + } return nil } -// Remove stops (if running) and unregisters the service. +// grantAll gives the virtual account every ACL the service needs. +func grantAll(exe, configPath string) error { + exeDir := filepath.Dir(exe) + if err := grantAccess(exeDir, "(OI)(CI)(M)"); err != nil { + return err + } + if configPath != "" && !strings.HasPrefix(strings.ToLower(configPath), strings.ToLower(exeDir)+`\`) { + if err := grantAccess(configPath, "(R)"); err != nil { + return err + } + } + if logFile := configuredLogFile(configPath); logFile != "" { + dir := filepath.Dir(logFile) + if err := os.MkdirAll(dir, 0o755); err == nil { + if err := grantAccess(dir, "(OI)(CI)(M)"); err != nil { + return err + } + } + } + return nil +} + +// Remove stops (if running) and unregisters the service. The virtual +// account ceases to exist with it; the ACL grants on the install and log +// directories are left in place (harmless without the account). func Remove() error { m, err := mgr.Connect() if err != nil { @@ -118,3 +171,28 @@ func Remove() error { } return nil } + +// grantAccess gives the virtual account the icacls permission set (e.g. +// "(OI)(CI)(M)") on path. +func grantAccess(path, perms string) error { + out, err := exec.Command("icacls", path, "/grant", virtualAccount+":"+perms).CombinedOutput() + if err != nil { + return fmt.Errorf("grant %s access to %s: %w (%s)", virtualAccount, path, err, strings.TrimSpace(string(out))) + } + return nil +} + +// configuredLogFile reads LOG_FILE from the config file so the installer +// can pre-create and ACL the log directory. "" when unset or unreadable. +func configuredLogFile(configPath string) string { + f, err := os.Open(configPath) + if err != nil { + return "" + } + defer f.Close() + values, err := config.ParseEnvFile(f) + if err != nil { + return "" + } + return values["LOG_FILE"] +}