//go:build windows // Package service integrates gpu-turnstile with the Windows Service // Control Manager: running as a service with graceful stop, plus // 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() return err == nil && isSvc } // Run executes run as a Windows service. SCM Stop and Shutdown cancel the // context passed to run, triggering the same graceful shutdown as SIGTERM // in interactive mode. func Run(run func(ctx context.Context) error) error { return svc.Run(Name, &handler{run: run}) } type handler struct { run func(ctx context.Context) error } func (h *handler) Execute(_ []string, requests <-chan svc.ChangeRequest, status chan<- svc.Status) (bool, uint32) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() status <- svc.Status{State: svc.StartPending} errCh := make(chan error, 1) go func() { errCh <- h.run(ctx) }() status <- svc.Status{State: svc.Running, Accepts: svc.AcceptStop | svc.AcceptShutdown} for { select { case err := <-errCh: status <- svc.Status{State: svc.Stopped} if err != nil { return true, 1 } return false, 0 case c := <-requests: switch c.Cmd { case svc.Interrogate: status <- c.CurrentStatus case svc.Stop, svc.Shutdown: status <- svc.Status{State: svc.StopPending} cancel() } } } } // 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) } defer m.Disconnect() 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", ServiceStartName: virtualAccount, }) if err != nil { return fmt.Errorf("create service: %w", err) } defer s.Close() restart := mgr.RecoveryAction{Type: mgr.ServiceRestart, Delay: 5 * time.Second} if err := s.SetRecoveryActions([]mgr.RecoveryAction{restart, restart, restart}, 24*60*60); err != nil { return fmt.Errorf("set recovery actions: %w", err) } 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 } // 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 { return fmt.Errorf("connect to service manager (run as administrator): %w", err) } defer m.Disconnect() s, err := m.OpenService(Name) if err != nil { return fmt.Errorf("open service: %w", err) } defer s.Close() s.Control(svc.Stop) // ignore error: may already be stopped if err := s.Delete(); err != nil { return fmt.Errorf("delete service: %w", err) } 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"] }