Add --force-update: immediate signed-update check, stage + service restart

This commit is contained in:
mram
2026-09-21 08:22:11 +02:00
parent e4e4348e8d
commit 6a6359a630
6 changed files with 191 additions and 28 deletions
+2
View File
@@ -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`, (one-time setup: `openssl genpkey -algorithm ed25519 -out private.pem`,
`openssl pkey -in private.pem -pubout -out public.pem`; private key goes `openssl pkey -in private.pem -pubout -out public.pem`; private key goes
to the `RELEASE_SIGNING_KEY` repo secret, public key is committed). 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 ### Docker
+5
View File
@@ -246,6 +246,11 @@ executable location as-is instead.
idle the process exits with code 3 so the service recovery restarts it idle the process exits with code 3 so the service recovery restarts it
on the new version. Interactive runs only log "restart to apply". on the new version. Interactive runs only log "restart to apply".
`dev` builds and builds without an embedded public key never update. `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 - **Signing setup (one time)**: `openssl genpkey -algorithm ed25519 -out
private.pem`; `openssl pkey -in private.pem -pubout -out public.pem`. private.pem`; `openssl pkey -in private.pem -pubout -out public.pem`.
Private key → repo secret `RELEASE_SIGNING_KEY`; public key → committed into Private key → repo secret `RELEASE_SIGNING_KEY`; public key → committed into
+123 -28
View File
@@ -8,6 +8,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"io/fs"
"log/slog" "log/slog"
"net" "net"
"net/http" "net/http"
@@ -45,8 +46,9 @@ func stdoutIsTerminal() bool {
// parseFlags extracts -config <path> (or -config=<path>), the // parseFlags extracts -config <path> (or -config=<path>), the
// --install-service / --remove-service switches, --no-copy, -h/--help, // --install-service / --remove-service switches, --no-copy, -h/--help,
// -v/--version and the hidden --elevated-child marker from args. // -v/--version, --force-update and the hidden --elevated-child marker from
func parseFlags(args []string) (configPath string, install, remove, noCopy, help, showVersion, elevatedChild bool, rest []string) { // args.
func parseFlags(args []string) (configPath string, install, remove, noCopy, help, showVersion, forceUpdate, 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 {
@@ -65,13 +67,15 @@ func parseFlags(args []string) (configPath string, install, remove, noCopy, help
help = true help = true
case args[i] == "-v" || args[i] == "--version" || args[i] == "-version": case args[i] == "-v" || args[i] == "--version" || args[i] == "-version":
showVersion = true showVersion = true
case args[i] == "--force-update" || args[i] == "-force-update":
forceUpdate = true
case args[i] == "--elevated-child": case args[i] == "--elevated-child":
elevatedChild = true elevatedChild = true
default: default:
rest = append(rest, args[i]) 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. // 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 --install-service [--no-copy] [-config path] install + start as a service
gpu-turnstile --remove-service stop + uninstall the service gpu-turnstile --remove-service stop + uninstall the service
gpu-turnstile -v | --version print just the version 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 gpu-turnstile -h | --help this help
Options: Options:
@@ -114,12 +120,12 @@ func fatalUsage(format string, args ...any) {
} }
func main() { 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 { if showVersion {
fmt.Println(version) fmt.Println(version)
return return
} }
bare := configPath == "" && !install && !remove && !elevatedChild && len(args) == 0 bare := configPath == "" && !install && !remove && !forceUpdate && !elevatedChild && len(args) == 0
if help || (bare && stdoutIsTerminal()) { if help || (bare && stdoutIsTerminal()) {
// Bare invocation in a terminal (e.g. double-clicked on Windows) // Bare invocation in a terminal (e.g. double-clicked on Windows)
// shows the help instead of starting a proxy window with no visible // shows the help instead of starting a proxy window with no visible
@@ -140,10 +146,14 @@ func main() {
switch { switch {
case install && remove: case install && remove:
fatalUsage("error: --install-service and --remove-service are mutually exclusive") 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: case install:
os.Exit(serviceCommand(configPath, true, noCopy, elevatedChild)) os.Exit(serviceCommand(configPath, true, noCopy, elevatedChild))
case remove: case remove:
os.Exit(serviceCommand(configPath, false, noCopy, elevatedChild)) os.Exit(serviceCommand(configPath, false, noCopy, elevatedChild))
case forceUpdate:
os.Exit(forceUpdateCommand(configPath, elevatedChild))
} }
if len(args) > 0 { if len(args) > 0 {
fatalUsage("error: unknown arguments: %s", strings.Join(args, " ")) 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 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) // serviceCommand installs (copyBin = register the canonical-layout copy)
// or removes the service and reports the result. On Windows, when the // or removes the service and reports the result. On Windows, when the
// shell is not elevated, the command relaunches itself through a UAC // 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 // waits for a keypress so its console window does not flash closed before
// the output can be read. // the output can be read.
func serviceCommand(configPath string, install, noCopy, elevatedChild bool) int { func serviceCommand(configPath string, install, noCopy, elevatedChild bool) int {
verb, done := "remove", "removed" verb, doneVerb := "remove", "removed"
if install { if install {
verb, done = "install", "installed" verb, doneVerb = "install", "installed"
}
if elevatedChild {
defer waitForEnter()
} }
if !service.Elevated() { if !service.Elevated() {
args := append(append([]string{}, os.Args[1:]...), "--elevated-child") code, done := elevateAndMirror(verb)
code, err := service.RelaunchElevated(args) if done && code != 0 {
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 return code
} }
fmt.Printf("service %s: %s (elevated)\n", service.Name, done) if done {
return 0 fmt.Printf("service %s: %s (elevated)\n", service.Name, doneVerb)
return 0
}
} }
var err error var err error
if install { if install {
@@ -284,17 +385,11 @@ func serviceCommand(configPath string, install, noCopy, elevatedChild 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
} }
fmt.Printf("service %s: %s\n", service.Name, done) fmt.Printf("service %s: %s\n", service.Name, doneVerb)
return 0 return 0
} }
+13
View File
@@ -186,3 +186,16 @@ func Remove() error {
} }
return nil 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
}
+3
View File
@@ -40,3 +40,6 @@ func Elevated() bool { return true }
// RelaunchElevated is unsupported on non-Windows, non-Linux platforms. // RelaunchElevated is unsupported on non-Windows, non-Linux platforms.
func RelaunchElevated([]string) (int, error) { return 0, errUnsupported } 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 }
+45
View File
@@ -351,3 +351,48 @@ func configuredLogFile(configPath string) string {
} }
return values["LOG_FILE"] 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
}