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'.
This commit is contained in:
mram
2026-09-21 08:00:24 +02:00
parent 802a64280f
commit fbab0bba33
7 changed files with 157 additions and 16 deletions
+3 -3
View File
@@ -97,9 +97,9 @@ 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 and install it as a Windows Download `gpu-turnstile.exe` from a release and install it as a Windows
service from an elevated shell — the installer creates the canonical service — no admin shell needed, a UAC prompt appears automatically and
layout, copies the binary and (if none exists yet) your the elevated child does the work (its window waits for Enter so you can
`gpu-turnstile.env` into it, and registers the copy: read the result):
```sh ```sh
gpu-turnstile.exe --install-service # installs into Program Files, auto-start gpu-turnstile.exe --install-service # installs into Program Files, auto-start
+5 -3
View File
@@ -170,9 +170,11 @@ 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]` installs, registers and `gpu-turnstile --install-service [-config path]` installs, registers and
starts an auto-start service; `--remove-service` stops and uninstalls it starts an auto-start service; `--remove-service` stops and uninstalls it.
(both need an elevated/root shell). The legacy form `gpu-turnstile service Both need admin/root; on Windows a non-elevated shell triggers a UAC
install|remove` does the same thing. 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 By default install creates the canonical layout and copies the binary into
it (Windows: `%ProgramFiles%\gpu-turnstile\`, plus it (Windows: `%ProgramFiles%\gpu-turnstile\`, plus
+43 -9
View File
@@ -3,6 +3,7 @@
package main package main
import ( import (
"bufio"
"context" "context"
"errors" "errors"
"fmt" "fmt"
@@ -35,7 +36,7 @@ var version = "dev"
const exitCodeUpdate = 3 const exitCodeUpdate = 3
func main() { func main() {
configPath, install, remove, noCopy, args := parseFlags(os.Args[1:]) configPath, install, remove, noCopy, elevatedChild, args := parseFlags(os.Args[1:])
if len(args) > 0 && args[0] == "service" { if len(args) > 0 && args[0] == "service" {
// Legacy subcommand form: gpu-turnstile service install|remove. // Legacy subcommand form: gpu-turnstile service install|remove.
if len(args) != 2 || (args[1] != "install" && args[1] != "remove") { if len(args) != 2 || (args[1] != "install" && args[1] != "remove") {
@@ -51,9 +52,9 @@ func main() {
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, true, noCopy)) os.Exit(serviceCommand(configPath, true, noCopy, elevatedChild))
case remove: case remove:
os.Exit(serviceCommand(configPath, false, noCopy)) os.Exit(serviceCommand(configPath, false, noCopy, elevatedChild))
} }
if len(args) > 0 { if len(args) > 0 {
fmt.Fprintf(os.Stderr, "usage: gpu-turnstile [-config path] [--install-service [--no-copy] | --remove-service]\n") fmt.Fprintf(os.Stderr, "usage: gpu-turnstile [-config path] [--install-service [--no-copy] | --remove-service]\n")
@@ -89,8 +90,9 @@ func main() {
} }
// parseFlags extracts -config <path> (or -config=<path>), the // parseFlags extracts -config <path> (or -config=<path>), the
// --install-service / --remove-service switches and --no-copy from args. // --install-service / --remove-service switches, --no-copy and the hidden
func parseFlags(args []string) (configPath string, install, remove, noCopy bool, rest []string) { // --elevated-child marker from args.
func parseFlags(args []string) (configPath string, install, remove, noCopy, elevatedChild 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 {
@@ -105,11 +107,13 @@ func parseFlags(args []string) (configPath string, install, remove, noCopy bool,
remove = true remove = true
case args[i] == "--no-copy" || args[i] == "-no-copy": case args[i] == "--no-copy" || args[i] == "-no-copy":
noCopy = true noCopy = true
case args[i] == "--elevated-child":
elevatedChild = true
default: default:
rest = append(rest, args[i]) rest = append(rest, args[i])
} }
} }
return configPath, install, remove, noCopy, rest return configPath, install, remove, noCopy, elevatedChild, rest
} }
// defaultConfigPath returns gpu-turnstile.env next to the executable. // defaultConfigPath returns gpu-turnstile.env next to the executable.
@@ -182,12 +186,36 @@ func newLogger(cfg config.Config) (*slog.Logger, io.Writer, io.Closer) {
} }
// serviceCommand installs (copyBin = register the canonical-layout copy) // serviceCommand installs (copyBin = register the canonical-layout copy)
// or removes the service and reports the result. // or removes the service and reports the result. On Windows, when the
func serviceCommand(configPath string, install, noCopy bool) int { // 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 := "remove" verb := "remove"
var err error
if install { if install {
verb = "install" verb = "install"
}
if !service.Elevated() {
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
}
if err != nil {
fmt.Fprintf(os.Stderr, "gpu-turnstile: could not elevate: %v\n", err)
return 1
}
if code != 0 {
fmt.Fprintf(os.Stderr, "gpu-turnstile service %s failed in the elevated process (exit %d)\n", verb, code)
return code
}
fmt.Printf("service %s: %sd (elevated)\n", service.Name, verb)
return 0
}
var err error
if 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
@@ -196,6 +224,12 @@ func serviceCommand(configPath string, install, noCopy bool) int {
} else { } else {
err = service.Remove() err = service.Remove()
} }
if elevatedChild {
defer func() {
fmt.Print("\nPress Enter to close this window...")
bufio.NewReader(os.Stdin).ReadString('\n')
}()
}
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "gpu-turnstile service %s: %v\n", verb, err) fmt.Fprintf(os.Stderr, "gpu-turnstile service %s: %v\n", verb, err)
return 1 return 1
+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")
+9
View File
@@ -33,6 +33,15 @@ const (
// IsService reports whether the process was started by systemd. // IsService reports whether the process was started by systemd.
func IsService() bool { return os.Getenv("INVOCATION_ID") != "" } 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 // Run executes run with SIGINT/SIGTERM cancellation (which is how systemctl
// stop signals the process) and tells systemd when the shutdown begins. // stop signals the process) and tells systemd when the shutdown begins.
func Run(run func(ctx context.Context) error) error { func Run(run func(ctx context.Context) error) error {
+8 -1
View File
@@ -31,5 +31,12 @@ func Run(run func(ctx context.Context) error) error {
// Install is unsupported on non-Windows, non-Linux platforms. // Install is unsupported on non-Windows, non-Linux platforms.
func Install(string, bool) error { return errUnsupported } 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 } 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 }
+81
View File
@@ -9,14 +9,18 @@ package service
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"io" "io"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"strings" "strings"
"syscall"
"time" "time"
"unsafe"
"golang.org/x/sys/windows"
"golang.org/x/sys/windows/svc" "golang.org/x/sys/windows/svc"
"golang.org/x/sys/windows/svc/mgr" "golang.org/x/sys/windows/svc/mgr"
@@ -242,6 +246,83 @@ func Remove() error {
return nil 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. // grantAccess gives the virtual account the icacls permission set (e.g.
// "(OI)(CI)(M)") on path. // "(OI)(CI)(M)") on path.
func grantAccess(path, perms string) error { func grantAccess(path, perms string) error {