Three load-bearing fixes that together let `banger update` (and its auto-rollback path) restart the helper + daemon without killing every running VM. New smoke scenarios prove the property end-to-end. Bug fixes: 1. Disable the firecracker SDK's signal-forwarding goroutine. The default ForwardSignals = [SIGINT, SIGQUIT, SIGTERM, SIGHUP, SIGABRT] installs a handler in the helper that propagates the helper's SIGTERM (sent by systemd on `systemctl stop bangerd- root.service`) to every running firecracker child. Set ForwardSignals to an empty (non-nil) slice so setupSignals short-circuits at len()==0. 2. Add SendSIGKILL=no to bangerd-root.service. KillMode=process limits the initial SIGTERM to the helper main, but systemd still SIGKILLs leftover cgroup processes during the FinalKillSignal stage unless SendSIGKILL=no. 3. Route restart-helper / restart-daemon / wait-daemon-ready failures through rollbackAndRestart instead of rollbackAndWrap. rollbackAndWrap restored .previous binaries but didn't re- restart the failed unit, leaving the helper dead with the rolled-back binary on disk after a failed update. Testing infrastructure (production binaries unaffected): - Hidden --manifest-url and --pubkey-file flags on `banger update` let the smoke harness redirect the updater at locally-built release artefacts. Marked Hidden in cobra; not advertised in --help. - FetchManifestFrom / VerifyBlobSignatureWithKey / FetchAndVerifySignatureWithKey export the existing logic against caller-supplied URL / pubkey. The default entry points still call them with the embedded canonical values. Smoke scenarios: - update_check: --check against fake manifest reports update available - update_to_unknown: --to v9.9.9 fails before any host mutation - update_no_root: refuses without sudo, install untouched - update_dry_run: stages + verifies, no swap, version unchanged - update_keeps_vm_alive: real swap to v0.smoke.0; same VM (same boot_id) answers SSH after the daemon restart - update_rollback_keeps_vm_alive: v0.smoke.broken-bangerd ships a bangerd that passes --check-migrations but exits 1 as the daemon. The post-swap `systemctl restart bangerd` fails, rollbackAndRestart fires, the .previous binaries are restored and re-restarted; the same VM still answers SSH afterwards - daemon_admin (separate prep): covers `banger daemon socket`, `bangerd --check-migrations --system`, `sudo banger daemon stop` The smoke release builder generates a fresh ECDSA P-256 keypair with openssl, signs SHA256SUMS cosign-compatibly, and serves artefacts from a backgrounded python http.server. verify_smoke_check_test.go pins the openssl/cosign signature equivalence so the smoke release builder can't silently drift. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
144 lines
5.9 KiB
Go
144 lines
5.9 KiB
Go
package updater
|
|
|
|
import (
|
|
"context"
|
|
"crypto/ecdsa"
|
|
"crypto/sha256"
|
|
"crypto/x509"
|
|
"encoding/base64"
|
|
"encoding/pem"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
)
|
|
|
|
// MaxSignatureBytes caps the cosign signature download. A blob
|
|
// signature is ~70 bytes raw (an ECDSA P-256 ASN.1 signature) plus
|
|
// some base64 overhead and a trailing newline; 1 KiB is generous.
|
|
const MaxSignatureBytes int64 = 1024
|
|
|
|
// BangerReleasePublicKey is the cosign-generated public key used to
|
|
// verify SHA256SUMS for every banger release. SET ME BEFORE THE
|
|
// FIRST RELEASE. The placeholder below is intentionally invalid so
|
|
// `banger update` refuses every download until a real key lands.
|
|
//
|
|
// 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.
|
|
//
|
|
// 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.
|
|
//
|
|
// 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.
|
|
var BangerReleasePublicKey = `-----BEGIN PUBLIC KEY-----
|
|
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAElWFSLKLosBrdjfuF8ZS6U01Ufky4
|
|
zNeVPCkA6HEJ/oe634fRqwFxkXKGWg03eGFSnlwRxnUxN2+duXQSsR0pzQ==
|
|
-----END PUBLIC KEY-----`
|
|
|
|
// ErrSignatureRequired is returned by VerifyManifestRelease when the
|
|
// embedded public key is the placeholder. Surfaces as a clear "the
|
|
// release maintainer hasn't published their cosign key yet, refusing
|
|
// to update" rather than a cryptic crypto error.
|
|
var ErrSignatureRequired = errors.New("banger release public key is the placeholder; the maintainer must replace it and re-cut a release before update can proceed")
|
|
|
|
// VerifyBlobSignature checks that sigBase64 is a valid cosign-blob
|
|
// signature over body, made with the private counterpart of
|
|
// BangerReleasePublicKey.
|
|
func VerifyBlobSignature(body, sigBase64 []byte) error {
|
|
return VerifyBlobSignatureWithKey(body, sigBase64, BangerReleasePublicKey)
|
|
}
|
|
|
|
// VerifyBlobSignatureWithKey is VerifyBlobSignature against an
|
|
// explicit PEM-encoded public key. Used by the smoke suite (via
|
|
// `banger update --pubkey-file …`) so an end-to-end update test can
|
|
// trust a locally-generated keypair without rebuilding the binary.
|
|
//
|
|
// Refuses outright if pubKeyPEM is the build-time placeholder so an
|
|
// unset key can't slip through as "verification disabled".
|
|
//
|
|
// cosign's blob signature format is a base64-encoded ASN.1-DER ECDSA
|
|
// signature over SHA256(body) — that's what ecdsa.VerifyASN1 takes.
|
|
func VerifyBlobSignatureWithKey(body, sigBase64 []byte, pubKeyPEM string) error {
|
|
if isPlaceholderKey(pubKeyPEM) {
|
|
return ErrSignatureRequired
|
|
}
|
|
block, _ := pem.Decode([]byte(pubKeyPEM))
|
|
if block == nil {
|
|
return fmt.Errorf("decode banger release public key: no PEM block")
|
|
}
|
|
pubAny, err := x509.ParsePKIXPublicKey(block.Bytes)
|
|
if err != nil {
|
|
return fmt.Errorf("parse banger release public key: %w", err)
|
|
}
|
|
pub, ok := pubAny.(*ecdsa.PublicKey)
|
|
if !ok {
|
|
return fmt.Errorf("banger release public key is not ECDSA")
|
|
}
|
|
sigBytes, err := base64.StdEncoding.DecodeString(strings.TrimSpace(string(sigBase64)))
|
|
if err != nil {
|
|
return fmt.Errorf("decode signature base64: %w", err)
|
|
}
|
|
digest := sha256.Sum256(body)
|
|
if !ecdsa.VerifyASN1(pub, digest[:], sigBytes) {
|
|
return fmt.Errorf("signature does not verify against banger release public key")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// FetchAndVerifySignature pulls the SHA256SUMS.sig URL from the
|
|
// release, downloads it (capped), and verifies it against sumsBody.
|
|
// Returns nil on a clean pass, or an error describing exactly why
|
|
// verification failed.
|
|
//
|
|
// If release.SHA256SumsSigURL is empty, treat that as "release was
|
|
// not signed" — refuse rather than silently proceeding. v0.1.0
|
|
// requires every release to be cosign-signed; an unsigned release
|
|
// is a manifest publishing bug we'd rather catch loudly.
|
|
func FetchAndVerifySignature(ctx context.Context, client *http.Client, release Release, sumsBody []byte) error {
|
|
return FetchAndVerifySignatureWithKey(ctx, client, release, sumsBody, BangerReleasePublicKey)
|
|
}
|
|
|
|
// FetchAndVerifySignatureWithKey is FetchAndVerifySignature against
|
|
// an explicit PEM-encoded public key.
|
|
func FetchAndVerifySignatureWithKey(ctx context.Context, client *http.Client, release Release, sumsBody []byte, pubKeyPEM string) error {
|
|
if strings.TrimSpace(release.SHA256SumsSigURL) == "" {
|
|
return fmt.Errorf("release %s has no sha256sums_sig_url; refusing to install an unsigned release", release.Version)
|
|
}
|
|
if client == nil {
|
|
client = http.DefaultClient
|
|
}
|
|
sig, err := fetchBounded(ctx, client, release.SHA256SumsSigURL, MaxSignatureBytes)
|
|
if err != nil {
|
|
return fmt.Errorf("fetch signature: %w", err)
|
|
}
|
|
if err := VerifyBlobSignatureWithKey(sumsBody, sig, pubKeyPEM); err != nil {
|
|
return fmt.Errorf("verify SHA256SUMS signature: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// isPlaceholderKey detects the build-time placeholder constant. A
|
|
// real cosign-generated PEM never contains the string "PLACEHOLDER";
|
|
// a real ECDSA P-256 key block decodes to ~91 bytes of content,
|
|
// nowhere near our padded constant.
|
|
func isPlaceholderKey(pem string) bool {
|
|
return strings.Contains(pem, "PLACEHOLDER")
|
|
}
|