#!/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 # metrics (--metrics-hosts) the fleet Prometheus/Grafana host: a scrape # config drop-in and a provisioned dashboard # -> deploy.yaml deploy-metrics # # 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 # # 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}" METRICS_HOSTS="${METRICS_HOSTS-golgafrinchans.kosherinata.internal}" PUBKEY="${PUBKEY:-${HOME}/.ssh/id_gitea_ci.pub}" CHAIN="${CHAIN:-planck}" NODE_VERSION="${NODE_VERSION:-0.10.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 ;; --metrics-hosts) METRICS_HOSTS="$2"; shift 2 ;; --admin) ADMIN_USER="$2"; shift 2 ;; --node-version) NODE_VERSION="$2"; shift 2 ;; *) 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" # ------------------------------------------------------------------- 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" # /bin/bash, NOT nologin. The deploy runs `ssh gitea_ci@host `, and # a nologin shell refuses that with "This account is currently not # available": the key authenticates, then the command cannot run. Every # pre-existing gitea_ci on this fleet has bash, so a nologin one is a bug — # repair it rather than leaving a host that can never be deployed to. if ! getent passwd gitea_ci >/dev/null; then useradd --system --create-home --home-dir /var/lib/gitea_ci \ --shell /bin/bash gitea_ci echo " + created gitea_ci" else echo " = gitea_ci already present" fi cur=$(getent passwd gitea_ci | cut -d: -f7) if [ "$cur" != /bin/bash ]; then usermod -s /bin/bash gitea_ci echo " ~ gitea_ci shell was ${cur} — set to /bin/bash so ssh commands can run" 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/rsync * /etc/firewalld/services/quantus-node-metrics.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" # /bin/bash, NOT nologin. The deploy runs `ssh gitea_ci@host `, and # a nologin shell refuses that with "This account is currently not # available": the key authenticates, then the command cannot run. Every # pre-existing gitea_ci on this fleet has bash, so a nologin one is a bug — # repair it rather than leaving a host that can never be deployed to. if ! getent passwd gitea_ci >/dev/null; then useradd --system --create-home --home-dir /var/lib/gitea_ci \ --shell /bin/bash gitea_ci echo " + created gitea_ci" else echo " = gitea_ci already present" fi cur=$(getent passwd gitea_ci | cut -d: -f7) if [ "$cur" != /bin/bash ]; then usermod -s /bin/bash gitea_ci echo " ~ gitea_ci shell was ${cur} — set to /bin/bash so ssh commands can run" 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/rsync * /etc/firewalld/services/quantus-miner-metrics.xml 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-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/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 fleet Prometheus/Grafana host. Two of the things this does are edits to # SHARED config that other projects also rely on, which is exactly why they live # here (operator-run, one-time) and not in the deploy workflow: # # * prometheus.yml gains `scrape_config_files:` so every project can drop its # own scrape config into scrape_configs.d/ instead of all of them editing one # shared file and racing each other. # * grafana.container gains a dashboards bind mount — it currently mounts only # the datasource file, so there is nowhere for a provisioned dashboard to go. # # Both are additive, guarded, and back up what they touch. provision_metrics() { local host="$1" echo "== ${host} (metrics) ==" 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" # /bin/bash, NOT nologin. The deploy runs `ssh gitea_ci@host `, and # a nologin shell refuses that with "This account is currently not # available": the key authenticates, then the command cannot run. Every # pre-existing gitea_ci on this fleet has bash, so a nologin one is a bug — # repair it rather than leaving a host that can never be deployed to. if ! getent passwd gitea_ci >/dev/null; then useradd --system --create-home --home-dir /var/lib/gitea_ci \ --shell /bin/bash gitea_ci echo " + created gitea_ci" else echo " = gitea_ci already present" fi cur=$(getent passwd gitea_ci | cut -d: -f7) if [ "$cur" != /bin/bash ]; then usermod -s /bin/bash gitea_ci echo " ~ gitea_ci shell was ${cur} — set to /bin/bash so ssh commands can run" 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 install -d -m 0755 /etc/prometheus/scrape_configs.d install -d -m 0755 /etc/grafana/provisioning/dashboards/quantus # 1. include drop-in scrape configs if grep -q '^scrape_config_files:' /etc/prometheus/prometheus.yml; then echo " = prometheus.yml already includes scrape_configs.d" else cp -a /etc/prometheus/prometheus.yml \ "/etc/prometheus/prometheus.yml.bak.$(date +%s)" cat >> /etc/prometheus/prometheus.yml <<'PROM' # Per-project scrape configs. Each project ships its own file here rather than # every project editing this one. Added by lair/quantus script/infra-setup.sh. scrape_config_files: - /etc/prometheus/scrape_configs.d/*.yml PROM echo " + prometheus.yml now includes scrape_configs.d" curl -fsS -X POST http://127.0.0.1:26559/-/reload && echo " = prometheus reloaded" fi # 2. give Grafana somewhere to read provisioned dashboards from q=/etc/containers/systemd/grafana.container if grep -q 'provisioning/dashboards:' "$q"; then echo " = grafana.container already mounts the dashboards dir" else cp -a "$q" "${q}.bak.$(date +%s)" # Insert alongside the existing Volume= lines, before [Service]. sed -i 's#^\(Volume=/etc/grafana/provisioning/datasources.*\)$#\1\nVolume=/etc/grafana/provisioning/dashboards:/etc/grafana/provisioning/dashboards:Z,ro#' "$q" grep -q 'provisioning/dashboards:' "$q" || { echo " ! failed to add the mount" >&2; exit 1; } systemctl daemon-reload systemctl restart grafana.service echo " + grafana.container mounts dashboards; grafana restarted" fi tmp=/etc/sudoers.d/.quantus-metrics_gitea_ci.tmp cat > "$tmp" <<'SUDO' gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/prometheus/scrape_configs.d/quantus.yml gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/grafana/provisioning/dashboards/quantus/quantus.json gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/grafana/provisioning/dashboards/quantus-dashboards.yaml gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl restart grafana.service SUDO chmod 0440 "$tmp" visudo -cf "$tmp" mv "$tmp" /etc/sudoers.d/quantus-metrics_gitea_ci echo " = sudoers quantus-metrics_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 --- [[ -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 for h in $METRICS_HOSTS; do provision_metrics "$h" || rc=1 done echo echo "done. remaining operator steps:" echo " 1. set QUANTUS_INNER_HASH in .gitea/workflows/deploy.yaml (it is public config," echo " not a secret — the node publishes it in every block header)" echo " 2. confirm RSYNC_SSH_KEY is set in the repo settings" echo " 3. push to main, or run the deploy workflow manually" exit $rc