510 lines
15 KiB
Go
510 lines
15 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 (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
"unsafe"
|
|
|
|
"golang.org/x/sys/windows"
|
|
"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
|
|
|
|
// 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()
|
|
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. 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.
|
|
//
|
|
// Re-running install on an existing service converges instead of failing:
|
|
// the service is stopped first if running (so the binary can be replaced),
|
|
// the installed binary is refreshed only when the content differs, the
|
|
// registration is updated only where it drifted, and the service is
|
|
// started again only if it was running before.
|
|
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
|
|
}
|
|
}
|
|
|
|
m, err := mgr.Connect()
|
|
if err != nil {
|
|
return fmt.Errorf("connect to service manager (run as administrator): %w", err)
|
|
}
|
|
defer m.Disconnect()
|
|
|
|
// An existing service is converged, not an error. Stop it first so the
|
|
// binary copy can be replaced, and remember whether to start it again.
|
|
var s *mgr.Service
|
|
wasRunning := false
|
|
if existing, openErr := m.OpenService(Name); openErr == nil {
|
|
s = existing
|
|
defer s.Close()
|
|
if st, qErr := s.Query(); qErr == nil &&
|
|
(st.State == svc.Running || st.State == svc.StartPending) {
|
|
wasRunning = true
|
|
if err := stopAndWait(s); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
|
|
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 same, _ := sameFileContent(exe, installedExe); !same {
|
|
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
|
|
}
|
|
}
|
|
binPath := fmt.Sprintf(`"%s" -config "%s"`, exe, configPath)
|
|
|
|
if s == nil {
|
|
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()
|
|
|
|
if err := ensureRecovery(s); err != nil {
|
|
s.Delete() // roll back so a retry starts clean
|
|
return err
|
|
}
|
|
if err := grantAll(exe, configPath); err != nil {
|
|
s.Delete() // roll back so a retry starts clean
|
|
return err
|
|
}
|
|
// Best effort: start now instead of waiting for the next boot. A
|
|
// missing config (no consumer URLs) fails the start; the service stays
|
|
// registered and can be started once the config exists.
|
|
s.Start()
|
|
return nil
|
|
}
|
|
|
|
// Existing service: update the registration only where it drifted.
|
|
cur, err := s.Config()
|
|
if err != nil {
|
|
return fmt.Errorf("query service config: %w", err)
|
|
}
|
|
const displayName = "gpu-turnstile"
|
|
const description = "GPU arbitration proxy for Ollama and ComfyUI"
|
|
if cur.BinaryPathName != binPath ||
|
|
cur.StartType != mgr.StartAutomatic ||
|
|
cur.ServiceStartName != virtualAccount ||
|
|
cur.DisplayName != displayName ||
|
|
cur.Description != description {
|
|
upd := cur
|
|
upd.BinaryPathName = binPath
|
|
upd.StartType = mgr.StartAutomatic
|
|
upd.ServiceStartName = virtualAccount
|
|
upd.DisplayName = displayName
|
|
upd.Description = description
|
|
if err := s.UpdateConfig(upd); err != nil {
|
|
return fmt.Errorf("update service config: %w", err)
|
|
}
|
|
}
|
|
if err := ensureRecovery(s); err != nil {
|
|
return err
|
|
}
|
|
// Idempotent: re-assert the virtual account's ACLs (granting an
|
|
// existing ACE is a no-op).
|
|
if err := grantAll(exe, configPath); err != nil {
|
|
return err
|
|
}
|
|
if wasRunning {
|
|
if err := s.Start(); err != nil {
|
|
return fmt.Errorf("start service: %w", err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ensureRecovery makes sure the service restarts 5s after a failure — the
|
|
// mechanism that also brings up a staged update. The current actions are
|
|
// queried first so an up-to-date service is left untouched.
|
|
func ensureRecovery(s *mgr.Service) error {
|
|
want := mgr.RecoveryAction{Type: mgr.ServiceRestart, Delay: 5 * time.Second}
|
|
actions, err := s.RecoveryActions()
|
|
onFailure, flagErr := s.RecoveryActionsOnNonCrashFailures()
|
|
if err == nil && flagErr == nil && onFailure && len(actions) == 3 {
|
|
ok := true
|
|
for _, a := range actions {
|
|
if a.Type != want.Type || a.Delay != want.Delay {
|
|
ok = false
|
|
}
|
|
}
|
|
if ok {
|
|
return nil
|
|
}
|
|
}
|
|
if err := s.SetRecoveryActions([]mgr.RecoveryAction{want, want, want}, 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)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// stopAndWait stops the service and waits up to 30s for the stopped state.
|
|
func stopAndWait(s *mgr.Service) error {
|
|
if _, err := s.Control(svc.Stop); err != nil {
|
|
return fmt.Errorf("stop service: %w", err)
|
|
}
|
|
deadline := time.Now().Add(30 * time.Second)
|
|
for {
|
|
st, err := s.Query()
|
|
if err != nil {
|
|
return fmt.Errorf("query service: %w", err)
|
|
}
|
|
if st.State == svc.Stopped {
|
|
return nil
|
|
}
|
|
if time.Now().After(deadline) {
|
|
return fmt.Errorf("service did not stop within 30s")
|
|
}
|
|
time.Sleep(300 * time.Millisecond)
|
|
}
|
|
}
|
|
|
|
// sameFileContent reports whether two files hold identical bytes. A missing
|
|
// destination is simply "different".
|
|
func sameFileContent(a, b string) (bool, error) {
|
|
ba, err := os.ReadFile(a)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
bb, err := os.ReadFile(b)
|
|
if os.IsNotExist(err) {
|
|
return false, nil
|
|
}
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return bytes.Equal(ba, bb), nil
|
|
}
|
|
|
|
// 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
|
|
}
|
|
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
|
|
}
|
|
|
|
var procShellExecuteExW = windows.NewLazySystemDLL("shell32.dll").NewProc("ShellExecuteExW")
|
|
|
|
const seeMaskNoCloseProcess = 0x40
|
|
|
|
// shellExecuteInfo mirrors SHELLEXECUTEINFOW (64-bit layout).
|
|
type shellExecuteInfo struct {
|
|
cbSize uint32
|
|
fMask uint32
|
|
hwnd uintptr
|
|
lpVerb *uint16
|
|
lpFile *uint16
|
|
lpParameters *uint16
|
|
lpDirectory *uint16
|
|
nShow int32
|
|
_ int32
|
|
hInstApp uintptr
|
|
lpIDList unsafe.Pointer
|
|
lpClass *uint16
|
|
hkeyClass uintptr
|
|
dwHotKey uint32
|
|
_ uint32
|
|
hIcon uintptr
|
|
hProcess windows.Handle
|
|
}
|
|
|
|
// Elevated reports whether the current process token is UAC-elevated.
|
|
func Elevated() bool {
|
|
var token windows.Token
|
|
if err := windows.OpenProcessToken(windows.CurrentProcess(), windows.TOKEN_QUERY, &token); err != nil {
|
|
return false
|
|
}
|
|
defer token.Close()
|
|
return token.IsElevated()
|
|
}
|
|
|
|
// RelaunchElevated re-runs the current executable elevated via the UAC
|
|
// "runas" verb with the given arguments, waits for the child, and returns
|
|
// its exit code. The child gets a fresh console window for its output.
|
|
func RelaunchElevated(args []string) (int, error) {
|
|
exe, err := os.Executable()
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
quoted := make([]string, len(args))
|
|
for i, a := range args {
|
|
quoted[i] = syscall.EscapeArg(a)
|
|
}
|
|
cwd, _ := os.Getwd()
|
|
verb, _ := windows.UTF16PtrFromString("runas")
|
|
exeP, _ := windows.UTF16PtrFromString(exe)
|
|
params, _ := windows.UTF16PtrFromString(strings.Join(quoted, " "))
|
|
dir, _ := windows.UTF16PtrFromString(cwd)
|
|
info := shellExecuteInfo{
|
|
fMask: seeMaskNoCloseProcess,
|
|
lpVerb: verb,
|
|
lpFile: exeP,
|
|
lpParameters: params,
|
|
lpDirectory: dir,
|
|
nShow: windows.SW_NORMAL,
|
|
}
|
|
info.cbSize = uint32(unsafe.Sizeof(info))
|
|
r, _, callErr := procShellExecuteExW.Call(uintptr(unsafe.Pointer(&info)))
|
|
if r == 0 {
|
|
if errors.Is(callErr, syscall.Errno(1223)) { // ERROR_CANCELLED
|
|
return 0, ErrUserCancelled
|
|
}
|
|
return 0, fmt.Errorf("ShellExecuteEx: %w", callErr)
|
|
}
|
|
defer windows.CloseHandle(windows.Handle(info.hProcess))
|
|
windows.WaitForSingleObject(windows.Handle(info.hProcess), windows.INFINITE)
|
|
var code uint32
|
|
if err := windows.GetExitCodeProcess(windows.Handle(info.hProcess), &code); err != nil {
|
|
return 0, err
|
|
}
|
|
return int(code), 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"]
|
|
}
|
|
|
|
// RestartIfRunning restarts the service when it is installed and running
|
|
// (used after a forced update staged a new binary). Reports whether a
|
|
// restart happened. A service that is not installed or not running is not
|
|
// an error. Needs elevation.
|
|
func RestartIfRunning() (bool, error) {
|
|
m, err := mgr.Connect()
|
|
if err != nil {
|
|
return false, fmt.Errorf("connect to service manager (run as administrator): %w", err)
|
|
}
|
|
defer m.Disconnect()
|
|
s, err := m.OpenService(Name)
|
|
if err != nil {
|
|
return false, nil // not installed
|
|
}
|
|
defer s.Close()
|
|
st, err := s.Query()
|
|
if err != nil {
|
|
return false, fmt.Errorf("query service: %w", err)
|
|
}
|
|
if st.State != svc.Running && st.State != svc.StartPending {
|
|
return false, nil
|
|
}
|
|
if err := stopAndWait(s); err != nil {
|
|
return false, err
|
|
}
|
|
if err := s.Start(); err != nil {
|
|
return false, fmt.Errorf("start service: %w", err)
|
|
}
|
|
return true, nil
|
|
}
|