#!/usr/bin/env bash # Publish the bastion agent distributable to the public edges. It is served at # BOTH addresses, because one of them is compiled into every bastion already # installed: # # new https://privatae.ai/bastion-agent/version.json # legacy https://privatae.ai/freeagent/version.json # # Those are two Caddy paths over ONE directory on each edge (WEBROOT below). # There is a single copy of every file; the new address is an alias, so the two # addresses can never drift or fall out of step at release time. # # THE LEGACY NAME, and when it ends. A bastion older than 0.20.0 knows only the # legacy address, builds the artifact's filename itself and asks the manifest # for `freeagent-.pyz`. If that address answers with a manifest that lists # only the new name, the bastion gets "no entry" and loses the agent; if the # address does not answer at all, the bastion keeps the agent it has. So: # # - while the legacy address is served, every publish writes the SAME BYTES # under BOTH filenames and lists both, each with the same sha256; # - once it is not served (it answers 404), a publish writes the new name only. # # The script reads which of the two is true on every run, on every edge, so it # cannot publish the one combination that strands a machine. Retiring the legacy address is a # step on the edges, taken once the published bastion is 0.42.0 or later (the # first release that never reads it); docs/packaging.md has it. # # The bastion's own deploy/publish.sh is the model: build # reproducibly, write a manifest with a sha256 per file, upload to EVERY edge, # then verify each edge's bytes against the manifest IT serves. Same key, same # edges, same webroot walls (Caddy refuses backups and dotfiles under both paths). # # Ratchet: refuse to publish a version that is not strictly newer than the one # already published (a rebuilt older version would regress installers that # compare versions). Override deliberately with ALLOW_REPUBLISH=1. set -euo pipefail # Repo root. This script sits in bastion_agent/, which is a top-level package # here, hence one `..`. HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" # WHERE THIS PUBLISHES IS NOT BAKED IN. This repository is read by everyone who # runs the agent, so it names no host, no key path and no webroot of ours: the # three below are required, and an unset one stops the script with a message # rather than publishing somewhere wrong. Whoever cuts a release supplies them. : "${KEY:?set KEY to the ssh key that reaches the edges}" # EDGE (one box) satisfies the requirement on its own -- it is the documented # single-box override below, and checking EDGES first would kill it with an error # naming a variable the operator was never told to set. if [ -n "${EDGE:-}" ]; then EDGES="$EDGE"; fi : "${EDGES:?set EDGES to the space-separated edge hosts to publish to (or EDGE= for a single box)}" : "${WEBROOT:?set WEBROOT to the directory on each edge that both URLs serve}" : "${BACKUP_DIR:?set BACKUP_DIR to a directory OUTSIDE the webroot for rotated installers}" # OUTSIDE is load-bearing: a backup inside the webroot is served at a stable public # URL, which is how a superseded installer stays reachable forever. Assert it. case "$BACKUP_DIR" in "$WEBROOT"|"$WEBROOT"/*) echo "BACKUP_DIR ($BACKUP_DIR) is inside WEBROOT ($WEBROOT) -- rotated installers would be served publicly" >&2 exit 1;; esac # The manifest is signed with the bastion's release key, which never comes to # this machine: it stays on the key holder's own computer. So a publish runs # twice. The first run builds, writes the manifest and prints the one # release-sign command to run there; the second, with SIGNATURE=, rebuilds (the build is reproducible, so the manifest is the same # bytes), checks the signature against the keys the bastion pins, and # publishes. BASTION_TREE is a checkout of privatae-bastion at the release the # fleet is moving to: its deploy/release-verify.sh and pinned keys are what a # bastion will check this manifest against. : "${BASTION_TREE:?set BASTION_TREE to a privatae-bastion checkout (its deploy/release-verify.sh checks the signature)}" [ -x "$BASTION_TREE/deploy/release-verify.sh" ] || { echo "$BASTION_TREE has no deploy/release-verify.sh" >&2; exit 1; } # The bastions that will read this manifest run the published bastion release, # and check it with the keys THAT release pins. So the signature is held to # those keys too, not only to BASTION_TREE's. FLEET_FROM="$(curl -fsS --max-time 15 https://privatae.ai/bastion/version.json | python3 -c 'import sys,json;print(json.load(sys.stdin).get("built_from",""))' 2>/dev/null || true)" git -C "$BASTION_TREE" fetch -q origin 2>/dev/null || true if [ -n "$FLEET_FROM" ] && ! git -C "$BASTION_TREE" cat-file -e "$FLEET_FROM^{commit}" 2>/dev/null; then echo "BASTION_TREE does not have the published bastion commit $FLEET_FROM; fetch it first" >&2 exit 1 fi BASE_NEW="https://privatae.ai/bastion-agent" BASE_OLD="https://privatae.ai/freeagent" VER="$(tr -d '[:space:]' < "$HERE/bastion_agent/VERSION")" # Is the legacy address still served? It is asked of EVERY edge, not only of # the one the public name reaches now: each edge serves from its own disk and # its own Caddy config, and an edge that still has the route would serve a # manifest without the legacy name the day traffic fails over to it. Each edge # is asked for the route in the config Caddy has loaded (its local admin API). # Anything but a clear "no route" (an answer, a timeout, an error) counts as # served, because writing the legacy name when it is not needed costs one extra # file, and leaving it out when it is needed costs every old bastion its agent. # The public address is asked too, and a 404 there is also required. # Counts the routes whose path matcher would take /freeagent/version.json, # however the matcher is spelled (/freeagent/*, /freeagent*, a regexp), which # covers handle, handle_path and redir alike. Anything unreadable is "unknown". ROUTES_PY=' import fnmatch, json, re, sys try: cfg = json.load(sys.stdin) except Exception: print("unknown"); sys.exit() if not isinstance(cfg, dict) or "apps" not in cfg: print("unknown"); sys.exit() target = "/freeagent/version.json" n = 0 def walk(o): global n if isinstance(o, dict): for k, v in o.items(): if k == "path" and isinstance(v, list) and any(isinstance(p, str) and fnmatch.fnmatchcase(target, p) and p not in ("*", "/*") for p in v): n += 1 elif k == "path_regexp" and isinstance(v, dict) and isinstance(v.get("pattern"), str): try: if re.search(v["pattern"], target) and "freeagent" in v["pattern"].lower(): n += 1 except re.error: n += 1 walk(v) elif isinstance(o, list): for x in o: walk(x) walk(cfg) print(n) ' LEGACY=0 for E in $EDGES; do n="$(ssh -n -i "$KEY" "$E" 'curl -fsS --max-time 10 http://localhost:2019/config/' 2>/dev/null \ | python3 -c "$ROUTES_PY" 2>/dev/null || true)" if [ "$n" = 0 ]; then echo " $E: no /freeagent route" else LEGACY=1; echo " $E: /freeagent route ${n:-unknown} (served)"; fi done LEGACY_CODE="$(curl -sS -o /dev/null -w '%{http_code}' --max-time 15 "$BASE_OLD/version.json" 2>/dev/null || true)" [ "$LEGACY_CODE" = 404 ] || LEGACY=1 if [ "$LEGACY" = 0 ]; then echo "legacy address retired on every edge (public: 404): publishing the new name only" else echo "legacy address still served somewhere (public: HTTP ${LEGACY_CODE:-none}): publishing both names"; fi GITSHA="$(git -C "$HERE" rev-parse --short HEAD 2>/dev/null || echo unknown)" echo "publishing bastion agent v$VER ($GITSHA)" # Both addresses serve one directory, so the new one is the record of what is # published, and it stays served after the legacy one is retired. CUR="$(curl -fsS --max-time 15 "$BASE_NEW/version.json" 2>/dev/null | python3 -c 'import sys,json;print(json.load(sys.stdin).get("version",""))' 2>/dev/null || true)" if [ -n "$CUR" ]; then newer="$(python3 -c 'import sys a=[int(x) for x in sys.argv[1].split(".")]; b=[int(x) for x in sys.argv[2].split(".")] print("yes" if a>b else "no")' "$VER" "$CUR")" if [ "$newer" = yes ]; then echo "ratchet ok: $CUR -> $VER"; else echo "RATCHET REFUSED: v$VER is not newer than published v$CUR (ALLOW_REPUBLISH=1 to override)" >&2 [ "${ALLOW_REPUBLISH:-}" = "1" ] || exit 1 fi fi DIST="$HERE/dist" (cd "$HERE" && python3 -m bastion_agent.build --out dist >/dev/null) PYZ="$DIST/bastion-agent-$VER.pyz" [ -f "$PYZ" ] || { echo "build did not produce $PYZ" >&2; exit 1; } python3 "$PYZ" --version >/dev/null UPLOAD=("$PYZ" "$PYZ.sha256") if [ "$LEGACY" = 1 ]; then # The same bytes under the legacy filename, for bastions that ask for it by name. LEGACY_PYZ="$DIST/freeagent-$VER.pyz" cp -f "$PYZ" "$LEGACY_PYZ" sed "s|bastion-agent-$VER.pyz|freeagent-$VER.pyz|" "$PYZ.sha256" > "$LEGACY_PYZ.sha256" # Prove they are the same artifact rather than assuming the copy worked. a="$(python3 -c 'import hashlib,sys;print(hashlib.sha256(open(sys.argv[1],"rb").read()).hexdigest())' "$PYZ")" b="$(python3 -c 'import hashlib,sys;print(hashlib.sha256(open(sys.argv[1],"rb").read()).hexdigest())' "$LEGACY_PYZ")" [ "$a" = "$b" ] || { echo "the two filenames do not carry the same bytes" >&2; exit 1; } echo "artifact: $a (published under both names)" UPLOAD+=("$LEGACY_PYZ" "$LEGACY_PYZ.sha256") fi python3 - "$VER" "$GITSHA" "$DIST" "$HERE/bastion_agent" "$BASE_NEW" "$BASE_OLD" "$LEGACY" > "$DIST/version.json" <<'PY' import sys, os, hashlib, json ver, sha, dist, pkg, base_new, base_old, legacy = sys.argv[1:8] def h(p): return hashlib.sha256(open(p, "rb").read()).hexdigest() def entry(name, base): p = os.path.join(dist, name) return name, {"url": "%s/dl/%s" % (base, name), "sha256": h(p), "bytes": os.path.getsize(p)} files = {} names = [("bastion-agent-%s.pyz" % ver, base_new), ("bastion-agent-%s.pyz.sha256" % ver, base_new)] # While the legacy address is served, the legacy pair is what a bastion older # than 0.20.0 asks for by name; leave it out and that bastion loses the agent. if legacy == "1": names += [("freeagent-%s.pyz" % ver, base_old), ("freeagent-%s.pyz.sha256" % ver, base_old)] for name, base in names: k, v = entry(name, base) files[k] = v installers = {n: {"url": "%s/%s" % (base_new, n), "sha256": h(os.path.join(pkg, n))} for n in ("install.sh", "install.ps1")} json.dump({"version": ver, "built_from": sha, "python_floor": "3.8", "files": files, "installers": installers, "notes": "https://github.com/Privatae/bastion-agent/releases/tag/v%s" % ver}, sys.stdout, indent=2) PY MAN="$DIST/version.json" MAN_SHA="$(python3 -c 'import hashlib,sys;print(hashlib.sha256(open(sys.argv[1],"rb").read()).hexdigest())' "$MAN")" if [ -z "${SIGNATURE:-}" ]; then cat < "$DIST/$SIG_NAME" SIGNED_BY="$("$BASTION_TREE/deploy/release-verify.sh" bastion-agent "$MAN" "$DIST/$SIG_NAME" ${FLEET_FROM:+"$FLEET_FROM"})" \ || { echo "SIGNATURE does not verify for bastion agent v$VER (manifest $MAN_SHA); nothing was published." >&2 echo "If the key holder signed the command the first run printed, compare the 'legacy address' lines of the two runs: a change on the edges between them changes the manifest." >&2 exit 1; } echo "manifest signature ok (key $SIGNED_BY)" for E in $EDGES; do echo "--- publishing to $E ---" ssh -i "$KEY" "$E" 'rm -rf /tmp/ba && mkdir -p /tmp/ba' scp -q -i "$KEY" "${UPLOAD[@]}" "$DIST/version.json" "$DIST/$SIG_NAME" \ "$HERE/bastion_agent/install.sh" "$HERE/bastion_agent/install.ps1" "$E":/tmp/ba/ ssh -i "$KEY" "$E" " set -e install -d -m755 \"$WEBROOT/dl\" install -d -m700 \"$BACKUP_DIR\" ts=\$(date +%s) for f in install.sh install.ps1 version.json; do cp -a \"$WEBROOT/\$f\" \"$BACKUP_DIR/\$f.bak-\$ts\" 2>/dev/null || true; done for f in /tmp/ba/bastion-agent-* /tmp/ba/freeagent-*; do [ -e \"\$f\" ] || continue; b=\$(basename \"\$f\"); mv \"\$f\" \"$WEBROOT/dl/\$b\"; chmod 644 \"$WEBROOT/dl/\$b\"; done # The signature lands before the manifest it signs. for f in /tmp/ba/version.json.*.sig; do b=\$(basename \"\$f\"); mv \"\$f\" \"$WEBROOT/\$b\"; chmod 644 \"$WEBROOT/\$b\"; done for f in version.json install.sh install.ps1; do mv /tmp/ba/\$f \"$WEBROOT/\$f\"; chmod 644 \"$WEBROOT/\$f\"; done rm -rf /tmp/ba" done # Verify each edge against the manifest IT serves, and, while the legacy address # is served, assert the legacy entries are present: their absence is the failure # that strands old bastions. for E in $EDGES; do printf ' %s: ' "$E" ssh -i "$KEY" "$E" "cd $WEBROOT && LEGACY=$LEGACY python3 - <.sig), checked against # the manifest that address serves and the keys the bastion pins. SIGCHECK="$(mktemp -d)"; trap 'rm -rf "$SIGCHECK"' EXIT # The legacy address is checked from outside only when it answers there: while # edges disagree, the public name may reach one that no longer has the route, # and the per-edge check above has already held every edge's files to the # manifest. ADDRS=("$BASE_NEW"); [ "$LEGACY" = 1 ] && [ "$LEGACY_CODE" != 404 ] && ADDRS+=("$BASE_OLD") for B in "${ADDRS[@]}"; do curl -fsS --max-time 30 "$B/version.json" -o "$SIGCHECK/m" H="$(python3 -c 'import hashlib,sys;print(hashlib.sha256(open(sys.argv[1],"rb").read()).hexdigest()[:16])' "$SIGCHECK/m")" curl -fsS --max-time 30 "$B/version.json.$H.sig" -o "$SIGCHECK/s" \ || { echo " VERIFY FAILED: $B/version.json.$H.sig is not served publicly" >&2; exit 1; } if "$BASTION_TREE/deploy/release-verify.sh" bastion-agent "$SIGCHECK/m" "$SIGCHECK/s" ${FLEET_FROM:+"$FLEET_FROM"} >/dev/null; then echo " $B/version.json.$H.sig ok" else echo " VERIFY FAILED: $B/version.json.$H.sig does not verify against the manifest served there" >&2 exit 1 fi done echo "published bastion agent v$VER to: $EDGES" echo " new: $BASE_NEW/version.json · $BASE_NEW/dl/bastion-agent-$VER.pyz" if [ "$LEGACY" = 1 ]; then echo " legacy: $BASE_OLD/version.json · $BASE_OLD/dl/freeagent-$VER.pyz" fi