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:
180
.gitea/workflows/deploy.yaml
Normal file
180
.gitea/workflows/deploy.yaml
Normal 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"
|
||||
58
README.md
58
README.md
@@ -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.
|
||||
|
||||
70
asset/nginx/qapi.blackbeard.observer.conf
Normal file
70
asset/nginx/qapi.blackbeard.observer.conf
Normal 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.
|
||||
}
|
||||
45
asset/nginx/qapi.internal.conf
Normal file
45
asset/nginx/qapi.internal.conf
Normal 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";
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
398
script/infra-setup.sh
Executable 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"
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user