Files
qapi/script/infra-setup.sh
rob thijssen 99483d90ca feat: rename to the qapi console, and deploy it to oolon
The console is now **qapi console**, served at https://qapi.blackbeard.observer.
The papi logo and the upstream licence stay: this is a fork of
polkadot-api/papi-console, not a rewrite, and the sidebar link points at our
fork rather than theirs.

Deployment follows architecture/deployment-gitea-actions.md — the workflow is
the source of infra truth, and one-time host provisioning is an operator script:

  script/infra-setup.sh   dns  -> the Cloudflare CNAME to bl.thgttg.com
                          cert -> Let's Encrypt, DNS-01, ECDSA
                          edge -> gitea_ci, scoped sudoers, webroot, vhosts

  .gitea/workflows/deploy.yaml   build (pnpm) -> rsync the bundle -> reload

The roles run in that order because certbot uses a DNS-01 challenge and nginx
fails its config test on a missing ssl_certificate — which would block every
reload on a SHARED proxy, not just this vhost. The same reasoning bounds what CI
may do: the sudoers drop-in grants rsync into one webroot, restorecon on it,
a config test and a reload. No certbot, no /etc/letsencrypt, no sites-available
write, no useradd.

Two vhosts, per architecture/reverse-proxies.md: the public one on the https
tier at 127.0.0.1:14443 behind the stream SNI router that owns TCP 443 (binding
443 directly is undetectable by `nginx -t` and ends with nginx silently serving
stale certificates), and a qapi.internal one for mesh clients, which stays
disabled until someone mints its internal certificate.

There is no application host and no upstream: the console is a static SPA that
opens WebSockets straight from the browser to the chains' own RPC endpoints.

The build gate is `pnpm build` (tsc -b + vite build) plus a check that the
signers-common patch is applied to the copy node actually resolves. Without that
patch every signing attempt dies with `Unkown signer` in a browser, after
deploy, in front of a user — and a lockfile drift is all it would take.

`pnpm lint` is deliberately not in the gate: it is broken upstream and was
before this fork touched anything (typescript-eslint 8.69 refuses to load
against the TypeScript 7.0 this repo resolves).

Live and verified: the public name answers 200 with this console's title over
the WAN address, serving the Let's Encrypt certificate for it, and the whole
deploy path — rsync as gitea_ci through the scoped sudoers, restorecon, config
test, reload, fetch — has been run by hand end to end.

Refs #3

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uDUodEcRbBwNRi3UCmw8f
2026-09-16 08:42:06 +03:00

399 lines
18 KiB
Bash
Executable File

#!/usr/bin/env bash
#
# One-time host provisioning for the qapi console.
#
# Run by an OPERATOR from a workstation with full sudo ssh to the target — not
# by CI. The runner deploys as a scoped `gitea_ci` user and deliberately has no
# rights to create accounts, read certificate keys, or reload nginx on a shared
# edge proxy.
#
# ./script/infra-setup.sh --pubkey ~/.ssh/id_gitea_ci.pub
#
# Idempotent, and it skips past an unreachable host rather than failing halfway.
# Re-run it whenever the deploy gains a new file to ship: the deploy job
# preflights the target's sudoers against the grants below and fails up front
# naming what is missing, so the two cannot silently drift.
#
# Roles (all run by default; pass --role to narrow):
#
# dns the public CNAME on Cloudflare
# cert the Let's Encrypt certificate for the public name
# edge the nginx proxy: gitea_ci, vhosts, webroot, internal cert
#
# They run in that order and the order matters: certbot uses a DNS-01 challenge,
# and `nginx -t` fails on a missing ssl_certificate — which blocks every reload
# on this proxy, not just this vhost.
#
# Conventions: architecture/deployment-gitea-actions.md §2, reverse-proxies.md,
# external-tls.md, public-dns.md.
set -euo pipefail
# --- infra truth, matching .gitea/workflows/deploy.yaml -----------------------
EDGE_HOST="${EDGE_HOST:-oolon.kosherinata.internal}"
PUBLIC_NAME="${PUBLIC_NAME:-qapi.blackbeard.observer}"
INTERNAL_NAME="${INTERNAL_NAME:-qapi.internal}"
WEBROOT="${WEBROOT:-/var/www/qapi.blackbeard.observer}"
# The zone apex, which is not the record name: this is a subdomain of an
# existing zone, so the Cloudflare lookup is by apex and the record by full name.
ZONE="${ZONE:-blackbeard.observer}"
# The per-site indirection name the public record CNAMEs to. `bl` is the DC
# (oolon), `nh` the office (hanzalova) — architecture/public-dns.md §2. A vhost
# record must never carry a site address directly: the address changes and every
# hardcoded A record then has to be hunted down across zones.
SITE_INDIRECTION="${SITE_INDIRECTION:-bl.thgttg.com}"
CERT_EMAIL="${CERT_EMAIL:-ops@blackbeard.observer}"
PUBKEY=""
ROLES="dns cert edge"
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
info() { printf '\033[36m==\033[0m %s\n' "$*"; }
warn() { printf '\033[33m!!\033[0m %s\n' "$*" >&2; }
die() { printf '\033[31mXX\033[0m %s\n' "$*" >&2; exit 1; }
usage() {
sed -n '3,28p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
exit "${1:-0}"
}
while [ $# -gt 0 ]; do
case "$1" in
--pubkey) PUBKEY="$2"; shift 2 ;;
--role) ROLES="$2"; shift 2 ;;
-h|--help) usage 0 ;;
*) die "unknown argument: $1 (try --help)" ;;
esac
done
has_role() { [[ " $ROLES " == *" $1 "* ]]; }
_reachable=""
reachable() {
local host="$1"
if [ -z "$_reachable" ]; then
if ssh -o ConnectTimeout=8 -o BatchMode=yes "$host" true 2>/dev/null; then
_reachable=yes
else
_reachable=no
warn "$host is unreachable; skipping every role that needs it"
fi
fi
[ "$_reachable" = yes ]
}
# --- the scoped sudoers grants ------------------------------------------------
#
# These strings are the single source of truth for what CI may do on the edge
# proxy. The deploy workflow's preflight extracts them from THIS FILE and
# compares against `sudo -n -l` on the target, so adding a file to the deploy
# means adding a line here and re-running this script — nothing else keeps them
# in step.
#
# Note what is absent: no certbot, no access to /etc/letsencrypt, no
# sites-available write, no ability to create users. CI ships a static bundle
# into one directory and asks nginx to reload. Everything that touches keys or
# the shared proxy's configuration is an operator action, here.
#
# `:` and `=` are reserved in sudoers and must be escaped inside command
# arguments, or visudo rejects the file.
edge_sudoers() {
cat <<'EOF'
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /var/www/qapi.blackbeard.observer/
gitea_ci ALL=(root) NOPASSWD: /usr/sbin/restorecon -R /var/www/qapi.blackbeard.observer
gitea_ci ALL=(root) NOPASSWD: /usr/sbin/nginx -t
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl reload nginx
EOF
}
# --- gitea_ci -----------------------------------------------------------------
provision_gitea_ci() {
local host="$1" sudoers_body="$2"
[ -n "$PUBKEY" ] || die "--pubkey is required to provision gitea_ci (the runner's public key)"
[ -f "$PUBKEY" ] || die "$PUBKEY does not exist"
info "$host: gitea_ci account"
# A real shell, never nologin. The deploy runs `ssh gitea_ci@host <command>`;
# a nologin shell authenticates the key and then refuses the command with
# "This account is currently not available", which reads as an auth problem
# rather than a shell one.
ssh "$host" sudo bash -euo pipefail <<'REMOTE'
if ! id gitea_ci >/dev/null 2>&1; then
useradd --system --create-home --home-dir /var/lib/gitea_ci --shell /bin/bash gitea_ci
fi
current=$(getent passwd gitea_ci | cut -d: -f7)
if [ "$current" != "/bin/bash" ]; then
echo "repairing gitea_ci shell: $current -> /bin/bash"
usermod --shell /bin/bash gitea_ci
fi
install -d -o gitea_ci -g gitea_ci -m 0700 /var/lib/gitea_ci/.ssh
usermod -aG systemd-journal gitea_ci
REMOTE
# Append rather than overwrite: oolon is a SHARED proxy and other projects'
# keys are already in this file. Overwriting it would break every other
# deploy on the fleet, silently, until each one next ran.
info "$host: authorized_keys (appending; this host is shared)"
local key
key="$(cat "$PUBKEY")"
ssh "$host" sudo bash -euo pipefail <<REMOTE
f=/var/lib/gitea_ci/.ssh/authorized_keys
touch "\$f"
if grep -qxF '$key' "\$f"; then
echo "runner key already present"
else
printf '%s\n' '$key' >> "\$f"
echo "runner key appended"
fi
chown gitea_ci:gitea_ci "\$f"
chmod 0600 "\$f"
REMOTE
info "$host: scoped sudoers"
# Named <app>_gitea_ci, not bare gitea_ci, so several apps can drop their own
# files on this shared host without clobbering each other.
printf '%s\n' "$sudoers_body" | \
ssh "$host" 'sudo tee /etc/sudoers.d/qapi_gitea_ci > /dev/null && sudo chmod 0440 /etc/sudoers.d/qapi_gitea_ci'
# Verify before leaving: a syntax error in a sudoers drop-in can lock every
# sudo on the host, not just this one.
ssh "$host" sudo visudo -cf /etc/sudoers.d/qapi_gitea_ci
}
# --- roles --------------------------------------------------------------------
role_dns() {
reachable "$EDGE_HOST" || return 0
info "$EDGE_HOST: public DNS for $PUBLIC_NAME"
# Run from the proxy, not from a workstation. The token there can rewrite
# DNS for every domain on the account — including MX records for domains
# whose mail we host — so copying it off the host spreads a credential with
# that blast radius for no benefit (architecture/public-dns.md §1).
ssh "$EDGE_HOST" "PUBLIC_NAME='$PUBLIC_NAME' ZONE='$ZONE' SITE='$SITE_INDIRECTION' bash -euo pipefail" <<'REMOTE'
TOKEN=$(sudo grep -oP '(?<=dns_cloudflare_api_token\s=\s).*' /root/.certbot-internal | tr -d "\"' ")
[ -n "$TOKEN" ] || { echo "no cloudflare token in /root/.certbot-internal" >&2; exit 1; }
api() { curl -sS -H "Authorization: Bearer $TOKEN" "$@"; }
zid=$(api "https://api.cloudflare.com/client/v4/zones?name=${ZONE}" \
| python3 -c 'import sys,json; r=json.load(sys.stdin)["result"]; print(r[0]["id"] if r else "")')
[ -n "$zid" ] || { echo "zone $ZONE is not on this Cloudflare account" >&2; exit 1; }
# List the name's FULL record set first, all types. Filtering by the one
# type you expect and finding nothing does not mean the name is free —
# it may be a CNAME, and adding an A beside it is a conflict at best
# (architecture/public-dns.md §4).
existing=$(api "https://api.cloudflare.com/client/v4/zones/${zid}/dns_records?name=${PUBLIC_NAME}")
echo "$existing" | python3 -c '
import sys, json
rs = json.load(sys.stdin)["result"]
print("existing records for this name: %d" % len(rs))
for r in rs:
print(" %s %s -> %s proxied=%s" % (r["type"], r["name"], r["content"], r["proxied"]))
'
current=$(echo "$existing" | python3 -c '
import sys, json
rs = json.load(sys.stdin)["result"]
cn = [r for r in rs if r["type"] == "CNAME"]
print(cn[0]["content"] if cn else "")
')
if [ "$current" = "$SITE" ]; then
echo "already CNAMEs to $SITE — nothing to do"
exit 0
fi
if [ -n "$current" ]; then
echo "already CNAMEs to $current, not $SITE." >&2
echo "Refusing to repoint an existing record; change it deliberately." >&2
exit 1
fi
if echo "$existing" | grep -q '"type"'; then
# A CNAME cannot coexist with other types at a name.
echo "name carries non-CNAME records; refusing to add a CNAME beside them." >&2
exit 1
fi
api -X POST "https://api.cloudflare.com/client/v4/zones/${zid}/dns_records" \
-H 'Content-Type: application/json' \
--data "$(python3 -c '
import json, os
print(json.dumps({"type":"CNAME","name":os.environ["PUBLIC_NAME"],"content":os.environ["SITE"],
"ttl":1,"proxied":False,"comment":"Quantus-Network/papi-console (qapi console)"}))')" \
| python3 -c '
import sys, json
d = json.load(sys.stdin)
print("created %s -> %s" % (d["result"]["name"], d["result"]["content"]) if d["success"]
else "FAILED %s" % d["errors"])
sys.exit(0 if d["success"] else 1)
'
REMOTE
}
role_cert() {
reachable "$EDGE_HOST" || return 0
info "$EDGE_HOST: Let's Encrypt certificate for $PUBLIC_NAME"
# DNS-01 via the same Cloudflare credential, ECDSA, per
# architecture/external-tls.md §1. --keep-until-expiring makes this a no-op
# when the cert is still valid, so the role is safe to run every time.
#
# `sudo test`, not bare `test`: /etc/letsencrypt/live is root-only 0700 and
# an unprivileged check silently returns false, concluding the cert is
# missing and re-issuing on every run.
ssh "$EDGE_HOST" "PUBLIC_NAME='$PUBLIC_NAME' CERT_EMAIL='$CERT_EMAIL' bash -euo pipefail" <<'REMOTE'
if sudo test -d "/etc/letsencrypt/live/${PUBLIC_NAME}"; then
echo "certificate lineage ${PUBLIC_NAME} already exists"
fi
sudo certbot certonly \
-m "$CERT_EMAIL" --agree-tos --no-eff-email --noninteractive \
--cert-name "$PUBLIC_NAME" \
--key-type ecdsa \
--dns-cloudflare \
--dns-cloudflare-credentials /root/.certbot-internal \
--dns-cloudflare-propagation-seconds 60 \
--keep-until-expiring \
-d "$PUBLIC_NAME"
sudo test -f "/etc/letsencrypt/live/${PUBLIC_NAME}/fullchain.pem" \
|| { echo "certbot reported success but no fullchain.pem exists" >&2; exit 1; }
# One deploy hook per host, not per cert; oolon already has one for its
# other lineages, so this is normally a no-op.
hook=/etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
if sudo test -x "$hook"; then
echo "certbot deploy hook already present"
else
sudo install -d -m 0755 /etc/letsencrypt/renewal-hooks/deploy
printf '%s\n' '#!/bin/sh' \
'systemctl reload nginx || logger -t reload-nginx -p daemon.err \' \
' "nginx reload failed after certbot renewal; cert on disk is newer than the one served"' \
| sudo tee "$hook" > /dev/null
sudo chmod +x "$hook"
echo "installed the certbot deploy hook"
fi
systemctl is-enabled certbot-renew.timer >/dev/null 2>&1 \
&& echo "certbot-renew.timer is enabled" \
|| echo "WARNING: certbot-renew.timer is not enabled on this host"
REMOTE
}
role_edge() {
reachable "$EDGE_HOST" || return 0
provision_gitea_ci "$EDGE_HOST" "$(edge_sudoers)"
info "$EDGE_HOST: webroot"
ssh "$EDGE_HOST" sudo bash -euo pipefail <<REMOTE
install -d -o root -g root -m 0755 "$WEBROOT"
# An unlabelled webroot makes nginx return 403 for every file, with
# nothing in the nginx error log to explain it.
restorecon -R "$WEBROOT"
REMOTE
info "$EDGE_HOST: internal certificate for $INTERNAL_NAME"
ssh "$EDGE_HOST" sudo bash -euo pipefail <<REMOTE
install -d -m 0755 /etc/nginx/tls/cert
install -d -m 0700 /etc/nginx/tls/key
if [ -f "/etc/nginx/tls/cert/$INTERNAL_NAME.pem" ]; then
echo "$INTERNAL_NAME certificate present"
systemctl enable --now "step@qapi.timer" 2>/dev/null \
|| echo "step@qapi.timer not armed — renew $INTERNAL_NAME manually until it is"
else
# Not fatal. The mesh vhost is a convenience for operators; the
# public site is the deliverable, and nginx -t fails on a missing
# ssl_certificate — which would block every reload on this shared
# proxy, not just ours.
echo "NOTE: no internal certificate for $INTERNAL_NAME; the mesh vhost will be skipped."
echo " Mint it with the lair provisioner (architecture/internal-tls.md §4)"
echo " and re-run --role edge to enable it. Until then, reach the"
echo " console from the mesh with curl --resolve, or from off-mesh."
fi
REMOTE
info "$EDGE_HOST: nginx configuration"
rsync -a --rsync-path 'sudo rsync' \
"$REPO_ROOT/asset/nginx/$PUBLIC_NAME.conf" \
"$REPO_ROOT/asset/nginx/$INTERNAL_NAME.conf" \
"$EDGE_HOST:/etc/nginx/sites-available/"
ssh "$EDGE_HOST" sudo bash -euo pipefail <<REMOTE
# sites-enabled holds only symlinks, and relative ones.
if [ -d "/etc/letsencrypt/live/$PUBLIC_NAME" ]; then
ln -sfn "../sites-available/$PUBLIC_NAME.conf" "/etc/nginx/sites-enabled/$PUBLIC_NAME.conf"
else
# Gate on the cert: enabling a vhost whose ssl_certificate does
# not exist fails the nginx config test and blocks every reload on
# this proxy, not just ours.
#
# No backticks anywhere in this heredoc: it is UNQUOTED so that
# the names interpolate, which also means bash performs command
# substitution on it. A backticked "nginx -t" in a comment here ran
# nginx on the operator workstation instead of being a comment.
echo "no certificate for $PUBLIC_NAME yet; leaving the public vhost disabled" >&2
rm -f "/etc/nginx/sites-enabled/$PUBLIC_NAME.conf"
fi
if [ -f "/etc/nginx/tls/cert/$INTERNAL_NAME.pem" ]; then
ln -sfn "../sites-available/$INTERNAL_NAME.conf" "/etc/nginx/sites-enabled/$INTERNAL_NAME.conf"
else
rm -f "/etc/nginx/sites-enabled/$INTERNAL_NAME.conf"
fi
# nginx -t parses without binding, so it catches syntax and missing cert
# files and nothing else. A pass is not evidence the reload landed —
# hence the served-certificate check below.
nginx -t
systemctl reload nginx
REMOTE
info "$EDGE_HOST: what is actually being served"
# The proof that matters. `nginx -t` passing and `systemctl reload` exiting
# 0 are both compatible with nginx still serving whatever it loaded last
# (architecture/reverse-proxies.md §4). Ask the listener.
#
# --resolve to loopback: from the mesh the public name resolves to the
# site's WAN address and dead-ends on the OPNsense LAN interface. Pinning it
# to 127.0.0.1 still traverses the real path — the :443 stream router, SNI,
# the https tier, this vhost.
ssh "$EDGE_HOST" "PUBLIC_NAME='$PUBLIC_NAME' WEBROOT='$WEBROOT' bash -euo pipefail" <<'REMOTE'
# `sudo test`: /etc/letsencrypt/live is root-only 0700, so an
# unprivileged check silently returns false.
if ! sudo test -d "/etc/letsencrypt/live/$PUBLIC_NAME"; then
echo "skipped: no certificate yet"
exit 0
fi
echo | openssl s_client -connect 127.0.0.1:443 -servername "$PUBLIC_NAME" 2>/dev/null \
| openssl x509 -noout -subject -enddate -ext subjectAltName 2>/dev/null \
|| echo "could not read the served certificate"
code=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 20 \
--resolve "$PUBLIC_NAME:443:127.0.0.1" "https://$PUBLIC_NAME/" || echo 000)
echo "GET / -> $code"
case "$code" in
200) ;;
403|404)
# Distinguish the two causes rather than guessing. An empty
# webroot answers 403 under try_files with autoindex off, and
# that is the expected state before the first deploy — not a
# labelling fault, which is what this used to claim.
if sudo test -f "$WEBROOT/index.html"; then
echo "the bundle is present but not served; check the SELinux label:" >&2
ls -ldZ "$WEBROOT" >&2
exit 1
fi
echo "webroot is empty — expected until the deploy workflow has run"
;;
*) echo "unexpected status $code" >&2; exit 1 ;;
esac
REMOTE
}
# --- run ----------------------------------------------------------------------
info "roles: $ROLES"
# dns before cert (the DNS-01 challenge needs the zone; the record itself is not
# required, but publishing it first means the verification below can be real),
# cert before edge (nginx -t fails on a missing ssl_certificate).
has_role dns && role_dns
has_role cert && role_cert
has_role edge && role_edge
info "done"