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
This commit is contained in:
2026-09-16 08:42:06 +03:00
parent 933ffecc8f
commit 99483d90ca
8 changed files with 755 additions and 8 deletions

View File

@@ -0,0 +1,180 @@
name: deploy
# The workflow is the source of infra truth (architecture/deployment-gitea-actions.md):
# hosts, paths and the component→host mapping live here and nowhere else. There
# is no separate deployment manifest.
on:
push:
branches: [main]
workflow_dispatch:
concurrency:
# Serialise deploys; never half-apply two at once.
group: deploy
cancel-in-progress: false
env:
# --- infra truth -----------------------------------------------------------
# oolon is the kosherinata edge proxy. There is no application host: the
# console is a static SPA that opens WebSockets straight from the browser to
# the chains' own RPC endpoints, so nothing is deployed beside a node.
EDGE_HOST: oolon.kosherinata.internal
PUBLIC_NAME: qapi.blackbeard.observer
WEBROOT: /var/www/qapi.blackbeard.observer
DEPLOY_KEY: |
${{ secrets.RSYNC_SSH_KEY }}
jobs:
build:
# fedora-44 carries node + npm + pnpm (gitea-runners.md §4) and nothing else
# is needed — there is no Rust half here.
runs-on: fedora-44
steps:
- uses: actions/checkout@v4
- name: install
# No `corepack enable`: no runner image bundles it, and pnpm is already
# on PATH from `npm i -g pnpm` (gitea-runners.md §4).
#
# `--frozen-lockfile` is what makes the patch in patches/ load-bearing
# rather than advisory: pnpm applies patchedDependencies during install,
# so a lockfile drift that dropped the patch would fail here instead of
# shipping a console that rejects every Quantus signature at run time.
run: pnpm install --frozen-lockfile
- name: the patch is applied
# Cheap, and it is the one thing about this fork that a normal build
# would not notice going missing. Without it every signing attempt dies
# with `Unkown signer` — in the browser, after deploy, to a user.
run: |
set -euo pipefail
# Resolve the package entry and walk to its sibling: the package's
# `exports` map has no `./dist/*` subpath, so resolving the file
# directly throws ERR_PACKAGE_PATH_NOT_EXPORTED.
resolved=$(node -e "
const path = require('node:path');
const entry = require.resolve('@polkadot-api/signers-common');
console.log(path.join(path.dirname(entry), 'v4.js'));
")
echo "resolved: $resolved"
grep -q 'Quantus patch' "$resolved" || {
echo "the signers-common patch is NOT applied to the resolved copy" >&2
echo "see patches/@polkadot-api__signers-common.patch and papi-console#1" >&2
exit 1
}
echo "signers-common carries the Quantus patch"
- name: build
# `pnpm build` is `tsc -b && vite build`, so the typecheck is the gate.
#
# `pnpm lint` is deliberately NOT run: it is broken in upstream and was
# before this fork touched anything — typescript-eslint 8.69 refuses to
# load against the TypeScript 7.0 this repo resolves ("typescript-eslint
# does not support TS 7.0"). Adding it here would fail every deploy for
# a reason that has nothing to do with the change being deployed. Put it
# back when upstream's dependency skew resolves.
run: pnpm build
- uses: actions/upload-artifact@v3
with:
name: qapi-console
path: dist
deploy-web:
needs: build
runs-on: fedora-44
steps:
- uses: actions/download-artifact@v3
with:
name: qapi-console
path: dist
- name: ssh key and reachability
run: |
set -euo pipefail
install -d -m 0700 ~/.ssh
printf '%s' "$DEPLOY_KEY" > ~/.ssh/id_deploy
chmod 0600 ~/.ssh/id_deploy
cat > ~/.ssh/config <<EOF
Host *
IdentityFile ~/.ssh/id_deploy
StrictHostKeyChecking accept-new
User gitea_ci
EOF
ssh "$EDGE_HOST" hostname -f
- name: preflight the sudoers grants
# Fail up front, naming what is missing, rather than halfway through a
# deploy. The grants live in script/infra-setup.sh and nothing else keeps
# the two in step — so when this fails the fix is to re-run that script,
# not to widen anything here.
run: |
set -euo pipefail
allowed=$(ssh "$EDGE_HOST" sudo -n -l 2>/dev/null || true)
missing=0
for cmd in \
"/usr/bin/rsync" \
"/usr/sbin/restorecon" \
"/usr/sbin/nginx" \
"/usr/bin/systemctl reload nginx"
do
if ! grep -qF -- "$cmd" <<<"$allowed"; then
echo "missing sudo grant: $cmd" >&2
missing=1
fi
done
[ "$missing" -eq 0 ] || {
echo "re-run script/infra-setup.sh --role edge on a workstation" >&2
exit 1
}
echo "sudoers grants present"
- name: ship the bundle
# --delete: Vite's hashed asset filenames accumulate forever otherwise.
# The webroot holds only build output, so there is nothing else to lose.
# --checksum rather than the default mtime+size: artifact download
# rewrites every mtime, which would otherwise re-send the whole bundle.
run: |
set -euo pipefail
rsync -a --checksum --delete --mkpath --rsync-path='sudo rsync' \
dist/ "$EDGE_HOST:$WEBROOT/"
- name: label and reload
run: |
set -euo pipefail
ssh "$EDGE_HOST" bash -euo pipefail <<REMOTE
# SELinux: rsynced files inherit the directory's type, but a file
# arriving into an unlabelled tree makes nginx return 403 with
# nothing in its error log to explain it.
sudo restorecon -R "$WEBROOT"
sudo nginx -t
sudo systemctl reload nginx
REMOTE
- name: fetch the page
# `nginx -t` parses without binding and `systemctl reload` exits 0 even
# when nginx aborted the reconfiguration and kept what it had
# (architecture/reverse-proxies.md §4). Neither is evidence. Fetching is.
#
# --resolve to loopback: from inside the mesh the public name resolves
# to the site's WAN address and dead-ends on the OPNsense LAN interface
# (reverse-proxies.md §2). Pinning it to 127.0.0.1 still exercises the
# real path — the :443 stream router, SNI, the https tier, the vhost.
run: |
set -euo pipefail
ssh "$EDGE_HOST" "curl -sSf --max-time 20 --resolve $PUBLIC_NAME:443:127.0.0.1 https://$PUBLIC_NAME/" > index.html
# A 200 serving the wrong thing is a broken deploy that a status code
# alone would pass: nginx falls back to /index.html for anything it
# cannot find, so an empty webroot answers 200 with the SPA shell of
# whatever was there before.
grep -q '<title>qapi console</title>' index.html || {
echo "the page served is not this console:" >&2
head -c 400 index.html >&2
exit 1
}
bundle=$(grep -oE '/assets/[A-Za-z0-9_.-]+\.js' index.html | head -1)
[ -n "$bundle" ] || { echo "index.html references no bundle" >&2; exit 1; }
ssh "$EDGE_HOST" "curl -sSf -o /dev/null -w '%{http_code} %{size_download}\n' --max-time 20 --resolve $PUBLIC_NAME:443:127.0.0.1 https://$PUBLIC_NAME$bundle"
echo "served $bundle"

View File

@@ -1,3 +1,57 @@
# https://dev.papi.how
# qapi console
Docs coming soon(ish)
A fork of the [papi console](https://github.com/polkadot-api/papi-console), pointed at
the [Quantus](https://quantus.com) chains and patched to accept their post-quantum
signature type.
Deployed at **https://qapi.blackbeard.observer**.
## Why this fork exists
The console is built on [polkadot-api](https://github.com/polkadot-api/polkadot-api),
which shares no code with the polkadot-js stack the Quantus browser extension forks
(`quantus/extension`, `quantus/common`, `quantus/ui`, `quantus/wasm`). That makes it an
**independent implementation** of the same wire format — and the only way to find out
whether our stack is right about Quantus or merely self-consistent.
It already is: for the same call, nonce and signature, `createV4Tx` here produces bytes
identical to `quantus/extension`'s tier-1 harness.
## The one patch
`getSignerType` in `@polkadot-api/signers-common` reads the extrinsic's `Address` and
`Signature` types **out of the metadata** — the right thing, and better than polkadot-js,
which hardcodes `ExtrinsicSignature: 'MultiSignature'` in a type definition file. It then
threw that answer away and required the enum to carry `Ecdsa`, `Ed25519` and `Sr25519`.
Quantus's `DilithiumSignatureScheme` carries `Dilithium87` and `Dilithium65`, so every
signing attempt raised `Unkown signer` before reaching code that would have worked —
`createV4Tx` is entirely length-agnostic, and a 7 219-byte ML-DSA-87 signature drops
straight in.
`patches/@polkadot-api__signers-common.patch` drops the names and keeps the structural
check. See [quantus/papi-console#1](https://git.lair.cafe/quantus/papi-console/issues/1).
## Develop
```sh
pnpm install
pnpm dev
```
## Deploy
Pushes to `main` deploy to `oolon` via `.gitea/workflows/deploy.yaml`. One-time host
provisioning — DNS, certificate, nginx vhost, webroot — is
`script/infra-setup.sh`, run by an operator:
```sh
./script/infra-setup.sh --pubkey ~/.ssh/id_gitea_ci.pub
```
Conventions: [`architecture/`](https://git.lair.cafe/lair/architecture) —
`deployment-gitea-actions.md`, `reverse-proxies.md`, `external-tls.md`, `public-dns.md`.
---
Upstream's own README, and the licence, are unchanged: this is a fork, not a rewrite.

View File

@@ -0,0 +1,70 @@
# Public vhost for the qapi console, on the oolon edge proxy (kosherinata).
#
# Cert: Let's Encrypt via certbot + Cloudflare DNS-01 (architecture/external-tls.md).
# Installed by script/infra-setup.sh --role edge, never by CI — the runner has no
# rights to read certificate keys or reload nginx on a shared edge proxy. CI only
# rsyncs the built bundle into the webroot.
#
# ln -sf ../sites-available/qapi.blackbeard.observer.conf /etc/nginx/sites-enabled/
#
# listen 127.0.0.1:14443 + proxy_protocol, NOT 443: an nginx stream SNI router
# owns TCP 443 on this host and hands every non-passthrough name to the local
# https tier (architecture/reverse-proxies.md §5). Binding 443 here double-binds
# the port across http{} and stream{} — `nginx -t` cannot detect it, and the
# failure mode is nginx silently serving stale certificates on every later
# reload, which the 24h internal certs sharing this proxy would surface first.
#
# There is no upstream. The console is a static SPA that opens WebSockets
# straight from the browser to the chains' own RPC endpoints
# (wss://rpc1-mainnet.quantus.com and the testnets), so nothing here proxies
# chain traffic and nothing here needs to know a node address.
server {
listen 127.0.0.1:14443 ssl proxy_protocol;
http2 on;
server_name qapi.blackbeard.observer;
ssl_certificate /etc/letsencrypt/live/qapi.blackbeard.observer/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/qapi.blackbeard.observer/privkey.pem;
# TLS 1.2 stays enabled, unlike the .internal vhost: this is a public site
# and its audience is not a controlled fleet.
ssl_protocols TLSv1.2 TLSv1.3;
# The vhost believes it is serving 127.0.0.1:14443, so any redirect it
# generates itself carries that port and is unreachable from anywhere but
# this machine. Latent for a pure SPA, but the console ships real
# directories under the webroot — public/rpc_schemas/ — so a request for one
# without a trailing slash would 301 to https://…:14443/ without this
# (architecture/reverse-proxies.md §4).
absolute_redirect off;
root /var/www/qapi.blackbeard.observer;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
# Vite's hashed asset filenames are immutable.
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# index.html must never be cached, or a deploy leaves browsers loading a
# bundle whose hashed filenames no longer exist.
location = /index.html {
add_header Cache-Control "no-cache";
}
add_header X-Content-Type-Options nosniff always;
add_header Referrer-Policy same-origin always;
# Deliberately no Content-Security-Policy. The console's entire purpose is
# to connect to chain endpoints the user names — including a custom one they
# type in — and to load the polkadot-api wasm. A connect-src list would have
# to be reopened for every endpoint anyone ever adds, and a stale one fails
# as "the console will not connect" with the reason only in the browser
# console. If a CSP is added later it needs 'wasm-unsafe-eval' and a
# connect-src that does not enumerate hosts.
}

View File

@@ -0,0 +1,45 @@
# Mesh vhost for the qapi console, on the oolon edge proxy.
#
# This exists because a public name does not hairpin. From inside the mesh,
# qapi.blackbeard.observer resolves via public DNS to the site's WAN address, so
# the packet arrives on the OPNsense LAN interface — which forwards :443 inbound
# from the WAN only. The connection dead-ends (architecture/reverse-proxies.md §2).
# Operators testing the console from a workstation need this name, not the public
# one.
#
# Cert: the internal `lair` CA, 24-hour lifetime, renewed by step@qapi.timer
# (architecture/internal-tls.md). infra-setup.sh skips enabling this vhost
# entirely when the certificate is absent — nginx -t fails on a missing
# ssl_certificate and that would block every reload on this shared proxy, not
# just ours.
server {
listen 127.0.0.1:14443 ssl proxy_protocol;
http2 on;
server_name qapi.internal;
ssl_certificate /etc/nginx/tls/cert/qapi.internal.pem;
ssl_certificate_key /etc/nginx/tls/key/qapi.internal.pem;
ssl_trusted_certificate /etc/pki/ca-trust/source/anchors/root-internal.pem;
# Mesh-only, so the audience is a controlled fleet and 1.3 is safe to pin.
ssl_protocols TLSv1.3;
absolute_redirect off;
# One webroot, two vhosts. The deploy ships the bundle once.
root /var/www/qapi.blackbeard.observer;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
location = /index.html {
add_header Cache-Control "no-cache";
}
}

View File

@@ -5,7 +5,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/papi_logo-dark.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PAPI Console</title>
<title>qapi console</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link

View File

@@ -1,5 +1,5 @@
{
"name": "papi-console",
"name": "qapi-console",
"private": true,
"version": "0.0.4",
"type": "module",

398
script/infra-setup.sh Executable file
View File

@@ -0,0 +1,398 @@
#!/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"

View File

@@ -161,15 +161,15 @@ const SidebarContent: FC<{ mobile?: boolean; onNavigate?: () => void }> = ({
<img
className="h-10 w-10 shrink-0 hidden dark:inline-block"
src="/papi_logo-dark.svg"
alt="papi logo"
alt="polkadot-api logo"
/>
<img
className="h-10 w-10 shrink-0 dark:hidden"
src="/papi_logo-light.svg"
alt="papi logo"
alt="polkadot-api logo"
/>
<div className="min-w-0 leading-tight truncate text-base">
<span className="poppins-regular">papi</span>{" "}
<span className="poppins-regular">qapi</span>{" "}
<span className="poppins-extralight">console</span>
</div>
</div>
@@ -195,7 +195,7 @@ const SidebarContent: FC<{ mobile?: boolean; onNavigate?: () => void }> = ({
<div className="shrink-0 border-t p-3">
<ThemeToggle />
<a
href="https://github.com/polkadot-api/papi-console"
href="https://git.lair.cafe/quantus/papi-console"
target="_blank"
rel="noreferrer"
className="mt-2 flex items-center gap-2 rounded-md px-3 py-2 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"