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:
parent
8ed351ea47
commit
fae28e3d8b
10 changed files with 310 additions and 33 deletions
22
README.md
22
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -125,4 +125,3 @@ func lastID(xs []int) int {
|
|||
}
|
||||
return max
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
177
scripts/publish-banger-release.sh
Executable 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"
|
||||
Loading…
Add table
Add a link
Reference in a new issue