Files
echolot/server/internal/selfupdate/selfupdate.go
T
mrambossekandClaude Fable 5 3cdbccee18
server-release / image (push) Successful in 15s
server-test / test (push) Successful in 33s
server-release / release (push) Successful in 34s
cli: serving is an explicit verb; no arguments prints usage
Running an unfamiliar binary by name should tell you what it does, not bind a
dozen ports and start answering the internet. --serve (or --daemon) now does
that, and a bare invocation prints usage and exits 2 - non-zero on purpose, so a
service manager sees a failure rather than concluding the server ran and
finished cleanly.

The hazard this creates is worth spelling out, because it bites once and
silently: three places started the binary with no arguments - the systemd unit,
the unit template, and the Dockerfile - and --self-update replaces the binary
but never the unit. A routine update would therefore leave a service that cannot
start, discovered whenever the host next rebooted.

So the updater repairs it: after replacing the binary it appends --serve to an
ExecStart that has no flags, but only in a unit this program wrote (identified
by its description). Editing an operator's hand-written unit would be overreach;
leaving ours broken would be negligence.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 17:42:20 +02:00

148 lines
4.5 KiB
Go

// 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/system"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"runtime"
"strings"
"time"
)
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 <api>/releases/latest for an asset named
// echolot-server_<GOOS>_<GOARCH> newer than currentVersion and atomically
// replaces the current executable. The caller (or systemd Restart=) handles
// the restart; we never exec ourselves.
func Run(api, currentVersion string) error {
if api == "" {
return fmt.Errorf("self-update disabled: no --self-update-api / ECHOLOT_SELF_UPDATE_API configured")
}
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; refuse to update without it. This
// protects download integrity (truncation, proxy mangling). It is NOT a
// defense against a compromised Gitea — both files come from the same
// place; a detached signature would be needed for that (still TODO).
var sums string
for _, a := range rel.Assets {
if a.Name == "SHA256SUMS" {
resp, err := client.Get(a.URL)
if err != nil {
return fmt.Errorf("fetching SHA256SUMS: %w", err)
}
b, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
resp.Body.Close()
if err != nil {
return err
}
sums = string(b)
}
}
wantSum := ""
for _, line := range strings.Split(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
}