From fbab0bba337b55937bf638276f5c2531219091d5 Mon Sep 17 00:00:00 2001 From: mram Date: Mon, 21 Sep 2026 08:00:24 +0200 Subject: [PATCH] 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'. --- README.md | 6 +-- SPEC.md | 8 +-- cmd/gpu-turnstile/main.go | 52 ++++++++++++++---- internal/service/errors.go | 8 +++ internal/service/service_linux.go | 9 ++++ internal/service/service_other.go | 9 +++- internal/service/service_windows.go | 81 +++++++++++++++++++++++++++++ 7 files changed, 157 insertions(+), 16 deletions(-) create mode 100644 internal/service/errors.go diff --git a/README.md b/README.md index a5e3e34..1b6734d 100644 --- a/README.md +++ b/README.md @@ -97,9 +97,9 @@ go build ./cmd/gpu-turnstile ### Run natively on Windows (current primary deployment) Download `gpu-turnstile.exe` from a release and install it as a Windows -service from an elevated shell — the installer creates the canonical -layout, copies the binary and (if none exists yet) your -`gpu-turnstile.env` into it, and registers the copy: +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 # installs into Program Files, auto-start diff --git a/SPEC.md b/SPEC.md index 0e709d6..dc6c575 100644 --- a/SPEC.md +++ b/SPEC.md @@ -170,9 +170,11 @@ 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]` installs, registers and -starts an auto-start service; `--remove-service` stops and uninstalls it -(both need an elevated/root shell). The legacy form `gpu-turnstile service -install|remove` does the same thing. +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 diff --git a/cmd/gpu-turnstile/main.go b/cmd/gpu-turnstile/main.go index 3a72fb2..59d69b6 100644 --- a/cmd/gpu-turnstile/main.go +++ b/cmd/gpu-turnstile/main.go @@ -3,6 +3,7 @@ package main import ( + "bufio" "context" "errors" "fmt" @@ -35,7 +36,7 @@ var version = "dev" const exitCodeUpdate = 3 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" { // Legacy subcommand form: gpu-turnstile service install|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") os.Exit(2) case install: - os.Exit(serviceCommand(configPath, true, noCopy)) + os.Exit(serviceCommand(configPath, true, noCopy, elevatedChild)) case remove: - os.Exit(serviceCommand(configPath, false, noCopy)) + os.Exit(serviceCommand(configPath, false, noCopy, elevatedChild)) } if len(args) > 0 { 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 (or -config=), the -// --install-service / --remove-service switches and --no-copy from args. -func parseFlags(args []string) (configPath string, install, remove, noCopy bool, rest []string) { +// --install-service / --remove-service switches, --no-copy and the hidden +// --elevated-child marker from args. +func parseFlags(args []string) (configPath string, install, remove, noCopy, elevatedChild bool, rest []string) { rest = args[:0] for i := 0; i < len(args); i++ { switch { @@ -105,11 +107,13 @@ func parseFlags(args []string) (configPath string, install, remove, noCopy bool, remove = true case args[i] == "--no-copy" || args[i] == "-no-copy": noCopy = true + case args[i] == "--elevated-child": + elevatedChild = true default: 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. @@ -182,12 +186,36 @@ func newLogger(cfg config.Config) (*slog.Logger, io.Writer, io.Closer) { } // serviceCommand installs (copyBin = register the canonical-layout copy) -// or removes the service and reports the result. -func serviceCommand(configPath string, install, noCopy bool) int { +// 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 := "remove" - var err error if 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) if abs, absErr := filepath.Abs(path); absErr == nil { path = abs @@ -196,6 +224,12 @@ func serviceCommand(configPath string, install, noCopy bool) int { } else { err = service.Remove() } + if elevatedChild { + defer func() { + fmt.Print("\nPress Enter to close this window...") + bufio.NewReader(os.Stdin).ReadString('\n') + }() + } if err != nil { fmt.Fprintf(os.Stderr, "gpu-turnstile service %s: %v\n", verb, err) return 1 diff --git a/internal/service/errors.go b/internal/service/errors.go new file mode 100644 index 0000000..637fa9a --- /dev/null +++ b/internal/service/errors.go @@ -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") diff --git a/internal/service/service_linux.go b/internal/service/service_linux.go index 943e2dc..c784b99 100644 --- a/internal/service/service_linux.go +++ b/internal/service/service_linux.go @@ -33,6 +33,15 @@ const ( // 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 { diff --git a/internal/service/service_other.go b/internal/service/service_other.go index 05e4bdf..a57111d 100644 --- a/internal/service/service_other.go +++ b/internal/service/service_other.go @@ -31,5 +31,12 @@ func Run(run func(ctx context.Context) error) error { // 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 } diff --git a/internal/service/service_windows.go b/internal/service/service_windows.go index 8046707..509c5f0 100644 --- a/internal/service/service_windows.go +++ b/internal/service/service_windows.go @@ -9,14 +9,18 @@ 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" @@ -242,6 +246,83 @@ 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 {