feat(chains): discover every Quantus chain from telemetry; track the ones we can reach
deploy / build (push) Failing after 5s
deploy / deploy-api (push) Has been skipped
deploy / deploy-web (push) Has been skipped

The chain list is no longer config. substrate-telemetry announces every chain it
knows — with live node counts — before and independent of any subscription, so
one feed connection that never subscribes is a complete, self-maintaining index.
A chain that launches tomorrow appears in the nav on its own.

Config now contributes only RPC endpoints. Authorship lives in the `pow_` digest
of a block *header*, and telemetry publishes hashes and heights but never
headers — so a chain with no endpoint is listed with its node count and is not
navigable, and the nav says why rather than offering an empty board. The two
sets join on genesis hash, never on name.

Two chains are tracked in full: Planck (our own node on bob) and Heisenberg
(a1-heisenberg.quantus.cat, found via Quantus-Network/quantus-apps). Both decode
identically — same digest shape, same QPoW runtime API — which is the proof that
adding a chain is one [[chains]] entry and no new code. Quantus Staging Mainnet
and Quantus Dirac Testnet are listed and disabled: no endpoint exists under the
a1-/a2- pattern and none resolves under any plausible name.

Nav is ordered by node count descending, ties broken on id so it cannot reshuffle
between refreshes.

Telemetry now also yields chain-wide best height, best finalized height, average
block time and the client-version histogram (feed codes 1, 2, 12 and 22).

Infra moves to a single site: oolon fronts blackbeard.observer, reverse-proxying
cross-site over the mesh to the API beside the node on bob. That hop is the one
the loopback health probe cannot see, so the deploy now checks it explicitly —
a firewalld service scoped to bob's own /16 would leave a live site with a dead
/v1 and nothing would fail. infra-setup gains `dns` and `cert` roles that run on
oolon with the credential already there: the Cloudflare token is never copied off
the proxy, and it refuses to repoint an apex record that already exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSDYiibCtELsrjQq6KXnoi
This commit is contained in:
2026-09-04 13:04:11 +03:00
co-authored by Claude Opus 5
parent 110fbc3631
commit 5f3aac2a13
22 changed files with 1317 additions and 183 deletions
+138 -22
View File
@@ -17,9 +17,15 @@
# Roles (all run by default; pass --role to narrow):
#
# api the host running blackbeard-api beside quantus-node
# dns the public apex CNAME on Cloudflare
# cert the Let's Encrypt certificate for the public name
# edge the site's nginx proxy: vhosts, webroot, internal cert
# database Postgres roles, database, and the pg_ident CN mapping
#
# `dns` and `cert` run BEFORE `edge`, and in that order: certbot uses a DNS-01
# challenge, and nginx -t fails on a missing ssl_certificate — which blocks
# every reload on the proxy, not just this vhost.
#
# Conventions: architecture/generic.md §8-§11, deployment-gitea-actions.md.
set -euo pipefail
@@ -27,7 +33,7 @@ set -euo pipefail
# --- infra truth, matching .gitea/workflows/deploy.yaml -----------------------
API_HOST="${API_HOST:-bob.hanzalova.internal}"
API_PORT="${API_PORT:-25864}"
EDGE_HOST="${EDGE_HOST:-hanzalova.internal}"
EDGE_HOST="${EDGE_HOST:-oolon.kosherinata.internal}"
PG_PRIMARY="${PG_PRIMARY:-magrathea.kosherinata.internal}"
# The standby needs the same ident mapping: pg_ident.conf contents are NOT
# replicated, and a failover to a server missing it locks the app out.
@@ -38,9 +44,15 @@ PUBLIC_NAME="${PUBLIC_NAME:-blackbeard.observer}"
INTERNAL_NAME="${INTERNAL_NAME:-blackbeard.internal}"
DB_NAME="${DB_NAME:-blackbeard}"
DB_ROLE="${DB_ROLE:-blackbeard_rw}"
# 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="api edge database"
ROLES="api dns cert edge database"
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
info() { printf '\033[36m==\033[0m %s\n' "$*"; }
@@ -217,16 +229,17 @@ 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 already present"
echo "$INTERNAL_NAME certificate present"
systemctl enable --now "step@blackbeard.timer" || \
echo "step@ renewal timer not armed — renew $INTERNAL_NAME manually until it is"
else
echo "MISSING: /etc/nginx/tls/cert/$INTERNAL_NAME.pem" >&2
echo "Mint it with the lair provisioner per architecture/internal-tls.md §4," >&2
echo "then re-run this script. The vhost is NOT installed until it exists:" >&2
echo "nginx -t fails on a missing ssl_certificate and blocks every reload." >&2
exit 1
# Not fatal. The mesh vhost is a convenience; the public site is the
# deliverable, and nginx -t fails on a missing ssl_certificate —
# which would block every reload on this 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."
fi
systemctl enable --now "step@$(basename "$INTERNAL_NAME" .internal).timer" || \
echo "step@ renewal timer not armed — renew $INTERNAL_NAME manually until it is"
REMOTE
info "$EDGE_HOST: nginx configuration"
@@ -239,8 +252,12 @@ REMOTE
"$EDGE_HOST:/etc/nginx/sites-available/"
ssh "$EDGE_HOST" sudo bash -euo pipefail <<REMOTE
# sites-enabled holds only symlinks, and relative ones.
ln -sfn "../sites-available/$PUBLIC_NAME.conf" "/etc/nginx/sites-enabled/$PUBLIC_NAME.conf"
ln -sfn "../sites-available/$INTERNAL_NAME.conf" "/etc/nginx/sites-enabled/$INTERNAL_NAME.conf"
ln -sfn "../sites-available/$PUBLIC_NAME.conf" "/etc/nginx/sites-enabled/$PUBLIC_NAME.conf"
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
# The vhosts use \$connection_upgrade for the WebSocket upgrade; without
# the map the socket silently degrades to a hanging request and the page
@@ -268,13 +285,10 @@ REMOTE
cat <<EOF
$EDGE_HOST is configured, but two steps are NOT automated here:
$EDGE_HOST is configured. One step is NOT automated here:
1. The public certificate for $PUBLIC_NAME (Let's Encrypt, certbot,
Cloudflare DNS-01) — see architecture/external-tls.md.
2. Split-horizon DNS for $INTERNAL_NAME, on BOTH site routers. A record on
only one router NXDOMAINs everywhere else:
Split-horizon DNS for $INTERNAL_NAME, on BOTH site routers. A record on
only one router NXDOMAINs everywhere else:
for site in hanzalova kosherinata; do
opn-cli --config ~/.opn-cli/\$site.yml unbound host create \\
@@ -284,13 +298,111 @@ $EDGE_HOST is configured, but two steps are NOT automated here:
`create` only saves; POST /api/unbound/service/reconfigure on each router
to apply.
A public DNS record for $PUBLIC_NAME goes in Cloudflare, unproxied, CNAMEd to
the site indirection name rather than carrying a site address directly —
architecture/public-dns.md.
EOF
}
# The Cloudflare API token lives on the edge proxy and is never copied off it:
# it can rewrite DNS for every zone on the account, including MX records for
# domains whose mail we host (architecture/public-dns.md §1). Every call that
# needs it therefore runs ON the proxy.
role_dns() {
reachable "$EDGE_HOST" || return 0
info "$EDGE_HOST: public DNS for $PUBLIC_NAME"
ssh "$EDGE_HOST" "PUBLIC_NAME='$PUBLIC_NAME' 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=${PUBLIC_NAME}" | python3 -c 'import sys,json; r=json.load(sys.stdin)["result"]; print(r[0]["id"] if r else "")')
[ -n "$zid" ] || { echo "zone $PUBLIC_NAME 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(f"existing records for the apex: {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 "apex already CNAMEs to $SITE — nothing to do"
exit 0
fi
if [ -n "$current" ]; then
echo "apex 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
# Records exist but none is a CNAME. A CNAME cannot coexist with
# other types at a name, so this needs a human decision.
echo "apex carries non-CNAME records; refusing to add a CNAME beside them." >&2
exit 1
fi
# Cloudflare flattens an apex CNAME, answering with an A — which is what
# makes this convention work at a zone apex at all. It is
# Cloudflare-specific, not portable DNS.
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/blackbeard.observer"}))')" | 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"])
import sys as s
s.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. Never silence it: a reload is
# only a request, and nginx exits 0 having kept its old certificates
# whenever it cannot re-acquire a socket. On a 90-day public cert a
# stuck reload takes months to become visible.
hook=/etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
if ! sudo test -x "$hook"; then
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"
else
echo "certbot deploy hook already present"
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_database() {
reachable "$PG_PRIMARY" || return 0
@@ -338,6 +450,10 @@ REMOTE
info "roles: $ROLES"
has_role api && role_api
# dns before cert (DNS-01 needs the zone), cert before edge (nginx -t fails on
# a missing ssl_certificate and blocks every reload on the proxy).
has_role dns && role_dns
has_role cert && role_cert
has_role edge && role_edge
has_role database && role_database
info "done"