// Package updater drives `banger update`: discover a new release, // download + verify it, swap binaries atomically, restart the systemd // units, run doctor, roll back on failure. The package is split across // files by responsibility — manifest.go owns the release-discovery // shape, the rest is in their own files. package updater import ( "context" "encoding/json" "fmt" "io" "net/http" "strings" "time" ) // manifestURL is the canonical URL of banger's release manifest on // the Cloudflare R2 bucket. Hardcoded (rather than pulling from // config) so a compromised daemon config can't redirect the updater // to a different bucket. Var (not const) only because tests need to // point at an httptest.Server; production never mutates it. // // The bucket lives at releases.thaloco.com; the path /banger/ scopes // it inside the bucket so the same host can serve other projects' // release artifacts later. var manifestURL = "https://releases.thaloco.com/banger/manifest.json" // ManifestURL exposes the configured URL for callers that want to // surface it in user-facing output (e.g. `banger update --check`). func ManifestURL() string { return manifestURL } // MaxManifestBytes caps the manifest download size. The manifest is // JSON with a small bounded shape (10s of releases × ~200 bytes // each); 1 MiB is generous and protects us from a server that // accidentally serves an arbitrary file. const MaxManifestBytes int64 = 1 << 20 // MaxSHA256SumsBytes caps the SHA256SUMS download. One line per // release artifact (today: one line for the tarball); 16 KiB is // orders of magnitude over what we'd ever publish. const MaxSHA256SumsBytes int64 = 16 * 1024 // MaxTarballBytes caps the release-tarball download. Banger's three // binaries plus a SHA256SUMS file fit comfortably under this; if a // future release approaches the cap, bump intentionally and ship a // note in CHANGELOG. const MaxTarballBytes int64 = 256 * 1024 * 1024 // Manifest is the top-level shape of releases.thaloco.com/banger/manifest.json. // SchemaVersion lets us evolve the structure without breaking older // CLIs — a CLI that doesn't recognise its current SchemaVersion // refuses to update rather than guessing. type Manifest struct { SchemaVersion int `json:"schema_version"` LatestStable string `json:"latest_stable"` Releases []Release `json:"releases"` } // Release describes one published banger build. The tarball + the // SHA256SUMS file (and optionally its cosign signature) live at the // URLs listed here; the actual binary hashes come from SHA256SUMS, // not from the manifest, so manifest tampering can't substitute a // hash for a known-good tarball. type Release struct { Version string `json:"version"` TarballURL string `json:"tarball_url"` SHA256SumsURL string `json:"sha256sums_url"` SHA256SumsSigURL string `json:"sha256sums_sig_url,omitempty"` ReleasedAt time.Time `json:"released_at"` } // ManifestSchemaVersion is the SchemaVersion this CLI knows how to // parse. Bumped together with any breaking change in Manifest / // Release. const ManifestSchemaVersion = 1 // FetchManifest downloads the release manifest and validates its // shape. Returns an error if the server is unreachable, returns // non-2xx, exceeds the size cap, or the schema_version is newer // than this CLI knows. func FetchManifest(ctx context.Context, client *http.Client) (Manifest, error) { if client == nil { client = http.DefaultClient } req, err := http.NewRequestWithContext(ctx, http.MethodGet, manifestURL, nil) if err != nil { return Manifest{}, err } resp, err := client.Do(req) if err != nil { return Manifest{}, fmt.Errorf("fetch manifest: %w", err) } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { return Manifest{}, fmt.Errorf("fetch manifest: HTTP %s", resp.Status) } if resp.ContentLength > MaxManifestBytes { return Manifest{}, fmt.Errorf("manifest is %d bytes, exceeds %d-byte cap", resp.ContentLength, MaxManifestBytes) } body, err := io.ReadAll(io.LimitReader(resp.Body, MaxManifestBytes+1)) if err != nil { return Manifest{}, fmt.Errorf("read manifest: %w", err) } if int64(len(body)) > MaxManifestBytes { return Manifest{}, fmt.Errorf("manifest body exceeded %d-byte cap", MaxManifestBytes) } return ParseManifest(body) } // ParseManifest unmarshals manifest bytes and validates the schema // version. Exposed as a separate function so tests can drive it // without an HTTP server. func ParseManifest(body []byte) (Manifest, error) { var m Manifest if err := json.Unmarshal(body, &m); err != nil { return Manifest{}, fmt.Errorf("parse manifest: %w", err) } if m.SchemaVersion == 0 { return Manifest{}, fmt.Errorf("manifest missing schema_version") } if m.SchemaVersion > ManifestSchemaVersion { return Manifest{}, fmt.Errorf("manifest schema_version %d is newer than this CLI knows (%d); upgrade banger to read it", m.SchemaVersion, ManifestSchemaVersion) } if strings.TrimSpace(m.LatestStable) == "" && len(m.Releases) > 0 { return Manifest{}, fmt.Errorf("manifest missing latest_stable") } for i, r := range m.Releases { if strings.TrimSpace(r.Version) == "" { return Manifest{}, fmt.Errorf("release[%d]: empty version", i) } if strings.TrimSpace(r.TarballURL) == "" { return Manifest{}, fmt.Errorf("release[%d] (%s): empty tarball_url", i, r.Version) } if strings.TrimSpace(r.SHA256SumsURL) == "" { return Manifest{}, fmt.Errorf("release[%d] (%s): empty sha256sums_url", i, r.Version) } } return m, nil } // LookupRelease finds the release with the given version (e.g. // "v0.1.0") in the manifest. Returns an error when no match exists — // helpful when a user passes `--to v9.9.9` against a manifest that // hasn't seen v9.9.9 yet. func (m Manifest) LookupRelease(version string) (Release, error) { wanted := strings.TrimSpace(version) if wanted == "" { return Release{}, fmt.Errorf("version is required") } for _, r := range m.Releases { if r.Version == wanted { return r, nil } } available := make([]string, 0, len(m.Releases)) for _, r := range m.Releases { available = append(available, r.Version) } return Release{}, fmt.Errorf("release %q not found in manifest (available: %s)", wanted, strings.Join(available, ", ")) } // Latest returns the release matching the manifest's latest_stable // pointer. Errors when the pointer doesn't reference any listed // release — that's a manifest publishing bug worth surfacing rather // than silently picking some other release. func (m Manifest) Latest() (Release, error) { return m.LookupRelease(m.LatestStable) }