From 6a6359a63065207da6b4fe93ff41357f2744c066 Mon Sep 17 00:00:00 2001 From: mram Date: Mon, 21 Sep 2026 08:22:11 +0200 Subject: [PATCH] Add --force-update: immediate signed-update check, stage + service restart --- README.md | 2 + SPEC.md | 5 + cmd/gpu-turnstile/main.go | 151 ++++++++++++++++++++++------ internal/service/service_linux.go | 13 +++ internal/service/service_other.go | 3 + internal/service/service_windows.go | 45 +++++++++ 6 files changed, 191 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index a486f32..a41ae8c 100644 --- a/README.md +++ b/README.md @@ -149,6 +149,8 @@ OpenSSL; the matching public key lives in `internal/update/pubkey.go` (one-time setup: `openssl genpkey -algorithm ed25519 -out private.pem`, `openssl pkey -in private.pem -pubout -out public.pem`; private key goes to the `RELEASE_SIGNING_KEY` repo secret, public key is committed). +`gpu-turnstile --force-update` checks immediately, stages the new binary +and restarts the running service (elevating via UAC only if needed). ### Docker diff --git a/SPEC.md b/SPEC.md index dc6c575..38bb387 100644 --- a/SPEC.md +++ b/SPEC.md @@ -246,6 +246,11 @@ executable location as-is instead. idle the process exits with code 3 so the service recovery restarts it on the new version. Interactive runs only log "restart to apply". `dev` builds and builds without an embedded public key never update. +- **`--force-update`** runs the same check immediately: it downloads, + verifies and stages a newer release, and if the service is running it + restarts it right away (otherwise the new version applies on next + start). On Windows it elevates via UAC only when the stage or restart + needs permissions the caller does not have. - **Signing setup (one time)**: `openssl genpkey -algorithm ed25519 -out private.pem`; `openssl pkey -in private.pem -pubout -out public.pem`. Private key → repo secret `RELEASE_SIGNING_KEY`; public key → committed into diff --git a/cmd/gpu-turnstile/main.go b/cmd/gpu-turnstile/main.go index f6bf311..290b242 100644 --- a/cmd/gpu-turnstile/main.go +++ b/cmd/gpu-turnstile/main.go @@ -8,6 +8,7 @@ import ( "errors" "fmt" "io" + "io/fs" "log/slog" "net" "net/http" @@ -45,8 +46,9 @@ func stdoutIsTerminal() bool { // parseFlags extracts -config (or -config=), the // --install-service / --remove-service switches, --no-copy, -h/--help, -// -v/--version and the hidden --elevated-child marker from args. -func parseFlags(args []string) (configPath string, install, remove, noCopy, help, showVersion, elevatedChild bool, rest []string) { +// -v/--version, --force-update and the hidden --elevated-child marker from +// args. +func parseFlags(args []string) (configPath string, install, remove, noCopy, help, showVersion, forceUpdate, elevatedChild bool, rest []string) { rest = args[:0] for i := 0; i < len(args); i++ { switch { @@ -65,13 +67,15 @@ func parseFlags(args []string) (configPath string, install, remove, noCopy, help help = true case args[i] == "-v" || args[i] == "--version" || args[i] == "-version": showVersion = true + case args[i] == "--force-update" || args[i] == "-force-update": + forceUpdate = true case args[i] == "--elevated-child": elevatedChild = true default: rest = append(rest, args[i]) } } - return configPath, install, remove, noCopy, help, showVersion, elevatedChild, rest + return configPath, install, remove, noCopy, help, showVersion, forceUpdate, elevatedChild, rest } // versionLine is printed at the top of every help and error screen. @@ -84,6 +88,8 @@ Usage: gpu-turnstile --install-service [--no-copy] [-config path] install + start as a service gpu-turnstile --remove-service stop + uninstall the service gpu-turnstile -v | --version print just the version + gpu-turnstile --force-update check for a signed update now, + apply it and restart the service gpu-turnstile -h | --help this help Options: @@ -114,12 +120,12 @@ func fatalUsage(format string, args ...any) { } func main() { - configPath, install, remove, noCopy, help, showVersion, elevatedChild, args := parseFlags(os.Args[1:]) + configPath, install, remove, noCopy, help, showVersion, forceUpdate, elevatedChild, args := parseFlags(os.Args[1:]) if showVersion { fmt.Println(version) return } - bare := configPath == "" && !install && !remove && !elevatedChild && len(args) == 0 + bare := configPath == "" && !install && !remove && !forceUpdate && !elevatedChild && len(args) == 0 if help || (bare && stdoutIsTerminal()) { // Bare invocation in a terminal (e.g. double-clicked on Windows) // shows the help instead of starting a proxy window with no visible @@ -140,10 +146,14 @@ func main() { switch { case install && remove: fatalUsage("error: --install-service and --remove-service are mutually exclusive") + case forceUpdate && (install || remove): + fatalUsage("error: --force-update cannot be combined with --install-service/--remove-service") case install: os.Exit(serviceCommand(configPath, true, noCopy, elevatedChild)) case remove: os.Exit(serviceCommand(configPath, false, noCopy, elevatedChild)) + case forceUpdate: + os.Exit(forceUpdateCommand(configPath, elevatedChild)) } if len(args) > 0 { fatalUsage("error: unknown arguments: %s", strings.Join(args, " ")) @@ -245,6 +255,102 @@ func newLogger(cfg config.Config) (*slog.Logger, io.Writer, io.Closer) { return log, out, closer } +// waitForEnter keeps an elevated child's console window open until the +// user has read the output. +func waitForEnter() { + fmt.Print("\nPress Enter to close this window...") + bufio.NewReader(os.Stdin).ReadString('\n') +} + +// elevateAndMirror relaunches the current command elevated (UAC) and +// mirrors the child's exit code. verb is used in messages. +func elevateAndMirror(verb string) (int, bool) { + 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, true + } + if err != nil { + fmt.Fprintf(os.Stderr, "gpu-turnstile: could not elevate: %v\n", err) + return 1, true + } + if code != 0 { + fmt.Fprintf(os.Stderr, "gpu-turnstile %s failed in the elevated process (exit %d)\n", verb, code) + return code, true + } + return 0, true +} + +// isPermission reports whether err is a permission problem (Windows +// ERROR_ACCESS_DENIED, POSIX EACCES/EPERM, possibly wrapped). +func isPermission(err error) bool { + return errors.Is(err, fs.ErrPermission) || strings.Contains(strings.ToLower(err.Error()), "access is denied") +} + +// forceUpdateCommand checks for a signed update immediately, stages it if +// newer, and restarts the service when it is running so the new binary +// takes effect. Staging into a system directory and restarting a service +// need admin rights; instead of prompting unconditionally, permission +// failures trigger the UAC relaunch so a dev copy in a user-writable +// directory updates without a prompt. +func forceUpdateCommand(configPath string, elevatedChild bool) int { + if elevatedChild { + defer waitForEnter() + } + cfg, err := loadMergedConfig(configPath) + if err != nil { + fmt.Fprintf(os.Stderr, "%s\n\ngpu-turnstile: %v\n", versionLine(), err) + return 1 + } + exePath, err := os.Executable() + if err != nil { + fmt.Fprintf(os.Stderr, "gpu-turnstile: cannot locate executable: %v\n", err) + return 1 + } + log, _, logCloser := newLogger(cfg) + defer logCloser.Close() + u := &update.Updater{Repo: cfg.UpdateRepo, Asset: cfg.UpdateAsset, Version: version, Log: log} + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + staged, err := u.Check(ctx, exePath) + if err != nil && isPermission(err) && !service.Elevated() { + code, _ := elevateAndMirror("--force-update") + if code == 0 { + fmt.Println("update applied (elevated)") + } + return code + } + if err != nil { + fmt.Fprintf(os.Stderr, "%s\n\ngpu-turnstile: update check failed: %v\n", versionLine(), err) + return 1 + } + if !staged { + fmt.Printf("%s is up to date\n", versionLine()) + return 0 + } + fmt.Printf("%s: update staged\n", versionLine()) + restarted, err := service.RestartIfRunning() + if err != nil && isPermission(err) && !service.Elevated() { + code, _ := elevateAndMirror("--force-update") + if code == 0 { + fmt.Println("update applied (elevated)") + } + return code + } + if err != nil { + fmt.Fprintf(os.Stderr, "gpu-turnstile: update staged but service restart failed: %v\n", err) + return 1 + } + if restarted { + fmt.Println("service restarted on the new version") + } else { + fmt.Println("no running service; the new version applies on next start") + } + return 0 +} + // serviceCommand installs (copyBin = register the canonical-layout copy) // or removes the service and reports the result. On Windows, when the // shell is not elevated, the command relaunches itself through a UAC @@ -252,27 +358,22 @@ func newLogger(cfg config.Config) (*slog.Logger, io.Writer, io.Closer) { // 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, done := "remove", "removed" + verb, doneVerb := "remove", "removed" if install { - verb, done = "install", "installed" + verb, doneVerb = "install", "installed" + } + if elevatedChild { + defer waitForEnter() } 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) + code, done := elevateAndMirror(verb) + if done && code != 0 { return code } - fmt.Printf("service %s: %s (elevated)\n", service.Name, done) - return 0 + if done { + fmt.Printf("service %s: %s (elevated)\n", service.Name, doneVerb) + return 0 + } } var err error if install { @@ -284,17 +385,11 @@ func serviceCommand(configPath string, install, noCopy, elevatedChild 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 } - fmt.Printf("service %s: %s\n", service.Name, done) + fmt.Printf("service %s: %s\n", service.Name, doneVerb) return 0 } diff --git a/internal/service/service_linux.go b/internal/service/service_linux.go index c784b99..c175525 100644 --- a/internal/service/service_linux.go +++ b/internal/service/service_linux.go @@ -186,3 +186,16 @@ func Remove() error { } return nil } + +// RestartIfRunning restarts the systemd unit when it is active (used after +// a forced update staged a new binary). Reports whether a restart +// happened. An inactive or missing unit is not an error. Needs root. +func RestartIfRunning() (bool, error) { + if err := exec.Command("systemctl", "is-active", "--quiet", Name+".service").Run(); err != nil { + return false, nil // inactive or not installed + } + if out, err := exec.Command("systemctl", "restart", Name+".service").CombinedOutput(); err != nil { + return false, fmt.Errorf("systemctl restart (run as root): %w (%s)", err, out) + } + return true, nil +} diff --git a/internal/service/service_other.go b/internal/service/service_other.go index a57111d..a08813d 100644 --- a/internal/service/service_other.go +++ b/internal/service/service_other.go @@ -40,3 +40,6 @@ func Elevated() bool { return true } // RelaunchElevated is unsupported on non-Windows, non-Linux platforms. func RelaunchElevated([]string) (int, error) { return 0, errUnsupported } + +// RestartIfRunning is a no-op on platforms without service integration. +func RestartIfRunning() (bool, error) { return false, nil } diff --git a/internal/service/service_windows.go b/internal/service/service_windows.go index 11e96d8..a61f7d2 100644 --- a/internal/service/service_windows.go +++ b/internal/service/service_windows.go @@ -351,3 +351,48 @@ func configuredLogFile(configPath string) string { } return values["LOG_FILE"] } + +// RestartIfRunning restarts the service when it is installed and running +// (used after a forced update staged a new binary). Reports whether a +// restart happened. A service that is not installed or not running is not +// an error. Needs elevation. +func RestartIfRunning() (bool, error) { + m, err := mgr.Connect() + if err != nil { + return false, fmt.Errorf("connect to service manager (run as administrator): %w", err) + } + defer m.Disconnect() + s, err := m.OpenService(Name) + if err != nil { + return false, nil // not installed + } + defer s.Close() + st, err := s.Query() + if err != nil { + return false, fmt.Errorf("query service: %w", err) + } + if st.State != svc.Running && st.State != svc.StartPending { + return false, nil + } + if _, err := s.Control(svc.Stop); err != nil { + return false, fmt.Errorf("stop service: %w", err) + } + deadline := time.Now().Add(30 * time.Second) + for { + st, err = s.Query() + if err != nil { + return false, fmt.Errorf("query service: %w", err) + } + if st.State == svc.Stopped { + break + } + if time.Now().After(deadline) { + return false, fmt.Errorf("service did not stop within 30s") + } + time.Sleep(300 * time.Millisecond) + } + if err := s.Start(); err != nil { + return false, fmt.Errorf("start service: %w", err) + } + return true, nil +}