// Copyright (c) 2026 Petr BalvĂ­n (https://petrbalvin.org) // SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 // Package updater checks Gitea releases for new versions and replaces // the running binary in place, preserving the process ID so systemd // never notices the restart. package updater import ( "bufio" "crypto/sha256" "encoding/hex" json "encoding/json/v2" "fmt" "io" "math" "net/http" "os" "path/filepath" "strings" "time" ) // client is shared by the release check and the download; the timeout // bounds a hung server. var client = &http.Client{Timeout: 2 * time.Minute} // maxBinarySize caps the downloaded binary. const maxBinarySize = 50 << 20 // ReleaseBase is the Gitea instance hosting volumen releases. var ReleaseBase = "https://sourcedock.dev" // syscallExec and executable are the two process-level operations the // self-update performs; they are variables so a test can observe them // without replacing the running binary. var ( syscallExec = defaultSyscallExec executable = os.Executable ) // RepoPath identifies the volumen repository on the Gitea instance. const RepoPath = "/api/v1/repos/petrbalvin/volumen" // RepoSlug is the repository path used by non-API URLs such as release // downloads. const RepoSlug = "/petrbalvin/volumen" // CheckLatest returns the newest published release tag (without the // leading "v"), or "" when no release exists. func CheckLatest() (string, error) { req, err := http.NewRequest(http.MethodGet, ReleaseBase+RepoPath+"/releases/latest", nil) if err != nil { return "", err } resp, err := client.Do(req) if err != nil { return "", fmt.Errorf("release check: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("release check: HTTP %d", resp.StatusCode) } var payload struct { TagName string `json:"tag_name"` } if err := json.UnmarshalRead(io.LimitReader(resp.Body, 1<<20), &payload); err != nil { return "", fmt.Errorf("release check: %w", err) } return strings.TrimPrefix(payload.TagName, "v"), nil } // UpdateAvailable returns the newer version when one exists, else "". func UpdateAvailable(current string) string { latest, err := CheckLatest() if err != nil || latest == "" || latest == current { return "" } if CompareVersions(latest, current) > 0 { return latest } return "" } // fetchChecksum reads checksums.txt from the release and returns the // SHA-256 recorded for name. func fetchChecksum(version, name string) (string, error) { url := fmt.Sprintf("%s%s/releases/download/v%s/checksums.txt", ReleaseBase, RepoSlug, version) body, err := fetchBounded(url, 1<<20) if err != nil { return "", fmt.Errorf("download checksums: %w", err) } scanner := bufio.NewScanner(strings.NewReader(string(body))) for scanner.Scan() { fields := strings.Fields(scanner.Text()) if len(fields) == 2 && strings.TrimPrefix(fields[1], "*") == name { return fields[0], nil } } return "", fmt.Errorf("checksums.txt has no entry for %s", name) } // fetchBounded downloads url, refusing bodies larger than limit. The // URL is built from the release base and the version, so it is never // caller-supplied; redirects are followed by the shared client. func fetchBounded(url string, limit int64) ([]byte, error) { resp, err := client.Get(url) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("fetch %s: HTTP %d", url, resp.StatusCode) } body, err := io.ReadAll(io.LimitReader(resp.Body, limit+1)) if err != nil { return nil, err } if int64(len(body)) > limit { return nil, fmt.Errorf("body exceeds %d bytes", limit) } return body, nil } // CompareVersions compares two dotted versions numerically per part. func CompareVersions(a, b string) int { as, bs := strings.Split(a, "."), strings.Split(b, ".") for i := 0; i < len(as) || i < len(bs); i++ { av, bv := part(as, i), part(bs, i) if av != bv { if av < bv { return -1 } return 1 } } return 0 } // part returns the i-th numeric component of a version. A component // longer than an int is clamped rather than allowed to overflow, because // an overflowed comparison can invert. func part(parts []string, i int) int { if i >= len(parts) { return 0 } value := 0 for _, r := range parts[i] { if r < '0' || r > '9' { break } digit := int(r - '0') if value > (math.MaxInt-digit)/10 { return math.MaxInt } value = value*10 + digit } return value } // SelfUpdate downloads the release binary for this platform, replaces // the running executable atomically, and restarts the process in // place. It returns the target version. func SelfUpdate(current string) (string, error) { latest, err := CheckLatest() if err != nil { return "", err } if latest == "" || CompareVersions(latest, current) <= 0 { return latest, fmt.Errorf("already running the latest release") } exe, err := executable() if err != nil { return latest, fmt.Errorf("locate executable: %w", err) } exe, err = filepath.EvalSymlinks(exe) if err != nil { return latest, fmt.Errorf("resolve executable: %w", err) } binaryName := fmt.Sprintf("volumen-%s-%s-%s", latest, goos(), goarch()) asset := fmt.Sprintf("%s%s/releases/download/v%s/%s", ReleaseBase, RepoSlug, latest, binaryName) expected, err := fetchChecksum(latest, binaryName) if err != nil { return latest, err } body, err := fetchBounded(asset, maxBinarySize) if err != nil { return latest, fmt.Errorf("download %s: %w", asset, err) } sum := sha256.Sum256(body) if hex.EncodeToString(sum[:]) != expected { return latest, fmt.Errorf("checksum mismatch for %s", binaryName) } dir := filepath.Dir(exe) tmp, err := os.CreateTemp(dir, ".volumen-update-*") if err != nil { return latest, fmt.Errorf("stage download: %w", err) } tmpPath := tmp.Name() if _, err := tmp.Write(body); err != nil { tmp.Close() os.Remove(tmpPath) return latest, fmt.Errorf("stage download: %w", err) } if err := tmp.Sync(); err != nil { tmp.Close() os.Remove(tmpPath) return latest, fmt.Errorf("stage download: %w", err) } if err := tmp.Close(); err != nil { os.Remove(tmpPath) return latest, fmt.Errorf("stage download: %w", err) } if err := os.Chmod(tmpPath, 0o755); err != nil { os.Remove(tmpPath) return latest, fmt.Errorf("stage download: %w", err) } if err := os.Rename(tmpPath, exe); err != nil { os.Remove(tmpPath) return latest, fmt.Errorf("replace executable: %w", err) } // Restart in place; the PID stays the same so systemd sees nothing. argv := append([]string{exe}, os.Args[1:]...) if err := syscallExec(exe, argv); err != nil { return latest, fmt.Errorf("restart: %w", err) } return latest, nil }