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.
320 lines
14 KiB
Bash
Executable File
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
|