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
+31 -16
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
// sure /etc/gpu-turnstile.env exists (copied from the given config file if
// provided), writes the hardened unit, then enables and starts it. Needs
// root.
func Install(configPath string) error {
// 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
@@ -123,21 +130,29 @@ func Install(configPath string) error {
if abs, absErr := filepath.Abs(exe); absErr == nil {
exe = abs
}
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)
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.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
}
if err := os.WriteFile(unitPath, []byte(renderUnit(installedExe, etcConfig)), 0o644); err != nil {
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 {
+2 -2
View File
@@ -28,8 +28,8 @@ 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.
func Remove() error { return errUnsupported }
+79 -9
View File
@@ -10,6 +10,7 @@ package service
import (
"context"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
@@ -30,6 +31,20 @@ const Name = "gpu-turnstile"
// 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()
@@ -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
// 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 {
// 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)
@@ -129,8 +171,36 @@ func Install(configPath string) error {
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 {
_, 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