Self-update now verifies SHA256SUMS.sig (ed25519, relsign package) against a public key baked into the binary; the private key exists only in the CI secret store, so a compromised release host can withhold updates but not inject one. CI signs on every server-v* tag and hard-fails without the secret. Operators with their own pipeline override the key via ECHOLOT_SELF_UPDATE_PUBKEY (mint a pair with release-sign -gen). Startup also now proves 80/443 are actually free on the reserved measurement addresses by asking the OS (throwaway bind), not the config - CheckReserved could never see a stray process, and the adb-beacon receiver on 0.0.0.0:443 was exactly that. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
172 lines
5.8 KiB
Go
172 lines
5.8 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/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 <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.
|
|
//
|
|
// 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
|
|
}
|