feat(chains): discover every Quantus chain from telemetry; track the ones we can reach
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:
+138
-22
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user