Files
argus-installer/update.sh
T
root f00352a659 fix: verify digest + cosign signature before pulling/restarting anything
Companion to argus-appliance issue #88. update.sh no longer trusts the
version manifest alone -- before pulling or restarting any service, it
independently resolves each image's live registry digest and verifies it
matches what the release pipeline recorded, then cosign-verifies the
signature against the committed public key. Aborts (installation
untouched) on any mismatch, missing tool, or ambiguity.
2026-08-15 01:37:48 +00:00

201 lines
9.0 KiB
Bash
Executable File

#!/usr/bin/env bash
#
# ARGUS Enterprise Web Security Platform — updater.
#
# cd /opt/argus && ./update.sh
# (or, if you don't have it locally yet)
# curl -fsSL https://git-cloud.weboria.eu/weboria/argus-installer/raw/branch/main/update.sh | bash
#
# Requires: docker (with the buildx plugin, bundled by default in current
# Docker/docker.io packages) and cosign (https://docs.sigstore.dev/cosign/
# installation/) on PATH. Both are used only to VERIFY what's about to be
# installed, before anything is pulled or restarted -- see below.
#
# ADR-0051 (argus-appliance repo): this pulls a specific, pinned version --
# never :latest. :latest is three independently-mutable registry tags with
# no cross-image transaction; a version resolved from latest-version.json
# is a single atomically-committed pointer that the release pipeline only
# ever writes AFTER every image it names has finished promoting AND passed
# scripts/verify-release.sh's full release-gate (argus-appliance repo's
# release.yml `release-gate` job) -- so the mere presence of a version in
# this manifest already means it passed CI-side verification. This script
# does NOT re-trust that alone: 2026-08-15 incident (see argus-appliance's
# docs/adr/0051-...md incident note) established that neither this script
# nor the Appliance GUI may ever treat a version as installable without
# independently re-verifying it here, at the point of actual install --
# a bug in the gate, a compromised pointer, or a torn registry push must
# still be caught here, not merely trusted upstream.
set -euo pipefail
INSTALL_DIR="${ARGUS_INSTALL_DIR:-/opt/argus}"
MANIFEST_URL="https://git-cloud.weboria.eu/weboria/argus-installer/raw/branch/main/latest-version.json"
COSIGN_PUBKEY_URL="https://git-cloud.weboria.eu/weboria/argus-installer/raw/branch/main/argus-images.pub"
IMAGE_PREFIX="git-cloud.weboria.eu/weboria/argus"
bold() { printf '\033[1m%s\033[0m\n' "$1"; }
info() { printf ' %s\n' "$1"; }
ok() { printf ' \033[32m✓\033[0m %s\n' "$1"; }
fail() { printf ' \033[31m✗\033[0m %s\n' "$1" >&2; exit 1; }
bold "ARGUS Updater"
echo
[ -f "$INSTALL_DIR/docker-compose.yml" ] || fail "No ARGUS installation found at $INSTALL_DIR (set ARGUS_INSTALL_DIR if you installed elsewhere)."
cd "$INSTALL_DIR"
API_HOST_PORT="$(grep -oP '^API_HOST_PORT=\K.*' .env 2>/dev/null || true)"
API_HOST_PORT="${API_HOST_PORT:-9080}"
BEFORE_VERSION="$(curl -fsS "http://127.0.0.1:${API_HOST_PORT}/health" 2>/dev/null | grep -oP '"version"\s*:\s*"\K[^"]+' || echo "unknown")"
info "Current version: ${BEFORE_VERSION}"
bold "Resolving target version..."
MANIFEST="$(curl -fsS "$MANIFEST_URL" 2>/dev/null)" \
|| fail "Could not reach the update manifest ($MANIFEST_URL). Nothing was changed -- your installation is untouched. Check your network connection and try again."
TARGET_VERSION="$(printf '%s' "$MANIFEST" | grep -oP '"version"\s*:\s*"\K[^"]+' || true)"
[ -n "$TARGET_VERSION" ] || fail "The update manifest was malformed (no 'version' field). Nothing was changed -- your installation is untouched."
info "Target version: ${TARGET_VERSION}"
if [ "$BEFORE_VERSION" = "$TARGET_VERSION" ]; then
ok "Already up to date (${TARGET_VERSION})."
exit 0
fi
# ------------------------------------------------------------------
# Fail-safe re-verification, BEFORE anything is pulled or restarted.
#
# The manifest's mere existence already implies it passed the CI-side
# release gate (see header comment) -- but this script does not stop
# there. It independently re-derives, from the registry and the committed
# public key alone (no Gitea credentials needed or used), that what it's
# about to install is really what the release pipeline promoted: the
# live image digest must match what the manifest recorded, AND that
# digest must carry a valid cosign signature. Any failure, missing tool,
# missing data, or ambiguity aborts here -- nothing is pulled, nothing is
# restarted, the running installation is untouched.
# ------------------------------------------------------------------
bold "Verifying ${TARGET_VERSION} before installing anything..."
command -v docker >/dev/null 2>&1 || fail "docker not found on PATH -- cannot verify the release. Nothing was changed."
command -v cosign >/dev/null 2>&1 || fail "cosign not found on PATH (see https://docs.sigstore.dev/cosign/installation/) -- signature verification is required before install, not optional. Nothing was changed."
COSIGN_PUBKEY_FILE="$(mktemp)"
trap 'rm -f "$COSIGN_PUBKEY_FILE"' EXIT
curl -fsS "$COSIGN_PUBKEY_URL" -o "$COSIGN_PUBKEY_FILE" 2>/dev/null \
|| fail "Could not fetch the image signing public key ($COSIGN_PUBKEY_URL). Nothing was changed -- your installation is untouched."
[ -s "$COSIGN_PUBKEY_FILE" ] || fail "Fetched signing public key was empty. Nothing was changed."
manifest_image_digest() {
printf '%s' "$MANIFEST" | python3 -c "
import json, sys
try:
print(json.load(sys.stdin).get('images', {}).get('$1', ''))
except Exception:
print('')
" 2>/dev/null
}
VERIFY_FAILED=false
for svc_component in "api:api" "ui:ui" "nginx:proxy"; do
svc="${svc_component%%:*}"
component="${svc_component##*:}"
IMG="${IMAGE_PREFIX}-${component}:${TARGET_VERSION}"
RECORDED_DIGEST="$(manifest_image_digest "$component")"
if [ -z "$RECORDED_DIGEST" ] || [ "$RECORDED_DIGEST" = "null" ]; then
info " ✗ ${svc}: manifest has no recorded digest for ${TARGET_VERSION} -- cannot verify"
VERIFY_FAILED=true
continue
fi
ACTUAL_DIGEST="$(docker buildx imagetools inspect "$IMG" --format '{{json .Manifest.Digest}}' 2>/dev/null | tr -d '"' || true)"
if [ -z "$ACTUAL_DIGEST" ] || [ "$ACTUAL_DIGEST" = "null" ]; then
info " ✗ ${svc}: could not resolve ${IMG} in the registry"
VERIFY_FAILED=true
continue
fi
if [ "$ACTUAL_DIGEST" != "$RECORDED_DIGEST" ]; then
info " ✗ ${svc}: registry digest ${ACTUAL_DIGEST} does not match manifest-recorded ${RECORDED_DIGEST}"
VERIFY_FAILED=true
continue
fi
if ! cosign verify --key "$COSIGN_PUBKEY_FILE" "${IMAGE_PREFIX}-${component}@${ACTUAL_DIGEST}" >/dev/null 2>&1; then
info " ✗ ${svc}: signature verification failed for ${ACTUAL_DIGEST}"
VERIFY_FAILED=true
continue
fi
info " ✓ ${svc}: digest ${ACTUAL_DIGEST} matches manifest and signature verified"
done
if [ "$VERIFY_FAILED" = "true" ]; then
fail "Release verification failed for ${TARGET_VERSION} -- treating this update as unavailable. Nothing was pulled or changed. This is not a transient error to retry blindly; investigate before trying again."
fi
ok "Release ${TARGET_VERSION} independently verified (digest + signature) for every service."
# Pinned, not :latest -- see the header comment. Exported only for this
# script's own `docker compose` invocations below, not written to .env, so
# a later manual `docker compose pull` by the operator still defaults to
# :latest exactly as it always has.
export ARGUS_VERSION="$TARGET_VERSION"
bold "Pulling ${TARGET_VERSION} (pinned, not :latest)..."
docker compose pull
bold "Restarting..."
docker compose up -d
echo
bold "Waiting for ARGUS to become healthy..."
READY=false
for _ in $(seq 1 60); do
if curl -fsS "http://127.0.0.1:${API_HOST_PORT}/health" >/dev/null 2>&1; then
READY=true
break
fi
sleep 5
done
if [ "$READY" != "true" ]; then
fail "ARGUS did not come back healthy within 5 minutes. Check: docker compose -f $INSTALL_DIR/docker-compose.yml logs"
fi
AFTER_VERSION="$(curl -fsS "http://127.0.0.1:${API_HOST_PORT}/health" 2>/dev/null | grep -oP '"version"\s*:\s*"\K[^"]+' || echo "unknown")"
# The API being healthy only proves the API container itself came up on
# some version -- it says nothing about whether the UI and proxy images
# actually match. A torn pull (network hiccup mid-pull, disk full on one
# image, etc.) would otherwise be reported as a clean success. Check every
# service's actual running image tag against the target explicitly.
bold "Verifying every service is running ${TARGET_VERSION}..."
INCONSISTENT=false
for svc in api ui nginx; do
CONTAINER="$(docker compose ps -q "$svc" 2>/dev/null || true)"
if [ -z "$CONTAINER" ]; then
info " ✗ ${svc}: container not found"
INCONSISTENT=true
continue
fi
RUNNING_IMAGE="$(docker inspect --format '{{.Config.Image}}' "$CONTAINER" 2>/dev/null || echo "")"
RUNNING_TAG="${RUNNING_IMAGE##*:}"
if [ "$RUNNING_TAG" = "$TARGET_VERSION" ]; then
info " ✓ ${svc}: ${RUNNING_TAG}"
else
info " ✗ ${svc}: ${RUNNING_TAG:-unknown} (expected ${TARGET_VERSION})"
INCONSISTENT=true
fi
done
if [ "$INCONSISTENT" = "true" ]; then
fail "Update did not complete cleanly -- not every service is running ${TARGET_VERSION}. Your services were NOT automatically rolled back; investigate (docker compose ps, docker compose logs) before retrying."
fi
if [ "$AFTER_VERSION" != "$TARGET_VERSION" ]; then
fail "Every service's image is ${TARGET_VERSION}, but the API's own /health still reports ${AFTER_VERSION} -- it may not have finished restarting. Check: docker compose -f $INSTALL_DIR/docker-compose.yml logs api"
fi
ok "ARGUS is healthy and every service is confirmed running ${AFTER_VERSION}"
info "Updated: ${BEFORE_VERSION}${AFTER_VERSION}"