Files
rustingface/script/infra-setup.sh
T
grenadeandClaude Opus 5 d3be7937d5
deploy / build (push) Successful in 6m22s
deploy / deploy (push) Failing after 17s
fix(deploy): stop the placeholder guard tripping over its own comment
The render step guards against shipping a config with an unrendered
{{PLACEHOLDER}} by grepping for a bare "{{". The template's own header
comment explains the substitution syntax and contains that string, so
the guard matched line 3 of every successfully rendered config and
failed the deploy with "unrendered placeholder in the config" -- while
every actual placeholder had been substituted correctly.

Matches the placeholder shape ({{UPPER_SNAKE}}) instead, so prose about
the syntax is not a false positive, and rewores the template comment to
keep it clear of the guard either way.

Also whitelists the post-deploy `doctor` invocation in the scoped
sudoers drop-in. It runs as the service account rather than root -- the
point is to prove the credentials the service will actually use can
reach and write to the bucket -- and `sudo -u rustingface` was not
covered by any existing rule, so the step would have been refused.
Verified with `sudo -l -U gitea_ci`: the rule is listed, and `sudo -u
rustingface /bin/bash` remains denied.

Found by auditing every deploy step against the installed whitelist
after run 7 failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XZG2i4AmfSqE97EJGBVb64
2026-08-31 11:30:50 +03:00

321 lines
14 KiB
Bash
Executable File

#!/usr/bin/env bash
#
# One-time host provisioning for rustingface.
#
# Run by an operator from a workstation with full sudo (not by CI, and not as
# the scoped gitea_ci account). Idempotent: re-running with nothing to change
# is a no-op. Skips past unreachable hosts so one offline node does not block
# the rest.
#
# script/infra-setup.sh # provision everything
# script/infra-setup.sh --skip-minio # leave the bucket and credentials alone
#
# What it does:
# 1. bob — gitea_ci user, runner key, scoped sudoers, SELinux port label
# 2. caveman — the rustingface bucket and a scoped MinIO service account
# 3. hanzalova— rf.internal cert, nginx vhost, step@ renewal timer
#
# Application config is NOT shipped here. The deploy workflow renders it from
# Gitea secrets on every deploy.
set -euo pipefail
readonly APP=rustingface
readonly PORT=20482
readonly SERVICE_HOST=bob.hanzalova.internal
readonly PROXY_HOST=hanzalova.internal
readonly MINIO_HOST=caveman.kosherinata.internal
readonly MINIO_URL=http://caveman.kosherinata.internal:9000
readonly BUCKET=rustingface
readonly VHOST=rf
readonly RUNNER_PUBKEY="${RUNNER_PUBKEY:-$HOME/.ssh/id_gitea_ci.pub}"
readonly PROVISIONER_PASSWORD="${PROVISIONER_PASSWORD:-$HOME/.step/secrets/provisioner}"
readonly ROOT_CA=/etc/pki/ca-trust/source/anchors/root-internal.pem
SKIP_MINIO=0
for arg in "$@"; do
case "$arg" in
--skip-minio) SKIP_MINIO=1 ;;
-h|--help) sed -n '2,22p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) echo "unknown argument: $arg" >&2; exit 2 ;;
esac
done
info() { printf '\033[1;34m==>\033[0m %s\n' "$*"; }
ok() { printf ' \033[32mok\033[0m %s\n' "$*"; }
warn() { printf ' \033[33m!!\033[0m %s\n' "$*" >&2; }
die() { printf '\033[1;31m!!\033[0m %s\n' "$*" >&2; exit 1; }
# Returns non-zero (visibly) when a host cannot be reached, so the caller can
# skip it deliberately rather than by swallowing an error.
reachable() {
local host=$1
if ssh -o BatchMode=yes -o ConnectTimeout=8 -o StrictHostKeyChecking=accept-new \
"$host" true; then
return 0
fi
warn "$host is unreachable; skipping it. Re-run this script when it is back."
return 1
}
# ---------------------------------------------------------------------------
# 1. The service host: bob
# ---------------------------------------------------------------------------
provision_service_host() {
info "service host $SERVICE_HOST"
[ -f "$RUNNER_PUBKEY" ] || die "runner public key not found at $RUNNER_PUBKEY.
The keypair is maintained at ~/.ssh/id_gitea_ci on the operator workstation and is
shared by every project's deploy. Copy it from there; do not generate a new one, or
every other project's deploy breaks."
# The gitea_ci account, its key, and journal access. Creating the user is
# idempotent via `useradd` guarded on `id`.
ssh "$SERVICE_HOST" 'sudo bash -s' <<'REMOTE'
set -euo pipefail
if id gitea_ci >/dev/null 2>&1; then
echo " gitea_ci exists"
else
useradd --system --create-home --home-dir /var/lib/gitea_ci --shell /bin/bash gitea_ci
echo " created gitea_ci"
fi
install -d -o gitea_ci -g gitea_ci -m 0700 /var/lib/gitea_ci/.ssh
usermod -aG systemd-journal gitea_ci
REMOTE
ok "gitea_ci account"
# shellcheck disable=SC2029 # the key must expand locally
ssh "$SERVICE_HOST" "sudo bash -c '
umask 077
touch /var/lib/gitea_ci/.ssh/authorized_keys
grep -qxF \"$(cat "$RUNNER_PUBKEY")\" /var/lib/gitea_ci/.ssh/authorized_keys \
|| echo \"$(cat "$RUNNER_PUBKEY")\" >> /var/lib/gitea_ci/.ssh/authorized_keys
chown -R gitea_ci:gitea_ci /var/lib/gitea_ci/.ssh
chmod 600 /var/lib/gitea_ci/.ssh/authorized_keys'"
ok "runner key installed"
# Scoped sudoers: exactly the commands the deploy runs, pinned to literal
# destinations, and verified before it can lock anyone out.
ssh "$SERVICE_HOST" 'sudo bash -s' <<REMOTE
set -euo pipefail
cat > /etc/sudoers.d/.${APP}_gitea_ci.new <<'SUDOERS'
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /usr/local/bin/${APP}
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/${APP}/config.toml
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/${APP}/s3-access-key
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/${APP}/s3-secret-key
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/${APP}/hf-token
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/systemd/system/${APP}.service
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/sysusers.d/${APP}.conf
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/firewalld/services/${APP}.xml
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemd-sysusers
gitea_ci ALL=(root) NOPASSWD: /usr/bin/install -d -o root -g ${APP} -m 0750 /etc/${APP}
gitea_ci ALL=(root) NOPASSWD: /usr/bin/chown -R root\:${APP} /etc/${APP}
gitea_ci ALL=(root) NOPASSWD: /usr/bin/chmod 0640 /etc/${APP}/config.toml /etc/${APP}/s3-access-key /etc/${APP}/s3-secret-key /etc/${APP}/hf-token
gitea_ci ALL=(root) NOPASSWD: /usr/bin/chmod 0755 /usr/local/bin/${APP}
gitea_ci ALL=(root) NOPASSWD: /usr/sbin/restorecon -R /usr/local/bin/${APP} /etc/${APP}
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl daemon-reload
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl enable ${APP}.service
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl restart ${APP}.service
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl is-active ${APP}.service
gitea_ci ALL=(root) NOPASSWD: /usr/bin/firewall-cmd --reload
gitea_ci ALL=(root) NOPASSWD: /usr/bin/firewall-cmd --get-default-zone
gitea_ci ALL=(root) NOPASSWD: /usr/bin/firewall-cmd --zone=* --query-service=${APP}
gitea_ci ALL=(root) NOPASSWD: /usr/bin/firewall-cmd --permanent --zone=* --add-service=${APP}
gitea_ci ALL=(root) NOPASSWD: /usr/bin/firewall-cmd --zone=* --add-service=${APP}
# The post-deploy health check, run as the service account rather than root:
# it proves the credentials the service will actually use can reach and write
# to the bucket, which \`systemctl is-active\` cannot.
gitea_ci ALL=(${APP}) NOPASSWD: /usr/local/bin/${APP} --config /etc/${APP}/config.toml doctor
SUDOERS
chmod 0440 /etc/sudoers.d/.${APP}_gitea_ci.new
# Verify before moving it into place: a typo in a sudoers file that is already
# installed can lock the host out of sudo entirely.
visudo -cf /etc/sudoers.d/.${APP}_gitea_ci.new
mv /etc/sudoers.d/.${APP}_gitea_ci.new /etc/sudoers.d/${APP}_gitea_ci
REMOTE
ok "scoped sudoers installed and visudo-verified"
# SELinux: the daemon cannot bind an unlabelled port. This must happen
# before the first service start, which is why it lives here and not in
# the deploy workflow.
ssh "$SERVICE_HOST" "sudo bash -s" <<REMOTE
set -euo pipefail
if semanage port -l | grep -Eq "^http_port_t .*\\b${PORT}\\b"; then
echo " tcp/${PORT} already labelled http_port_t"
else
semanage port -a -t http_port_t -p tcp ${PORT} \
|| semanage port -m -t http_port_t -p tcp ${PORT}
echo " labelled tcp/${PORT} http_port_t"
fi
REMOTE
ok "SELinux port label"
}
# ---------------------------------------------------------------------------
# 2. Object storage: caveman
# ---------------------------------------------------------------------------
provision_bucket() {
info "object storage $MINIO_HOST"
if ! ssh "$MINIO_HOST" 'command -v mc'; then
warn "mc (the MinIO client) is not installed on $MINIO_HOST.
Create the bucket and a scoped service account by hand, then re-run with --skip-minio:
mc alias set local $MINIO_URL <root-user> <root-password>
mc mb local/$BUCKET
mc admin user svcacct add local <root-user> --name rustingface
Put the resulting access key and secret in the Gitea repo secrets
S3_ACCESS_KEY_ID and S3_SECRET_ACCESS_KEY."
return 0
fi
if ssh "$MINIO_HOST" "mc --version >/dev/null && test -d /srv/minio/$BUCKET"; then
ok "bucket $BUCKET exists"
return 0
fi
warn "bucket and service-account creation needs the MinIO root credentials, which
this script deliberately does not read -- they are root-equivalent for every bucket on
that host, not just this one. Run there as an operator (architecture object-storage.md §3):
set -a; source <(sudo cat /etc/minio/minio.env); set +a
export MC_CONFIG_DIR=\$(mktemp -d); trap 'rm -rf \"\$MC_CONFIG_DIR\"' EXIT
mc alias set local http://127.0.0.1:9000 \"\$MINIO_ROOT_USER\" \"\$MINIO_ROOT_PASSWORD\"
mc mb --ignore-existing local/$BUCKET
mc admin user svcacct add local \"\$MINIO_ROOT_USER\" --name $APP \\
--access-key '$APP-<8 random>' --policy <policy scoped to $BUCKET>
then set the Gitea repo secrets S3_ACCESS_KEY_ID and S3_SECRET_ACCESS_KEY.
Re-run this script with --skip-minio once that is done."
}
# ---------------------------------------------------------------------------
# 3. The edge proxy: hanzalova
# ---------------------------------------------------------------------------
provision_proxy() {
info "edge proxy $PROXY_HOST"
local cert=/etc/nginx/tls/cert/${VHOST}.internal.pem
local key=/etc/nginx/tls/key/${VHOST}.internal.pem
# `step certificate verify` checks chain and expiry, not the name, which is
# enough to decide whether a fresh mint is needed.
local state
state=$(ssh "$PROXY_HOST" "if sudo test -f $cert && sudo step certificate verify $cert --roots $ROOT_CA; then echo valid; else echo missing; fi" \
| tail -1)
if [ "$state" != valid ]; then
[ -f "$PROVISIONER_PASSWORD" ] \
|| die "provisioner password not found at $PROVISIONER_PASSWORD; cannot mint ${VHOST}.internal"
rsync -az --rsync-path='sudo rsync' --chmod=0600 \
"$PROVISIONER_PASSWORD" "$PROXY_HOST:/tmp/${VHOST}-provisioner"
# Remove the credential whether or not the mint succeeds, then
# propagate the failure.
ssh "$PROXY_HOST" "
set -euo pipefail
sudo mkdir -p /etc/nginx/tls/cert /etc/nginx/tls/key
rc=0
sudo step ca certificate --force \
--provisioner lair \
--provisioner-password-file /tmp/${VHOST}-provisioner \
--ca-url https://ca.internal \
--root $ROOT_CA \
--san ${VHOST}.internal \
${VHOST}.internal $cert $key || rc=\$?
sudo rm -f /tmp/${VHOST}-provisioner
[ \$rc -eq 0 ] || { echo 'minting ${VHOST}.internal failed' >&2; exit \$rc; }
sudo chown root:root $cert $key
sudo chmod 0644 $cert
sudo chmod 0640 $key
sudo setfacl -m u:nginx:r $key"
ok "minted ${VHOST}.internal"
else
ok "${VHOST}.internal cert is present and valid"
fi
ssh "$PROXY_HOST" "sudo systemctl enable --now step@${VHOST}.timer"
ok "renewal timer enabled"
# The vhost references the cert paths, so it is only installed after the
# cert exists: nginx -t fails on a missing ssl_certificate and that blocks
# every unrelated reload on the host.
rsync -az --rsync-path='sudo rsync' --mkpath \
asset/nginx/${VHOST}.internal.conf \
"$PROXY_HOST:/etc/nginx/sites-available/${VHOST}.internal.conf"
ssh "$PROXY_HOST" "
set -euo pipefail
sudo ln -sfn ../sites-available/${VHOST}.internal.conf \
/etc/nginx/sites-enabled/${VHOST}.internal.conf
sudo nginx -t
sudo systemctl reload nginx"
ok "vhost installed and nginx reloaded"
# A reload is a request, not a guarantee: nginx keeps its old cycle if it
# cannot rebind, and then serves the certificate it loaded then. Compare
# what is actually on the wire against what is on disk, by serial.
#
# Old workers finish their in-flight connections before exiting, so for a
# second or two after a reload either cycle may answer. Retry rather than
# report a mismatch that is really just the handover in progress.
local served disk
disk=$(ssh "$PROXY_HOST" "sudo openssl x509 -noout -serial -in $cert")
for _ in $(seq 1 10); do
served=$(echo | openssl s_client -servername ${VHOST}.internal -connect ${PROXY_HOST}:443 2>/dev/null \
| openssl x509 -noout -serial || echo "serial=unavailable")
[ "$served" = "$disk" ] && break
sleep 2
done
if [ "$served" = "$disk" ]; then
ok "the certificate on the wire matches the one on disk"
else
warn "nginx is serving a different certificate than the one on disk
wire: $served
disk: $disk
A reload was requested and reported success, but nginx kept its previous cycle.
With 24h certs this puts an expired certificate on the wire within a day.
Investigate the bind failure in the nginx error log, then restart nginx."
fi
info "DNS"
if getent hosts "${VHOST}.internal" >/dev/null; then
ok "${VHOST}.internal resolves"
return 0
fi
echo " Add the split-horizon record on both routers:"
echo " opn-cli --config ~/.opn-cli/hanzalova.yml unbound host create \\"
echo " --hostname ${VHOST} --domain internal --server 10.6.0.46 \\"
echo " --description 'rustingface via hanzalova reverse proxy (bob backend)'"
echo " opn-cli --config ~/.opn-cli/kosherinata.yml unbound host create \\"
echo " --hostname ${VHOST} --domain internal --server 10.6.0.46 \\"
echo " --description 'rustingface via hanzalova reverse proxy (bob backend)'"
echo " Never give ${VHOST}.internal a public / Cloudflare record."
}
# ---------------------------------------------------------------------------
main() {
cd "$(dirname "$0")/.."
if reachable "$SERVICE_HOST"; then
provision_service_host
fi
if [ "$SKIP_MINIO" -eq 0 ] && reachable "$MINIO_HOST"; then
provision_bucket
fi
if reachable "$PROXY_HOST"; then
provision_proxy
fi
info "done"
echo " Next: set the Gitea repo secrets (RSYNC_SSH_KEY, S3_ACCESS_KEY_ID,"
echo " S3_SECRET_ACCESS_KEY, HF_TOKEN) and push to main to deploy."
}
main "$@"