From 8e832601237f365538b87925baccd483410e9bb5 Mon Sep 17 00:00:00 2001 From: root Date: Fri, 1 May 2026 19:33:10 +0300 Subject: [PATCH] cmd/drover: UAC re-launch helper for non-admin invocations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLI subcommands (check/version/update) don't need driver access and run as user. Bare drover.exe (GUI/engine mode) requires admin for WinDivertOpen — re-launches via ShellExecute("runas") and exits. Per spec decision B1: prompt at every launch, no scheduled-task trampoline. Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/drover/main.go | 11 +++++ cmd/drover/uac_windows.go | 87 ++++++++++++++++++++++++++++++++++ cmd/drover/uac_windows_test.go | 32 +++++++++++++ 3 files changed, 130 insertions(+) create mode 100644 cmd/drover/uac_windows.go create mode 100644 cmd/drover/uac_windows_test.go diff --git a/cmd/drover/main.go b/cmd/drover/main.go index f829223..6cf2679 100644 --- a/cmd/drover/main.go +++ b/cmd/drover/main.go @@ -29,6 +29,17 @@ func main() { // AttachConsole(ATTACH_PARENT_PROCESS) wires that up. No-op elsewhere. attachToParentConsole() + // Detect if we need admin for the command in os.Args[1:]. If we do and + // we're not admin, re-launch via ShellExecute("runas", ...) and exit. + // CLI subcommands like "check", "version", "update" don't need admin + // and will run without UAC prompt. + if CmdNeedsAdmin(os.Args[1:]) && !IsAdmin() { + if err := ReElevate(os.Args[1:]); err != nil { + fmt.Fprintf(os.Stderr, "failed to re-elevate: %v\n", err) + } + os.Exit(0) + } + // Inject our build version so the updater package can stamp it on the // User-Agent header it sends to git.okcu.io. updater.SetVersion(Version) diff --git a/cmd/drover/uac_windows.go b/cmd/drover/uac_windows.go new file mode 100644 index 0000000..093c20d --- /dev/null +++ b/cmd/drover/uac_windows.go @@ -0,0 +1,87 @@ +//go:build windows + +package main + +import ( + "os" + "syscall" + "unsafe" + + "golang.org/x/sys/windows" +) + +// IsAdmin returns true when the current process token has elevation. +// Wraps GetTokenInformation(TokenElevation). +func IsAdmin() bool { + var token windows.Token + if err := windows.OpenProcessToken(windows.CurrentProcess(), windows.TOKEN_QUERY, &token); err != nil { + return false + } + defer token.Close() + + var elevation uint32 + var sz uint32 + err := windows.GetTokenInformation( + token, + windows.TokenElevation, + (*byte)(unsafe.Pointer(&elevation)), + uint32(unsafe.Sizeof(elevation)), + &sz, + ) + if err != nil { + return false + } + return elevation != 0 +} + +// CmdNeedsAdmin reports whether the given CLI args land in a code path +// that requires a WinDivert handle (and therefore admin). The default +// (no args = GUI mode) needs admin; explicit subcommands like check, +// version, update do not. +func CmdNeedsAdmin(args []string) bool { + if len(args) == 0 { + return true // bare drover.exe → GUI/engine + } + switch args[0] { + case "check", "version", "--version", "-v", "update", "--help", "-h", "help": + return false + default: + return true + } +} + +// ReElevate re-launches the current executable with the given args via +// ShellExecuteW("runas", ...). On success the caller should os.Exit(0) +// immediately. Returns nil even when the user cancels UAC — the caller +// can't distinguish; we just exit cleanly afterward. +func ReElevate(args []string) error { + exe, err := os.Executable() + if err != nil { + return err + } + verb, _ := syscall.UTF16PtrFromString("runas") + exePtr, _ := syscall.UTF16PtrFromString(exe) + + var paramsPtr *uint16 + if len(args) > 0 { + // Quote each arg in case of spaces. + quoted := make([]string, len(args)) + for i, a := range args { + quoted[i] = `"` + a + `"` + } + joined := "" + for i, q := range quoted { + if i > 0 { + joined += " " + } + joined += q + } + paramsPtr, _ = syscall.UTF16PtrFromString(joined) + } + + cwd, _ := os.Getwd() + cwdPtr, _ := syscall.UTF16PtrFromString(cwd) + + // SW_NORMAL = 1 + return windows.ShellExecute(0, verb, exePtr, paramsPtr, cwdPtr, 1) +} diff --git a/cmd/drover/uac_windows_test.go b/cmd/drover/uac_windows_test.go new file mode 100644 index 0000000..0d1de09 --- /dev/null +++ b/cmd/drover/uac_windows_test.go @@ -0,0 +1,32 @@ +package main + +import ( + "testing" +) + +func TestIsAdmin_Smoke(t *testing.T) { + // Smoke test: IsAdmin returns a bool without panicking. + // We can't assert true/false without knowing the test environment, + // but we ensure the syscall path doesn't crash. + _ = IsAdmin() +} + +func TestCmdNeedsAdmin_NoAdminFlags(t *testing.T) { + cases := []struct { + args []string + needsAdm bool + }{ + {[]string{}, true}, // bare drover.exe → GUI mode → needs admin + {[]string{"check"}, false}, // diagnostic only, no driver + {[]string{"check", "--host", "x"}, false}, + {[]string{"--version"}, false}, + {[]string{"version"}, false}, + {[]string{"update"}, false}, // self-update doesn't need driver + } + for _, c := range cases { + got := CmdNeedsAdmin(c.args) + if got != c.needsAdm { + t.Errorf("CmdNeedsAdmin(%v) = %v, want %v", c.args, got, c.needsAdm) + } + } +}