diff --git a/README.md b/README.md index 77a7ecb..0e656b6 100644 --- a/README.md +++ b/README.md @@ -267,6 +267,28 @@ directory are skipped with a warning — they'd otherwise leak files from outside the named tree (e.g. a symlink inside `~/.aws` pointing to an unrelated credential dir). +## Updating + +```bash +banger update --check # is a newer release available? +sudo banger update # download, verify, swap, restart, run doctor +sudo banger update --to v0.1.1 +sudo banger update --dry-run +``` + +`banger update` pulls the release manifest from +`https://releases.thaloco.com/banger/manifest.json`, downloads the +release tarball + `SHA256SUMS` + `SHA256SUMS.sig`, verifies the +cosign signature against the public key embedded in the running +binary, hashes the tarball, atomically swaps the three banger +binaries, restarts both systemd services, and runs `banger doctor`. +On any failure post-swap, it auto-restores the previous install +from `.previous` backups before surfacing the original error. + +Refuses to start while any banger operation is in flight. No +background update checks; updates only happen when you ask. See +[`docs/privileges.md`](docs/privileges.md) for the trust model. + ## Advanced The common path is `vm run`. Power-user flows (`vm create`, OCI pull diff --git a/docs/privileges.md b/docs/privileges.md index bef1411..51da232 100644 --- a/docs/privileges.md +++ b/docs/privileges.md @@ -198,6 +198,84 @@ What `uninstall` does NOT do automatically: - It does not remove the owner user, the owner's home, or anything the user wrote into a guest from inside the guest. +## Updating banger + +`banger update` is a user-triggered, manually-invoked operation. It +never runs in the background and never auto-checks for new releases. + +The flow: + +1. **Discover.** GET `https://releases.thaloco.com/banger/manifest.json` + over HTTPS. The URL is hardcoded in the binary at compile time — + a compromised daemon config can't redirect the updater. Manifest + schema_version gates forward compat: a CLI that doesn't recognise + the server's schema_version refuses to update. +2. **In-flight gate.** `daemon.operations.list` RPC. If any operation + is not Done, refuse with the operation list. `--force` overrides. +3. **Download.** Capped GET on the tarball + `SHA256SUMS` (≤ 256 MiB + and ≤ 16 KiB respectively). Tarball is sha256-verified on the fly + against the digest published in `SHA256SUMS`; partial files are + removed on any verification failure. +4. **Cosign signature.** `SHA256SUMS.sig` is fetched (≤ 1 KiB) and + verified against the `BangerReleasePublicKey` embedded in the + running banger binary. The signature is an ECDSA P-256 / SHA-256 + blob signature produced by `cosign sign-blob` — verified by Go's + stdlib `crypto/ecdsa.VerifyASN1`, no third-party crypto deps. A + missing signature URL or a verification failure aborts the update + before any binary is touched. +5. **Sanity-run.** Staged `banger --version` must mention the + expected version; staged `bangerd --check-migrations --system` + must exit 0 (compatible) or 1 (will auto-migrate). Exit 2 + (incompatible — DB has migrations the new binary doesn't know) + aborts the swap; the running install is untouched. +6. **Swap.** Atomic `os.Rename` for each of the three binaries + (banger-vsock-agent → bangerd → banger), with `.previous` backups. +7. **Restart.** `systemctl restart bangerd-root.service` then + `bangerd.service`. Wait for the new daemon socket to answer + `ping`. Running VMs survive the daemon restart — they're each + their own firecracker process and live in `bangerd-root.service`'s + cgroup; restart's `KillMode=control-group` doesn't reach them. + The new daemon's `reconcile` step re-attaches by reading the + per-VM `handles.json` scratch file and verifying the firecracker + process is still alive. +8. **Verify.** Run `banger doctor` against the just-installed CLI. + FAIL triggers auto-rollback: restore `.previous` backups, restart + services again so the OLD binaries take over. The original error + bubbles to the operator; `--force` skips this step. +9. **Finalise.** Update `/etc/banger/install.toml`'s Version / + Commit / BuiltAt. Remove `.previous` backups. Wipe the staging + directory under `/var/cache/banger/updates/`. + +What you're trusting in this flow: + +- The cosign **public key** baked into the binary you're updating + FROM. The maintainer rotates it by cutting a new release with a + new key embedded; from then on, only signatures made with the + new private key are accepted. v0.1.x predates a clean rotation + story. +- TLS to `releases.thaloco.com` for transport. The cosign signature + is the actual integrity check; TLS just gets us the bytes faster. +- The systemd unit owners (root for the helper, owner for the + daemon). `banger update` requires root because it writes + `/usr/local/bin` and talks to systemctl; it does NOT run via the + helper RPC interface. + +What `banger update` deliberately does NOT do: + +- No background check timers. Operators run `banger update --check` + on a schedule themselves if they want. +- No update across MINOR boundaries without an explicit `--to` + flag. v0.x is pre-stable; we don't promise that v0.1.5 → v0.2.0 + is automatic. +- No state-DB downgrade. Schema migrations are forward-only; + `--check-migrations` refuses to swap a binary that's older than + the running schema. +- No agent re-injection into existing VMs. The vsock agent inside + each VM is the version banger had at image-pull time, not the + current install. v0.1.x doesn't enforce or detect skew here; the + agent's HTTP API is small enough that compat across MINORs is + expected. + ## Running outside the system install Everything above describes the supported deployment: `banger system diff --git a/internal/cli/bangerd.go b/internal/cli/bangerd.go index 1754978..c1d2867 100644 --- a/internal/cli/bangerd.go +++ b/internal/cli/bangerd.go @@ -125,4 +125,3 @@ func lastID(xs []int) int { } return max } - diff --git a/internal/daemon/dispatch.go b/internal/daemon/dispatch.go index e4a79f8..20886d5 100644 --- a/internal/daemon/dispatch.go +++ b/internal/daemon/dispatch.go @@ -50,8 +50,8 @@ func noParamHandler[R any](call func(ctx context.Context, d *Daemon) (R, error)) // live below the map; they need pre-service validation or raw result // encoding that the generic wrapper can't express. var rpcHandlers = map[string]handler{ - "ping": pingHandler, - "shutdown": shutdownHandler, + "ping": pingHandler, + "shutdown": shutdownHandler, "daemon.operations.list": noParamHandler(daemonOperationsListDispatch), "vm.create": paramHandler(vmCreateDispatch), diff --git a/internal/daemon/doctor_test.go b/internal/daemon/doctor_test.go index fdc9c6d..37f766c 100644 --- a/internal/daemon/doctor_test.go +++ b/internal/daemon/doctor_test.go @@ -468,9 +468,9 @@ func TestFirecrackerInstallHintDispatchesByDistro(t *testing.T) { // dispatcher lets us run a real script for one command without // rewiring the rest. type firecrackerVersionRunner struct { - real system.Runner - canned []byte - bin string + real system.Runner + canned []byte + bin string } func (r *firecrackerVersionRunner) Run(ctx context.Context, name string, args ...string) ([]byte, error) { diff --git a/internal/updater/manifest.go b/internal/updater/manifest.go index b949bdd..96156f8 100644 --- a/internal/updater/manifest.go +++ b/internal/updater/manifest.go @@ -63,11 +63,11 @@ type Manifest struct { // 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"` + 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 diff --git a/internal/updater/stage.go b/internal/updater/stage.go index 2c0967e..3a7794c 100644 --- a/internal/updater/stage.go +++ b/internal/updater/stage.go @@ -23,9 +23,9 @@ var expectedReleaseEntries = []string{ // StagedRelease describes the result of unpacking a release tarball // into a staging directory. type StagedRelease struct { - BangerPath string - BangerdPath string - VsockAgentPath string + BangerPath string + BangerdPath string + VsockAgentPath string } // StageTarball reads the gzipped tar at tarballPath and extracts the diff --git a/internal/updater/swap.go b/internal/updater/swap.go index 761dbae..f299deb 100644 --- a/internal/updater/swap.go +++ b/internal/updater/swap.go @@ -19,9 +19,9 @@ const previousSuffix = ".previous" // banger update is a system-mode operation; the developer non- // system-mode flow doesn't go through this code path. type InstallTargets struct { - Banger string // /usr/local/bin/banger - Bangerd string // /usr/local/bin/bangerd - VsockAgent string // /usr/local/lib/banger/banger-vsock-agent + Banger string // /usr/local/bin/banger + Bangerd string // /usr/local/bin/bangerd + VsockAgent string // /usr/local/lib/banger/banger-vsock-agent } // DefaultInstallTargets returns the canonical paths a system install diff --git a/internal/updater/verify_signature.go b/internal/updater/verify_signature.go index b17ee3e..fb536cd 100644 --- a/internal/updater/verify_signature.go +++ b/internal/updater/verify_signature.go @@ -25,25 +25,26 @@ const MaxSignatureBytes int64 = 1024 // // Production-cut workflow (for the maintainer cutting v0.1.0): // -// 1. Generate the keypair (one-time, store the private key offline): -// cosign generate-key-pair -// Produces cosign.key (private) and cosign.pub (public). The -// private key is password-protected; remember the password. +// 1. Generate the keypair (one-time, store the private key offline): +// cosign generate-key-pair +// Produces cosign.key (private) and cosign.pub (public). The +// private key is password-protected; remember the password. // -// 2. Replace the PEM block below with the contents of cosign.pub. -// Commit. From this point on, every banger CLI baked from this -// repo will only trust signatures made with cosign.key. +// 2. Replace the PEM block below with the contents of cosign.pub. +// Commit. From this point on, every banger CLI baked from this +// repo will only trust signatures made with cosign.key. // -// 3. At release time, sign SHA256SUMS: -// cosign sign-blob --key cosign.key --output-signature \ -// SHA256SUMS.sig SHA256SUMS -// Publish SHA256SUMS.sig alongside SHA256SUMS in the bucket; -// the manifest's `sha256sums_sig_url` field references it. +// 3. At release time, sign SHA256SUMS: +// cosign sign-blob --key cosign.key --output-signature \ +// SHA256SUMS.sig SHA256SUMS +// Publish SHA256SUMS.sig alongside SHA256SUMS in the bucket; +// the manifest's `sha256sums_sig_url` field references it. +// +// 4. Rotating the key after publication means publishing a new +// banger release that embeds the new key, then re-signing +// every release artifact with the new key. v0.1.x is too +// early to design a clean rotation story; defer. // -// 4. Rotating the key after publication means publishing a new -// banger release that embeds the new key, then re-signing -// every release artifact with the new key. v0.1.x is too -// early to design a clean rotation story; defer. // var (rather than const) only because tests need to swap it for an // in-test-generated key; production sets it at compile time and // never mutates it. diff --git a/scripts/publish-banger-release.sh b/scripts/publish-banger-release.sh new file mode 100755 index 0000000..24d76e4 --- /dev/null +++ b/scripts/publish-banger-release.sh @@ -0,0 +1,177 @@ +#!/usr/bin/env bash +# publish-banger-release.sh +# +# Cut and publish a banger release tarball + SHA256SUMS + cosign +# signature to the R2 bucket consumed by `banger update`. +# +# Usage: +# scripts/publish-banger-release.sh v0.1.0 +# +# Environment overrides: +# COSIGN_KEY path to the cosign private key (default: cosign.key) +# RCLONE_REMOTE rclone remote name (default: releases) +# BUCKET_PATH object-key prefix in the bucket (default: banger) +# BASE_URL public URL prefix for objects (default: https://releases.thaloco.com) +# SKIP_UPLOAD set to 1 to stage everything locally without rclone upload +# +# Prerequisites: +# * cosign in PATH (https://github.com/sigstore/cosign) +# * rclone in PATH, configured with a remote named ${RCLONE_REMOTE} +# pointing at the R2 bucket served at ${BASE_URL}. +# * A cosign keypair already generated. The public key MUST already +# be embedded in internal/updater/verify_signature.go's +# BangerReleasePublicKey constant — running this script with a +# placeholder key would publish a release no installed banger can +# verify. +# +# Output (under build/release//): +# banger--linux-amd64.tar.gz +# SHA256SUMS +# SHA256SUMS.sig +# manifest.json (the freshly-mutated copy uploaded to the bucket) + +set -euo pipefail + +log() { printf '[publish-banger-release] %s\n' "$*" >&2; } +die() { log "$*"; exit 1; } + +if [[ $# -lt 1 ]]; then + die "usage: $0 (e.g. $0 v0.1.0)" +fi + +VERSION="$1" +case "$VERSION" in + v*.*.*) ;; + *) die "version must look like vX.Y.Z, got $VERSION" ;; +esac + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +COSIGN_KEY="${COSIGN_KEY:-cosign.key}" +RCLONE_REMOTE="${RCLONE_REMOTE:-releases}" +BUCKET_PATH="${BUCKET_PATH:-banger}" +BASE_URL="${BASE_URL:-https://releases.thaloco.com}" +SKIP_UPLOAD="${SKIP_UPLOAD:-0}" + +command -v cosign >/dev/null || die "cosign not in PATH" +command -v rclone >/dev/null || die "rclone not in PATH" +command -v sha256sum >/dev/null || die "sha256sum not in PATH" +command -v jq >/dev/null || die "jq not in PATH" + +[[ -f "$COSIGN_KEY" ]] || die "cosign key not found at $COSIGN_KEY (override with COSIGN_KEY=...)" + +cd "$REPO_ROOT" + +OUT_DIR="$REPO_ROOT/build/release/$VERSION" +TARBALL_NAME="banger-$VERSION-linux-amd64.tar.gz" +TARBALL_PATH="$OUT_DIR/$TARBALL_NAME" + +log "preparing $OUT_DIR" +rm -rf "$OUT_DIR" +mkdir -p "$OUT_DIR" + +log "building binaries with version=$VERSION" +COMMIT="$(git rev-parse HEAD)" +BUILT_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)" +LDFLAGS="-X banger/internal/buildinfo.Version=$VERSION \ + -X banger/internal/buildinfo.Commit=$COMMIT \ + -X banger/internal/buildinfo.BuiltAt=$BUILT_AT" + +BUILD_STAGE="$OUT_DIR/stage" +mkdir -p "$BUILD_STAGE" +go build -ldflags "$LDFLAGS" -o "$BUILD_STAGE/banger" ./cmd/banger +go build -ldflags "$LDFLAGS" -o "$BUILD_STAGE/bangerd" ./cmd/bangerd +CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ + go build -ldflags "$LDFLAGS" -o "$BUILD_STAGE/banger-vsock-agent" \ + ./cmd/banger-vsock-agent + +log "tarring → $TARBALL_PATH" +# -C into the stage dir so the tarball's root entries are bare +# basenames (banger, bangerd, banger-vsock-agent) — the updater's +# StageTarball validator rejects anything else. +tar -czf "$TARBALL_PATH" -C "$BUILD_STAGE" \ + banger bangerd banger-vsock-agent + +log "computing SHA256SUMS" +( + cd "$OUT_DIR" + sha256sum "$TARBALL_NAME" > SHA256SUMS + cat SHA256SUMS +) >&2 + +log "cosign sign-blob → SHA256SUMS.sig" +COSIGN_PASSWORD="${COSIGN_PASSWORD:-}" \ + cosign sign-blob --yes \ + --key "$COSIGN_KEY" \ + --output-signature "$OUT_DIR/SHA256SUMS.sig" \ + "$OUT_DIR/SHA256SUMS" + +log "verifying signature against the embedded public key" +EMBEDDED_PUB="$OUT_DIR/embedded-pubkey.pem" +awk '/BEGIN PUBLIC KEY/,/END PUBLIC KEY/' \ + "$REPO_ROOT/internal/updater/verify_signature.go" \ + | grep -v '"' | grep -v '^//' \ + > "$EMBEDDED_PUB" +if grep -q PLACEHOLDER "$EMBEDDED_PUB"; then + die "BangerReleasePublicKey is the placeholder in verify_signature.go; replace it with cosign.pub before publishing" +fi +cosign verify-blob \ + --key "$EMBEDDED_PUB" \ + --signature "$OUT_DIR/SHA256SUMS.sig" \ + "$OUT_DIR/SHA256SUMS" + +# Build the manifest. Pull the existing manifest from the bucket so +# we don't lose previous release entries, append this one, bump +# latest_stable, write back. +log "fetching existing manifest" +PREV_MANIFEST="$OUT_DIR/manifest.previous.json" +if curl -fsSL "$BASE_URL/$BUCKET_PATH/manifest.json" -o "$PREV_MANIFEST" 2>/dev/null; then + log " found previous manifest" +else + log " no previous manifest (first release); seeding" + printf '{"schema_version":1,"latest_stable":"","releases":[]}' > "$PREV_MANIFEST" +fi + +NEW_MANIFEST="$OUT_DIR/manifest.json" +RELEASED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)" +jq --arg version "$VERSION" \ + --arg tarball_url "$BASE_URL/$BUCKET_PATH/$VERSION/$TARBALL_NAME" \ + --arg sums_url "$BASE_URL/$BUCKET_PATH/$VERSION/SHA256SUMS" \ + --arg sig_url "$BASE_URL/$BUCKET_PATH/$VERSION/SHA256SUMS.sig" \ + --arg released_at "$RELEASED_AT" \ + ' + .schema_version = 1 + | .latest_stable = $version + | .releases = ( + (.releases // []) + | map(select(.version != $version)) + | . + [{ + "version": $version, + "tarball_url": $tarball_url, + "sha256sums_url": $sums_url, + "sha256sums_sig_url": $sig_url, + "released_at": $released_at + }] + ) + ' "$PREV_MANIFEST" > "$NEW_MANIFEST" + +log "manifest:" +jq '.' "$NEW_MANIFEST" >&2 + +if [[ "$SKIP_UPLOAD" == "1" ]]; then + log "SKIP_UPLOAD=1, not uploading. Artifacts staged under $OUT_DIR" + exit 0 +fi + +log "uploading to $RCLONE_REMOTE:$BUCKET_PATH/$VERSION/" +rclone copy "$TARBALL_PATH" "$RCLONE_REMOTE:$BUCKET_PATH/$VERSION/" +rclone copy "$OUT_DIR/SHA256SUMS" "$RCLONE_REMOTE:$BUCKET_PATH/$VERSION/" +rclone copy "$OUT_DIR/SHA256SUMS.sig" "$RCLONE_REMOTE:$BUCKET_PATH/$VERSION/" + +log "uploading manifest" +rclone copy "$NEW_MANIFEST" "$RCLONE_REMOTE:$BUCKET_PATH/" + +log "done. verify with:" +log " curl -fsSL $BASE_URL/$BUCKET_PATH/manifest.json | jq ." +log " banger update --check"