#!/usr/bin/env bash # Cross-build the bastion for all platforms (VERSION-stamped) + publish to the edge # # RENAMED: the published artifact is privatae-bastion--. The files # under the previous binary name are deliberately NOT pruned from the webroot's dl/ # — an installer or a doc still pointing at the old filename keeps resolving, and # self-update swaps a bastion's own executable in place (update.go) so a field # machine on the old name upgrades onto the new binary without renaming itself. # (privatae.ai/bastion/) with a version manifest. Run after committing client-go changes. # Bump deploy/VERSION first. Replaces the old hand-copy. set -euo pipefail HERE="$(cd "$(dirname "$0")/.." && pwd)" # WHERE THIS PUBLISHES IS NOT BAKED IN. Everyone who runs a bastion can read # this repository, so it names none of our hosts and no key path of ours: EDGES # and KEY are required, and an unset one stops the script with a message instead # of publishing somewhere wrong. Whoever cuts a release supplies them. # # EDGES: publish to EVERY front door. Each edge serves /bastion from its OWN # local disk -- that locality is the whole point, it is what keeps the installer # serving during a platform outage -- so a one-edge publish leaves the fleet # serving two different binaries behind a single manifest. The installer # verifies the digest, so that fails CLOSED (the stale edge refuses to # install rather than installing the wrong bytes), but it is still an outage for # whoever DNS happens to point at. Publish to all, verify each. Override with # EDGE= for a deliberate single-box publish. # EDGE (one box) satisfies the requirement on its own — it is the documented # single-box override above, and requiring EDGES first would have killed 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)}" : "${KEY:?set KEY to the ssh key that reaches the edges}" # The webroot and the backup directory are supplied for the same reason EDGES and # KEY are: everyone who runs a bastion reads this file, so it names no path of # ours. : "${WEBROOT:?set WEBROOT to the directory on each edge that serves /bastion}" : "${BACKUP_DIR:?set BACKUP_DIR to a directory OUTSIDE the webroot for rotated installers}" # OUTSIDE is load-bearing, not advice. Backups used to land beside the live files # and every superseded installer was served at a stable public URL -- the earlier # install.sh, the one with no checksum verification, at HTTP 200 (measured). # While the backup directory was hardcoded outside the webroot that could not # recur by construction; as an operator-supplied value it can, so the guarantee # is re-asserted here. 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 # A release is published in two runs, because the release key never comes to # this machine: it stays on the key holder's own computer. # # 1. deploy/publish.sh builds, writes the manifest, and prints # the one release-sign command to run # 2. SIGNATURE= deploy/publish.sh builds again, checks SIGNATURE against # the keys this tree pins, and publishes # # The build is reproducible, so the second run's manifest is byte-for-byte the # one that was signed; if it is not (a commit landed in between), the signature # does not verify and nothing is published. VER="$(tr -d '[:space:]' < "$HERE/deploy/VERSION")" GITSHA="$(git -C "$HERE" rev-parse --short HEAD 2>/dev/null || echo unknown)" # The license we are about to publish, digested HERE from the repo, so both # verifications below compare the edges and the public URL against this tree # rather than against each other. Digested with python3 for the same reason the # manifest is built with it: python3 is already required by this script, while # sha256sum/shasum differ across the machines a release might be cut from. LIC_SHA="$(python3 -c 'import hashlib,sys;print(hashlib.sha256(open(sys.argv[1],"rb").read()).hexdigest())' "$HERE/LICENSE")" echo "building bastion v$VER ($GITSHA)" # RATCHET: refuse to publish a build that is not a descendant of the # currently-published built_from. A rebuild from a DIVERGENT branch was once # republished with a HIGHER version but a SMALLER feature set (lost the worker lanes), # regressing every field bastion. A proper forward move (including a merge) passes; # a divergent tip fails. Override an intentional non-linear publish with ALLOW_NONLINEAR=1. CUR_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)" if [ -n "$CUR_FROM" ]; then if git -C "$HERE" merge-base --is-ancestor "$CUR_FROM" HEAD 2>/dev/null; then echo "ratchet ok: published $CUR_FROM is an ancestor of $GITSHA (forward move)" else echo "RATCHET REFUSED: $GITSHA is not a descendant of published built_from $CUR_FROM (would regress the field). Merge/rebase forward, or ALLOW_NONLINEAR=1 to override." >&2 [ "${ALLOW_NONLINEAR:-}" = "1" ] || exit 1 fi fi # Reproducible: deploy/verify-release.sh rebuilds a published release with the # same script and compares every digest. "$HERE/deploy/build.sh" "$VER" # version manifest: {version, built_from, binaries:{os-arch:{url,sha256}}} — built with python # (clean JSON; the host + the bastion self-update read this to decide "out of date"). python3 - "$VER" "$GITSHA" "$HERE/client-go/dist" > "$HERE/client-go/dist/version.json" <<'PY' import sys, os, hashlib, json ver, sha, dist = sys.argv[1], sys.argv[2], sys.argv[3] binaries = {} for f in sorted(os.listdir(dist)): if not f.startswith("privatae-bastion-"): continue h = hashlib.sha256(open(os.path.join(dist, f), "rb").read()).hexdigest() # ?v= IS LOAD-BEARING, not decoration. dl.privatae.ai sits behind # Cloudflare, which caches these objects and keys on the full URL including # the query string. Without it a publish replaces the bytes on both edges, # both edges verify, the manifest is fresh — and Cloudflare keeps serving the # PREVIOUS binary to anyone whose request lands on a warm edge. The client # downloads the old bytes, compares them against the new digest, and aborts # with "sha256 mismatch" — which reads like a corrupt build and is actually a # stale cache. Measured publishing 0.23.0: windows-amd64.exe came # back cf-cache-status HIT, age 3917, carrying 0.22.0's bytes. It was the # ONLY stale one, because it was the only object anything had requested since # the previous publish — so this breaks one platform at a time, at random, # for whoever happens to have downloaded last. The worst possible shape. # # A new version string is a new cache key is a guaranteed miss. Needs no # Cloudflare credential and cannot be forgotten at publish time. update.go # downloads binaries[key].url verbatim, so bastions already in the field pick # this up from the manifest with no client change. binaries[f[len("privatae-bastion-"):]] = { "url": f"https://dl.privatae.ai/dl/{f}?v={ver}", "sha256": h} json.dump({"version": ver, "built_from": sha, "binaries": binaries}, sys.stdout, indent=2) PY MAN="$HERE/client-go/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 < "$HERE/client-go/dist/$SIG_NAME" # Checked against this tree's keys AND the keys of the release the fleet runs # now (CUR_FROM, read by the ratchet above): those bastions read this manifest # with their own keys, so a signature only the new keys accept would strand # them. SIGNED_BY="$("$HERE/deploy/release-verify.sh" bastion "$MAN" "$HERE/client-go/dist/$SIG_NAME" ${CUR_FROM:+"$CUR_FROM"})" \ || { echo "SIGNATURE does not verify for bastion v$VER (manifest $MAN_SHA); nothing was published" >&2; exit 1; } echo "manifest signature ok (key $SIGNED_BY)" # A bastion from this tree refuses an agent manifest that is not signed, so the # agent must already be published signed at the address it reads, or every # new folder grant on an updated machine fails to install the agent. Checked # here, before this release can reach any machine. (The legacy /freeagent # address is read only by bastions older than 0.42.0; this release never asks # it.) AGENTCHECK="$(mktemp -d)" for B in https://privatae.ai/bastion-agent; do curl -fsS --max-time 30 "$B/version.json" -o "$AGENTCHECK/m" \ || { echo "agent manifest $B/version.json is unreachable; publish the signed agent first" >&2; rm -rf "$AGENTCHECK"; exit 1; } AH="$(python3 -c 'import hashlib,sys;print(hashlib.sha256(open(sys.argv[1],"rb").read()).hexdigest()[:16])' "$AGENTCHECK/m")" if ! curl -fsS --max-time 30 "$B/version.json.$AH.sig" -o "$AGENTCHECK/s" \ || ! "$HERE/deploy/release-verify.sh" bastion-agent "$AGENTCHECK/m" "$AGENTCHECK/s" ${CUR_FROM:+"$CUR_FROM"} >/dev/null; then echo "the agent manifest at $B is not signed with a key this release pins; publish the signed agent first" >&2 rm -rf "$AGENTCHECK"; exit 1 fi done rm -rf "$AGENTCHECK" echo "agent manifests are signed at both addresses" for E in $EDGES; do echo "--- publishing to $E ---" ssh -i "$KEY" "$E" 'rm -rf /tmp/bn && mkdir -p /tmp/bn' # install.ps1 ships from the repo too: it once existed only as bytes in the # webroot, so the Windows installer customers piped to `iex` had # no review history and no deploy path. # LICENSE ships with the binaries: GPL-3 sections 4 and 5 require the license to # travel with the program, and a downloaded binary carries no text of its own. # It lands at /bastion/LICENSE.txt, next to version.json — the URL both # installers print after a successful install. The .txt is what makes a browser # render it inline instead of offering a download. scp -i "$KEY" "$HERE"/client-go/dist/privatae-bastion-* "$HERE"/client-go/dist/version.json \ "$HERE/client-go/dist/$SIG_NAME" \ "$HERE"/deploy/install.sh "$HERE"/deploy/install.ps1 "$HERE"/LICENSE "$E":/tmp/bn/ # WEBROOT/BACKUP_DIR are passed as remote ENVIRONMENT and expand REMOTELY, like # every other variable in this body. That is deliberate: the heredoc delimiter is # quoted so nothing here expands locally, which is what keeps $ts/$f/$b working. # Do not "fix" this by interpolating the paths locally or unquoting the delimiter # -- unquoting it would expand $ts/$f/$b on THIS box, where they are empty. ssh -i "$KEY" "$E" "WEBROOT='$WEBROOT' BACKUP_DIR='$BACKUP_DIR' sh -s" <<'REMOTE' set -e # A second edge starts with no webroot at all, so create it rather than # assuming it. The .bak of install.sh is best-effort for the same reason. install -d -m755 "$WEBROOT/dl" ts=$(date +%s) # Backups go OUTSIDE the webroot. They used to land next to the live files, # which served every superseded installer and binary at a stable public URL: # /bastion/install.sh.bak- was the earlier script -- the one with no # checksum verification -- reachable at HTTP 200 (measured). # Hardening install.sh is worth nothing while the vulnerable one sits next to # it. Caddy now 404s \.bak and dotfiles too; this is the other half, so the # exposure cannot come back if that rule is ever lost. install -d -m700 "$BACKUP_DIR" cp -a "$WEBROOT/install.sh" "$BACKUP_DIR/install.sh.bak-$ts" 2>/dev/null || true cp -a "$WEBROOT/install.ps1" "$BACKUP_DIR/install.ps1.bak-$ts" 2>/dev/null || true for f in /tmp/bn/privatae-bastion-*; do b=$(basename "$f"); mv "$f" "$WEBROOT/dl/$b"; chmod 755 "$WEBROOT/dl/$b"; done # The signature lands before the manifest it signs. A bastion that reads # the new manifest in between asks for its signature by the manifest's # digest and finds it already there. for f in /tmp/bn/version.json.*.sig; do b=$(basename "$f"); mv "$f" "$WEBROOT/$b"; chmod 644 "$WEBROOT/$b"; done mv /tmp/bn/version.json "$WEBROOT/version.json"; chmod 644 "$WEBROOT/version.json" mv /tmp/bn/install.sh "$WEBROOT/install.sh"; chmod 644 "$WEBROOT/install.sh" mv /tmp/bn/install.ps1 "$WEBROOT/install.ps1"; chmod 644 "$WEBROOT/install.ps1" mv /tmp/bn/LICENSE "$WEBROOT/LICENSE.txt"; chmod 644 "$WEBROOT/LICENSE.txt" rm -rf /tmp/bn REMOTE done # Verify each edge independently: the bytes on ITS disk must match the digests in # the manifest IT serves. This is the one failure the manifest cannot catch on its # own -- a publish that reported success while leaving an edge half-written looks # identical to a good one until a customer's install refuses. for E in $EDGES; do printf ' %s: ' "$E" ssh -i "$KEY" "$E" 'cd '"$WEBROOT"' && python3 - <.sig), # and checked against the manifest the internet is served. SIGCHECK="$(mktemp -d)"; trap 'rm -rf "$SIGCHECK"' EXIT curl -fsS --max-time 30 "https://privatae.ai/bastion/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 "https://privatae.ai/bastion/version.json.$H.sig" -o "$SIGCHECK/s" \ || { echo " VERIFY FAILED: version.json.$H.sig is not served publicly" >&2; exit 1; } if "$HERE/deploy/release-verify.sh" bastion "$SIGCHECK/m" "$SIGCHECK/s" ${CUR_FROM:+"$CUR_FROM"} >/dev/null; then echo " version.json.$H.sig ok (verifies against the public manifest)" else echo " VERIFY FAILED: the public version.json.$H.sig does not verify against the public manifest" >&2 exit 1 fi echo "published bastion v$VER to:" for E in $EDGES; do echo " $E"; done echo " https://privatae.ai/bastion/ (manifest: /bastion/version.json)"