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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <path> (or -config=<path>), 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
|
||||
|
||||
@@ -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")
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user