update: docs + publish script for the self-update feature

README gets a top-level Updating section; docs/privileges.md gains
a step-by-step trust-model writeup of `banger update`. The new
scripts/publish-banger-release.sh drives the manual release cut:
build, tar, sha256sum, cosign sign-blob, verify against the embedded
public key, jq-merge into manifest.json, rclone upload to the R2
bucket. Refuses outright if the embedded key is still the placeholder
so we can't accidentally publish an unverifiable release. Also folds
in gofmt drift accumulated across the updater package and a few
sibling files.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Thales Maciel 2026-04-29 12:43:46 -03:00
parent 8ed351ea47
commit fae28e3d8b
No known key found for this signature in database
GPG key ID: 33112E6833C34679
10 changed files with 310 additions and 33 deletions

View file

@ -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

View file

@ -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

View file

@ -125,4 +125,3 @@ func lastID(xs []int) int {
}
return max
}

View file

@ -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),

View file

@ -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) {

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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.

177
scripts/publish-banger-release.sh Executable file
View file

@ -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/<version>/):
# banger-<version>-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 <version> (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"