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.
This commit is contained in:
mram
2026-09-21 07:52:33 +02:00
parent a88955e35c
commit 802a64280f
6 changed files with 183 additions and 77 deletions
+14 -14
View File
@@ -96,31 +96,31 @@ go build ./cmd/gpu-turnstile
### Run natively on Windows (current primary deployment) ### Run natively on Windows (current primary deployment)
Download `gpu-turnstile.exe` from a release, put a `gpu-turnstile.env` Download `gpu-turnstile.exe` from a release and install it as a Windows
next to it, and run it — or install it as a Windows service from an service from an elevated shell — the installer creates the canonical
elevated shell: layout, copies the binary and (if none exists yet) your
`gpu-turnstile.env` into it, and registers the copy:
```sh ```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 gpu-turnstile.exe --remove-service
``` ```
The service uses the config file (services have no convenient Layout: `C:\Program Files\gpu-turnstile\` holds the exe and
environment); set `LOG_FILE` in it since there is no console. `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
Suggested layout: `C:\Program Files\gpu-turnstile\` for the exe and as the virtual account `NT SERVICE\gpu-turnstile` (low-privilege,
`gpu-turnstile.env`, logs under `C:\ProgramData\gpu-turnstile\` via per-service, no password); the installer automatically grants it write
`LOG_FILE`. The service always runs as the virtual account access to the install and data directories — nothing else to do.
`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) ### Run natively on Linux (systemd)
The same binary works on Linux. Install it as a systemd service as root: The same binary works on Linux. Install it as a systemd service as root:
```sh ```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 gpu-turnstile --remove-service
``` ```
+28 -16
View File
@@ -169,29 +169,36 @@ The binary runs natively on Windows (the current primary deployment) and on
Linux with systemd (the future GPU server), as well as in Docker. Linux with systemd (the future GPU server), as well as in Docker.
Service management is the same on both platforms: Service management is the same on both platforms:
`gpu-turnstile --install-service [-config path]` registers and starts an `gpu-turnstile --install-service [-config path]` installs, registers and
auto-start service; `--remove-service` stops and unregisters it (both need starts an auto-start service; `--remove-service` stops and uninstalls it
an elevated/root shell). The legacy form `gpu-turnstile service (both need an elevated/root shell). The legacy form `gpu-turnstile service
install|remove` does the same thing. 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 ### Windows
- `--install-service` registers a Windows service; recovery actions restart - `--install-service` creates `%ProgramFiles%\gpu-turnstile\` and
it 5 s after any failure. `%ProgramData%\gpu-turnstile\`, copies the exe and (if none exists there
- **Layout**: install to `C:\Program Files\gpu-turnstile\` (exe plus yet) the `gpu-turnstile.env` into the Program Files directory, and
`gpu-turnstile.env`); logs belong in `C:\ProgramData\gpu-turnstile\` via registers that copy as a Windows service; recovery actions restart it
`LOG_FILE`. 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 - **Account**: the service always runs as the virtual account
`NT SERVICE\gpu-turnstile` — a per-service low-privilege identity the `NT SERVICE\gpu-turnstile` — a per-service low-privilege identity the
SCM manages (no password, automatic logon-as-a-service right, no admin SCM manages (no password, automatic logon-as-a-service right, no admin
rights, gone when the service is removed). The installer grants it rights, gone when the service is removed). The installer grants it
modify access to the install directory (self-updates rewrite the exe) modify access to the install and data directories (self-updates rewrite
and the `LOG_FILE` directory (created if missing), plus read access to the exe) and the `LOG_FILE` directory (created if missing), plus read
the config file when it lives elsewhere. The grants happen after service access to the config file when it lives elsewhere. The grants happen
registration because the virtual account's SID only exists from that after service registration because the virtual account's SID only exists
point on; if a grant fails the service registration is rolled back. from that point on; if a grant fails the service registration is rolled
- Use a config file (above) for the service — Windows services have no back.
convenient environment. Logs go to `LOG_FILE` since there is no console.
### Linux (systemd) ### Linux (systemd)
@@ -199,7 +206,12 @@ install|remove` does the same thing.
copies the config to `/etc/gpu-turnstile.env` if none exists there yet, copies the config to `/etc/gpu-turnstile.env` if none exists there yet,
writes `/etc/systemd/system/gpu-turnstile.service`, then runs `systemctl writes `/etc/systemd/system/gpu-turnstile.service`, then runs `systemctl
daemon-reload` and `enable --now`. `--remove-service` removes the unit daemon-reload` and `enable --now`. `--remove-service` removes the unit
and the installed binary; the `/etc` config stays. 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 - **Sandboxing** mirrors the Windows virtual account: the unit runs with
`DynamicUser=yes` — a transient per-service UID with no login, no home `DynamicUser=yes` — a transient per-service UID with no login, no home
and no password, managed entirely by systemd. `ProtectSystem=strict` and no password, managed entirely by systemd. `ProtectSystem=strict`
+29 -20
View File
@@ -35,21 +35,28 @@ var version = "dev"
const exitCodeUpdate = 3 const exitCodeUpdate = 3
func main() { func main() {
configPath, install, remove, args := parseFlags(os.Args[1:]) configPath, install, remove, noCopy, args := parseFlags(os.Args[1:])
if len(args) > 0 && args[0] == "service" {
// Legacy subcommand form: gpu-turnstile service install|remove.
if len(args) != 2 || (args[1] != "install" && args[1] != "remove") {
fmt.Fprintf(os.Stderr, "usage: gpu-turnstile service install|remove [-config path]\n")
os.Exit(2)
}
install = args[1] == "install"
remove = !install
args = nil
}
switch { switch {
case install && remove: case install && remove:
fmt.Fprintf(os.Stderr, "gpu-turnstile: --install-service and --remove-service are mutually exclusive\n") fmt.Fprintf(os.Stderr, "gpu-turnstile: --install-service and --remove-service are mutually exclusive\n")
os.Exit(2) os.Exit(2)
case install: case install:
os.Exit(serviceCommand(configPath, []string{"install"})) os.Exit(serviceCommand(configPath, true, noCopy))
case remove: case remove:
os.Exit(serviceCommand(configPath, []string{"remove"})) os.Exit(serviceCommand(configPath, false, noCopy))
}
if len(args) > 0 && args[0] == "service" {
os.Exit(serviceCommand(configPath, args[1:]))
} }
if len(args) > 0 { if len(args) > 0 {
fmt.Fprintf(os.Stderr, "usage: gpu-turnstile [-config path] [--install-service | --remove-service]\n") fmt.Fprintf(os.Stderr, "usage: gpu-turnstile [-config path] [--install-service [--no-copy] | --remove-service]\n")
fmt.Fprintf(os.Stderr, " gpu-turnstile service install|remove [-config path]\n") fmt.Fprintf(os.Stderr, " gpu-turnstile service install|remove [-config path]\n")
os.Exit(2) os.Exit(2)
} }
@@ -81,9 +88,9 @@ func main() {
} }
} }
// parseFlags extracts -config <path> (or -config=<path>) and the // parseFlags extracts -config <path> (or -config=<path>), the
// --install-service / --remove-service switches from args. // --install-service / --remove-service switches and --no-copy from args.
func parseFlags(args []string) (configPath string, install, remove bool, rest []string) { func parseFlags(args []string) (configPath string, install, remove, noCopy bool, rest []string) {
rest = args[:0] rest = args[:0]
for i := 0; i < len(args); i++ { for i := 0; i < len(args); i++ {
switch { switch {
@@ -96,11 +103,13 @@ func parseFlags(args []string) (configPath string, install, remove bool, rest []
install = true install = true
case args[i] == "--remove-service" || args[i] == "-remove-service": case args[i] == "--remove-service" || args[i] == "-remove-service":
remove = true remove = true
case args[i] == "--no-copy" || args[i] == "-no-copy":
noCopy = true
default: default:
rest = append(rest, args[i]) rest = append(rest, args[i])
} }
} }
return configPath, install, remove, rest return configPath, install, remove, noCopy, rest
} }
// defaultConfigPath returns gpu-turnstile.env next to the executable. // defaultConfigPath returns gpu-turnstile.env next to the executable.
@@ -172,26 +181,26 @@ func newLogger(cfg config.Config) (*slog.Logger, io.Writer, io.Closer) {
return log, out, closer return log, out, closer
} }
func serviceCommand(configPath string, args []string) int { // serviceCommand installs (copyBin = register the canonical-layout copy)
if len(args) != 1 || (args[0] != "install" && args[0] != "remove") { // or removes the service and reports the result.
fmt.Fprintf(os.Stderr, "usage: gpu-turnstile service install|remove [-config path]\n") func serviceCommand(configPath string, install, noCopy bool) int {
return 2 verb := "remove"
}
var err error var err error
if args[0] == "install" { if install {
verb = "install"
path := resolveConfigPath(configPath) path := resolveConfigPath(configPath)
if abs, absErr := filepath.Abs(path); absErr == nil { if abs, absErr := filepath.Abs(path); absErr == nil {
path = abs path = abs
} }
err = service.Install(path) err = service.Install(path, !noCopy)
} else { } else {
err = service.Remove() err = service.Remove()
} }
if err != nil { 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 return 1
} }
fmt.Printf("service %s: %sd\n", service.Name, args[0]) fmt.Printf("service %s: %sd\n", service.Name, verb)
return 0 return 0
} }
+21 -6
View File
@@ -113,9 +113,16 @@ func copyFile(src, dst string, mode os.FileMode) error {
// Install copies the current executable into /var/lib/gpu-turnstile, makes // Install copies the current executable into /var/lib/gpu-turnstile, makes
// sure /etc/gpu-turnstile.env exists (copied from the given config file if // sure /etc/gpu-turnstile.env exists (copied from the given config file if
// provided), writes the hardened unit, then enables and starts it. Needs // provided), writes the hardened unit, then enables and starts it. With
// root. // copyBin=false the current executable location and config path are
func Install(configPath string) error { // 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() exe, err := os.Executable()
if err != nil { if err != nil {
return err return err
@@ -123,6 +130,8 @@ func Install(configPath string) error {
if abs, absErr := filepath.Abs(exe); absErr == nil { if abs, absErr := filepath.Abs(exe); absErr == nil {
exe = abs exe = abs
} }
cfg := etcConfig
if copyBin {
if err := os.MkdirAll(stateDir, 0o755); err != nil { if err := os.MkdirAll(stateDir, 0o755); err != nil {
return fmt.Errorf("create %s (run as root): %w", stateDir, err) return fmt.Errorf("create %s (run as root): %w", stateDir, err)
} }
@@ -132,12 +141,18 @@ func Install(configPath string) error {
return fmt.Errorf("install binary to %s: %w", installedExe, err) return fmt.Errorf("install binary to %s: %w", installedExe, err)
} }
} }
exe = installedExe
if _, err := os.Stat(etcConfig); os.IsNotExist(err) && configPath != "" { if _, err := os.Stat(etcConfig); os.IsNotExist(err) && configPath != "" {
// Missing config is not fatal: the service fails fast with a clear // Missing config is not fatal: the service fails fast with a
// "no consumer URL" error until the user writes one. // clear "no consumer URL" error until the user writes one.
copyFile(configPath, etcConfig, 0o644) //nolint:errcheck // best effort copyFile(configPath, etcConfig, 0o644) //nolint:errcheck // best effort
} }
if err := os.WriteFile(unitPath, []byte(renderUnit(installedExe, etcConfig)), 0o644); err != nil { } 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) return fmt.Errorf("write %s (run as root): %w", unitPath, err)
} }
if out, err := exec.Command("systemctl", "daemon-reload").CombinedOutput(); err != nil { if out, err := exec.Command("systemctl", "daemon-reload").CombinedOutput(); err != nil {
+2 -2
View File
@@ -28,8 +28,8 @@ func Run(run func(ctx context.Context) error) error {
return run(ctx) return run(ctx)
} }
// Install is unsupported on non-Windows platforms. // Install is unsupported on non-Windows, non-Linux platforms.
func Install(string) error { return errUnsupported } func Install(string, bool) error { return errUnsupported }
// Remove is unsupported on non-Windows platforms. // Remove is unsupported on non-Windows platforms.
func Remove() error { return errUnsupported } func Remove() error { return errUnsupported }
+79 -9
View File
@@ -10,6 +10,7 @@ package service
import ( import (
"context" "context"
"fmt" "fmt"
"io"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
@@ -30,6 +31,20 @@ const Name = "gpu-turnstile"
// when the service is removed. // when the service is removed.
const virtualAccount = `NT SERVICE\` + Name 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. // IsService reports whether the process is running as a Windows service.
func IsService() bool { func IsService() bool {
isSvc, err := svc.IsWindowsService() isSvc, err := svc.IsWindowsService()
@@ -78,24 +93,51 @@ func (h *handler) Execute(_ []string, requests <-chan svc.ChangeRequest, status
// Install registers gpu-turnstile as an auto-start Windows service running // Install registers gpu-turnstile as an auto-start Windows service running
// as the NT SERVICE\gpu-turnstile virtual account, whose binPath loads the // as the NT SERVICE\gpu-turnstile virtual account, whose binPath loads the
// given config file. Recovery actions restart the service after 5s on // given config file. With copyBin it first creates the canonical layout —
// failure — this is also what brings up a staged update after the updater // the binary is copied into %ProgramFiles%\gpu-turnstile and the config
// exits with a non-zero code. After registering, the virtual account is // next to it (an existing config there is kept), %ProgramData%\gpu-turnstile
// granted modify access to the install directory (self-updates rewrite the // is created for logs — and registers that copy; with copyBin=false the
// exe) and to the LOG_FILE directory, and read access to the config file // current executable location is registered as-is. Recovery actions restart
// if it lives elsewhere. The grants must come after CreateService: the // the service after 5s on failure — this is also what brings up a staged
// virtual account's SID only exists once the service is registered. // update after the updater exits with a non-zero code. After registering,
func Install(configPath string) error { // 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() exe, err := os.Executable()
if err != nil { if err != nil {
return err return err
} }
if abs, absErr := filepath.Abs(exe); absErr == nil {
exe = abs
}
if configPath != "" { if configPath != "" {
if abs, absErr := filepath.Abs(configPath); absErr == nil { if abs, absErr := filepath.Abs(configPath); absErr == nil {
configPath = abs 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() m, err := mgr.Connect()
if err != nil { if err != nil {
return fmt.Errorf("connect to service manager (run as administrator): %w", err) return fmt.Errorf("connect to service manager (run as administrator): %w", err)
@@ -129,8 +171,36 @@ func Install(configPath string) error {
return nil return nil
} }
// grantAll gives the virtual account every ACL the service needs. // 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 { 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) exeDir := filepath.Dir(exe)
if err := grantAccess(exeDir, "(OI)(CI)(M)"); err != nil { if err := grantAccess(exeDir, "(OI)(CI)(M)"); err != nil {
return err return err