Install the Windows service as the NT SERVICE virtual account only

--install-service now registers the service under
NT SERVICE\gpu-turnstile (low-privilege, per-service, no password) and
grants it modify access to the install dir (for self-updates) and the
LOG_FILE dir, plus read access to an external config file. Grants run
after CreateService because the virtual account's SID does not exist
before registration; a failed grant rolls back the registration.
This commit is contained in:
mram
2026-09-20 23:53:54 +02:00
parent 75f16a0229
commit 97624470eb
3 changed files with 101 additions and 20 deletions
+4 -4
View File
@@ -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 Suggested layout: `C:\Program Files\gpu-turnstile\` for the exe and
`gpu-turnstile.env`, logs under `C:\ProgramData\gpu-turnstile\` via `gpu-turnstile.env`, logs under `C:\ProgramData\gpu-turnstile\` via
`LOG_FILE`. The service runs as `LocalSystem` by default, which can write `LOG_FILE`. The service always runs as the virtual account
the install directory for self-updates. For least privilege, run it as the `NT SERVICE\gpu-turnstile` (low-privilege, per-service, no password); the
virtual account `NT SERVICE\gpu-turnstile` and grant write access to just installer automatically grants it write access to the install and log
those two directories. directories — nothing else to do.
### Run natively on Linux (systemd) ### Run natively on Linux (systemd)
+10 -7
View File
@@ -180,13 +180,16 @@ install|remove` does the same thing.
it 5 s after any failure. it 5 s after any failure.
- **Layout**: install to `C:\Program Files\gpu-turnstile\` (exe plus - **Layout**: install to `C:\Program Files\gpu-turnstile\` (exe plus
`gpu-turnstile.env`); logs belong in `C:\ProgramData\gpu-turnstile\` via `gpu-turnstile.env`); logs belong in `C:\ProgramData\gpu-turnstile\` via
`LOG_FILE`. The service must be able to write its install directory for `LOG_FILE`.
self-updates — Program Files is writable by LocalSystem and admins, which - **Account**: the service always runs as the virtual account
is why running as the default `LocalSystem` account is the simple choice. `NT SERVICE\gpu-turnstile` — a per-service low-privilege identity the
- **Account**: the default `LocalSystem` works out of the box. For least SCM manages (no password, automatic logon-as-a-service right, no admin
privilege, create the service with the virtual account rights, gone when the service is removed). The installer grants it
`NT SERVICE\gpu-turnstile` and grant it write access to the install and modify access to the install directory (self-updates rewrite the exe)
log directories only (no network logon, no user profile). 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 - Use a config file (above) for the service — Windows services have no
convenient environment. Logs go to `LOG_FILE` since there is no console. convenient environment. Logs go to `LOG_FILE` since there is no console.
+84 -6
View File
@@ -2,22 +2,34 @@
// Package service integrates gpu-turnstile with the Windows Service // Package service integrates gpu-turnstile with the Windows Service
// Control Manager: running as a service with graceful stop, plus // 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 package service
import ( import (
"context" "context"
"fmt" "fmt"
"os" "os"
"os/exec"
"path/filepath"
"strings"
"time" "time"
"golang.org/x/sys/windows/svc" "golang.org/x/sys/windows/svc"
"golang.org/x/sys/windows/svc/mgr" "golang.org/x/sys/windows/svc/mgr"
"gpu-turnstile/internal/config"
) )
// Name is the Windows service name. // Name is the Windows service name.
const Name = "gpu-turnstile" 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. // IsService reports whether the process is running as a Windows service.
func IsService() bool { func IsService() bool {
isSvc, err := svc.IsWindowsService() 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 // Install registers gpu-turnstile as an auto-start Windows service running
// binPath loads the given config file. Recovery actions restart the // as the NT SERVICE\gpu-turnstile virtual account, whose binPath loads the
// service after 5s on failure — this is also what brings up a staged // given config file. Recovery actions restart the service after 5s on
// update after the updater exits with a non-zero code. // 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 { func Install(configPath string) error {
exe, err := os.Executable() exe, err := os.Executable()
if err != nil { if err != nil {
return err return err
} }
if configPath != "" {
if abs, absErr := filepath.Abs(configPath); absErr == nil {
configPath = abs
}
}
m, err := mgr.Connect() m, err := mgr.Connect()
if err != nil { if err != nil {
return fmt.Errorf("connect to service manager (run as administrator): %w", err) return fmt.Errorf("connect to service manager (run as administrator): %w", err)
@@ -84,6 +107,7 @@ func Install(configPath string) error {
StartType: mgr.StartAutomatic, StartType: mgr.StartAutomatic,
DisplayName: "gpu-turnstile", DisplayName: "gpu-turnstile",
Description: "GPU arbitration proxy for Ollama and ComfyUI", Description: "GPU arbitration proxy for Ollama and ComfyUI",
ServiceStartName: virtualAccount,
}) })
if err != nil { if err != nil {
return fmt.Errorf("create service: %w", err) return fmt.Errorf("create service: %w", err)
@@ -97,10 +121,39 @@ func Install(configPath string) error {
if err := s.SetRecoveryActionsOnNonCrashFailures(true); err != nil { if err := s.SetRecoveryActionsOnNonCrashFailures(true); err != nil {
return fmt.Errorf("set failure actions flag: %w", err) 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 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 { func Remove() error {
m, err := mgr.Connect() m, err := mgr.Connect()
if err != nil { if err != nil {
@@ -118,3 +171,28 @@ func Remove() error {
} }
return nil 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"]
}