Files
quantus/script/infra-setup.sh
T
Rob Thijssen 4b88d55449 fix: take the derived inner_hash, not a seed phrase
Neither the node nor the miner needs a mnemonic. The node needs only the
32-byte preimage, from which it derives the reward address; the mining
protocol carries no reward address at all, so the miner cannot know it.

Deriving an inner_hash from a wallet is a one-time offline step and has
no business happening on the deploy workstation. infra-setup.sh now
accepts only the inner_hash, validates it is 0x + 64 hex, and refuses a
mnemonic outright. quantus-node itself refuses --words/--seed as argv
for the same reason: argv is world-readable and recorded in shell
history and audit logs.

This drops the seed phrase, the temporary binary download and the local
key derivation from the provisioning path entirely.
2026-08-31 17:01:48 +03:00

320 lines
14 KiB
Bash
Executable File

#!/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 an inner_hash 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 node needs ONE thing to be paid: the 32-byte wormhole preimage
# (`--rewards-inner-hash`). It derives the reward address from that by Poseidon
# hashing and logs the address on startup. It never sees, needs, or stores a
# seed phrase. The miner needs even less — the mining protocol carries only
# {job_id, mining_hash, difficulty} out and {nonce, work, hash_count} back, so
# it has no knowledge of the payout address at all.
#
# Deriving an inner_hash that belongs to an existing wallet is therefore a
# ONE-TIME OFFLINE step that deliberately does not happen here. Run it wherever
# you already trust with the seed — the wallet app, or an airgapped box:
#
# quantus-node key quantus --scheme wormhole --words < mnemonic.txt
#
# (the binary refuses --words/--seed as argv, because argv is world-readable and
# lands in shell history and audit logs — same reason this script won't take it).
#
# This function accepts only the derived inner_hash, on stdin, and writes it
# straight to the Gitea Actions secret. The seed phrase never reaches this
# workstation, so nothing here is escalation-grade (agent-credentials.md §1).
mint_reward_key() {
command -v pass >/dev/null || { echo "error: 'pass' not found; needed to read the Gitea secret-writer token" >&2; exit 1; }
echo "Paste the wormhole inner_hash (0x + 64 hex), then press Enter."
echo "Derive it with: quantus-node key quantus --scheme wormhole --words < mnemonic.txt"
echo "Do NOT paste a seed phrase — nothing here needs one."
local inner
IFS= read -r inner
inner="${inner//[[:space:]]/}"
# Refuse a mnemonic outright rather than shipping it to a secret store.
case "$inner" in
*[!0-9a-fA-Fx]*)
echo "error: that is not a hex inner_hash. If you pasted a seed phrase, stop:" >&2
echo " derive the inner_hash first and paste only that." >&2
exit 1 ;;
esac
[[ "$inner" =~ ^0x[0-9a-fA-F]{64}$ ]] || {
echo "error: expected 0x + 64 hex characters, got ${#inner} characters" >&2
exit 1
}
echo "inner_hash accepted (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 body code
body="$(printf '%s' "$inner" | python3 -c 'import json,sys; print(json.dumps({"data": sys.stdin.read()}))')"
code="$(printf '%s' "$body" | curl -sS -o /dev/null -w '%{http_code}' -X PUT \
-H "Authorization: token ${token}" \
-H 'Content-Type: application/json' \
--data-binary @- \
"${GITEA_API}/repos/${GITEA_REPO}/actions/secrets/QUANTUS_INNER_HASH")"
unset inner token body
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