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:
@@ -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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user