Files
newsfeed/script/infra-setup.sh
rob thijssen 81fe5f7d4d
All checks were successful
deploy / build-web (push) Successful in 1m34s
deploy / deploy-web (push) Successful in 4s
deploy / build-api (push) Successful in 6m17s
deploy / deploy-api (push) Successful in 10s
fix: move newsfeed-api off 8081 to 22672 (conflict-unlikely port)
8081 is the alt-HTTP port — one of the crowded defaults every proxy/dev server
grabs — so it's collision-prone on a shared host. Move to 22672, a registered-
range port in the sparse 20000s band, derived deterministically from the
service name and recorded in architecture port-allocations.md.

Updated everywhere the number appears: api bind config + default, firewalld
service XML, nginx upstream, vite dev proxy, deploy workflow API_PORT,
infra-setup (SELinux semanage label), and docs.

Operator note: re-run script/infra-setup.sh on slartibartfast to relabel the
SELinux port (22672) and ship the updated firewalld XML before the next deploy
restarts the api on the new port.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fKZzDpvjiJ9eYbPGgJvUP
2026-07-08 12:25:26 +03:00

275 lines
12 KiB
Bash
Executable File

#!/usr/bin/env bash
#
# One-time host provisioning for newsfeed's CI-driven deploy (architecture
# deployment-gitea-actions.md §2). Run by the operator from a workstation with full sudo
# and mesh ssh access — NOT by the CI runner. Idempotent; skips unreachable hosts.
#
# It provisions, per host:
# * the `gitea_ci` deploy user + the runner's authorized_key + systemd-journal group
# * a scoped /etc/sudoers.d/newsfeed_gitea_ci drop-in (visudo-verified)
# and per role:
# * API host (slartibartfast): the `newsfeed` service account, /etc/newsfeed +
# /var/lib/newsfeed, SELinux port label for 22672, firewalld service
# * WEB host (oolon): /var/www/newsfeed webroot, the internal `newsfeed.internal` TLS
# cert (lair provisioner) + step@newsfeed renewal, and the nginx vhost
#
# Usage: ./script/infra-setup.sh [--dry-run]
set -euo pipefail
# --- infra truth (edit here if the topology changes) -------------------------------
API_HOST="slartibartfast.kosherinata.internal" # newsfeed-api + newsfeed-worker
WEB_HOST="oolon.kosherinata.internal" # nginx: SPA + API reverse proxy
API_PORT="22672"
CERT_NAME="newsfeed" # dot-free; serves ${CERT_NAME}.internal
PUBLIC_FQDN="rob.fyi" # WAN name (Let's Encrypt); "" to skip
CERT_EMAIL="ops@rob.fyi" # certbot registration email
CF_CREDS="/root/.certbot-internal" # Cloudflare API token on the web host
RUNNER_PUBKEY="${HOME}/.ssh/id_gitea_ci.pub" # runner key distributed to gitea_ci
PROVISIONER_PW="${HOME}/.step/secrets/provisioner"
ROOT_CA="/etc/pki/ca-trust/source/anchors/root-internal.pem"
CA_URL="https://ca.internal"
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
DRY_RUN=0
[[ "${1:-}" == "--dry-run" ]] && DRY_RUN=1
info() { printf '\033[1;34m==>\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33mWARN\033[0m %s\n' "$*" >&2; }
run_remote() {
# run_remote <host> <script>
local host="$1" script="$2"
if [[ "$DRY_RUN" == 1 ]]; then
printf -- '--- would run on %s ---\n%s\n' "$host" "$script"
return 0
fi
ssh -o StrictHostKeyChecking=accept-new "$host" "sudo bash -euo pipefail -s" <<<"$script"
}
reachable() {
ssh -o StrictHostKeyChecking=accept-new -o ConnectTimeout=8 "$1" true 2>/dev/null
}
require_files() {
[[ -f "$RUNNER_PUBKEY" ]] || { warn "runner pubkey $RUNNER_PUBKEY missing (ssh-keygen -t ed25519 -f ~/.ssh/id_gitea_ci)"; exit 1; }
}
# --- gitea_ci user, key, journal group, sudoers (both hosts) ------------------------
provision_gitea_ci() {
local host="$1" sudoers="$2"
local pubkey; pubkey="$(cat "$RUNNER_PUBKEY")"
info "[$host] provisioning gitea_ci deploy user"
run_remote "$host" "
id gitea_ci >/dev/null 2>&1 || useradd --system --create-home --home-dir /var/lib/gitea_ci --shell /usr/sbin/nologin gitea_ci
usermod -aG systemd-journal gitea_ci
install -d -m 0700 -o gitea_ci -g gitea_ci /var/lib/gitea_ci/.ssh
grep -qF '${pubkey}' /var/lib/gitea_ci/.ssh/authorized_keys 2>/dev/null || echo '${pubkey}' >> /var/lib/gitea_ci/.ssh/authorized_keys
chown gitea_ci:gitea_ci /var/lib/gitea_ci/.ssh/authorized_keys
chmod 0600 /var/lib/gitea_ci/.ssh/authorized_keys
umask 022
cat > /etc/sudoers.d/newsfeed_gitea_ci <<'SUDOERS'
${sudoers}
SUDOERS
chmod 0440 /etc/sudoers.d/newsfeed_gitea_ci
visudo -cf /etc/sudoers.d/newsfeed_gitea_ci
"
}
API_SUDOERS='# Scoped deploy permissions for newsfeed api+worker on this host.
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /usr/local/bin/newsfeed-api
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /usr/local/bin/newsfeed-worker
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/newsfeed/config.toml
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/newsfeed/worker.toml
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl daemon-reload
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl restart newsfeed-api.service
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl restart newsfeed-worker.service
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl is-active newsfeed-api.service
gitea_ci ALL=(root) NOPASSWD: /usr/sbin/restorecon -R /usr/local/bin/newsfeed-api /usr/local/bin/newsfeed-worker /etc/newsfeed /var/lib/newsfeed'
WEB_SUDOERS='# Scoped deploy permissions for the newsfeed SPA on the oolon proxy.
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /var/www/newsfeed/
gitea_ci ALL=(root) NOPASSWD: /usr/sbin/restorecon -R /var/www/newsfeed
gitea_ci ALL=(root) NOPASSWD: /usr/sbin/nginx -t
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl reload nginx.service'
# --- API host: service account, dirs, SELinux port, firewalld -----------------------
provision_api_host() {
local host="$API_HOST"
if ! reachable "$host"; then warn "[$host] unreachable, skipping"; return 0; fi
provision_gitea_ci "$host" "$API_SUDOERS"
info "[$host] service account, directories, SELinux, firewalld"
local sysusers firewalld
sysusers="$(cat "$REPO_ROOT/asset/systemd/newsfeed.sysusers.conf")"
firewalld="$(cat "$REPO_ROOT/asset/firewalld/newsfeed-api.xml")"
run_remote "$host" "
# service account
cat > /etc/sysusers.d/newsfeed.conf <<'SYSU'
${sysusers}
SYSU
systemd-sysusers /etc/sysusers.d/newsfeed.conf
# directories (config: root-owned, readable by service; state: service-owned)
install -d -m 0750 -o root -g newsfeed /etc/newsfeed
install -d -m 0750 -o newsfeed -g newsfeed /var/lib/newsfeed
install -d -m 0755 -o root -g root /usr/local/bin
restorecon -R /etc/newsfeed /var/lib/newsfeed
# SELinux: label the API port so the daemon may bind it
semanage port -l | grep -qE '^http_port_t.*[[:space:]]${API_PORT}([,[:space:]]|\$)' || semanage port -a -t http_port_t -p tcp ${API_PORT}
# firewalld named service (mesh-reachable API port)
cat > /etc/firewalld/services/newsfeed-api.xml <<'FWD'
${firewalld}
FWD
firewall-cmd --reload
zone=\$(firewall-cmd --get-default-zone)
firewall-cmd --permanent --zone=\$zone --query-service=newsfeed-api >/dev/null 2>&1 || firewall-cmd --permanent --zone=\$zone --add-service=newsfeed-api
firewall-cmd --zone=\$zone --query-service=newsfeed-api >/dev/null 2>&1 || firewall-cmd --zone=\$zone --add-service=newsfeed-api
"
info "[$host] installing systemd units"
for unit in newsfeed-api.service newsfeed-worker.service; do
local content; content="$(cat "$REPO_ROOT/asset/systemd/$unit")"
run_remote "$host" "cat > /etc/systemd/system/$unit <<'UNIT'
${content}
UNIT
systemctl daemon-reload
systemctl enable $unit"
done
}
# --- WEB host (oolon): webroot, internal cert, nginx vhost --------------------------
provision_web_host() {
local host="$WEB_HOST"
if ! reachable "$host"; then warn "[$host] unreachable, skipping"; return 0; fi
provision_gitea_ci "$host" "$WEB_SUDOERS"
info "[$host] webroot"
run_remote "$host" "
install -d -m 0755 -o nginx -g nginx /var/www/newsfeed
# placeholder so nginx -t passes before the first SPA deploy
[ -f /var/www/newsfeed/index.html ] || echo '<!doctype html><title>newsfeed</title>' > /var/www/newsfeed/index.html
restorecon -R /var/www/newsfeed
"
mint_internal_cert "$host"
info "[$host] nginx vhost (newsfeed.internal)"
local vhost; vhost="$(cat "$REPO_ROOT/asset/nginx/newsfeed.internal.conf")"
run_remote "$host" "
install -d -m 0755 /etc/nginx/sites-available /etc/nginx/sites-enabled
cat > /etc/nginx/sites-available/newsfeed.internal.conf <<'VHOST'
${vhost}
VHOST
ln -sfn ../sites-available/newsfeed.internal.conf /etc/nginx/sites-enabled/newsfeed.internal.conf
nginx -t
systemctl reload nginx
"
provision_public_vhost "$host"
}
# Issue the public Let's Encrypt cert for $PUBLIC_FQDN (idempotent) and install the WAN
# vhost, gated on the cert existing (external-tls.md). Skips cleanly if PUBLIC_FQDN is
# unset or the Cloudflare credential isn't on the host.
provision_public_vhost() {
local host="$1"
[[ -n "$PUBLIC_FQDN" ]] || { info "[$host] PUBLIC_FQDN unset; skipping WAN vhost"; return 0; }
if [[ "$DRY_RUN" == 1 ]]; then info "[$host] would issue LE cert + install ${PUBLIC_FQDN} vhost"; return 0; fi
info "[$host] issuing Let's Encrypt cert for ${PUBLIC_FQDN} (certbot, Cloudflare DNS-01)"
# --keep-until-expiring makes this a no-op when the cert is still valid, so it is safe
# to run unconditionally. DNS-01 needs no inbound :80 and works even before the WAN A
# record / OPNsense forward exist. Requires the Cloudflare token at $CF_CREDS.
run_remote "$host" "
if [ ! -f '$CF_CREDS' ]; then
echo 'WARN: $CF_CREDS missing; cannot issue ${PUBLIC_FQDN} cert' >&2
exit 0
fi
command -v certbot >/dev/null || { echo 'WARN: certbot not installed' >&2; exit 0; }
certbot certonly \
-m '$CERT_EMAIL' --agree-tos --no-eff-email --noninteractive \
--cert-name '$PUBLIC_FQDN' \
--key-type ecdsa \
--dns-cloudflare \
--dns-cloudflare-credentials '$CF_CREDS' \
--dns-cloudflare-propagation-seconds 60 \
--keep-until-expiring \
-d '$PUBLIC_FQDN'
# nginx reload after future renewals (external-tls.md §3), once per host.
install -d -m 0755 /etc/letsencrypt/renewal-hooks/deploy
cat > /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh <<'HOOK'
#!/bin/sh
systemctl reload nginx 2>/dev/null || true
HOOK
chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
"
info "[$host] installing WAN vhost (${PUBLIC_FQDN})"
local vhost; vhost="$(cat "$REPO_ROOT/asset/nginx/newsfeed.public.conf")"
# Gate the vhost on the cert existing — nginx -t fails on a missing ssl_certificate and
# would block ALL of nginx from reloading (external-tls.md §4).
run_remote "$host" "
if ! test -d /etc/letsencrypt/live/${PUBLIC_FQDN}; then
echo 'NOTE: ${PUBLIC_FQDN} cert not present; skipping WAN vhost install' >&2
exit 0
fi
cat > /etc/nginx/sites-available/newsfeed.public.conf <<'VHOST'
${vhost}
VHOST
ln -sfn ../sites-available/newsfeed.public.conf /etc/nginx/sites-enabled/newsfeed.public.conf
nginx -t
systemctl reload nginx
"
info "[$host] ${PUBLIC_FQDN} served. Ensure the Cloudflare A record ${PUBLIC_FQDN} ->"
info " kosherinata WAN IP exists (unproxied) and OPNsense forwards :443 -> oolon"
info " (reverse-proxies.md §2)."
}
# Mint the first internal cert (idempotent) and enable renewal (internal-tls.md §4).
mint_internal_cert() {
local host="$1"
local cert="/etc/nginx/tls/cert/${CERT_NAME}.internal.pem"
local key="/etc/nginx/tls/key/${CERT_NAME}.internal.pem"
if [[ "$DRY_RUN" == 1 ]]; then info "[$host] would mint ${CERT_NAME}.internal cert"; return 0; fi
[[ -f "$PROVISIONER_PW" ]] || { warn "[$host] provisioner password $PROVISIONER_PW missing; skipping cert mint"; return 0; }
local state
state="$(ssh "$host" "[ -f $cert ] && step certificate verify $cert --roots $ROOT_CA >/dev/null 2>&1 && echo valid || echo missing")"
if [[ "$state" != valid ]]; then
info "[$host] minting ${CERT_NAME}.internal cert via lair provisioner"
rsync -az --rsync-path='sudo rsync' --chmod=0600 "$PROVISIONER_PW" "$host:/tmp/${CERT_NAME}-provisioner"
run_remote "$host" "
mkdir -p /etc/nginx/tls/cert /etc/nginx/tls/key
rc=0
step ca certificate --force \
--provisioner lair \
--provisioner-password-file /tmp/${CERT_NAME}-provisioner \
--ca-url $CA_URL --root $ROOT_CA \
--san ${CERT_NAME}.internal \
${CERT_NAME}.internal $cert $key || rc=\$?
rm -f /tmp/${CERT_NAME}-provisioner
[ \$rc -eq 0 ] || { echo 'cert mint failed' >&2; exit \$rc; }
chown root:root $cert $key
chmod 644 $cert; chmod 640 $key
setfacl -m u:nginx:r $key
"
else
info "[$host] ${CERT_NAME}.internal cert already valid"
fi
run_remote "$host" "systemctl enable --now step@${CERT_NAME}.timer"
}
main() {
require_files
info "newsfeed infra-setup (api=$API_HOST web=$WEB_HOST)$([[ $DRY_RUN == 1 ]] && echo ' [dry-run]')"
provision_api_host
provision_web_host
info "done"
}
main "$@"