--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.
199 lines
5.9 KiB
Go
199 lines
5.9 KiB
Go
//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"]
|
|
}
|