server: mirror the signed release feed to S3 — updater falls back on failure or staleness (#662)
GitHub's asset CDN is the fleet's update SPOF and has served freshly-clobbered manifests stale (and new binaries 404) for minutes after a publish. The feed is Ed25519-signed with sha256-pinned binaries, so a mirror is trust-preserving by construction — it only has to be available. - publish side: LCHAT_RELEASE_MIRROR_BUCKET makes release-server.sh copy manifest.json (on publish AND promote) + server-<ver>/ binaries to S3 via the aws CLI; unset, the script is unchanged. Best-effort with a loud warning — a lagging mirror is harmless because updaters take whichever verified feed is fresher. - fetch side: LCHAT_RELEASE_MIRROR_URL is a plain HTTPS base the updater falls back to when the primary manifest fetch fails, or verifies but is stale (generated_at older than the anti-replay stamp — exactly the post-publish CDN window); binary downloads fall back the same way. Signature + sha256 checks identical on both paths; fallbacks log loudly; a feed equal to the stamp never consults the mirror, so steady-state passes stay single-request. - infra/prod/releases.tf: versioned eleven-releases-<account> bucket, GET-only public-read policy (the one deliberately public bucket — public, signed, non-secret content), IAM-only writes via a no-delete release-publisher user. Planned (7 add / 0 change / 0 destroy), NOT applied — needs a named tofu apply. - tests: manifest fallback on failure, wrong-key mirror rejected, fresher mirror wins over a stale primary, equally-stale mirror never masks the replay refusal, mirror binary fallback stages + applies, tampered mirror binary refused. - also fixes a pre-existing go vet failure (unchecked http.Get) + gofmt drift in handlers_test.go so the suite runs clean.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
7 files changed,
+589
−18
deploy/release-server.sh+36 −0docs/server-releases.md+29 −0handlers_test.go+1 −3infra/prod/releases.tf+126 −0release.go+33 −0updater.go+88 −15updater_test.go+276 −0
diff --git a/deploy/release-server.sh b/deploy/release-server.sh| index 6129d44..8a86eab 100755 |
| --- a/deploy/release-server.sh |
| +++ b/deploy/release-server.sh |
| @@ -22,10 +22,20 @@ |
| # Needs: gh (authed, write access to $LCHAT_RELEASES_REPO), jq, go, and the |
| # signing key at $LCHAT_RELEASE_KEY_FILE (1Password: "Eleven Messenger server |
| # release signing key"). |
| +# |
| +# Optional S3 mirror: set LCHAT_RELEASE_MIRROR_BUCKET (needs the aws CLI) and |
| +# every feed artifact is also copied to s3://<bucket>/releases/… — GitHub's |
| +# asset CDN serves freshly-clobbered manifests stale (and new binaries 404) |
| +# for minutes after a publish, and it's the fleet's only update path, so the |
| +# mirror is a second boring home for the same signed bytes. Updaters fall back |
| +# to it via LCHAT_RELEASE_MIRROR_URL (docs/server-releases.md § The S3 mirror); |
| +# the feed's Ed25519 signature + sha256s make the mirror availability-only, |
| +# never trusted. Unset, this script is unchanged. |
| set -eu |
| REPO="${LCHAT_RELEASES_REPO:-elevenmessenger/releases}" |
| KEY="${LCHAT_RELEASE_KEY_FILE:-}" |
| +MIRROR_BUCKET="${LCHAT_RELEASE_MIRROR_BUCKET:-}" |
| BIN=lchat-linux-amd64 |
| BIN_ARM=lchat-linux-arm64 |
| @@ -37,6 +47,8 @@ case "${1:-}" in publish|promote) |
| echo "release-server: set LCHAT_RELEASE_KEY_FILE to the signing key file" >&2; exit 1; } |
| command -v jq >/dev/null || { echo "release-server: jq is required" >&2; exit 1; } |
| command -v gh >/dev/null || { echo "release-server: gh is required" >&2; exit 1; } |
| + [ -z "$MIRROR_BUCKET" ] || command -v aws >/dev/null || { |
| + echo "release-server: LCHAT_RELEASE_MIRROR_BUCKET is set but the aws CLI is missing" >&2; exit 1; } |
| ;; |
| esac |
| @@ -58,6 +70,20 @@ fetch_payload() { |
| fi |
| } |
| +# mirror_upload <file> <key>: copy an artifact into the S3 mirror (no-op when |
| +# LCHAT_RELEASE_MIRROR_BUCKET is unset). Best-effort by design: by the time |
| +# this runs the GitHub half has already landed, so a failed mirror write warns |
| +# instead of aborting a half-done publish — a lagging mirror is harmless |
| +# (updaters use whichever verified feed is fresher). |
| +mirror_upload() { |
| + [ -n "$MIRROR_BUCKET" ] || return 0 |
| + if aws s3 cp "$1" "s3://$MIRROR_BUCKET/releases/$2" >/dev/null; then |
| + echo "mirrored $2 -> s3://$MIRROR_BUCKET/releases/$2" |
| + else |
| + echo "release-server: WARNING: mirror upload of $2 failed — the S3 mirror lags until the next publish/promote" >&2 |
| + fi |
| +} |
| + |
| # sign_and_upload: sign $1 (payload json) and clobber the rolling feed asset. |
| sign_and_upload() { |
| "$TOOL" release sign --key "$KEY" --in "$1" --out "$TMP/manifest.json" |
| @@ -66,6 +92,9 @@ sign_and_upload() { |
| --notes "Rolling release: manifest.json is the channel feed instances update from. See the messenger repo's docs/server-releases.md." |
| gh release upload server "$TMP/manifest.json" -R "$REPO" --clobber |
| "$TOOL" release verify --in "$TMP/manifest.json" |
| + # Both publish and promote come through here, so the mirror's feed tracks |
| + # every channel move, not just new builds. |
| + mirror_upload "$TMP/manifest.json" manifest.json |
| } |
| cmd="${1:-}"; shift || true |
| @@ -90,6 +119,13 @@ publish) |
| fi |
| gh release create "server-$VER" -R "$REPO" --title "Server $VER" --notes "$NOTES" \ |
| "$TMP/$BIN" "$TMP/$BIN.sha256" "$TMP/$BIN_ARM" "$TMP/$BIN_ARM.sha256" |
| + # Binaries mirror before the manifest that points at them, so the mirror |
| + # never serves a feed whose mirror-side binaries don't exist yet. Keys copy |
| + # the GitHub layout (server-<ver>/<asset>) — that's what the updater's |
| + # mirror fallback derives from a primary binary URL. |
| + for A in "$BIN" "$BIN.sha256" "$BIN_ARM" "$BIN_ARM.sha256"; do |
| + mirror_upload "$TMP/$A" "server-$VER/$A" |
| + done |
| fetch_payload | jq \ |
| --arg ver "$VER" --arg sha "$SHA" --argjson size "$SIZE" --arg notes "$NOTES" \ |
diff --git a/docs/server-releases.md b/docs/server-releases.md| index a54b9e1..b521b53 100644 |
| --- a/docs/server-releases.md |
| +++ b/docs/server-releases.md |
| @@ -103,6 +103,35 @@ The updater downloads and *executes* native code, so the chain matters: |
| validly signed) manifest can't downgrade a fleet. An intentional pull-back is |
| a *re-signed* manifest with a newer timestamp, which still applies. |
| +## The S3 mirror |
| + |
| +GitHub's asset CDN is the feed's single point of failure — and it has served |
| +freshly-clobbered manifests stale (and brand-new binaries 404) for minutes |
| +after a publish (the gotcha above). The feed therefore also mirrors to S3 |
| +(bucket: `infra/prod/releases.tf` — public-read GETs so boxes fetch |
| +anonymously, IAM-only writes, versioned for manifest rollback): |
| + |
| +- **Publish side:** set `LCHAT_RELEASE_MIRROR_BUCKET` and |
| + `deploy/release-server.sh` copies everything it uploads to GitHub into |
| + `s3://<bucket>/releases/…` too — `manifest.json` at the top (refreshed on |
| + publish *and* promote), binaries under `server-<ver>/`. Unset, the script |
| + is unchanged. Mirror writes are best-effort: a failure warns rather than |
| + aborting a publish whose GitHub half already landed — a lagging mirror is |
| + harmless because updaters use whichever verified feed is fresher. |
| +- **Fetch side:** set `LCHAT_RELEASE_MIRROR_URL` (a plain HTTPS base, e.g. |
| + `https://<bucket>.s3.<region>.amazonaws.com/releases`) and the updater |
| + falls back to the mirror when the primary fetch fails — or when it verifies |
| + but is *stale*: generated_at older than the newest feed the box ever |
| + accepted (the anti-replay stamp), which is exactly what the post-publish |
| + CDN window looks like; a fresher mirror then wins. Binary downloads fall |
| + back the same way. Every fallback logs loudly; a feed merely *equal* to the |
| + stamp is indistinguishable from "no new release", so steady-state passes |
| + never touch the mirror. |
| + |
| +The mirror adds availability, never trust: both paths run the identical |
| +Ed25519 manifest verification and sha256 binary check, so a compromised |
| +mirror can only cause a refusal, not an install. |
| + |
| ## Publishing |
| ```sh |
diff --git a/handlers_test.go b/handlers_test.go| index 1fe9f9e..ba184a2 100644 |
| --- a/handlers_test.go |
| +++ b/handlers_test.go |
| @@ -6,8 +6,8 @@ import ( |
| "encoding/json" |
| "io" |
| "net/http" |
| - "net/url" |
| "net/http/httptest" |
| + "net/url" |
| "os" |
| "strconv" |
| "strings" |
| @@ -113,7 +113,6 @@ func TestMessagesThreadScoping(t *testing.T) { |
| } |
| } |
| - |
| func TestKeyInviteFlow(t *testing.T) { |
| ts, _ := newTestServer(t) |
| @@ -2457,7 +2456,6 @@ func mustUID(t *testing.T, srv *Server, token string) string { |
| return uid |
| } |
| - |
| // TestTierKeyLifecycle pins the one-way mint and the honest dead ends: a |
| // minted level whose holders are all gone is LOST (410, never re-minted), a |
| // level still gating groups can't be deleted, and removing a user cascades |
diff --git a/infra/prod/releases.tf b/infra/prod/releases.tf| new file mode 100644 |
| index 0000000..356e646 |
| --- /dev/null |
| +++ b/infra/prod/releases.tf |
| @@ -0,0 +1,126 @@ |
| +# 🤖 The server-release-feed mirror (issue #550 Part 12): a second, boring |
| +# home for the signed update feed — manifest.json plus the per-version |
| +# binaries. GitHub's asset CDN has served freshly-clobbered manifests stale |
| +# (and brand-new binaries 404) for minutes after a publish — documented |
| +# operator pain — and that feed is the entire fleet's update path. |
| +# deploy/release-server.sh copies every publish/promote here |
| +# (LCHAT_RELEASE_MIRROR_BUCKET); updaters fall back to it |
| +# (LCHAT_RELEASE_MIRROR_URL). Versioning doubles as the rollback story for a |
| +# clobbered manifest.json. |
| +# |
| +# NOT YET APPLIED — needs a deliberate, named |
| +# `AWS_PROFILE=… tofu apply` in infra/prod; plan only until then. |
| + |
| +resource "aws_s3_bucket" "releases" { |
| + bucket = "eleven-releases-${local.account_id}" |
| +} |
| + |
| +resource "aws_s3_bucket_versioning" "releases" { |
| + bucket = aws_s3_bucket.releases.id |
| + |
| + versioning_configuration { |
| + status = "Enabled" |
| + } |
| +} |
| + |
| +# Manifest clobbers are tiny and binaries land under fresh server-<ver>/ keys, |
| +# so noncurrent versions exist only as the manifest's rollback window — 90 |
| +# days is generous without growing forever. |
| +resource "aws_s3_bucket_lifecycle_configuration" "releases" { |
| + bucket = aws_s3_bucket.releases.id |
| + |
| + rule { |
| + id = "expire-noncurrent-90d" |
| + status = "Enabled" |
| + |
| + filter {} |
| + |
| + noncurrent_version_expiration { |
| + noncurrent_days = 90 |
| + } |
| + } |
| +} |
| + |
| +# This bucket is deliberately PUBLIC-READ — the one bucket in the account |
| +# where that is the point, against the everything-else-blocked posture |
| +# (backup.tf, hibernate.tf). Instances and self-hosted boxes must fetch |
| +# updates anonymously, and the content is public, non-secret, and |
| +# trust-anchored elsewhere: the manifest is Ed25519-signed against a key baked |
| +# into every binary, and binaries are sha256-pinned by that signed manifest, |
| +# so the mirror provides availability only — a tampered object can only cause |
| +# a refusal, never an install (docs/server-releases.md § Trust model). ACLs |
| +# stay fully blocked; only the bucket *policy* door is opened, and the |
| +# GET-only policy below is the entire public surface (no anonymous List). |
| +# Writes stay IAM-only via the release-publisher user. |
| +resource "aws_s3_bucket_public_access_block" "releases" { |
| + bucket = aws_s3_bucket.releases.id |
| + |
| + block_public_acls = true |
| + ignore_public_acls = true |
| + block_public_policy = false |
| + restrict_public_buckets = false |
| +} |
| + |
| +data "aws_iam_policy_document" "releases_public_read" { |
| + statement { |
| + sid = "PublicReadReleaseObjects" |
| + actions = ["s3:GetObject"] |
| + resources = ["${aws_s3_bucket.releases.arn}/*"] |
| + |
| + principals { |
| + type = "*" |
| + identifiers = ["*"] |
| + } |
| + } |
| +} |
| + |
| +resource "aws_s3_bucket_policy" "releases" { |
| + bucket = aws_s3_bucket.releases.id |
| + policy = data.aws_iam_policy_document.releases_public_read.json |
| + |
| + # The policy grants public access, so the access block must relax |
| + # block_public_policy before it can be attached. |
| + depends_on = [aws_s3_bucket_public_access_block.releases] |
| +} |
| + |
| +# The publisher's credentials: object put + list on this bucket, nothing else |
| +# in the account — and no DeleteObject, so a leaked publisher key can |
| +# overwrite the current manifest (noncurrent versions survive, versioning is |
| +# the rollback) but never destroy history. Mint the access key by hand |
| +# (aws iam create-access-key --user-name release-publisher) — keys never land |
| +# in tofu state. |
| +resource "aws_iam_user" "release_publisher" { |
| + name = "release-publisher" |
| +} |
| + |
| +data "aws_iam_policy_document" "release_publisher" { |
| + statement { |
| + sid = "ListReleasesBucket" |
| + actions = ["s3:ListBucket"] |
| + resources = [aws_s3_bucket.releases.arn] |
| + } |
| + |
| + statement { |
| + sid = "WriteReleaseObjects" |
| + actions = [ |
| + "s3:GetObject", |
| + "s3:PutObject", |
| + ] |
| + resources = ["${aws_s3_bucket.releases.arn}/*"] |
| + } |
| +} |
| + |
| +resource "aws_iam_user_policy" "release_publisher" { |
| + name = "release-publisher-bucket" |
| + user = aws_iam_user.release_publisher.name |
| + policy = data.aws_iam_policy_document.release_publisher.json |
| +} |
| + |
| +output "releases_bucket" { |
| + value = aws_s3_bucket.releases.bucket |
| +} |
| + |
| +# What instances set as LCHAT_RELEASE_MIRROR_URL. |
| +output "releases_mirror_url" { |
| + value = "https://${aws_s3_bucket.releases.bucket_regional_domain_name}/releases" |
| +} |
diff --git a/release.go b/release.go| index 581be52..cc42ab0 100644 |
| --- a/release.go |
| +++ b/release.go |
| @@ -61,6 +61,39 @@ var releasePubKeyB64 = "6pL2QKDCfXqcYGR6Vr5uUZZKrpV6YVwg9rV0Okb8LY8=" |
| var releaseManifestURL = envOr("LCHAT_UPDATE_MANIFEST_URL", |
| "https://github.com/elevenmessenger/releases/releases/download/server/manifest.json") |
| +// releaseMirrorURL is the optional second home for the feed — a plain HTTPS |
| +// base (e.g. https://<bucket>.s3.<region>.amazonaws.com/releases) that |
| +// deploy/release-server.sh keeps in sync when LCHAT_RELEASE_MIRROR_BUCKET is |
| +// set at publish time. GitHub's asset CDN has served freshly-clobbered |
| +// manifests stale (and brand-new binaries 404) for minutes after a publish, |
| +// and that feed is the whole fleet's update path — the mirror exists purely |
| +// for availability. It needs no integrity trust of its own: everything |
| +// fetched from it passes the exact same Ed25519 signature and sha256 checks |
| +// as the primary, so a compromised mirror can only cause a refusal, never an |
| +// install. Empty (the default) disables all mirror behaviour. |
| +var releaseMirrorURL = envOr("LCHAT_RELEASE_MIRROR_URL", "") |
| + |
| +// mirrorManifestURL is where a mirror base keeps the feed itself. |
| +func mirrorManifestURL(base string) string { |
| + return strings.TrimRight(base, "/") + "/manifest.json" |
| +} |
| + |
| +// mirrorBinaryURL maps a binary's primary URL onto the mirror, which stores |
| +// each artifact under its last two path segments |
| +// (server-<ver>/lchat-linux-<arch> — the GitHub layout, copied verbatim by |
| +// release-server.sh). "" when the primary URL doesn't have that shape. |
| +func mirrorBinaryURL(base, primary string) string { |
| + u, err := url.Parse(primary) |
| + if err != nil { |
| + return "" |
| + } |
| + segs := strings.Split(strings.Trim(u.Path, "/"), "/") |
| + if len(segs) < 2 || segs[len(segs)-2] == "" || segs[len(segs)-1] == "" { |
| + return "" |
| + } |
| + return strings.TrimRight(base, "/") + "/" + segs[len(segs)-2] + "/" + segs[len(segs)-1] |
| +} |
| + |
| type releaseEntry struct { |
| Version string `json:"version"` |
| // The flat fields are the amd64 binary — kept flat (not per-arch keyed) so |
diff --git a/updater.go b/updater.go| index 7fb3802..49b5210 100644 |
| --- a/updater.go |
| +++ b/updater.go |
| @@ -8,6 +8,7 @@ import ( |
| "flag" |
| "fmt" |
| "io" |
| + "log" |
| "net/http" |
| "os" |
| "path/filepath" |
| @@ -52,9 +53,9 @@ func writeManifestStamp(t time.Time) error { |
| return os.WriteFile(manifestStampPath(), []byte(t.Format(time.RFC3339)+"\n"), 0o644) |
| } |
| -// fetchReleaseManifest downloads and signature-verifies the channel feed. |
| -// Shared with the instance-side availability checker (updatecheck.go). |
| -func fetchReleaseManifest(url string) (*releaseManifest, error) { |
| +// fetchReleaseManifestFrom downloads and signature-verifies the channel feed |
| +// from one URL. |
| +func fetchReleaseManifestFrom(url string) (*releaseManifest, error) { |
| pub, err := releasePubKey() |
| if err != nil { |
| return nil, err |
| @@ -75,6 +76,52 @@ func fetchReleaseManifest(url string) (*releaseManifest, error) { |
| return verifyReleaseManifest(pub, raw) |
| } |
| +// fetchReleaseManifest reads the channel feed, falling back to the S3 mirror |
| +// (LCHAT_RELEASE_MIRROR_URL) when the primary fetch fails — or when it |
| +// verifies but is STALE: GitHub's CDN has served a pre-publish manifest for |
| +// minutes after a clobber, which errors nowhere. Stale here means a |
| +// generated_at strictly older than the newest feed this box ever accepted |
| +// (the anti-replay stamp); a feed equal to the stamp is indistinguishable |
| +// from "no new release", so it never consults the mirror — the hourly pass |
| +// stays a single request in the steady state. Signature verification is |
| +// identical on both paths (the mirror adds availability, never trust), and |
| +// every fallback logs loudly. Shared with the instance-side availability |
| +// checker (updatecheck.go), where no stamp file exists and so only outright |
| +// primary failure falls back. |
| +func fetchReleaseManifest(url string) (*releaseManifest, error) { |
| + m, err := fetchReleaseManifestFrom(url) |
| + base := releaseMirrorURL |
| + if base == "" { |
| + return m, err |
| + } |
| + stamp := readManifestStamp() |
| + stale := err == nil && m.GeneratedAt.Before(stamp) |
| + if err == nil && !stale { |
| + return m, nil |
| + } |
| + murl := mirrorManifestURL(base) |
| + mm, merr := fetchReleaseManifestFrom(murl) |
| + switch { |
| + case err != nil && merr != nil: |
| + log.Printf("update: primary feed failed (%v); mirror %s failed too (%v)", err, murl, merr) |
| + return nil, err |
| + case err != nil: |
| + log.Printf("update: primary feed failed (%v) — using mirror %s (signature verified)", err, murl) |
| + return mm, nil |
| + case merr != nil: |
| + log.Printf("update: primary feed is stale (generated_at %s < last accepted %s) and mirror %s failed (%v) — keeping the primary result", |
| + m.GeneratedAt.Format(time.RFC3339), stamp.Format(time.RFC3339), murl, merr) |
| + return m, nil |
| + case mm.GeneratedAt.After(m.GeneratedAt): |
| + log.Printf("update: primary feed is stale (generated_at %s < last accepted %s) — using fresher mirror %s (generated_at %s)", |
| + m.GeneratedAt.Format(time.RFC3339), stamp.Format(time.RFC3339), murl, mm.GeneratedAt.Format(time.RFC3339)) |
| + return mm, nil |
| + } |
| + // The mirror is no fresher: the stale primary stands, and updatePass's |
| + // replay check refuses it — the mirror never masks a replayed feed. |
| + return m, nil |
| +} |
| + |
| // updatePrefs is what an instance's admin chose (GET /api/update-prefs). |
| type updatePrefs struct { |
| AutoUpdate bool `json:"auto_update"` |
| @@ -119,16 +166,9 @@ func fetchUpdatePrefs(socket string) (updatePrefs, error) { |
| // the arm64 resolution without faking runtime.GOARCH. |
| var updaterArch = runtime.GOARCH |
| -// downloadReleaseBinary fetches this box's arch of a release into the incoming |
| -// dir and refuses anything whose sha256 doesn't match the signed manifest |
| -// entry. Returns the temp path; the caller stages it and removes the temp. |
| -func downloadReleaseBinary(e releaseEntry) (string, error) { |
| - url, sha, ok := e.forArch(updaterArch) |
| - if !ok { |
| - // An arm64 box facing a pre-arm64 release: skip, never install the |
| - // wrong arch. The next release publishes both and the box catches up. |
| - return "", fmt.Errorf("release %s has no %s build — skipping", e.Version, updaterArch) |
| - } |
| +// downloadBinaryToTemp streams one URL into a temp file in the incoming dir |
| +// and refuses bytes whose sha256 doesn't match the signed manifest entry. |
| +func downloadBinaryToTemp(url, sha, ver string) (string, error) { |
| if err := os.MkdirAll(incomingDir(), 0o755); err != nil { |
| return "", err |
| } |
| @@ -148,18 +188,51 @@ func downloadReleaseBinary(e releaseEntry) (string, error) { |
| } |
| defer resp.Body.Close() |
| if resp.StatusCode != http.StatusOK { |
| - return fail(fmt.Errorf("download %s: %s", e.Version, resp.Status)) |
| + return fail(fmt.Errorf("download %s: %s", ver, resp.Status)) |
| } |
| h := sha256.New() |
| if _, err := io.Copy(io.MultiWriter(f, h), resp.Body); err != nil { |
| return fail(err) |
| } |
| if got := hex.EncodeToString(h.Sum(nil)); got != sha { |
| - return fail(fmt.Errorf("download %s: sha256 mismatch (refusing to stage)", e.Version)) |
| + return fail(fmt.Errorf("download %s: sha256 mismatch (refusing to stage)", ver)) |
| } |
| return f.Name(), nil |
| } |
| +// downloadReleaseBinary fetches this box's arch of a release into the incoming |
| +// dir and refuses anything whose sha256 doesn't match the signed manifest |
| +// entry. A failed primary download (GitHub 404s brand-new assets for minutes |
| +// after a publish) falls back to the S3 mirror, loudly; the sha256 gate is |
| +// identical on both paths, so a mirror serving the wrong bytes only ever |
| +// causes a refusal. Returns the temp path; the caller stages it and removes |
| +// the temp. |
| +func downloadReleaseBinary(e releaseEntry) (string, error) { |
| + url, sha, ok := e.forArch(updaterArch) |
| + if !ok { |
| + // An arm64 box facing a pre-arm64 release: skip, never install the |
| + // wrong arch. The next release publishes both and the box catches up. |
| + return "", fmt.Errorf("release %s has no %s build — skipping", e.Version, updaterArch) |
| + } |
| + path, err := downloadBinaryToTemp(url, sha, e.Version) |
| + if err == nil { |
| + return path, nil |
| + } |
| + murl := "" |
| + if releaseMirrorURL != "" { |
| + murl = mirrorBinaryURL(releaseMirrorURL, url) |
| + } |
| + if murl == "" { |
| + return "", err |
| + } |
| + log.Printf("update: primary download of %s failed (%v) — trying mirror %s", e.Version, err, murl) |
| + path, merr := downloadBinaryToTemp(murl, sha, e.Version) |
| + if merr != nil { |
| + return "", fmt.Errorf("download %s: primary failed (%v); mirror failed too (%v)", e.Version, err, merr) |
| + } |
| + return path, nil |
| +} |
| + |
| // ensureVersionStaged makes a manifest entry's binary available in the version |
| // store, downloading it if needed. Idempotent across hosts in one pass. |
| func ensureVersionStaged(e releaseEntry) error { |
diff --git a/updater_test.go b/updater_test.go| index 4c82e61..2e521ba 100644 |
| --- a/updater_test.go |
| +++ b/updater_test.go |
| @@ -127,6 +127,35 @@ func startFeed(t *testing.T, binaries map[string]string) (*httptest.Server, func |
| return ts, setChannels |
| } |
| +// signManifestAt builds a signed wrapper with a caller-controlled |
| +// generated_at — signReleaseManifest always stamps now, but the mirror tests |
| +// need feeds whose relative ages are exact. |
| +func signManifestAt(t *testing.T, priv ed25519.PrivateKey, m *releaseManifest, at time.Time) []byte { |
| + t.Helper() |
| + m.Schema = 1 |
| + m.GeneratedAt = at.UTC().Truncate(time.Second) |
| + payload, err := json.Marshal(m) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + b64 := base64.StdEncoding.EncodeToString(payload) |
| + sig := ed25519.Sign(priv, []byte(b64)) |
| + w, err := json.Marshal(signedManifest{Payload: b64, Sig: base64.StdEncoding.EncodeToString(sig)}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return w |
| +} |
| + |
| +// swapFeedGlobals points the trust root, primary URL, and mirror base at test |
| +// servers, restoring them after. |
| +func swapFeedGlobals(t *testing.T, pubB64, primaryURL, mirrorBase string) { |
| + t.Helper() |
| + oldKey, oldURL, oldMirror := releasePubKeyB64, releaseManifestURL, releaseMirrorURL |
| + releasePubKeyB64, releaseManifestURL, releaseMirrorURL = pubB64, primaryURL, mirrorBase |
| + t.Cleanup(func() { releasePubKeyB64, releaseManifestURL, releaseMirrorURL = oldKey, oldURL, oldMirror }) |
| +} |
| + |
| // stubSystemctl records unit restarts and leaves the "restart" to the fake |
| // instance's symlink-following /api/version. |
| func stubSystemctl(t *testing.T) *[]string { |
| @@ -539,6 +568,253 @@ func TestPinHostWritesViaAdminAPI(t *testing.T) { |
| } |
| } |
| +// entryFor is a syntactically valid manifest entry (validateManifest passes) |
| +// whose binary URL nothing in the manifest tests ever downloads. |
| +func entryFor(ver string) releaseEntry { |
| + return releaseEntry{ |
| + Version: ver, |
| + URL: "https://example.com/server-" + ver + "/lchat-linux-amd64", |
| + SHA256: strings.Repeat("ab", 32), |
| + } |
| +} |
| + |
| +func TestFetchManifestFallsBackToMirrorOnFailure(t *testing.T) { |
| + withTempOpt(t) // the staleness check reads the stamp from optDir |
| + pub, priv, err := ed25519.GenerateKey(rand.Reader) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + good := signManifestAt(t, priv, |
| + &releaseManifest{Channels: map[string]releaseEntry{"stable": entryFor("new1234")}}, time.Now()) |
| + |
| + primary := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + http.Error(w, "gone", http.StatusNotFound) // GitHub's brand-new-asset 404 window |
| + })) |
| + defer primary.Close() |
| + mirror := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + // The mirror keeps the feed at <base>/manifest.json. |
| + if r.URL.Path != "/releases/manifest.json" { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + w.Write(good) |
| + })) |
| + defer mirror.Close() |
| + swapFeedGlobals(t, base64.StdEncoding.EncodeToString(pub), |
| + primary.URL+"/manifest.json", mirror.URL+"/releases") |
| + |
| + m, err := fetchReleaseManifest(releaseManifestURL) |
| + if err != nil { |
| + t.Fatalf("mirror fallback failed: %v", err) |
| + } |
| + if m.Channels["stable"].Version != "new1234" { |
| + t.Fatalf("mirror manifest channels = %+v", m.Channels) |
| + } |
| +} |
| + |
| +func TestFetchManifestRejectsBadMirrorSignature(t *testing.T) { |
| + withTempOpt(t) |
| + pub, _, err := ed25519.GenerateKey(rand.Reader) // the trusted root... |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + _, evilPriv, err := ed25519.GenerateKey(rand.Reader) // ...and an untrusted signer |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + forged := signManifestAt(t, evilPriv, |
| + &releaseManifest{Channels: map[string]releaseEntry{"stable": entryFor("evil999")}}, time.Now()) |
| + |
| + primary := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + http.Error(w, "down", http.StatusServiceUnavailable) |
| + })) |
| + defer primary.Close() |
| + mirror := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + w.Write(forged) |
| + })) |
| + defer mirror.Close() |
| + swapFeedGlobals(t, base64.StdEncoding.EncodeToString(pub), |
| + primary.URL+"/manifest.json", mirror.URL+"/releases") |
| + |
| + if _, err := fetchReleaseManifest(releaseManifestURL); err == nil { |
| + t.Fatal("a mirror manifest signed by the wrong key was accepted") |
| + } |
| +} |
| + |
| +func TestFetchManifestPrefersFresherMirrorWhenPrimaryStale(t *testing.T) { |
| + withTempOpt(t) |
| + pub, priv, err := ed25519.GenerateKey(rand.Reader) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + now := time.Now() |
| + staleFeed := signManifestAt(t, priv, |
| + &releaseManifest{Channels: map[string]releaseEntry{"stable": entryFor("old1111")}}, now.Add(-2*time.Hour)) |
| + freshFeed := signManifestAt(t, priv, |
| + &releaseManifest{Channels: map[string]releaseEntry{"stable": entryFor("new1234")}}, now) |
| + // The box last accepted a feed newer than what the primary now serves — |
| + // exactly what GitHub's stale CDN looks like right after a publish. |
| + if err := writeManifestStamp(now.Add(-time.Hour)); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + primary := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + w.Write(staleFeed) |
| + })) |
| + defer primary.Close() |
| + mirror := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + w.Write(freshFeed) |
| + })) |
| + defer mirror.Close() |
| + swapFeedGlobals(t, base64.StdEncoding.EncodeToString(pub), |
| + primary.URL+"/manifest.json", mirror.URL+"/releases") |
| + |
| + m, err := fetchReleaseManifest(releaseManifestURL) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := m.Channels["stable"].Version; got != "new1234" { |
| + t.Fatalf("stale primary won over a fresher mirror: stable = %q", got) |
| + } |
| +} |
| + |
| +func TestFetchManifestKeepsStalePrimaryWhenMirrorNoFresher(t *testing.T) { |
| + withTempOpt(t) |
| + pub, priv, err := ed25519.GenerateKey(rand.Reader) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + now := time.Now() |
| + staleFeed := signManifestAt(t, priv, |
| + &releaseManifest{Channels: map[string]releaseEntry{"stable": entryFor("old1111")}}, now.Add(-2*time.Hour)) |
| + if err := writeManifestStamp(now); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + serveStale := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write(staleFeed) }) |
| + primary := httptest.NewServer(serveStale) |
| + defer primary.Close() |
| + mirror := httptest.NewServer(serveStale) |
| + defer mirror.Close() |
| + swapFeedGlobals(t, base64.StdEncoding.EncodeToString(pub), |
| + primary.URL+"/manifest.json", mirror.URL+"/releases") |
| + |
| + // Both stale: the primary result stands, and updatePass's replay check is |
| + // what refuses it — the mirror must never mask a genuinely replayed feed. |
| + m, err := fetchReleaseManifest(releaseManifestURL) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if !m.GeneratedAt.Before(readManifestStamp()) { |
| + t.Fatal("expected the stale primary manifest back (for the replay check to refuse)") |
| + } |
| + reg, err := openRegistry(filepath.Join(t.TempDir(), "reg.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := updatePass(reg, "lchat@", false); err == nil || !strings.Contains(err.Error(), "replayed") { |
| + t.Fatalf("want replay refusal, got %v", err) |
| + } |
| +} |
| + |
| +// mirrorBinaryFeed wires a primary feed whose manifest is fine but whose |
| +// binary URL 404s, plus a mirror holding mirrorContent under the derived |
| +// server-<ver>/<name> key — the GitHub new-asset-404 shape. |
| +func mirrorBinaryFeed(t *testing.T, binaryContent, mirrorContent string) { |
| + t.Helper() |
| + pub, priv, err := ed25519.GenerateKey(rand.Reader) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + sum := sha256.Sum256([]byte(binaryContent)) |
| + |
| + primaryMux := http.NewServeMux() // serves only the manifest; the binary path 404s |
| + primary := httptest.NewServer(primaryMux) |
| + t.Cleanup(primary.Close) |
| + entry := releaseEntry{ |
| + Version: "new1234", |
| + URL: primary.URL + "/server-new1234/lchat-linux-amd64", |
| + SHA256: hex.EncodeToString(sum[:]), |
| + } |
| + wrapper := signManifestAt(t, priv, |
| + &releaseManifest{Channels: map[string]releaseEntry{"stable": entry}}, time.Now()) |
| + primaryMux.HandleFunc("/manifest.json", func(w http.ResponseWriter, r *http.Request) { w.Write(wrapper) }) |
| + |
| + mirror := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + // The mirror stores binaries under the primary URL's last two segments. |
| + if r.URL.Path != "/releases/server-new1234/lchat-linux-amd64" { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + w.Write([]byte(mirrorContent)) |
| + })) |
| + t.Cleanup(mirror.Close) |
| + swapFeedGlobals(t, base64.StdEncoding.EncodeToString(pub), |
| + primary.URL+"/manifest.json", mirror.URL+"/releases") |
| + |
| + prevArch := updaterArch |
| + updaterArch = "amd64" |
| + t.Cleanup(func() { updaterArch = prevArch }) |
| +} |
| + |
| +func TestUpdatePassFallsBackToMirrorBinary(t *testing.T) { |
| + withTempOpt(t) |
| + stubSystemctl(t) |
| + shrink := deployHealthTimeout |
| + deployHealthTimeout = 2 * time.Second |
| + t.Cleanup(func() { deployHealthTimeout = shrink }) |
| + |
| + content := "mirrored-binary-content" |
| + mirrorBinaryFeed(t, content, content) |
| + |
| + reg, err := openRegistry(filepath.Join(t.TempDir(), "reg.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + s1 := startFakeInstance(t, "h1.example.com", http.StatusNotFound, updatePrefs{}, "") |
| + seedInstance(t, reg, "h1.example.com", s1, "old1111") |
| + |
| + if err := updatePass(reg, "lchat@", false); err != nil { |
| + t.Fatal(err) |
| + } |
| + if v, _ := liveVersion("h1.example.com"); v != "new1234" { |
| + t.Fatalf("mirror binary fallback didn't land: live = %q", v) |
| + } |
| + staged, err := os.ReadFile(versionBinPath("new1234")) |
| + if err != nil || string(staged) != content { |
| + t.Fatalf("staged binary = %q, %v — must be the mirror's bytes", staged, err) |
| + } |
| +} |
| + |
| +func TestUpdatePassRejectsBadMirrorBinary(t *testing.T) { |
| + withTempOpt(t) |
| + calls := stubSystemctl(t) |
| + |
| + // The mirror serves bytes that don't hash to the signed sha256 — a |
| + // tampered (or stale) mirror must cause a refusal, never an install. |
| + mirrorBinaryFeed(t, "the-real-binary", "tampered-bytes") |
| + |
| + reg, err := openRegistry(filepath.Join(t.TempDir(), "reg.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + s1 := startFakeInstance(t, "h1.example.com", http.StatusNotFound, updatePrefs{}, "") |
| + seedInstance(t, reg, "h1.example.com", s1, "old1111") |
| + |
| + if err := updatePass(reg, "lchat@", false); err != nil { |
| + t.Fatal(err) |
| + } |
| + if v, _ := liveVersion("h1.example.com"); v != "old1111" { |
| + t.Fatalf("tampered mirror binary was installed: live = %q", v) |
| + } |
| + if _, err := os.Stat(versionBinPath("new1234")); !os.IsNotExist(err) { |
| + t.Fatal("tampered mirror binary was staged") |
| + } |
| + if len(*calls) != 0 { |
| + t.Fatalf("restarts despite a refused download: %v", *calls) |
| + } |
| +} |
| + |
| func TestAcquireDeployLockExcludes(t *testing.T) { |
| withTempOpt(t) |
| unlock, err := acquireDeployLock() |