From 99483d90caacd0a184f406f64b13a7d6fa19e522 Mon Sep 17 00:00:00 2001 From: rob thijssen Date: Wed, 16 Sep 2026 08:42:06 +0300 Subject: [PATCH] feat: rename to the qapi console, and deploy it to oolon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_012uDUodEcRbBwNRi3UCmw8f --- .gitea/workflows/deploy.yaml | 180 ++++++++++ README.md | 58 +++- asset/nginx/qapi.blackbeard.observer.conf | 70 ++++ asset/nginx/qapi.internal.conf | 45 +++ index.html | 2 +- package.json | 2 +- script/infra-setup.sh | 398 ++++++++++++++++++++++ src/pages/AppShell.tsx | 8 +- 8 files changed, 755 insertions(+), 8 deletions(-) create mode 100644 .gitea/workflows/deploy.yaml create mode 100644 asset/nginx/qapi.blackbeard.observer.conf create mode 100644 asset/nginx/qapi.internal.conf create mode 100755 script/infra-setup.sh diff --git a/.gitea/workflows/deploy.yaml b/.gitea/workflows/deploy.yaml new file mode 100644 index 0000000..f716d13 --- /dev/null +++ b/.gitea/workflows/deploy.yaml @@ -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 </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 < 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 'qapi console' 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" diff --git a/README.md b/README.md index d10a8db..1669c8c 100644 --- a/README.md +++ b/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. diff --git a/asset/nginx/qapi.blackbeard.observer.conf b/asset/nginx/qapi.blackbeard.observer.conf new file mode 100644 index 0000000..803575d --- /dev/null +++ b/asset/nginx/qapi.blackbeard.observer.conf @@ -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. +} diff --git a/asset/nginx/qapi.internal.conf b/asset/nginx/qapi.internal.conf new file mode 100644 index 0000000..369e6c7 --- /dev/null +++ b/asset/nginx/qapi.internal.conf @@ -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"; + } +} diff --git a/index.html b/index.html index ffe224b..3125a4a 100644 --- a/index.html +++ b/index.html @@ -5,7 +5,7 @@ - PAPI Console + qapi console &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 `; + # 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 <> "\$f" + echo "runner key appended" + fi + chown gitea_ci:gitea_ci "\$f" + chmod 0600 "\$f" +REMOTE + + info "$host: scoped sudoers" + # Named _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 </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 <&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" diff --git a/src/pages/AppShell.tsx b/src/pages/AppShell.tsx index 7060ebc..fef855e 100644 --- a/src/pages/AppShell.tsx +++ b/src/pages/AppShell.tsx @@ -161,15 +161,15 @@ const SidebarContent: FC<{ mobile?: boolean; onNavigate?: () => void }> = ({ papi logo papi logo
- papi{" "} + qapi{" "} console
@@ -195,7 +195,7 @@ const SidebarContent: FC<{ mobile?: boolean; onNavigate?: () => void }> = ({