package sandbar import ( "context" "log" "net/http" "os" "os/exec" "path/filepath" "runtime" "strconv" "strings" "sync" "time" "git.veillax.com/Veillax/reef/selfupdate" ) // Self-update replaces the running binary with the newest release published on the Gitea // server (Reef's selfupdate does the download, checksum and swap). // // In a container the binary lives in the image, where this user can't write and where a // change would be lost when the container is recreated. So an update is written to // /bin/sandbar on the data volume instead, and every start checks that file first // (UseNewerOverlay): it runs when it is a newer release than the image, and is deleted once // the image has caught up. const defaultUpdateServer = "https://git.veillax.com" type updateState struct { mu sync.Mutex running bool stage string pct int line string err string checked time.Time // the cached answer from the release server latest string newer bool checkErr string } func (s *Server) updateConfig() selfupdate.Config { o := s.opts server, repo := o.UpdateServer, o.UpdateRepo if server == "" { server = defaultUpdateServer } if repo == "" { repo = "Veillax/sandbar" } return selfupdate.Config{ Server: server, Repo: repo, App: "sandbar", Current: o.Version, ExePath: UpdateTarget(o.DataDir), Restart: o.Restart, Logf: log.Printf, } } // RunAutoUpdate checks for a new release shortly after start and then every few hours until // ctx ends, installing what it finds and restarting into it. func (s *Server) RunAutoUpdate(ctx context.Context) { selfupdate.Run(ctx, s.updateConfig()) } // canInstall says whether this build can update itself, and if not, why. func (s *Server) canInstall() (bool, string) { if _, ok := parseVersion(s.opts.Version); !ok { return false, "This is a development build, so it does not update itself." } if runtime.GOOS == "windows" { return false, "A running program can't be replaced on Windows. Download the new release and restart." } return true, "" } // updateStatus reports the running and newest versions. The release server is asked at most // every ten minutes unless ?fresh=1. func (s *Server) updateStatus(w http.ResponseWriter, r *http.Request) { ok, why := s.canInstall() u := &s.upd u.mu.Lock() stale := time.Since(u.checked) > 10*time.Minute || r.URL.Query().Get("fresh") == "1" running := u.running u.mu.Unlock() if stale && !running { ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second) latest, newer, err := selfupdate.Check(ctx, s.updateConfig()) cancel() u.mu.Lock() u.checked, u.latest, u.newer, u.checkErr = time.Now(), latest, newer, "" if err != nil { u.checkErr = err.Error() } u.mu.Unlock() } u.mu.Lock() out := map[string]any{ "current": s.opts.Version, "autoUpdate": s.opts.AutoUpdate, "canInstall": ok, "reason": why, "latest": u.latest, "newer": u.newer, "error": u.checkErr, "running": u.running, "stage": u.stage, "pct": u.pct, "line": u.line, "failed": u.err, } u.mu.Unlock() writeJSON(w, 200, out) } // updateApply installs the newest release now. It answers at once; the page polls updateStatus // for progress, and the server disappears for a moment when it restarts into the new binary. func (s *Server) updateApply(w http.ResponseWriter, r *http.Request) { if ok, why := s.canInstall(); !ok { bad(w, why) return } u := &s.upd u.mu.Lock() if u.running { u.mu.Unlock() writeJSON(w, 409, map[string]string{"error": "An update is already running."}) return } u.running, u.stage, u.pct, u.line, u.err = true, "starting", 0, "", "" u.mu.Unlock() cfg := s.updateConfig() cfg.Progress = func(e selfupdate.Event) { u.mu.Lock() defer u.mu.Unlock() u.stage = e.Stage if e.Pct >= 0 { u.pct = e.Pct } if e.Line != "" { u.line = e.Line } } go func() { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) defer cancel() _, err := selfupdate.CheckOnce(ctx, cfg) // when it installs, the process is replaced and this never returns u.mu.Lock() u.running = false if err != nil { u.err = err.Error() } u.checked = time.Time{} // look again next time u.mu.Unlock() }() writeJSON(w, 200, map[string]bool{"started": true}) } // UpdateTarget is the file an update replaces: the running binary when its folder is writable // (a plain install), and otherwise /bin/sandbar (a container). func UpdateTarget(dataDir string) string { if exe, err := os.Executable(); err == nil { if exe, err = filepath.EvalSymlinks(exe); err == nil && writableDir(filepath.Dir(exe)) { return exe } } dir, err := filepath.Abs(filepath.Join(dataDir, "bin")) if err != nil { dir = filepath.Join(dataDir, "bin") } os.MkdirAll(dir, 0o755) return filepath.Join(dir, "sandbar") } func writableDir(dir string) bool { f, err := os.CreateTemp(dir, ".sandbar-write-test-*") if err != nil { return false } f.Close() os.Remove(f.Name()) return true } // UseNewerOverlay runs /bin/sandbar instead of this binary when that file is a newer // release. It returns when there is nothing to switch to, and otherwise replaces the process. func UseNewerOverlay(dataDir, current string, logf func(string, ...any)) { if runtime.GOOS == "windows" || os.Getenv("SANDBAR_NO_OVERLAY") == "1" { return } overlay, err := filepath.Abs(filepath.Join(dataDir, "bin", "sandbar")) if err != nil { return } ofi, err := os.Stat(overlay) if err != nil { return } if exe, err := os.Executable(); err == nil { if efi, err := os.Stat(exe); err == nil && os.SameFile(efi, ofi) { return // this already is the overlay } } ver := overlayVersion(overlay) switch { case ver == "": logf("ignoring %s: it did not report a version", overlay) case versionNewer(ver, current): logf("running %s from %s, which is newer than this image's %s", ver, overlay, current) if err := selfupdate.Restart(overlay); err != nil { logf("could not switch to %s: %v", overlay, err) } default: if _, ok := parseVersion(current); ok { os.Remove(overlay) // the image has caught up with it } } } // overlayVersion asks a binary for its version, or returns "" if it will not run. func overlayVersion(path string) string { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() out, err := exec.CommandContext(ctx, path, "-version").Output() f := strings.Fields(string(out)) if err != nil || len(f) != 2 || f[0] != "sandbar" { return "" } return f[1] } // parseVersion reads vX.Y.Z. Pre-releases and local builds are not versions for this purpose. func parseVersion(s string) ([3]int, bool) { var v [3]int if !strings.HasPrefix(s, "v") || strings.ContainsAny(s, "-+") { return v, false } parts := strings.Split(s[1:], ".") if len(parts) != 3 { return v, false } for i, p := range parts { n, err := strconv.Atoi(p) if err != nil || n < 0 { return v, false } v[i] = n } return v, true } // versionNewer reports whether a is a later release than b. Either not being a release is false. func versionNewer(a, b string) bool { x, ok1 := parseVersion(a) y, ok2 := parseVersion(b) if !ok1 || !ok2 { return false } for i := range x { if x[i] != y[i] { return x[i] > y[i] } } return false }