// SPDX-FileCopyrightText: 2026 Echolot contributors // SPDX-License-Identifier: GPL-3.0-or-later // Package selfupdate replaces the running binary with the newest release // asset from a Gitea repo. Native mode only and strictly opt-in (twice: the // API base must be configured AND --self-update passed / timer enabled). // Containers update by pulling a new image tag instead. package selfupdate import ( "crypto/sha256" "echo-lot.app/server/internal/relsign" "echo-lot.app/server/internal/system" "encoding/hex" "encoding/json" "fmt" "io" "net/http" "os" "path/filepath" "runtime" "strings" "time" ) // DefaultPublicKeyB64 is the reference deployment's release-signing key (ed25519, base64). The // matching private key lives only in the CI secret store (RELEASE_SIGNING_KEY) — not in this // repo, not on the Gitea host, not on any server. Operators running their own release pipeline // override it with ECHOLOT_SELF_UPDATE_PUBKEY (mint a pair with `release-sign -gen`). const DefaultPublicKeyB64 = "KcytZd4zNIwqfhTyamtdSrXg8ZqYHGAkVxgn5zR7ZQI=" type release struct { TagName string `json:"tag_name"` Assets []asset `json:"assets"` } type asset struct { Name string `json:"name"` URL string `json:"browser_download_url"` } // Run checks /releases/latest for an asset named // echolot-server__ newer than currentVersion and atomically // replaces the current executable. The caller (or systemd Restart=) handles // the restart; we never exec ourselves. // // pubKeyB64 is the release-signing public key; empty means [DefaultPublicKeyB64]. func Run(api, pubKeyB64, currentVersion string) error { if api == "" { return fmt.Errorf("self-update disabled: no --self-update-api / ECHOLOT_SELF_UPDATE_API configured") } if pubKeyB64 == "" { pubKeyB64 = DefaultPublicKeyB64 } client := &http.Client{Timeout: 30 * time.Second} resp, err := client.Get(strings.TrimRight(api, "/") + "/releases/latest") if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("release API: %s", resp.Status) } var rel release if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil { return err } // Tags are namespaced (server-v1.2.3) but binaries are stamped with the // bare version (v1.2.3) — compare the normalized forms or the updater // would re-download the same version forever. latest := strings.TrimPrefix(rel.TagName, "server-") if rel.TagName == "" || latest == currentVersion { fmt.Printf("already current (%s)\n", currentVersion) return nil } want := fmt.Sprintf("echolot-server_%s_%s", runtime.GOOS, runtime.GOARCH) var url string for _, a := range rel.Assets { if a.Name == want { url = a.URL break } } if url == "" { return fmt.Errorf("release %s has no asset %q", rel.TagName, want) } // The release must carry SHA256SUMS *and* its detached signature. The checksums alone only // protect download integrity (truncation, proxy mangling) — they come from the same place as // the binaries, so whoever can alter one can alter both. The signature is the defense against // a compromised release host: its private key exists only in the CI secret store, so a valid // SHA256SUMS.sig means the project's pipeline published exactly these checksums, and the // checksum then extends that trust to the binary. fetch := func(name string) ([]byte, error) { for _, a := range rel.Assets { if a.Name != name { continue } resp, err := client.Get(a.URL) if err != nil { return nil, err } defer resp.Body.Close() return io.ReadAll(io.LimitReader(resp.Body, 1<<20)) } return nil, fmt.Errorf("release %s has no asset %q", rel.TagName, name) } sums, err := fetch("SHA256SUMS") if err != nil { return fmt.Errorf("fetching SHA256SUMS: %w", err) } sig, err := fetch("SHA256SUMS.sig") if err != nil { return fmt.Errorf("release %s is unsigned — refusing to update (%v)", rel.TagName, err) } if err := relsign.Verify(pubKeyB64, sums, string(sig)); err != nil { return fmt.Errorf("release %s: SHA256SUMS signature rejected — refusing to update: %w", rel.TagName, err) } wantSum := "" for _, line := range strings.Split(string(sums), "\n") { if fields := strings.Fields(line); len(fields) == 2 && fields[1] == want { wantSum = fields[0] } } if wantSum == "" { return fmt.Errorf("release %s has no SHA256SUMS entry for %q — refusing to update", rel.TagName, want) } self, err := os.Executable() if err != nil { return err } self, _ = filepath.EvalSymlinks(self) tmp := self + ".update" f, err := os.OpenFile(tmp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o755) if err != nil { return err } dl, err := client.Get(url) if err != nil { f.Close() os.Remove(tmp) return err } h := sha256.New() _, err = io.Copy(io.MultiWriter(f, h), dl.Body) dl.Body.Close() f.Close() if err != nil { os.Remove(tmp) return err } if got := hex.EncodeToString(h.Sum(nil)); got != wantSum { os.Remove(tmp) return fmt.Errorf("checksum mismatch for %s: got %s want %s", want, got, wantSum) } if err := os.Rename(tmp, self); err != nil { os.Remove(tmp) return fmt.Errorf("atomic replace failed (filesystem boundaries?): %w", err) } // Serving became an explicit verb, and a unit written before that change starts this binary // with no arguments - which now prints usage and exits non-zero. The unit is not part of what // an update replaces, so it is repaired here rather than left to fail at the next restart, // which might be a reboot months from now. if repaired, err := system.RepairExecStart(); err != nil { fmt.Println("WARNING: could not update the systemd unit for --serve:", err) } else if repaired { fmt.Println("updated the systemd unit to pass --serve (serving is now an explicit verb)") } fmt.Printf("updated %s -> %s (%s); restart to run it\n", currentVersion, rel.TagName, self) return nil }