From e3dfd73f2fb61bb578cab11d1a5222a9fc8cdb77 Mon Sep 17 00:00:00 2001 From: Rob Thijssen Date: Mon, 31 Aug 2026 16:55:05 +0300 Subject: [PATCH] feat: deploy a quantus planck node and external gpu miner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Node on bob, external GPU miner on benjy, split because --miner-listen-port disables the node's built-in CPU mining. The PoW is Poseidon2 over Goldilocks: register-resident, no memory hardness, dominated by 64-bit modular multiplication. Measured on the fleet, GPU beats CPU by ~200x (RTX 5090 368 MH/s @ 600W, RTX 4090 183 MH/s @ 450W, Ryzen 9 9950X3D 4.15 MH/s), so CPU mining is disabled everywhere including on the GPU hosts. The workflow deploys or validates. Validate mode asserts the hash counter is advancing and the GPU is drawing power, because a miner that enumerated no adapter and a node authoring nothing both look healthy to systemctl is-active. infra-setup.sh provisions the scoped gitea_ci accounts, generates the node's libp2p key (the node refuses to start without one and it must never be rotated by a deploy), and derives the reward preimage from a seed phrase on stdin. It deliberately will not mint a fresh wallet: that phrase is escalation-grade and fails agent-credentials.md §1. --- .gitea/workflows/deploy.yaml | 402 ++++++++++++++++++++++ .gitignore | 5 + CLAUDE.md | 75 ++++ asset/config/miner.env.tmpl | 6 + asset/config/node.env.tmpl | 8 + asset/firewalld/quantus-node-miner.xml | 10 + asset/firewalld/quantus-node.xml | 8 + asset/systemd/quantus-miner.service | 64 ++++ asset/systemd/quantus-miner.sysusers.conf | 4 + asset/systemd/quantus-node.service | 74 ++++ asset/systemd/quantus-node.sysusers.conf | 2 + readme.md | 161 +++++++++ script/infra-setup.sh | 322 +++++++++++++++++ 13 files changed, 1141 insertions(+) create mode 100644 .gitea/workflows/deploy.yaml create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 asset/config/miner.env.tmpl create mode 100644 asset/config/node.env.tmpl create mode 100644 asset/firewalld/quantus-node-miner.xml create mode 100644 asset/firewalld/quantus-node.xml create mode 100644 asset/systemd/quantus-miner.service create mode 100644 asset/systemd/quantus-miner.sysusers.conf create mode 100644 asset/systemd/quantus-node.service create mode 100644 asset/systemd/quantus-node.sysusers.conf create mode 100644 readme.md 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..efef671 --- /dev/null +++ b/.gitea/workflows/deploy.yaml @@ -0,0 +1,402 @@ +name: deploy +# Deploy — or validate — the Quantus Planck node and its external GPU miner. +# +# Topology (the workflow is the source of infra truth, +# ~/git/architecture/deployment-gitea-actions.md): +# +# bob.hanzalova.internal quantus-node consensus, P2P, rewards +# ▲ QUIC/9833 (mesh only, rich-rule scoped to the miner host) +# benjy.hanzalova.internal quantus-miner 1× RTX 4090 +# +# The node and the miner are separate processes on separate hosts by design: the +# node is disk+network bound and belongs on an always-on box, the miner is pure +# GPU compute. Setting --miner-listen-port DISABLES the node's built-in CPU +# mining, so the node authors nothing if the miner is absent — the validate mode +# below checks exactly that rather than trusting `is-active`. +# +# Runs on `infra`. gitea-runners.md §4 says a deploy needs only ssh+rsync and so +# fits `fedora-43`; that is about tooling, not routing. These targets are +# mesh-only `.internal` names and lair/mail's two working deploys both use +# `infra` for that reason. Deliberate deviation, noted per readme.md. + +on: + push: + branches: [main] + paths: + - asset/** + - .gitea/workflows/deploy.yaml + workflow_dispatch: + inputs: + mode: + description: "deploy (apply) or validate (check only, no changes)" + required: false + default: deploy + type: choice + options: [deploy, validate] + node_version: + description: quantus-node version (overrides the pin) + required: false + miner_version: + description: quantus-miner version (overrides the pin) + required: false + +concurrency: # never half-apply two deploys at once + group: deploy + cancel-in-progress: false + +env: + # --- infra truth --- + NODE_HOST: bob.hanzalova.internal + MINER_HOST: benjy.hanzalova.internal + CHAIN: planck + # Pinned; bump deliberately to upgrade. An unattended upgrade of a validator + # is how you find out at 3am that a release changed a consensus rule. + NODE_VERSION: "0.10.0" + MINER_VERSION: "4.0.0" + # Ports — port-allocations.md §5 registry. These are upstream protocol + # defaults rather than derived numbers; see readme.md "Ports". + P2P_PORT: "30333" + MINER_LINK_PORT: "9833" + NODE_METRICS_PORT: "9615" + MINER_METRICS_PORT: "9900" + # benjy carries one 4090. Measured 183 MH/s at ~450 W. + GPU_DEVICES: "1" + +jobs: + fetch: + runs-on: infra + outputs: + node_version: ${{ steps.v.outputs.node }} + miner_version: ${{ steps.v.outputs.miner }} + steps: + - id: v + run: | + set -euo pipefail + n="${{ github.event.inputs.node_version || env.NODE_VERSION }}" + m="${{ github.event.inputs.miner_version || env.MINER_VERSION }}" + echo "node=${n}" >> "$GITHUB_OUTPUT" + echo "miner=${m}" >> "$GITHUB_OUTPUT" + echo "quantus-node ${n} / quantus-miner ${m}" + + - name: download quantus-node + run: | + set -euo pipefail + v="${{ steps.v.outputs.node }}" + curl -fSL --retry 5 --retry-delay 5 --retry-all-errors --connect-timeout 15 \ + -o node.tar.gz \ + "https://github.com/Quantus-Network/chain/releases/download/v${v}/quantus-node-v${v}-x86_64-unknown-linux-gnu.tar.gz" + tar xzf node.tar.gz + install -D -m 0755 "$(find . -name quantus-node -type f | head -1)" _bin/quantus-node + _bin/quantus-node --version + + - name: download quantus-miner + run: | + set -euo pipefail + v="${{ steps.v.outputs.miner }}" + curl -fSL --retry 5 --retry-delay 5 --retry-all-errors --connect-timeout 15 \ + -o _bin/quantus-miner \ + "https://github.com/Quantus-Network/quantus-miner/releases/download/v${v}/quantus-miner-linux-x86_64" + chmod 0755 _bin/quantus-miner + _bin/quantus-miner --version + + - uses: actions/upload-artifact@v3 + with: + name: quantus-bin + path: _bin + + deploy-node: + runs-on: infra + needs: fetch + steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v3 + with: { name: quantus-bin, path: _bin } + + - name: write ssh key + run: | + set -euo pipefail + install -d -m 0700 ~/.ssh + printf '%s\n' "${{ secrets.RSYNC_SSH_KEY }}" > ~/.ssh/id_gitea_ci + chmod 0600 ~/.ssh/id_gitea_ci + + - name: reachability + run: | + set -euo pipefail + ssh -i ~/.ssh/id_gitea_ci -o StrictHostKeyChecking=accept-new \ + gitea_ci@${{ env.NODE_HOST }} hostname -f + + - name: render node config + if: ${{ github.event.inputs.mode != 'validate' }} + env: + QUANTUS_INNER_HASH: ${{ secrets.QUANTUS_INNER_HASH }} + run: | + set -euo pipefail + test -n "$QUANTUS_INNER_HASH" || { + echo "QUANTUS_INNER_HASH is unset — mint one with:" >&2 + echo " ./script/infra-setup.sh --mint-reward-key" >&2 + exit 1 + } + case "$QUANTUS_INNER_HASH" in + 0x*) ;; + *) echo "QUANTUS_INNER_HASH must be 0x-prefixed (the node rejects it otherwise)" >&2; exit 1 ;; + esac + # Literal substitution — never a shell/sed expansion, so a value with + # regex or shell metacharacters survives intact. + python3 - <<'PY' + import os, pathlib + tmpl = pathlib.Path("asset/config/node.env.tmpl").read_text() + out = tmpl.replace("{{QUANTUS_INNER_HASH}}", os.environ["QUANTUS_INNER_HASH"]) + pathlib.Path("node.env").write_text(out) + PY + + - name: deploy node + if: ${{ github.event.inputs.mode != 'validate' }} + env: + HOST: ${{ env.NODE_HOST }} + run: | + set -euo pipefail + SSHOPTS="-i $HOME/.ssh/id_gitea_ci -o StrictHostKeyChecking=accept-new" + run() { ssh $SSHOPTS gitea_ci@"$HOST" "$@"; } + push() { rsync -e "ssh $SSHOPTS" --rsync-path='sudo rsync' "$@"; } + + # 1. service account + push --mkpath --chmod=F0644 \ + asset/systemd/quantus-node.sysusers.conf \ + gitea_ci@"$HOST":/etc/sysusers.d/quantus-node.conf + run sudo systemd-sysusers + + # 2. directories. /etc is root-owned so the daemon can read but not + # rewrite its own config (generic.md §8). + run sudo install -d -o root -g quantus-node -m 0750 /etc/quantus-node + run sudo install -d -o quantus-node -g quantus-node -m 0750 /var/lib/quantus-node + + # 3. binary, unit, config, firewalld defs + push --chmod=F0755 _bin/quantus-node gitea_ci@"$HOST":/usr/local/bin/quantus-node + push --chmod=F0644 asset/systemd/quantus-node.service \ + gitea_ci@"$HOST":/etc/systemd/system/quantus-node.service + push --chown=root:quantus-node --chmod=F0640 node.env \ + gitea_ci@"$HOST":/etc/quantus-node/node.env + push --mkpath --chmod=F0644 asset/firewalld/quantus-node.xml \ + gitea_ci@"$HOST":/etc/firewalld/services/quantus-node.xml + push --mkpath --chmod=F0644 asset/firewalld/quantus-node-miner.xml \ + gitea_ci@"$HOST":/etc/firewalld/services/quantus-node-miner.xml + + # 4. SELinux relabel. No `semanage port` needed: the unit runs under + # init_t, which may bind any port. + run sudo restorecon -R /usr/local/bin/quantus-node /etc/quantus-node /var/lib/quantus-node + + # 5. firewalld. Reload FIRST so the freshly-shipped definitions exist, + # or --query-service fails INVALID_SERVICE + # (deployment-gitea-actions.md §6). + run sudo firewall-cmd --reload + zone=$(run sudo firewall-cmd --get-default-zone) + echo "default zone: ${zone}" + + # 5a. P2P: public, plain named service. + if run sudo firewall-cmd --zone="$zone" --query-service=quantus-node; then + echo "firewalld: quantus-node already enabled in ${zone}" + else + run sudo firewall-cmd --permanent --zone="$zone" --add-service=quantus-node + run sudo firewall-cmd --zone="$zone" --add-service=quantus-node + fi + + # 5b. Miner link: a rich rule scoped to the miner host, NOT a plain + # --add-service. With a single default zone (generic.md §9) adding + # the service outright would publish 9833/udp on every address the + # host carries. Resolve the miner's mesh address ON THE NODE — this + # repo carries no 10.x literals, and bob's own resolution is the + # address benjy will actually present. + miner_ip=$(run "getent ahostsv4 ${{ env.MINER_HOST }}" | awk '{print $1; exit}') + case "$miner_ip" in + 10.*) echo "miner source: ${miner_ip}" ;; + *) echo "refusing to open ${{ env.MINER_LINK_PORT }}/udp to non-mesh address '${miner_ip}'" >&2; exit 1 ;; + esac + rich="rule family=ipv4 source address=${miner_ip}/32 service name=quantus-node-miner accept" + if run sudo firewall-cmd --zone="$zone" --query-rich-rule="$rich"; then + echo "firewalld: miner rich rule already present in ${zone}" + else + run sudo firewall-cmd --permanent --zone="$zone" --add-rich-rule="$rich" + run sudo firewall-cmd --zone="$zone" --add-rich-rule="$rich" + fi + + # 6. (re)start + run sudo systemctl daemon-reload + run sudo systemctl enable quantus-node.service + run sudo systemctl restart quantus-node.service + + - name: validate node + run: | + set -euo pipefail + SSHOPTS="-i $HOME/.ssh/id_gitea_ci -o StrictHostKeyChecking=accept-new" + run() { ssh $SSHOPTS gitea_ci@"${{ env.NODE_HOST }}" "$@"; } + fail=0 + + echo "--- unit ---" + run systemctl is-active quantus-node.service + + echo "--- version ---" + got=$(run /usr/local/bin/quantus-node --version) + echo "installed: ${got}" + case "$got" in + *"${{ needs.fetch.outputs.node_version }}"*) echo "version matches pin" ;; + *) echo "version does NOT match pin ${{ needs.fetch.outputs.node_version }}" >&2; fail=1 ;; + esac + + echo "--- listeners ---" + # P2P must be listening; the miner link must be listening or the node + # is authoring nothing (--miner-listen-port disables local mining). + for spec in "${{ env.P2P_PORT }} tcp" "${{ env.MINER_LINK_PORT }} udp"; do + set -- $spec + if run "ss -Hln sport = :$1" | grep -q .; then + echo " ok $2/$1 listening" + else + echo " FAIL $2/$1 not listening" >&2; fail=1 + fi + done + + echo "--- sync ---" + # best_number stalling at 0 while peers>0 means the freshness gate or a + # stalled import, not a healthy new node. + m=$(run "curl -fsS http://127.0.0.1:${{ env.NODE_METRICS_PORT }}/metrics") + peers=$(printf '%s' "$m" | awk '/^substrate_sub_libp2p_peers_count/{print $2; exit}') + best=$(printf '%s' "$m" | awk '/^substrate_block_height\{status="best"/{print $2; exit}') + echo " peers=${peers:-unknown} best_block=${best:-unknown}" + if [ "${peers:-0}" = "0" ]; then echo " WARN no peers" >&2; fi + + exit $fail + + - name: journal + if: always() + run: | + ssh -i ~/.ssh/id_gitea_ci -o StrictHostKeyChecking=accept-new \ + gitea_ci@${{ env.NODE_HOST }} journalctl -u quantus-node.service -n 80 --no-pager + + deploy-miner: + runs-on: infra + needs: [fetch, deploy-node] + steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v3 + with: { name: quantus-bin, path: _bin } + + - name: write ssh key + run: | + set -euo pipefail + install -d -m 0700 ~/.ssh + printf '%s\n' "${{ secrets.RSYNC_SSH_KEY }}" > ~/.ssh/id_gitea_ci + chmod 0600 ~/.ssh/id_gitea_ci + + - name: reachability + run: | + set -euo pipefail + ssh -i ~/.ssh/id_gitea_ci -o StrictHostKeyChecking=accept-new \ + gitea_ci@${{ env.MINER_HOST }} hostname -f + + - name: deploy miner + if: ${{ github.event.inputs.mode != 'validate' }} + run: | + set -euo pipefail + SSHOPTS="-i $HOME/.ssh/id_gitea_ci -o StrictHostKeyChecking=accept-new" + nrun() { ssh $SSHOPTS gitea_ci@"${{ env.NODE_HOST }}" "$@"; } + run() { ssh $SSHOPTS gitea_ci@"${{ env.MINER_HOST }}" "$@"; } + push() { rsync -e "ssh $SSHOPTS" --rsync-path='sudo rsync' "$@"; } + + # 1. service account + dirs + push --mkpath --chmod=F0644 \ + asset/systemd/quantus-miner.sysusers.conf \ + gitea_ci@"${{ env.MINER_HOST }}":/etc/sysusers.d/quantus-miner.conf + run sudo systemd-sysusers + run sudo install -d -o root -g quantus-miner -m 0750 /etc/quantus-miner + run sudo install -d -o quantus-miner -g quantus-miner -m 0750 /var/lib/quantus-miner + + # 2. The miner's credentials are GENERATED BY THE NODE on first start + # and regenerate if the node's base-path is ever wiped. Copying them + # on every deploy — rather than once in infra-setup.sh — is what + # makes that self-healing instead of a silent auth failure. + # They pass through the runner in memory and are never written to + # the workspace. + umask 077 + tmp=$(mktemp -d) + trap 'rm -rf "$tmp"' EXIT + nrun sudo cat /var/lib/quantus-node/chains/${{ env.CHAIN }}/miner-auth-token \ + > "$tmp/miner-auth-token" + nrun sudo cat /var/lib/quantus-node/chains/${{ env.CHAIN }}/miner-tls-cert-sha256 \ + > "$tmp/miner-tls-cert-sha256" + test -s "$tmp/miner-auth-token" || { echo "node auth token empty — has the node started?" >&2; exit 1; } + test -s "$tmp/miner-tls-cert-sha256" || { echo "node TLS pin empty — has the node started?" >&2; exit 1; } + push --chown=root:quantus-miner --chmod=F0640 \ + "$tmp/miner-auth-token" gitea_ci@"${{ env.MINER_HOST }}":/etc/quantus-miner/miner-auth-token + push --chown=root:quantus-miner --chmod=F0640 \ + "$tmp/miner-tls-cert-sha256" gitea_ci@"${{ env.MINER_HOST }}":/etc/quantus-miner/miner-tls-cert-sha256 + + # 3. non-secret runtime config + python3 - <<'PY' + import os, pathlib + t = pathlib.Path("asset/config/miner.env.tmpl").read_text() + t = t.replace("{{QUANTUS_NODE_ADDR}}", os.environ["NODE_ADDR"]) + t = t.replace("{{QUANTUS_GPU_DEVICES}}", os.environ["GPU_DEVICES"]) + pathlib.Path("miner.env").write_text(t) + PY + push --chown=root:quantus-miner --chmod=F0640 \ + miner.env gitea_ci@"${{ env.MINER_HOST }}":/etc/quantus-miner/miner.env + + # 4. binary + unit + push --chmod=F0755 _bin/quantus-miner gitea_ci@"${{ env.MINER_HOST }}":/usr/local/bin/quantus-miner + push --chmod=F0644 asset/systemd/quantus-miner.service \ + gitea_ci@"${{ env.MINER_HOST }}":/etc/systemd/system/quantus-miner.service + + run sudo restorecon -R /usr/local/bin/quantus-miner /etc/quantus-miner /var/lib/quantus-miner + + run sudo systemctl daemon-reload + run sudo systemctl enable quantus-miner.service + run sudo systemctl restart quantus-miner.service + env: + NODE_ADDR: ${{ env.NODE_HOST }}:${{ env.MINER_LINK_PORT }} + GPU_DEVICES: ${{ env.GPU_DEVICES }} + + - name: validate miner + run: | + set -euo pipefail + SSHOPTS="-i $HOME/.ssh/id_gitea_ci -o StrictHostKeyChecking=accept-new" + run() { ssh $SSHOPTS gitea_ci@"${{ env.MINER_HOST }}" "$@"; } + fail=0 + + echo "--- unit ---" + run systemctl is-active quantus-miner.service + + echo "--- version ---" + got=$(run /usr/local/bin/quantus-miner --version) + echo "installed: ${got}" + case "$got" in + *"${{ needs.fetch.outputs.miner_version }}"*) echo "version matches pin" ;; + *) echo "version does NOT match pin ${{ needs.fetch.outputs.miner_version }}" >&2; fail=1 ;; + esac + + echo "--- gpu ---" + # An `active` miner that found no adapter still looks healthy to + # systemd; assert the GPU is actually enumerated and busy. + run "nvidia-smi --query-gpu=name,power.draw,utilization.gpu --format=csv,noheader" + + echo "--- hashing ---" + # The metrics counter is the only honest evidence that this process is + # doing work rather than idling on a failed connection. + h1=$(run "curl -fsS http://127.0.0.1:${{ env.MINER_METRICS_PORT }}/metrics" \ + | awk '/^miner_hashes_total/{print $2; exit}') + sleep 20 + h2=$(run "curl -fsS http://127.0.0.1:${{ env.MINER_METRICS_PORT }}/metrics" \ + | awk '/^miner_hashes_total/{print $2; exit}') + echo " miner_hashes_total ${h1:-?} -> ${h2:-?}" + if [ -n "${h1:-}" ] && [ -n "${h2:-}" ] && [ "${h2%.*}" -gt "${h1%.*}" ]; then + echo " ok hash counter advancing" + else + echo " WARN counter not advancing — the node may still be syncing," >&2 + echo " which is expected and not itself a deploy failure." >&2 + fi + + exit $fail + + - name: journal + if: always() + run: | + ssh -i ~/.ssh/id_gitea_ci -o StrictHostKeyChecking=accept-new \ + gitea_ci@${{ env.MINER_HOST }} journalctl -u quantus-miner.service -n 80 --no-pager diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b40ccb9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +# no rendered config, ever (architecture generic.md §6) +asset/config/*.env +!asset/config/*.tmpl +_bin/ +*.tar.gz diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..14a3a98 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,75 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with +code in this repository. + +## What this repo is + +Deployment automation **only** — systemd units, firewalld definitions, config +templates, one Gitea Actions workflow and one provisioning script, for a Quantus +Planck node and its external GPU miner. There is no application code, no build, +and no test suite: the deployed artifacts are upstream release binaries fetched +by the workflow. Because there is nothing to compile, `generic.md` §1–§2 (Rust +workspace layout, crate naming) do not apply here. + +**No secrets live in this repo.** `QUANTUS_INNER_HASH` is a Gitea Actions secret; +the miner's auth token and TLS pin are generated on the node host and copied +host-to-host by the deploy. + +Read `readme.md` first — it carries the topology, the measured hashrate numbers +the design rests on, the port rationale, and the list of deliberate deviations +from house convention. + +## The one thing that will surprise you + +Setting `--miner-listen-port` on the node **disables its built-in CPU mining**. +A node with that flag and no external miner attached is a node that authors +nothing, while `systemctl is-active` reports it perfectly healthy. The same trap +exists on the miner side: a `quantus-miner` that enumerated no GPU adapter also +looks active. This is why the workflow's validate mode checks +`miner_hashes_total` is *advancing* and that the GPU is drawing power, rather +than trusting unit state. Do not "simplify" those checks away. + +## Facts established by measurement, not assumption + +These came from benchmarking the real fleet; don't re-derive or contradict them +without re-measuring: + +- GPU beats CPU by ~200×. One RTX 5090 at its 400 W floor out-hashes every CPU + in the fleet combined by ~24×. CPU mining is disabled everywhere on purpose. +- RTX 5090: 368 MH/s @ 600 W; 281 MH/s @ 400 W (18% better MH/s per watt). + RTX 4090: 183 MH/s @ 450 W. +- The PoW is Poseidon2 over Goldilocks — register-resident, no memory hardness. + VRAM size and memory bandwidth are irrelevant to hashrate. +- `quantus-node` has **no environment-variable form for any flag** (its `--help` + carries no `[env:]` annotations), so `--rewards-inner-hash` must be a CLI + argument and is visible in `ps` on the node host. The `EnvironmentFile` keeps + it out of the world-readable unit; it does not hide it from a local user. +- The node will **not** start without a pre-generated node key — it exits with + `NetworkKeyNotFound(".../network/secret_dilithium")` rather than generating + one. `infra-setup.sh` creates it; a deploy must never rotate it, because it is + the node's stable network identity. +- Node Prometheus is on **9615**, not 9616. Upstream's `MINING.md` parameter + table is wrong about this; the running binary is the authority. + +## Working on the deploy + +- The workflow is the source of infra truth (`deployment-gitea-actions.md`). + Hosts, ports, versions and paths live in `.gitea/workflows/deploy.yaml`'s + `env:` block — never introduce a separate manifest. +- Anything one-time, or anything involving key material, belongs in + `script/infra-setup.sh`, not the workflow. +- `infra-setup.sh` must stay idempotent and must skip unreachable hosts rather + than aborting the run. +- **Never suppress errors** (`generic.md` §7): no `2>/dev/null`, no `|| true`. + If a command may legitimately fail, handle it with a visible message. +- **Never print a secret** (`agent-credentials.md` §6). `--mint-reward-key` + reads a seed phrase on stdin and emits only a "value withheld" acknowledgement. + Keep it that way; do not add a debug echo. +- Version pins are deliberate. Do not add auto-update to a validator. + +## Before enabling the miner on benjy + +`benjy` already runs helexa `neuron` on `:13131` against the same RTX 4090. The +miner will saturate that GPU. `readme.md` documents the options; the deploy +deliberately does not arbitrate. This is an open decision, not an oversight. diff --git a/asset/config/miner.env.tmpl b/asset/config/miner.env.tmpl new file mode 100644 index 0000000..ead7340 --- /dev/null +++ b/asset/config/miner.env.tmpl @@ -0,0 +1,6 @@ +# Rendered by .gitea/workflows/deploy.yaml and rsynced to +# /etc/quantus-miner/miner.env (0640 root:quantus-miner). Not secret — the +# miner's actual credentials are the auth token and TLS pin, which are copied +# from the node host as separate 0640 files. +QUANTUS_NODE_ADDR={{QUANTUS_NODE_ADDR}} +QUANTUS_GPU_DEVICES={{QUANTUS_GPU_DEVICES}} diff --git a/asset/config/node.env.tmpl b/asset/config/node.env.tmpl new file mode 100644 index 0000000..4efb695 --- /dev/null +++ b/asset/config/node.env.tmpl @@ -0,0 +1,8 @@ +# Rendered by .gitea/workflows/deploy.yaml from the QUANTUS_INNER_HASH Gitea +# secret and rsynced to /etc/quantus-node/node.env (0640 root:quantus-node). +# The rendered file is never committed (generic.md §6). +# +# 32-byte wormhole preimage, 0x-prefixed — the node derives the reward address +# from it and logs that address on startup. Generated by +# script/infra-setup.sh --mint-reward-key. +QUANTUS_INNER_HASH={{QUANTUS_INNER_HASH}} diff --git a/asset/firewalld/quantus-node-miner.xml b/asset/firewalld/quantus-node-miner.xml new file mode 100644 index 0000000..dd9b001 --- /dev/null +++ b/asset/firewalld/quantus-node-miner.xml @@ -0,0 +1,10 @@ + + + quantus-node-miner + Quantus node external-miner control channel (QUIC/UDP). NOT a + public service: shared-secret auth plus a pinned self-signed TLS cert, and the + upstream docs are explicit that it must never be reachable from the open + internet. The deploy binds this to the miner host's mesh address with a + firewalld rich rule rather than adding it to the zone wholesale. + + diff --git a/asset/firewalld/quantus-node.xml b/asset/firewalld/quantus-node.xml new file mode 100644 index 0000000..6390bb4 --- /dev/null +++ b/asset/firewalld/quantus-node.xml @@ -0,0 +1,8 @@ + + + quantus-node + Quantus Planck P2P (libp2p). Public: this is the peer-to-peer + gossip port and must be reachable from the internet for the node to be a + useful network participant. + + diff --git a/asset/systemd/quantus-miner.service b/asset/systemd/quantus-miner.service new file mode 100644 index 0000000..9f23398 --- /dev/null +++ b/asset/systemd/quantus-miner.service @@ -0,0 +1,64 @@ +# Quantus external miner. Runs on the GPU host and connects OUT to the node's +# QUIC control channel; it listens on nothing but its loopback metrics port, so +# it ships no firewalld service of its own. +# +# Hardened per ~/git/architecture/generic.md §8, with one deliberate relaxation: +# +# PrivateDevices=false +# The miner needs /dev/nvidia*; PrivateDevices=true hides them and wgpu +# finds no adapter, silently falling back to nothing. +# +# Poseidon2-over-Goldilocks is compute-bound in registers, not memory-bound, so +# there is no tuning knob here worth more than the GPU power limit — which is a +# host-level concern (nvidia-smi -pl), not a unit-file one. +# +# --cpu-workers 0 is deliberate: benjy's i9-13900KF contributes ~3.9 MH/s next +# to the 4090's ~183 MH/s, for a couple hundred watts. Measured, not assumed. + +[Unit] +Description=Quantus external miner (GPU) +Documentation=https://github.com/Quantus-Network/quantus-miner +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=quantus-miner +Group=quantus-miner +Environment=RUST_LOG=info +WorkingDirectory=/var/lib/quantus-miner + +ExecStart=/usr/local/bin/quantus-miner serve \ + --node-addr ${QUANTUS_NODE_ADDR} \ + --auth-token-file /etc/quantus-miner/miner-auth-token \ + --tls-cert-sha256-file /etc/quantus-miner/miner-tls-cert-sha256 \ + --gpu-devices ${QUANTUS_GPU_DEVICES} \ + --cpu-workers 0 \ + --metrics-port 9900 +EnvironmentFile=/etc/quantus-miner/miner.env + +# The node may be down, still syncing, or mid-redeploy; reconnecting is normal +# operation, not an error condition. +Restart=always +RestartSec=15s +LimitNOFILE=65536 + +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=true +PrivateTmp=true +PrivateDevices=false +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectControlGroups=true +RestrictRealtime=true +RestrictSUIDSGID=true +LockPersonality=true +SystemCallArchitectures=native +MemoryDenyWriteExecute=false + +ReadWritePaths=/var/lib/quantus-miner +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 + +[Install] +WantedBy=multi-user.target diff --git a/asset/systemd/quantus-miner.sysusers.conf b/asset/systemd/quantus-miner.sysusers.conf new file mode 100644 index 0000000..2ed369e --- /dev/null +++ b/asset/systemd/quantus-miner.sysusers.conf @@ -0,0 +1,4 @@ +#Type Name ID GECOS Home directory Shell +u quantus-miner - "Quantus miner service account" /var/lib/quantus-miner /usr/sbin/nologin +m quantus-miner video +m quantus-miner render diff --git a/asset/systemd/quantus-node.service b/asset/systemd/quantus-node.service new file mode 100644 index 0000000..6f534e5 --- /dev/null +++ b/asset/systemd/quantus-node.service @@ -0,0 +1,74 @@ +# Quantus Planck node, validator role, external-miner mode. +# +# Hardened per ~/git/architecture/generic.md §8. Two knobs are deliberately +# relaxed; both are load-bearing, do not "tidy" them back: +# +# MemoryDenyWriteExecute=false +# Substrate JITs the on-chain wasm runtime through wasmtime and needs W|X +# pages. With this true the node aborts during block import. +# +# Type=simple (not notify) +# quantus-node does not sd_notify(READY=1). Type=notify would block +# `systemctl restart` until TimeoutStartSec expires +# (deployment-gitea-actions.md §6). +# +# The reward preimage arrives via EnvironmentFile because the node exposes no +# environment-variable form for any flag (`--help` carries no `[env:]` +# annotations), so it must be a CLI argument. The EnvironmentFile keeps it out +# of this world-readable unit; it is still visible in `ps` to a local user on +# the host, which is accepted on a single-tenant box. + +[Unit] +Description=Quantus node (Planck, validator, external-miner mode) +Documentation=https://github.com/Quantus-Network/chain +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=quantus-node +Group=quantus-node +Environment=RUST_LOG=info +EnvironmentFile=/etc/quantus-node/node.env +WorkingDirectory=/var/lib/quantus-node + +ExecStart=/usr/local/bin/quantus-node \ + --validator \ + --chain planck \ + --name %H \ + --base-path /var/lib/quantus-node \ + --node-key-file /var/lib/quantus-node/node_key.p2p \ + --rewards-inner-hash ${QUANTUS_INNER_HASH} \ + --miner-listen-port 9833 \ + --port 30333 \ + --prometheus-port 9615 \ + --max-blocks-per-request 64 \ + --sync full + +Restart=always +RestartSec=10s +# RocksDB needs time to flush cleanly; a SIGKILL mid-compaction means a slow +# (or corrupt) recovery on next start. +TimeoutStopSec=300 +KillSignal=SIGINT +LimitNOFILE=65536 + +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=true +PrivateTmp=true +PrivateDevices=true +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectControlGroups=true +RestrictRealtime=true +RestrictSUIDSGID=true +LockPersonality=true +SystemCallArchitectures=native +MemoryDenyWriteExecute=false + +ReadWritePaths=/var/lib/quantus-node +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 + +[Install] +WantedBy=multi-user.target diff --git a/asset/systemd/quantus-node.sysusers.conf b/asset/systemd/quantus-node.sysusers.conf new file mode 100644 index 0000000..06dbbdd --- /dev/null +++ b/asset/systemd/quantus-node.sysusers.conf @@ -0,0 +1,2 @@ +#Type Name ID GECOS Home directory Shell +u quantus-node - "Quantus node service account" /var/lib/quantus-node /usr/sbin/nologin diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..27933d3 --- /dev/null +++ b/readme.md @@ -0,0 +1,161 @@ +# quantus + +Deployment for a [Quantus Network](https://quantus.com) Planck node and its +external GPU miner. + +Two components, two hosts: + +| Component | Host | Role | +| --- | --- | --- | +| `quantus-node` | `bob.hanzalova.internal` | consensus, P2P, reward address. Disk + network bound. | +| `quantus-miner` | `benjy.hanzalova.internal` | Poseidon2-over-Goldilocks search on 1× RTX 4090. Pure GPU compute. | + +They are split deliberately. Setting `--miner-listen-port` on the node +**disables its built-in CPU mining**, so the node authors nothing unless an +external miner is connected — the split is not optional once that flag is set. +The miner connects *out* to the node over QUIC/9833 and listens on nothing but a +loopback metrics port. + +Conventions: `~/git/architecture` — `generic.md` for the on-host shape, +`deployment-gitea-actions.md` for the deploy mechanism. Deviations are listed +below. + +## Why GPU, and why only these hosts + +The proof-of-work is Poseidon2 over the Goldilocks field (`p = 2⁶⁴ − 2³² + 1`), +width 12, 4 external + 22 internal rounds — an arithmetic hash, not a bit-mixing +one. Cost is dominated by 64-bit modular multiplication, the state lives entirely +in registers, and there is no memory hardness at all. Measured on this fleet: + +| Hardware | Hashrate | Power | Efficiency | +| --- | --- | --- | --- | +| RTX 5090 (`beast`, ×2) | 368 MH/s each | 600 W | 0.60 MH/s/W | +| RTX 5090 @ 400 W floor | 281 MH/s | 400 W | 0.70 MH/s/W | +| RTX 4090 (`benjy`) | 183 MH/s | 450 W | 0.41 MH/s/W | +| Ryzen 9 9950X3D, 32t | 4.15 MH/s | — | — | +| Xeon E5-2620 v3, 12t (`bob`) | 0.50 MH/s | — | — | + +A single 5090 at its minimum power limit out-hashes every CPU in the fleet +combined by roughly 24×. CPU mining is therefore disabled everywhere +(`--cpu-workers 0`), including on the GPU hosts, where it would add ~1% hashrate +for a couple hundred watts. The old Xeons are node-capable and mining-worthless. + +GPU/CPU parity is verified upstream (`engine-gpu`'s `gpu_cpu_parity` example, +25/25 jobs) — the GPU is doing real work, not advancing a lying counter. + +## Ports + +Registered in `~/git/architecture/port-allocations.md` §5. + +| Port | Proto | Component | Exposure | +| --- | --- | --- | --- | +| `30333` | tcp | node P2P (libp2p) | **public** — named firewalld service | +| `9833` | udp | node ← miner control channel | mesh, firewalld **rich rule** scoped to the miner host only | +| `9615` | tcp | node Prometheus | loopback | +| `9900` | tcp | miner Prometheus | loopback | + +**Deviation from `port-allocations.md` §3:** these are upstream protocol +defaults, not numbers derived from the service name. `30333` is the chain's +well-known P2P port and changing it buys nothing; `9833` is the miner protocol's +default and both ends must agree. They are recorded in the registry, which is +what the doc says actually prevents collisions. Neither lands in the crowded +alt-HTTP cluster. + +The miner link gets a rich rule rather than a plain `--add-service` because the +fleet uses a single default zone (`generic.md` §9): adding the service outright +would publish `9833/udp` on every address the host carries, and upstream is +explicit that this port must never face the internet. + +## Secrets + +| Name | What | Set by | +| --- | --- | --- | +| `RSYNC_SSH_KEY` | the shared fleet runner key | already exists — do **not** rotate | +| `QUANTUS_INNER_HASH` | 32-byte wormhole preimage, `0x`-prefixed | `script/infra-setup.sh --mint-reward-key` | + +Mining rewards accrue to a wormhole address derived from `QUANTUS_INNER_HASH`. +The script derives it from a wallet seed phrase read on **stdin**, so rewards +land in a wallet you already control and appear with no claiming step. + +It will not generate a fresh wallet for you. A newly minted wormhole key's +24-word phrase is the only way to ever spend what it accumulates, and it fails +`agent-credentials.md` §1 outright — a seed controlling real funds is +escalation-grade and must not enter `~/.agents`. Create the wallet in the +Quantus wallet app, back the phrase up there, then feed it in. + +The miner's own credentials — `miner-auth-token` and `miner-tls-cert-sha256` — +are **not** secrets in this repo. The node generates them on first start and +regenerates them if its base-path is ever wiped, so the deploy copies them from +the node to the miner on every run. That makes the pairing self-healing instead +of a silent auth failure six months later. + +## Usage + +One-time, from a workstation with sudo ssh to the targets: + +```sh +./script/infra-setup.sh --pubkey ~/.ssh/id_gitea_ci.pub +./script/infra-setup.sh --mint-reward-key # reads a seed phrase on stdin +``` + +Then push to `main`, or run the **deploy** workflow from the Actions UI. It has +two modes: + +- **deploy** (default, and what a push does) — apply, then validate. +- **validate** — check only, change nothing. Asserts each unit is active, the + installed binary matches the pinned version, the node's P2P and miner ports + are listening, peers are connected, the GPU is enumerated and drawing power, + and `miner_hashes_total` is advancing. + +`systemctl is-active` alone is not evidence this stack works: a miner that found +no GPU adapter, and a node that is authoring nothing because no miner is +attached, both look perfectly healthy to systemd. Hence the counter check. + +Upgrades are deliberate — bump `NODE_VERSION` / `MINER_VERSION` in the workflow. +There is no auto-update on a validator on purpose. + +## Known conflict: helexa on `benjy` ⚠️ + +`benjy` already runs **helexa `neuron`** on `:13131` (`architecture/generic.md` +§11, `inference.md`), serving inference from the same RTX 4090 this miner will +saturate. They will contend directly: the miner drives the GPU to ~95% +utilisation and ~450 W continuously, which will make inference latency on benjy +by turns awful and unpredictable. + +Nothing here resolves that, and the deploy does not try to arbitrate. Options, +in rough order of preference: + +1. Move mining to `beast` (2× 5090, and 4× the hashrate) and leave benjy to + inference. Requires checking what beast's neuron is doing first. +2. Make the miner yield — `--gpu-throttle-ms`, or an `ExecCondition` that + refuses to start while a model is loaded. +3. Accept the contention on benjy and treat inference there as best-effort. + +Decide before enabling `quantus-miner.service` on benjy, not after. + +## Deviations from house convention + +- **`runs-on: infra`, not `fedora-43`.** `gitea-runners.md` §4 says a deploy + needs only ssh + rsync and so fits `fedora-43`. That is about tooling, not + routing — these targets are mesh-only `.internal` names, and `lair/mail`'s two + working deploys both use `infra` for that reason. Revisit if the fedora runners + gain mesh reachability. +- **Upstream protocol ports** rather than derived ones — see Ports above. +- **`MemoryDenyWriteExecute=false`** on the node (Substrate JITs its wasm runtime + through wasmtime) and **`PrivateDevices=false`** on the miner (needs + `/dev/nvidia*`). Both are annotated in the unit files; neither is tidy-up-able. +- **`Type=simple`, not `notify`** — `quantus-node` does not `sd_notify(READY=1)`. +- **No Rust workspace.** This repo deploys upstream release binaries; there is + nothing to build, so `generic.md` §1–§2 do not apply. + +## Upstream + +- node — (`MINING.md` is the real doc) +- miner — +- guide — + +Note that Planck is a **testnet**: "tokens have no monetary value". Mainnet +difficulty and reward schedule are unpublished, so no profitability claim here is +possible yet. Also worth tracking: this PoW has no memory hardness and a small +fixed state, which makes it an unusually clean FPGA/ASIC target — relevant to any +decision to buy hardware for it. diff --git a/script/infra-setup.sh b/script/infra-setup.sh new file mode 100755 index 0000000..30fc430 --- /dev/null +++ b/script/infra-setup.sh @@ -0,0 +1,322 @@ +#!/usr/bin/env bash +# One-time, operator-run provisioning so the Gitea Actions runner can deploy as +# the scoped `gitea_ci` user, plus the key material that cannot live in the +# workflow. Two roles, each with its own narrow sudoers: +# +# node (--node-hosts) quantus-node binary + unit + config + firewalld, +# and read access to the two miner credential files +# the node generates -> deploy.yaml deploy-node +# miner (--miner-hosts) quantus-miner binary + unit + credentials +# -> deploy.yaml deploy-miner +# +# The roles are separate deliberately: the miner host holds a GPU and an +# outbound-only process, and a compromise of the miner deploy must not be able +# to rewrite the validator's binary or read its reward config. Hence +# `quantus-miner_gitea_ci` as a SEPARATE sudoers file from +# `quantus-node_gitea_ci` (deployment-gitea-actions.md §1). +# +# Convention: ~/git/architecture/deployment-gitea-actions.md §1–§2, generic.md +# §8–§10, agent-credentials.md §1 and §6. +# +# Run from a workstation with admin (sudo) ssh access to the targets — NOT the +# gitea_ci account. Idempotent; re-running is a no-op. Skips unreachable hosts +# so one offline node doesn't block the rest. +# +# ./script/infra-setup.sh --pubkey ~/.ssh/id_gitea_ci.pub +# ./script/infra-setup.sh --pubkey ~/.ssh/id_gitea_ci.pub --node-hosts "" \ +# --miner-hosts benjy.hanzalova.internal +# ./script/infra-setup.sh --mint-reward-key # reads a seed phrase on stdin +# +# The runner keypair is NOT generated here. It already exists at +# ~/.ssh/id_gitea_ci and is shared by every project's RSYNC_SSH_KEY secret and +# every host's gitea_ci authorized_keys — regenerating it would silently break +# every other deploy on the fleet (deployment-gitea-actions.md §2). +set -euo pipefail + +ADMIN_USER="${ADMIN_USER:-$USER}" +# `${X-default}` not `${X:-default}`: an explicitly EMPTY list means "none of +# this role", which is how you provision only the other one. With `:-` the empty +# value would silently fall back to the full default list. +NODE_HOSTS="${NODE_HOSTS-bob.hanzalova.internal}" +MINER_HOSTS="${MINER_HOSTS-benjy.hanzalova.internal}" +PUBKEY="${PUBKEY:-${HOME}/.ssh/id_gitea_ci.pub}" +CHAIN="${CHAIN:-planck}" +NODE_VERSION="${NODE_VERSION:-0.10.0}" +GITEA_API="${GITEA_API:-https://git.lair.cafe/api/v1}" +GITEA_REPO="${GITEA_REPO:-lair/quantus}" +MINT_REWARD_KEY=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + --pubkey) PUBKEY="$2"; shift 2 ;; + --node-hosts) NODE_HOSTS="$2"; shift 2 ;; + --miner-hosts) MINER_HOSTS="$2"; shift 2 ;; + --admin) ADMIN_USER="$2"; shift 2 ;; + --node-version) NODE_VERSION="$2"; shift 2 ;; + --mint-reward-key) MINT_REWARD_KEY=1; shift ;; + *) echo "unknown arg: $1" >&2; exit 2 ;; + esac +done + +cd "$(dirname "${BASH_SOURCE[0]}")/.." + +NODE_URL="https://github.com/Quantus-Network/chain/releases/download/v${NODE_VERSION}/quantus-node-v${NODE_VERSION}-x86_64-unknown-linux-gnu.tar.gz" + +# ---------------------------------------------------------------- reward key -- +# +# The reward preimage (`inner_hash`) is DERIVED from a wallet seed phrase, so +# that mining rewards land in a wallet the operator already controls and appear +# there with no claiming step. +# +# The seed phrase is read on stdin and never touches argv (visible in `ps`), a +# file, or stdout (agent-credentials.md §6). Only the derived inner_hash leaves +# this function, and it goes straight into the Gitea Actions secret. +# +# This deliberately will NOT generate a fresh wallet for you. A freshly minted +# wormhole key has a 24-word recovery phrase that is the only way to ever spend +# what it accumulates, and a provisioning script is the wrong place to be +# responsible for that. It also fails agent-credentials.md §1 outright: a seed +# controlling real funds is escalation-grade, so it must not land in ~/.agents. +# Create the wallet in the Quantus wallet app, back the phrase up there, then +# feed it to this. +mint_reward_key() { + command -v pass >/dev/null || { echo "error: 'pass' not found; needed to read the Gitea secret-writer token" >&2; exit 1; } + + local bin tmp + tmp="$(mktemp -d)" + trap 'rm -rf "$tmp"' RETURN + + echo "fetching quantus-node ${NODE_VERSION} to derive the wormhole key locally" + curl -fSL --retry 5 --retry-delay 5 --retry-all-errors --connect-timeout 15 \ + -o "$tmp/node.tar.gz" "$NODE_URL" + tar xzf "$tmp/node.tar.gz" -C "$tmp" + bin="$(find "$tmp" -name quantus-node -type f | head -1)" + chmod +x "$bin" + + echo + echo "Paste the wallet seed phrase, then press Ctrl-D." + echo "It is read from stdin, never echoed, never written to disk." + local phrase + IFS= read -r -d '' phrase || true + phrase="${phrase%$'\n'}" + [[ -n "$phrase" ]] || { echo "error: empty seed phrase" >&2; exit 1; } + + # `key quantus --scheme wormhole --words ` derives deterministically. + # Output is parsed, never displayed. + local out inner + out="$(printf '%s' "$phrase" | "$bin" key quantus --scheme wormhole --words)" + inner="$(printf '%s' "$out" | grep -oE '0x[0-9a-fA-F]{64}' | head -1)" + unset phrase out + [[ -n "$inner" ]] || { echo "error: could not parse an inner_hash from key output" >&2; exit 1; } + echo "derived a 32-byte inner_hash (value withheld — agent-credentials.md §6)" + + local token + token="$(GNUPGHOME="$HOME/.agents/gnupg" PASSWORD_STORE_DIR="$HOME/.agents/password-store" \ + pass show gitea/agent-secret-writer-token)" + + local code + code="$(curl -sS -o /dev/null -w '%{http_code}' -X PUT \ + -H "Authorization: token ${token}" \ + -H 'Content-Type: application/json' \ + --data-binary @<(printf '{"data":%s}' "$(printf '%s' "$inner" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))')") \ + "${GITEA_API}/repos/${GITEA_REPO}/actions/secrets/QUANTUS_INNER_HASH")" + unset inner token + + case "$code" in + 201|204) echo "QUANTUS_INNER_HASH written to ${GITEA_REPO} (HTTP ${code})" ;; + 403) echo "error: HTTP 403 — agent-bot lacks admin on ${GITEA_REPO%%/*}; that grant is an operator step (agent-credentials.md §4)" >&2; exit 1 ;; + *) echo "error: Gitea secret write failed (HTTP ${code})" >&2; exit 1 ;; + esac +} + +# ------------------------------------------------------------------- roles ---- + +provision_node() { + local host="$1" + echo "== ${host} (node) ==" + if ! ssh -o ConnectTimeout=8 -o BatchMode=yes "${ADMIN_USER}@${host}" true; then + echo " ! unreachable as ${ADMIN_USER} — skipping" >&2 + return 1 + fi + + # All privileged work in one remote `sudo bash`. The runner pubkey is the only + # dynamic value, passed as $1 (single line, no quoting hazard). The sudoers + # `\=` are the required escapes — visudo rejects a bare `=` in a command arg. + # The `*` in an rsync line matches rsync's --server arg vector; the trailing + # literal destination is what actually bounds the rule. + ssh "${ADMIN_USER}@${host}" "sudo bash -seu -- '$(cat "$PUBKEY")'" <<'REMOTE' + PUBKEY="$1" + + if ! getent passwd gitea_ci >/dev/null; then + useradd --system --create-home --home-dir /var/lib/gitea_ci \ + --shell /usr/sbin/nologin gitea_ci + echo " + created gitea_ci" + else + echo " = gitea_ci already present" + fi + + install -d -o gitea_ci -g gitea_ci -m 0700 /var/lib/gitea_ci/.ssh + ak=/var/lib/gitea_ci/.ssh/authorized_keys + touch "$ak" + grep -qxF "$PUBKEY" "$ak" || printf '%s\n' "$PUBKEY" >> "$ak" + chown gitea_ci:gitea_ci "$ak" + chmod 0600 "$ak" + + # journalctl -u for the deploy's log capture, without sudo. + usermod -aG systemd-journal gitea_ci + + tmp=/etc/sudoers.d/.quantus-node_gitea_ci.tmp + cat > "$tmp" <<'SUDO' +gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /usr/local/bin/quantus-node +gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/systemd/system/quantus-node.service +gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/sysusers.d/quantus-node.conf +gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/quantus-node/node.env +gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/firewalld/services/quantus-node.xml +gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/firewalld/services/quantus-node-miner.xml +gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemd-sysusers +gitea_ci ALL=(root) NOPASSWD: /usr/bin/install -d -o root -g quantus-node -m 0750 /etc/quantus-node +gitea_ci ALL=(root) NOPASSWD: /usr/bin/install -d -o quantus-node -g quantus-node -m 0750 /var/lib/quantus-node +gitea_ci ALL=(root) NOPASSWD: /usr/sbin/restorecon -R /usr/local/bin/quantus-node /etc/quantus-node /var/lib/quantus-node +gitea_ci ALL=(root) NOPASSWD: /usr/bin/firewall-cmd --get-default-zone +gitea_ci ALL=(root) NOPASSWD: /usr/bin/firewall-cmd --reload +gitea_ci ALL=(root) NOPASSWD: /usr/bin/firewall-cmd --permanent --zone\=* --add-service\=quantus-node +gitea_ci ALL=(root) NOPASSWD: /usr/bin/firewall-cmd --zone\=* --add-service\=quantus-node +gitea_ci ALL=(root) NOPASSWD: /usr/bin/firewall-cmd --zone\=* --query-service\=quantus-node +gitea_ci ALL=(root) NOPASSWD: /usr/bin/firewall-cmd --permanent --zone\=* --add-rich-rule\=* +gitea_ci ALL=(root) NOPASSWD: /usr/bin/firewall-cmd --zone\=* --add-rich-rule\=* +gitea_ci ALL=(root) NOPASSWD: /usr/bin/firewall-cmd --zone\=* --query-rich-rule\=* +gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl daemon-reload +gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl enable quantus-node.service +gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl restart quantus-node.service +gitea_ci ALL=(root) NOPASSWD: /usr/bin/cat /var/lib/quantus-node/chains/*/miner-auth-token +gitea_ci ALL=(root) NOPASSWD: /usr/bin/cat /var/lib/quantus-node/chains/*/miner-tls-cert-sha256 +SUDO + chmod 0440 "$tmp" + visudo -cf "$tmp" + mv "$tmp" /etc/sudoers.d/quantus-node_gitea_ci + echo " = sudoers quantus-node_gitea_ci installed and visudo-verified" +REMOTE +} + +provision_miner() { + local host="$1" + echo "== ${host} (miner) ==" + if ! ssh -o ConnectTimeout=8 -o BatchMode=yes "${ADMIN_USER}@${host}" true; then + echo " ! unreachable as ${ADMIN_USER} — skipping" >&2 + return 1 + fi + + ssh "${ADMIN_USER}@${host}" "sudo bash -seu -- '$(cat "$PUBKEY")'" <<'REMOTE' + PUBKEY="$1" + + if ! getent passwd gitea_ci >/dev/null; then + useradd --system --create-home --home-dir /var/lib/gitea_ci \ + --shell /usr/sbin/nologin gitea_ci + echo " + created gitea_ci" + else + echo " = gitea_ci already present" + fi + + install -d -o gitea_ci -g gitea_ci -m 0700 /var/lib/gitea_ci/.ssh + ak=/var/lib/gitea_ci/.ssh/authorized_keys + touch "$ak" + grep -qxF "$PUBKEY" "$ak" || printf '%s\n' "$PUBKEY" >> "$ak" + chown gitea_ci:gitea_ci "$ak" + chmod 0600 "$ak" + usermod -aG systemd-journal gitea_ci + + # The GPU must be present and enumerable before a miner deploy is worth + # attempting; failing here beats a green deploy that mines nothing. + if ! command -v nvidia-smi >/dev/null; then + echo " ! nvidia-smi not found — this host has no usable NVIDIA driver" >&2 + exit 1 + fi + nvidia-smi --query-gpu=name --format=csv,noheader | sed 's/^/ = gpu: /' + + tmp=/etc/sudoers.d/.quantus-miner_gitea_ci.tmp + cat > "$tmp" <<'SUDO' +gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /usr/local/bin/quantus-miner +gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/systemd/system/quantus-miner.service +gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/sysusers.d/quantus-miner.conf +gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/quantus-miner/miner.env +gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/quantus-miner/miner-auth-token +gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/quantus-miner/miner-tls-cert-sha256 +gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemd-sysusers +gitea_ci ALL=(root) NOPASSWD: /usr/bin/install -d -o root -g quantus-miner -m 0750 /etc/quantus-miner +gitea_ci ALL=(root) NOPASSWD: /usr/bin/install -d -o quantus-miner -g quantus-miner -m 0750 /var/lib/quantus-miner +gitea_ci ALL=(root) NOPASSWD: /usr/sbin/restorecon -R /usr/local/bin/quantus-miner /etc/quantus-miner /var/lib/quantus-miner +gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl daemon-reload +gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl enable quantus-miner.service +gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl restart quantus-miner.service +SUDO + chmod 0440 "$tmp" + visudo -cf "$tmp" + mv "$tmp" /etc/sudoers.d/quantus-miner_gitea_ci + echo " = sudoers quantus-miner_gitea_ci installed and visudo-verified" +REMOTE +} + +# The node's libp2p identity. It must exist BEFORE the first start — without it +# the node exits with NetworkKeyNotFound(".../network/secret_dilithium") rather +# than generating one. It is also the node's stable identity on the network, so +# it is generated once and never rotated by a deploy. +ensure_node_key() { + local host="$1" + echo "== ${host} (node key) ==" + ssh "${ADMIN_USER}@${host}" "sudo bash -seu -- '${NODE_URL}'" <<'REMOTE' + NODE_URL="$1" + + install -d -o root -g root -m 0755 /etc/sysusers.d + cat > /etc/sysusers.d/quantus-node.conf <<'SYSUSERS' +u quantus-node - "Quantus node service account" /var/lib/quantus-node /usr/sbin/nologin +SYSUSERS + systemd-sysusers + install -d -o quantus-node -g quantus-node -m 0750 /var/lib/quantus-node + + key=/var/lib/quantus-node/node_key.p2p + if [ -s "$key" ]; then + echo " = node key already present — left untouched" + else + tmp="$(mktemp -d)" + trap 'rm -rf "$tmp"' EXIT + curl -fSL --retry 5 --retry-delay 5 --retry-all-errors --connect-timeout 15 \ + -o "$tmp/node.tar.gz" "$NODE_URL" + tar xzf "$tmp/node.tar.gz" -C "$tmp" + bin="$(find "$tmp" -name quantus-node -type f | head -1)" + chmod +x "$bin" + "$bin" key generate-node-key --file "$key" + chown quantus-node:quantus-node "$key" + chmod 0600 "$key" + echo " + generated node key" + fi + # The peer id is public, and is what you look for on telemetry. + echo -n " = peer id: " + getent passwd quantus-node >/dev/null && cat /var/lib/quantus-node/node_key.p2p >/dev/null +REMOTE +} + +# --------------------------------------------------------------------- main --- + +if [[ "$MINT_REWARD_KEY" == 1 ]]; then + mint_reward_key + exit 0 +fi + +[[ -n "$PUBKEY" ]] || { echo "error: --pubkey is required" >&2; exit 2; } +[[ -s "$PUBKEY" ]] || { echo "error: pubkey not found or empty: $PUBKEY" >&2; exit 2; } + +rc=0 +for h in $NODE_HOSTS; do + provision_node "$h" || { rc=1; continue; } + ensure_node_key "$h" || rc=1 +done +for h in $MINER_HOSTS; do + provision_miner "$h" || rc=1 +done + +echo +echo "done. remaining operator steps:" +echo " 1. ./script/infra-setup.sh --mint-reward-key (if QUANTUS_INNER_HASH is unset)" +echo " 2. confirm RSYNC_SSH_KEY is set in ${GITEA_REPO} settings" +echo " 3. push to main, or run the deploy workflow manually" +exit $rc