10 Commits
Author SHA1 Message Date
mram da02457fd5 Pin compose example to v0.1.6
ci / test (push) Successful in 14s
ci / docker (push) Successful in 1m7s
ci / release (push) Successful in 14s
2026-09-21 08:22:42 +02:00
mram 6a6359a630 Add --force-update: immediate signed-update check, stage + service restart 2026-09-21 08:22:11 +02:00
mram e4e4348e8d Start the Windows service right after install (parity with enable --now) 2026-09-21 08:12:13 +02:00
mram b421bb7bfb Add --version and print the version atop every help and error screen 2026-09-21 08:10:46 +02:00
mram 909918657f Fix install/remove success message (installd -> installed) 2026-09-21 08:08:24 +02:00
mram a0435c858e Bare run in a terminal prints the help screen; add -h/--help
A zero-argument invocation now shows the usage text when stdout is a
console (double-clicked exe, interactive shell). Without a terminal —
Docker entrypoint, services, pipes — a bare invocation still starts the
proxy, so the container image and service behavior are unchanged.
2026-09-21 08:06:35 +02:00
mram fbab0bba33 Relaunch through UAC when (un)installing the service unprivileged
--install-service/--remove-service on Windows no longer fail with
'Access is denied' from a normal shell: the process re-runs itself via
ShellExecuteEx 'runas', waits for the elevated child and mirrors its
exit code. The child gets --elevated-child and pauses for a keypress so
its console output stays readable. Declining the prompt reports
'UAC prompt declined'.
2026-09-21 08:00:24 +02:00
mram 802a64280f Self-install into canonical layout on both platforms, --no-copy to opt out
Windows: --install-service creates %ProgramFiles%\gpu-turnstile and
%ProgramData%\gpu-turnstile, copies the exe and (if absent) the env
file in, and registers the copy. Linux: binary goes to
/var/lib/gpu-turnstile (not /usr/local/sbin: replacing a running binary
needs directory write, which must not be granted on a shared system dir
to a sandboxed service). --no-copy registers the current location
as-is on both platforms.
2026-09-21 07:52:33 +02:00
mram a88955e35c Sandbox the systemd unit: DynamicUser, read-only FS, no capabilities
The Linux install now mirrors the Windows virtual-account hardening: the
unit runs with DynamicUser=yes (transient per-service UID, no login),
ProtectSystem=strict with only StateDirectory writable (the install dir,
so self-update can rewrite the binary), NoNewPrivileges, empty
capability sets, restricted address families and a @system-service
syscall filter. Install copies the binary to /var/lib/gpu-turnstile and
the config to /etc/gpu-turnstile.env; Remove cleans up the unit and
binary but keeps the config.
2026-09-21 00:11:25 +02:00
mram 97624470eb 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.
2026-09-20 23:53:54 +02:00
9 changed files with 765 additions and 112 deletions
+28 -18
View File
@@ -91,45 +91,53 @@ override file values. Invalid values fail at startup.
```sh
go build ./cmd/gpu-turnstile
./gpu-turnstile
OLLAMA_URL=http://127.0.0.1:11435 COMFY_URL=http://127.0.0.1:8189 ./gpu-turnstile
```
Running the binary with no arguments in a terminal prints the help screen
(same as `-h`/`--help`); without a terminal (services, containers) a bare
invocation starts the proxy.
### Run natively on Windows (current primary deployment)
Download `gpu-turnstile.exe` from a release, put a `gpu-turnstile.env`
next to it, and run it — or install it as a Windows service from an
elevated shell:
Download `gpu-turnstile.exe` from a release and install it as a Windows
service — no admin shell needed, a UAC prompt appears automatically and
the elevated child does the work (its window waits for Enter so you can
read the result):
```sh
gpu-turnstile.exe --install-service # auto-start service, recovery = restart
gpu-turnstile.exe --install-service # installs into Program Files, auto-start
gpu-turnstile.exe --install-service --no-copy # register in place instead
gpu-turnstile.exe --remove-service
```
The service uses the config file (services have no convenient
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.
Layout: `C:\Program Files\gpu-turnstile\` holds the exe and
`gpu-turnstile.env`, logs go to `C:\ProgramData\gpu-turnstile\` (set
`LOG_FILE` in the env file — there is no console). 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 data directories — nothing else to do.
### Run natively on Linux (systemd)
The same binary works on Linux. Install it as a systemd service as root:
```sh
gpu-turnstile --install-service # writes + enables + starts the unit
gpu-turnstile --install-service # installs into /var/lib/gpu-turnstile, enables + starts
gpu-turnstile --install-service --no-copy # register in place instead
gpu-turnstile --remove-service
```
The unit (`/etc/systemd/system/gpu-turnstile.service`) is `Type=notify`:
`systemctl start` blocks until the listeners are actually bound, a 30 s
watchdog restarts the process if it wedges, and logs land in the journal
(`journalctl -u gpu-turnstile -f`) unless `LOG_FILE` is set. Put the
config in a `gpu-turnstile.env` next to the binary (or pass
`-config /path` during install). The notify integration is a no-op in
(`journalctl -u gpu-turnstile -f`) unless `LOG_FILE` is set. Install
copies the binary to `/var/lib/gpu-turnstile/` and the config to
`/etc/gpu-turnstile.env` (edit that one after installing). The service
runs sandboxed with `DynamicUser=yes` — a transient low-privilege UID,
read-only filesystem except its install dir (so self-update keeps
working), no capabilities, syscall-filtered: same least-privilege idea as
the Windows virtual account. The notify integration is a no-op in
containers and interactive shells.
**Auto-update is on by default**: the binary checks the repo's latest
@@ -141,6 +149,8 @@ OpenSSL; the matching public key lives in `internal/update/pubkey.go`
(one-time setup: `openssl genpkey -algorithm ed25519 -out private.pem`,
`openssl pkey -in private.pem -pubout -out public.pem`; private key goes
to the `RELEASE_SIGNING_KEY` repo secret, public key is committed).
`gpu-turnstile --force-update` checks immediately, stages the new binary
and restarts the running service (elevating via UAC only if needed).
### Docker
+55 -23
View File
@@ -169,35 +169,62 @@ The binary runs natively on Windows (the current primary deployment) and on
Linux with systemd (the future GPU server), as well as in Docker.
Service management is the same on both platforms:
`gpu-turnstile --install-service [-config path]` registers and starts an
auto-start service; `--remove-service` stops and unregisters it (both need
an elevated/root shell). The legacy form `gpu-turnstile service
install|remove` does the same thing.
`gpu-turnstile --install-service [-config path]` installs, registers and
starts an auto-start service; `--remove-service` stops and uninstalls it.
Both need admin/root; on Windows a non-elevated shell triggers a UAC
prompt instead of failing — the command relaunches itself elevated, waits
for the child, and mirrors its exit code. The legacy form
`gpu-turnstile service install|remove` does the same thing.
By default install creates the canonical layout and copies the binary into
it (Windows: `%ProgramFiles%\gpu-turnstile\`, plus
`%ProgramData%\gpu-turnstile\` for logs; Linux: `/var/lib/gpu-turnstile/`
with the config at `/etc/gpu-turnstile.env`). An existing config in the
target location is never overwritten. `--no-copy` registers the current
executable location as-is instead.
### Windows
- `--install-service` registers a Windows service; recovery actions restart
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).
- Use a config file (above) for the service — Windows services have no
convenient environment. Logs go to `LOG_FILE` since there is no console.
- `--install-service` creates `%ProgramFiles%\gpu-turnstile\` and
`%ProgramData%\gpu-turnstile\`, copies the exe and (if none exists there
yet) the `gpu-turnstile.env` into the Program Files directory, and
registers that copy as a Windows service; recovery actions restart it
5 s after any failure. Logs go to the ProgramData directory via
`LOG_FILE` since there is no console.
- **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 and data directories (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.
### Linux (systemd)
- `--install-service` writes `/etc/systemd/system/gpu-turnstile.service`
with `ExecStart` pointing at the current executable and the `-config`
file, then runs `systemctl daemon-reload` and `enable --now`. The unit
runs as root (it must be able to overwrite its own binary for
self-updates); harden with `ProtectSystem=strict` plus a writable
`ReadWritePaths` if desired.
- `--install-service` copies the binary to `/var/lib/gpu-turnstile/`,
copies the config to `/etc/gpu-turnstile.env` if none exists there yet,
writes `/etc/systemd/system/gpu-turnstile.service`, then runs `systemctl
daemon-reload` and `enable --now`. `--remove-service` removes the unit
and the installed binary; the `/etc` config stays. The binary does not
go to `/usr/local/sbin` on purpose: replacing a running binary needs
write access to its *directory*, and granting the sandboxed service
write access to a shared system directory would let a compromised
service overwrite other binaries — `/var/lib/gpu-turnstile` is
exclusively ours.
- **Sandboxing** mirrors the Windows virtual account: the unit runs with
`DynamicUser=yes` — a transient per-service UID with no login, no home
and no password, managed entirely by systemd. `ProtectSystem=strict`
makes the filesystem read-only except `StateDirectory=gpu-turnstile`
(the install dir, so self-updates can rewrite the binary), plus
`NoNewPrivileges`, `ProtectHome`, `PrivateTmp`, `ProtectKernel*`,
`ProtectControlGroups`, `RestrictNamespaces`, `RestrictSUIDSGID`,
`RestrictRealtime`, `LockPersonality`, `MemoryDenyWriteExecute`, empty
capability sets, `RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6` and
`SystemCallFilter=@system-service`. The proxy needs only outbound
TCP/UDP and the notify socket, so it loses nothing.
- The unit is `Type=notify`: the binary sends `READY=1` via
`github.com/coreos/go-systemd` only after the listeners are bound, so
`systemctl start` blocks until the proxy accepts connections. A 30 s
@@ -219,6 +246,11 @@ install|remove` does the same thing.
idle the process exits with code 3 so the service recovery restarts it
on the new version. Interactive runs only log "restart to apply".
`dev` builds and builds without an embedded public key never update.
- **`--force-update`** runs the same check immediately: it downloads,
verifies and stages a newer release, and if the service is running it
restarts it right away (otherwise the new version applies on next
start). On Windows it elevates via UAC only when the stage or restart
needs permissions the caller does not have.
- **Signing setup (one time)**: `openssl genpkey -algorithm ed25519 -out
private.pem`; `openssl pkey -in private.pem -pubout -out public.pem`.
Private key → repo secret `RELEASE_SIGNING_KEY`; public key → committed into
+242 -44
View File
@@ -3,10 +3,12 @@
package main
import (
"bufio"
"context"
"errors"
"fmt"
"io"
"io/fs"
"log/slog"
"net"
"net/http"
@@ -34,24 +36,127 @@ var version = "dev"
// process: a signed update has been staged and the GPU lock is idle.
const exitCodeUpdate = 3
// stdoutIsTerminal reports whether stdout is a console (char device), as
// opposed to a pipe or file — which is what Docker containers and services
// see.
func stdoutIsTerminal() bool {
fi, err := os.Stdout.Stat()
return err == nil && fi.Mode()&os.ModeCharDevice != 0
}
// parseFlags extracts -config <path> (or -config=<path>), the
// --install-service / --remove-service switches, --no-copy, -h/--help,
// -v/--version, --force-update and the hidden --elevated-child marker from
// args.
func parseFlags(args []string) (configPath string, install, remove, noCopy, help, showVersion, forceUpdate, elevatedChild bool, rest []string) {
rest = args[:0]
for i := 0; i < len(args); i++ {
switch {
case args[i] == "-config" && i+1 < len(args):
configPath = args[i+1]
i++
case strings.HasPrefix(args[i], "-config="):
configPath = strings.TrimPrefix(args[i], "-config=")
case args[i] == "--install-service" || args[i] == "-install-service":
install = true
case args[i] == "--remove-service" || args[i] == "-remove-service":
remove = true
case args[i] == "--no-copy" || args[i] == "-no-copy":
noCopy = true
case args[i] == "-h" || args[i] == "--help" || args[i] == "-help":
help = true
case args[i] == "-v" || args[i] == "--version" || args[i] == "-version":
showVersion = true
case args[i] == "--force-update" || args[i] == "-force-update":
forceUpdate = true
case args[i] == "--elevated-child":
elevatedChild = true
default:
rest = append(rest, args[i])
}
}
return configPath, install, remove, noCopy, help, showVersion, forceUpdate, elevatedChild, rest
}
// versionLine is printed at the top of every help and error screen.
func versionLine() string { return "gpu-turnstile " + version }
const usageText = `GPU arbitration proxy for Ollama + ComfyUI
Usage:
gpu-turnstile -config <path> run the proxy
gpu-turnstile --install-service [--no-copy] [-config path] install + start as a service
gpu-turnstile --remove-service stop + uninstall the service
gpu-turnstile -v | --version print just the version
gpu-turnstile --force-update check for a signed update now,
apply it and restart the service
gpu-turnstile -h | --help this help
Options:
-config <path> config file (default: gpu-turnstile.env next to the exe)
--install-service copies the binary into the canonical location
(%ProgramFiles%\gpu-turnstile or /var/lib/gpu-turnstile)
unless --no-copy; on Windows a UAC prompt appears when
the shell is not elevated
--no-copy with --install-service: register the current location as-is
All runtime settings are environment variables or KEY=VALUE lines in the
config file (OLLAMA_URL, COMFY_URL, LOGLEVEL, ...); see README.md.
`
// printHelp prints the version header plus the full help text.
func printHelp() {
fmt.Printf("%s — %s", versionLine(), usageText)
}
// fatalUsage prints the version header, an error message and the one-line
// usage summary, then exits with code 2.
func fatalUsage(format string, args ...any) {
fmt.Fprintf(os.Stderr, "%s\n\n", versionLine())
fmt.Fprintf(os.Stderr, format+"\n\n", args...)
fmt.Fprintln(os.Stderr, "usage: gpu-turnstile [-config path] [--install-service [--no-copy] | --remove-service]")
fmt.Fprintln(os.Stderr, " gpu-turnstile service install|remove [-config path]")
os.Exit(2)
}
func main() {
configPath, install, remove, args := parseFlags(os.Args[1:])
switch {
case install && remove:
fmt.Fprintf(os.Stderr, "gpu-turnstile: --install-service and --remove-service are mutually exclusive\n")
os.Exit(2)
case install:
os.Exit(serviceCommand(configPath, []string{"install"}))
case remove:
os.Exit(serviceCommand(configPath, []string{"remove"}))
configPath, install, remove, noCopy, help, showVersion, forceUpdate, elevatedChild, args := parseFlags(os.Args[1:])
if showVersion {
fmt.Println(version)
return
}
bare := configPath == "" && !install && !remove && !forceUpdate && !elevatedChild && len(args) == 0
if help || (bare && stdoutIsTerminal()) {
// Bare invocation in a terminal (e.g. double-clicked on Windows)
// shows the help instead of starting a proxy window with no visible
// explanation. Without a terminal — Docker containers, services,
// pipes — a bare invocation starts the proxy as before.
printHelp()
return
}
if len(args) > 0 && args[0] == "service" {
os.Exit(serviceCommand(configPath, args[1:]))
// Legacy subcommand form: gpu-turnstile service install|remove.
if len(args) != 2 || (args[1] != "install" && args[1] != "remove") {
fatalUsage("error: expected 'service install' or 'service remove'")
}
install = args[1] == "install"
remove = !install
args = nil
}
switch {
case install && remove:
fatalUsage("error: --install-service and --remove-service are mutually exclusive")
case forceUpdate && (install || remove):
fatalUsage("error: --force-update cannot be combined with --install-service/--remove-service")
case install:
os.Exit(serviceCommand(configPath, true, noCopy, elevatedChild))
case remove:
os.Exit(serviceCommand(configPath, false, noCopy, elevatedChild))
case forceUpdate:
os.Exit(forceUpdateCommand(configPath, elevatedChild))
}
if len(args) > 0 {
fmt.Fprintf(os.Stderr, "usage: gpu-turnstile [-config path] [--install-service | --remove-service]\n")
fmt.Fprintf(os.Stderr, " gpu-turnstile service install|remove [-config path]\n")
os.Exit(2)
fatalUsage("error: unknown arguments: %s", strings.Join(args, " "))
}
if exePath, err := os.Executable(); err == nil {
@@ -60,7 +165,7 @@ func main() {
cfg, err := loadMergedConfig(configPath)
if err != nil {
fmt.Fprintf(os.Stderr, "gpu-turnstile: %v\n", err)
fmt.Fprintf(os.Stderr, "%s\n\ngpu-turnstile: %v\n", versionLine(), err)
os.Exit(1)
}
log, logOut, logCloser := newLogger(cfg)
@@ -81,28 +186,6 @@ func main() {
}
}
// parseFlags extracts -config <path> (or -config=<path>) and the
// --install-service / --remove-service switches from args.
func parseFlags(args []string) (configPath string, install, remove bool, rest []string) {
rest = args[:0]
for i := 0; i < len(args); i++ {
switch {
case args[i] == "-config" && i+1 < len(args):
configPath = args[i+1]
i++
case strings.HasPrefix(args[i], "-config="):
configPath = strings.TrimPrefix(args[i], "-config=")
case args[i] == "--install-service" || args[i] == "-install-service":
install = true
case args[i] == "--remove-service" || args[i] == "-remove-service":
remove = true
default:
rest = append(rest, args[i])
}
}
return configPath, install, remove, rest
}
// defaultConfigPath returns gpu-turnstile.env next to the executable.
func defaultConfigPath() string {
exe, err := os.Executable()
@@ -172,26 +255,141 @@ func newLogger(cfg config.Config) (*slog.Logger, io.Writer, io.Closer) {
return log, out, closer
}
func serviceCommand(configPath string, args []string) int {
if len(args) != 1 || (args[0] != "install" && args[0] != "remove") {
fmt.Fprintf(os.Stderr, "usage: gpu-turnstile service install|remove [-config path]\n")
return 2
// waitForEnter keeps an elevated child's console window open until the
// user has read the output.
func waitForEnter() {
fmt.Print("\nPress Enter to close this window...")
bufio.NewReader(os.Stdin).ReadString('\n')
}
// elevateAndMirror relaunches the current command elevated (UAC) and
// mirrors the child's exit code. verb is used in messages.
func elevateAndMirror(verb string) (int, bool) {
args := append(append([]string{}, os.Args[1:]...), "--elevated-child")
code, err := service.RelaunchElevated(args)
if errors.Is(err, service.ErrUserCancelled) {
fmt.Fprintln(os.Stderr, "gpu-turnstile: UAC prompt declined")
return 1, true
}
if err != nil {
fmt.Fprintf(os.Stderr, "gpu-turnstile: could not elevate: %v\n", err)
return 1, true
}
if code != 0 {
fmt.Fprintf(os.Stderr, "gpu-turnstile %s failed in the elevated process (exit %d)\n", verb, code)
return code, true
}
return 0, true
}
// isPermission reports whether err is a permission problem (Windows
// ERROR_ACCESS_DENIED, POSIX EACCES/EPERM, possibly wrapped).
func isPermission(err error) bool {
return errors.Is(err, fs.ErrPermission) || strings.Contains(strings.ToLower(err.Error()), "access is denied")
}
// forceUpdateCommand checks for a signed update immediately, stages it if
// newer, and restarts the service when it is running so the new binary
// takes effect. Staging into a system directory and restarting a service
// need admin rights; instead of prompting unconditionally, permission
// failures trigger the UAC relaunch so a dev copy in a user-writable
// directory updates without a prompt.
func forceUpdateCommand(configPath string, elevatedChild bool) int {
if elevatedChild {
defer waitForEnter()
}
cfg, err := loadMergedConfig(configPath)
if err != nil {
fmt.Fprintf(os.Stderr, "%s\n\ngpu-turnstile: %v\n", versionLine(), err)
return 1
}
exePath, err := os.Executable()
if err != nil {
fmt.Fprintf(os.Stderr, "gpu-turnstile: cannot locate executable: %v\n", err)
return 1
}
log, _, logCloser := newLogger(cfg)
defer logCloser.Close()
u := &update.Updater{Repo: cfg.UpdateRepo, Asset: cfg.UpdateAsset, Version: version, Log: log}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
staged, err := u.Check(ctx, exePath)
if err != nil && isPermission(err) && !service.Elevated() {
code, _ := elevateAndMirror("--force-update")
if code == 0 {
fmt.Println("update applied (elevated)")
}
return code
}
if err != nil {
fmt.Fprintf(os.Stderr, "%s\n\ngpu-turnstile: update check failed: %v\n", versionLine(), err)
return 1
}
if !staged {
fmt.Printf("%s is up to date\n", versionLine())
return 0
}
fmt.Printf("%s: update staged\n", versionLine())
restarted, err := service.RestartIfRunning()
if err != nil && isPermission(err) && !service.Elevated() {
code, _ := elevateAndMirror("--force-update")
if code == 0 {
fmt.Println("update applied (elevated)")
}
return code
}
if err != nil {
fmt.Fprintf(os.Stderr, "gpu-turnstile: update staged but service restart failed: %v\n", err)
return 1
}
if restarted {
fmt.Println("service restarted on the new version")
} else {
fmt.Println("no running service; the new version applies on next start")
}
return 0
}
// serviceCommand installs (copyBin = register the canonical-layout copy)
// or removes the service and reports the result. On Windows, when the
// shell is not elevated, the command relaunches itself through a UAC
// prompt and mirrors the elevated child's exit code. An elevated child
// waits for a keypress so its console window does not flash closed before
// the output can be read.
func serviceCommand(configPath string, install, noCopy, elevatedChild bool) int {
verb, doneVerb := "remove", "removed"
if install {
verb, doneVerb = "install", "installed"
}
if elevatedChild {
defer waitForEnter()
}
if !service.Elevated() {
code, done := elevateAndMirror(verb)
if done && code != 0 {
return code
}
if done {
fmt.Printf("service %s: %s (elevated)\n", service.Name, doneVerb)
return 0
}
}
var err error
if args[0] == "install" {
if install {
path := resolveConfigPath(configPath)
if abs, absErr := filepath.Abs(path); absErr == nil {
path = abs
}
err = service.Install(path)
err = service.Install(path, !noCopy)
} else {
err = service.Remove()
}
if err != nil {
fmt.Fprintf(os.Stderr, "gpu-turnstile service %s: %v\n", args[0], err)
fmt.Fprintf(os.Stderr, "gpu-turnstile service %s: %v\n", verb, err)
return 1
}
fmt.Printf("service %s: %sd\n", service.Name, args[0])
fmt.Printf("service %s: %s\n", service.Name, doneVerb)
return 0
}
+1 -1
View File
@@ -6,7 +6,7 @@
# ComfyUI --listen 0.0.0.0 --port 8189).
services:
gpu-turnstile:
image: git.rambossek.at/public/gpu-turnstile:v0.1.5
image: git.rambossek.at/public/gpu-turnstile:v0.1.6
restart: unless-stopped
environment:
# Each consumer is enabled by setting its URL; leave one unset to
+8
View File
@@ -0,0 +1,8 @@
package service
import "errors"
// ErrUserCancelled is returned by RelaunchElevated when the user declines
// the UAC prompt. Windows-only in practice; defined here so cross-platform
// callers can compare against it.
var ErrUserCancelled = errors.New("UAC prompt declined")
+122 -11
View File
@@ -2,12 +2,13 @@
// Package service integrates gpu-turnstile with systemd on Linux: running
// under a unit with readiness notification and watchdog, plus
// install/remove helpers that manage a system unit.
// install/remove helpers that manage a hardened system unit.
package service
import (
"context"
"fmt"
"io"
"os"
"os/exec"
"os/signal"
@@ -21,9 +22,26 @@ const Name = "gpu-turnstile"
// unitPath is where Install writes the unit file.
const unitPath = "/etc/systemd/system/" + Name + ".service"
// stateDir holds the installed binary (and staged updates); the unit's
// StateDirectory= directive makes systemd own it and grant the dynamic
// user write access. etcConfig is the config file the unit loads.
const (
stateDir = "/var/lib/" + Name
etcConfig = "/etc/" + Name + ".env"
)
// IsService reports whether the process was started by systemd.
func IsService() bool { return os.Getenv("INVOCATION_ID") != "" }
// Elevated is always true on Linux: there is no UAC equivalent; privilege
// errors surface from the failing operation with a "run as root" hint.
func Elevated() bool { return true }
// RelaunchElevated is a Windows-only concept (UAC).
func RelaunchElevated([]string) (int, error) {
return 0, fmt.Errorf("elevated relaunch is only supported on Windows")
}
// Run executes run with SIGINT/SIGTERM cancellation (which is how systemctl
// stop signals the process) and tells systemd when the shutdown begins.
func Run(run func(ctx context.Context) error) error {
@@ -33,10 +51,17 @@ func Run(run func(ctx context.Context) error) error {
return run(ctx)
}
// renderUnit builds the systemd unit: Type=notify so systemctl start blocks
// until the listeners are bound, a 30s watchdog, and restart-on-failure
// with a 5s delay — which is also what brings up a staged update after the
// updater exits with a non-zero code.
// renderUnit builds the hardened systemd unit: Type=notify so systemctl
// start blocks until the listeners are bound, a 30s watchdog, and
// restart-on-failure with a 5s delay — which is also what brings up a
// staged update after the updater exits with a non-zero code.
//
// Sandboxing mirrors the Windows virtual account: DynamicUser=yes gives
// the service a transient per-service UID with no login and no home, the
// filesystem is read-only except StateDirectory (the install dir, so
// self-updates can rewrite the binary), and the usual no-privilege-escalation
// directives apply. The proxy needs nothing but outbound TCP/UDP and the
// notify socket, so it loses nothing.
func renderUnit(exePath, configPath string) string {
return fmt.Sprintf(`[Unit]
Description=gpu-turnstile GPU arbitration proxy for Ollama and ComfyUI
@@ -50,14 +75,63 @@ ExecStart=%q -config %q
Restart=on-failure
RestartSec=5s
DynamicUser=yes
StateDirectory=%s
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
NoNewPrivileges=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectClock=yes
RestrictNamespaces=yes
RestrictSUIDSGID=yes
RestrictRealtime=yes
LockPersonality=yes
MemoryDenyWriteExecute=yes
CapabilityBoundingSet=
AmbientCapabilities=
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
SystemCallFilter=@system-service
SystemCallErrorNumber=EPERM
[Install]
WantedBy=multi-user.target
`, exePath, configPath)
`, exePath, configPath, Name)
}
// Install writes the unit for the current executable and the given config
// file, then enables and starts it. Needs root.
func Install(configPath string) error {
// copyFile copies src to dst, creating dst with the given mode.
func copyFile(src, dst string, mode os.FileMode) 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, mode)
if err != nil {
return err
}
defer out.Close()
if _, err := io.Copy(out, in); err != nil {
return err
}
return out.Close()
}
// Install copies the current executable into /var/lib/gpu-turnstile, makes
// sure /etc/gpu-turnstile.env exists (copied from the given config file if
// provided), writes the hardened unit, then enables and starts it. With
// copyBin=false the current executable location and config path are
// registered as-is instead. Needs root.
//
// The binary lives in the StateDirectory rather than /usr/local/sbin on
// purpose: replacing a running binary needs write access to its
// *directory*, and granting the sandboxed service user write access to a
// shared system directory would let a compromised service overwrite other
// binaries. /var/lib/gpu-turnstile is exclusively ours.
func Install(configPath string, copyBin bool) error {
exe, err := os.Executable()
if err != nil {
return err
@@ -65,7 +139,29 @@ func Install(configPath string) error {
if abs, absErr := filepath.Abs(exe); absErr == nil {
exe = abs
}
if err := os.WriteFile(unitPath, []byte(renderUnit(exe, configPath)), 0o644); err != nil {
cfg := etcConfig
if copyBin {
if err := os.MkdirAll(stateDir, 0o755); err != nil {
return fmt.Errorf("create %s (run as root): %w", stateDir, err)
}
installedExe := filepath.Join(stateDir, Name)
if exe != installedExe {
if err := copyFile(exe, installedExe, 0o755); err != nil {
return fmt.Errorf("install binary to %s: %w", installedExe, err)
}
}
exe = installedExe
if _, err := os.Stat(etcConfig); os.IsNotExist(err) && configPath != "" {
// Missing config is not fatal: the service fails fast with a
// clear "no consumer URL" error until the user writes one.
copyFile(configPath, etcConfig, 0o644) //nolint:errcheck // best effort
}
} else if configPath != "" {
if abs, absErr := filepath.Abs(configPath); absErr == nil {
cfg = abs
}
}
if err := os.WriteFile(unitPath, []byte(renderUnit(exe, cfg)), 0o644); err != nil {
return fmt.Errorf("write %s (run as root): %w", unitPath, err)
}
if out, err := exec.Command("systemctl", "daemon-reload").CombinedOutput(); err != nil {
@@ -77,14 +173,29 @@ func Install(configPath string) error {
return nil
}
// Remove stops and disables the service and deletes the unit file.
// Remove stops and disables the service and deletes the unit file and the
// installed binary. The config file in /etc is left in place (user data).
func Remove() error {
exec.Command("systemctl", "disable", "--now", Name+".service").Run() // ignore: may not exist
if err := os.Remove(unitPath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("remove %s: %w", unitPath, err)
}
os.RemoveAll(stateDir) // installed binary + staged updates; ignore error
if out, err := exec.Command("systemctl", "daemon-reload").CombinedOutput(); err != nil {
return fmt.Errorf("systemctl daemon-reload: %w (%s)", err, out)
}
return nil
}
// RestartIfRunning restarts the systemd unit when it is active (used after
// a forced update staged a new binary). Reports whether a restart
// happened. An inactive or missing unit is not an error. Needs root.
func RestartIfRunning() (bool, error) {
if err := exec.Command("systemctl", "is-active", "--quiet", Name+".service").Run(); err != nil {
return false, nil // inactive or not installed
}
if out, err := exec.Command("systemctl", "restart", Name+".service").CombinedOutput(); err != nil {
return false, fmt.Errorf("systemctl restart (run as root): %w (%s)", err, out)
}
return true, nil
}
+8 -2
View File
@@ -8,13 +8,19 @@ import (
)
func TestRenderUnit(t *testing.T) {
unit := renderUnit("/usr/local/bin/gpu-turnstile", "/etc/gpu-turnstile.env")
unit := renderUnit("/var/lib/gpu-turnstile/gpu-turnstile", "/etc/gpu-turnstile.env")
for _, want := range []string{
"Type=notify",
"WatchdogSec=30s",
`ExecStart="/usr/local/bin/gpu-turnstile" -config "/etc/gpu-turnstile.env"`,
`ExecStart="/var/lib/gpu-turnstile/gpu-turnstile" -config "/etc/gpu-turnstile.env"`,
"Restart=on-failure",
"WantedBy=multi-user.target",
"DynamicUser=yes",
"StateDirectory=gpu-turnstile",
"ProtectSystem=strict",
"NoNewPrivileges=yes",
"RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6",
"SystemCallFilter=@system-service",
} {
if !strings.Contains(unit, want) {
t.Fatalf("unit missing %q:\n%s", want, unit)
+13 -3
View File
@@ -28,8 +28,18 @@ func Run(run func(ctx context.Context) error) error {
return run(ctx)
}
// Install is unsupported on non-Windows platforms.
func Install(string) error { return errUnsupported }
// Install is unsupported on non-Windows, non-Linux platforms.
func Install(string, bool) error { return errUnsupported }
// Remove is unsupported on non-Windows platforms.
// Remove is unsupported on non-Windows, non-Linux platforms.
func Remove() error { return errUnsupported }
// Elevated is always true here: there is no UAC concept, and privilege
// errors surface from the failing operation with a "run as root" hint.
func Elevated() bool { return true }
// RelaunchElevated is unsupported on non-Windows, non-Linux platforms.
func RelaunchElevated([]string) (int, error) { return 0, errUnsupported }
// RestartIfRunning is a no-op on platforms without service integration.
func RestartIfRunning() (bool, error) { return false, nil }
+288 -10
View File
@@ -2,22 +2,53 @@
// 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"
"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()
@@ -64,15 +95,53 @@ 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.
func Install(configPath string) error {
// 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.
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
}
}
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 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
}
}
m, err := mgr.Connect()
if err != nil {
return fmt.Errorf("connect to service manager (run as administrator): %w", err)
@@ -81,9 +150,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 +167,71 @@ 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
}
// 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
}
// Remove stops (if running) and unregisters the service.
// 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 {
@@ -118,3 +249,150 @@ func Remove() error {
}
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 := s.Control(svc.Stop); err != nil {
return false, fmt.Errorf("stop service: %w", err)
}
deadline := time.Now().Add(30 * time.Second)
for {
st, err = s.Query()
if err != nil {
return false, fmt.Errorf("query service: %w", err)
}
if st.State == svc.Stopped {
break
}
if time.Now().After(deadline) {
return false, fmt.Errorf("service did not stop within 30s")
}
time.Sleep(300 * time.Millisecond)
}
if err := s.Start(); err != nil {
return false, fmt.Errorf("start service: %w", err)
}
return true, nil
}