feat: deploy a quantus planck node and external gpu miner

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.
This commit is contained in:
Rob Thijssen
2026-08-31 16:55:05 +03:00
commit e3dfd73f2f
13 changed files with 1141 additions and 0 deletions
+402
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
# no rendered config, ever (architecture generic.md §6)
asset/config/*.env
!asset/config/*.tmpl
_bin/
*.tar.gz
+75
View File
@@ -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.
+6
View File
@@ -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}}
+8
View File
@@ -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}}
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<service>
<short>quantus-node-miner</short>
<description>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.</description>
<port protocol="udp" port="9833"/>
</service>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<service>
<short>quantus-node</short>
<description>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.</description>
<port protocol="tcp" port="30333"/>
</service>
+64
View File
@@ -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
@@ -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
+74
View File
@@ -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
+2
View File
@@ -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
+161
View File
@@ -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 — <https://github.com/Quantus-Network/chain> (`MINING.md` is the real doc)
- miner — <https://github.com/Quantus-Network/quantus-miner>
- guide — <https://docs.quantus.com/guides/mining>
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.
+322
View File
@@ -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 <phrase>` 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 <unit> 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 <path> 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