Files
pyre/scripts/phase0-provision.sh
RogueWave 571e5d04d2 feat(infra): Phase 0 provisioning + dev status dashboard
- scripts/phase0-provision.sh: idempotent root setup (nginx, PostgreSQL,
  Redis, certbot/TLS, UFW). Opens 22/2222/80/443 before enabling UFW so SSH
  and Gitea git-SSH can't be locked out. Redis/Postgres stay localhost-only.
- infra/nginx/feedthepyre.com.conf: vhost serving the status page; commented
  web(:3000)/api(:4000) reverse-proxy blocks ready for app deploy.
- infra/status/: data-driven dev status dashboard (status.json + gen-status.mjs
  + prebuilt index.html), served at feedthepyre.com.
- ecosystem.config.cjs (PM2), infra/systemd/pm2-pyre.service, infra/logrotate/pyre,
  scripts/backup.sh — process mgmt + ops (inert until apps are built).

Built by 4 parallel agents, reviewed by 2 audit agents; audit fixes applied
(logs dir creation, port-citation accuracy, status truthfulness). pm2 installed
user-level. Privileged steps gated on `sudo bash scripts/phase0-provision.sh`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 02:34:13 +00:00

395 lines
16 KiB
Bash
Executable File

#!/usr/bin/env bash
# =============================================================================
# PYRE — Phase 0 root provisioning script (Ubuntu 24.04)
# =============================================================================
# Idempotent, re-runnable. Provisions the system services PYRE needs:
# nginx, PostgreSQL, Redis, certbot (TLS), UFW firewall, systemd unit for
# pm2, logrotate, and the public status page.
#
# This script ONLY touches system-level config. It does NOT install Node.js,
# pnpm, the repo, or app .env files (those are handled elsewhere / by the
# pyre user). It must be run as root.
#
# Usage: sudo bash scripts/phase0-provision.sh
#
# Overridable environment variables:
# PYRE_DB_PASSWORD Postgres password for role 'pyre' (default: "pyre")
# CERTBOT_EMAIL email for Let's Encrypt (default below)
#
# Server base setup (pyre user, SSH key auth, root disabled, Fail2ban) is
# assumed ALREADY DONE per design doc §12/§19. DNS for feedthepyre.com and
# www.feedthepyre.com must already point at this box (required for TLS step).
# =============================================================================
set -euo pipefail
export DEBIAN_FRONTEND=noninteractive
# -----------------------------------------------------------------------------
# Path / value contracts — other agents depend on these EXACT values.
# -----------------------------------------------------------------------------
PYRE_USER="pyre"
REPO_DIR="/home/pyre/pyre"
# Postgres — matches DATABASE_URL=postgresql://pyre:pyre@localhost:5432/pyre
PYRE_DB_ROLE="pyre"
PYRE_DB_NAME="pyre"
PYRE_DB_PASSWORD="${PYRE_DB_PASSWORD:-pyre}" # dev-default; flagged loudly below
# Domain / TLS
DOMAIN="feedthepyre.com"
WWW_DOMAIN="www.feedthepyre.com"
CERTBOT_EMAIL="${CERTBOT_EMAIL:-a31s15.roguewave@gmail.com}"
# nginx vhost
NGINX_VHOST_SRC="${REPO_DIR}/infra/nginx/${DOMAIN}.conf"
NGINX_VHOST_AVAIL="/etc/nginx/sites-available/${DOMAIN}"
NGINX_VHOST_ENABLED="/etc/nginx/sites-enabled/${DOMAIN}"
NGINX_DEFAULT_ENABLED="/etc/nginx/sites-enabled/default"
# Status page
STATUS_SRC_DIR="${REPO_DIR}/infra/status"
STATUS_WEBROOT="/var/www/feedthepyre/status"
STATUS_WEBROOT_PARENT="/var/www/feedthepyre"
# systemd / logrotate sources
SYSTEMD_UNIT_SRC="${REPO_DIR}/infra/systemd/pm2-pyre.service"
SYSTEMD_UNIT_DST="/etc/systemd/system/pm2-pyre.service"
LOGROTATE_SRC="${REPO_DIR}/infra/logrotate/pyre"
LOGROTATE_DST="/etc/logrotate.d/pyre"
REDIS_CONF="/etc/redis/redis.conf"
# -----------------------------------------------------------------------------
# Helpers
# -----------------------------------------------------------------------------
section() { printf '\n\033[1;36m==> %s\033[0m\n' "$*"; }
info() { printf ' %s\n' "$*"; }
warn() { printf '\033[1;33m[WARN] %s\033[0m\n' "$*" >&2; }
err() { printf '\033[1;31m[ERROR] %s\033[0m\n' "$*" >&2; }
# -----------------------------------------------------------------------------
# Root check — re-exec under sudo if possible, else fail with instructions.
# -----------------------------------------------------------------------------
require_root() {
if [[ "${EUID}" -eq 0 ]]; then
return 0
fi
if command -v sudo >/dev/null 2>&1; then
warn "Not running as root — re-executing under sudo (you may be prompted for a password)..."
exec sudo -E bash "$0" "$@"
fi
err "This script must be run as root, and 'sudo' was not found."
err "Re-run as: sudo bash ${0}"
exit 1
}
# -----------------------------------------------------------------------------
# 1. APT update + install base packages (apt install is idempotent).
# -----------------------------------------------------------------------------
step_apt() {
section "1/10 apt-get update + install base packages"
apt-get update -y
apt-get install -y \
nginx \
postgresql \
postgresql-contrib \
redis-server \
certbot \
python3-certbot-nginx \
ufw
info "Base packages installed (nginx, postgresql(+contrib), redis-server, certbot, python3-certbot-nginx, ufw)."
}
# -----------------------------------------------------------------------------
# 2. Enable + start postgresql and redis-server.
# -----------------------------------------------------------------------------
step_services() {
section "2/10 Enable + start postgresql and redis-server"
systemctl enable --now postgresql
systemctl enable --now redis-server
info "postgresql: $(systemctl is-active postgresql 2>/dev/null || true)"
info "redis-server: $(systemctl is-active redis-server 2>/dev/null || true)"
}
# -----------------------------------------------------------------------------
# 3. Postgres role + database (idempotent; localhost-only, do NOT change
# listen_addresses).
# -----------------------------------------------------------------------------
step_postgres() {
section "3/10 PostgreSQL role + database"
if [[ "${PYRE_DB_PASSWORD}" == "pyre" ]]; then
warn "PYRE_DB_PASSWORD is the dev DEFAULT ('pyre'). This is fine for local/dev,"
warn "but set a real password for production: PYRE_DB_PASSWORD=... sudo -E bash $0"
fi
# Create role only if absent. LOGIN, no CREATEDB (db is created here as owner).
local role_exists
role_exists="$(sudo -u postgres psql -tAc \
"SELECT 1 FROM pg_roles WHERE rolname='${PYRE_DB_ROLE}'" || true)"
if [[ "${role_exists}" == "1" ]]; then
info "Role '${PYRE_DB_ROLE}' already exists — leaving it as-is (not changing password)."
else
info "Creating role '${PYRE_DB_ROLE}' (LOGIN, NOCREATEDB)."
# Password is passed via a parameter to avoid SQL-injection / quoting issues.
sudo -u postgres psql -v ON_ERROR_STOP=1 \
-v pw="${PYRE_DB_PASSWORD}" <<'SQL'
CREATE ROLE pyre WITH LOGIN NOCREATEDB NOSUPERUSER NOCREATEROLE PASSWORD :'pw';
SQL
fi
# Create database owned by pyre only if absent.
local db_exists
db_exists="$(sudo -u postgres psql -tAc \
"SELECT 1 FROM pg_database WHERE datname='${PYRE_DB_NAME}'" || true)"
if [[ "${db_exists}" == "1" ]]; then
info "Database '${PYRE_DB_NAME}' already exists — leaving it as-is."
else
info "Creating database '${PYRE_DB_NAME}' owned by '${PYRE_DB_ROLE}'."
sudo -u postgres createdb -O "${PYRE_DB_ROLE}" "${PYRE_DB_NAME}"
fi
info "DATABASE_URL should be: postgresql://${PYRE_DB_ROLE}:<password>@localhost:5432/${PYRE_DB_NAME}"
info "(listen_addresses left untouched — PostgreSQL stays localhost-only.)"
}
# -----------------------------------------------------------------------------
# 4. Redis — bind localhost-only + noeviction. Never expose publicly.
# -----------------------------------------------------------------------------
ensure_conf_line() {
# ensure_conf_line <file> <key-regex> <desired-line>
# If a line matching the key exists (commented or not), replace it.
# Otherwise append the desired line. Idempotent.
local file="$1" key="$2" line="$3"
if grep -Eq "^[#[:space:]]*${key}" "${file}"; then
# Replace any existing (active or commented) occurrence with the desired line.
sed -i -E "s|^[#[:space:]]*${key}.*|${line}|" "${file}"
else
printf '%s\n' "${line}" >>"${file}"
fi
}
step_redis() {
section "4/10 Redis hardening (localhost-only, noeviction)"
if [[ ! -f "${REDIS_CONF}" ]]; then
err "Redis config not found at ${REDIS_CONF} — is redis-server installed?"
return 1
fi
# Bind to loopback only (IPv4 + IPv6). NEVER expose Redis to the public net.
ensure_conf_line "${REDIS_CONF}" "bind" "bind 127.0.0.1 ::1"
# PYRE uses Redis as a durable job queue (BullMQ); do not silently evict keys.
ensure_conf_line "${REDIS_CONF}" "maxmemory-policy" "maxmemory-policy noeviction"
systemctl restart redis-server
info "Redis bound to 127.0.0.1/::1 with maxmemory-policy noeviction; restarted."
}
# -----------------------------------------------------------------------------
# 5. UFW firewall.
# !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
# !! THIS SCRIPT RUNS OVER SSH. Ports 22 (system SSH) and 2222 (Gitea !!
# !! git-SSH) MUST stay open, or we permanently lose remote access to !!
# !! the box. Allow rules are added BEFORE enabling the firewall. !!
# !! Never remove existing allow rules here. !!
# !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
# -----------------------------------------------------------------------------
step_ufw() {
section "5/10 UFW firewall (open SSH BEFORE enabling)"
# Open required ports first. 'ufw allow' is idempotent (re-adding is a no-op).
ufw allow 22/tcp comment 'system SSH - MUST stay open'
ufw allow 2222/tcp comment 'Gitea git-SSH - MUST stay open'
ufw allow 80/tcp comment 'HTTP (nginx / ACME)'
ufw allow 443/tcp comment 'HTTPS (nginx)'
# Enable only if currently inactive so we never toggle/reset an active firewall.
if ufw status 2>/dev/null | grep -q "Status: active"; then
info "UFW already active — left enabled, rules ensured."
else
warn "Enabling UFW now. SSH (22) and Gitea (2222) are already allowed above."
ufw --force enable
fi
ufw status verbose || true
}
# -----------------------------------------------------------------------------
# 6. nginx vhost.
# -----------------------------------------------------------------------------
step_nginx() {
section "6/10 nginx vhost for ${DOMAIN}"
if [[ ! -f "${NGINX_VHOST_SRC}" ]]; then
err "nginx vhost source not found: ${NGINX_VHOST_SRC}"
err "(It is authored under infra/nginx/ by another agent. Skipping nginx config.)"
return 1
fi
install -m 0644 "${NGINX_VHOST_SRC}" "${NGINX_VHOST_AVAIL}"
ln -sfn "${NGINX_VHOST_AVAIL}" "${NGINX_VHOST_ENABLED}"
info "Installed vhost -> ${NGINX_VHOST_AVAIL} and symlinked into sites-enabled."
# Remove the stock default site if present.
if [[ -e "${NGINX_DEFAULT_ENABLED}" || -L "${NGINX_DEFAULT_ENABLED}" ]]; then
rm -f "${NGINX_DEFAULT_ENABLED}"
info "Removed default enabled site (${NGINX_DEFAULT_ENABLED})."
fi
nginx -t
systemctl reload nginx
info "nginx config valid; reloaded."
}
# -----------------------------------------------------------------------------
# 7. Status page webroot.
# -----------------------------------------------------------------------------
step_status_page() {
section "7/10 Status page webroot (${STATUS_WEBROOT})"
install -d -m 0755 "${STATUS_WEBROOT}"
local copied=0
local f
for f in index.html status.json; do
if [[ -f "${STATUS_SRC_DIR}/${f}" ]]; then
install -m 0644 "${STATUS_SRC_DIR}/${f}" "${STATUS_WEBROOT}/${f}"
copied=$((copied + 1))
else
warn "Status source missing: ${STATUS_SRC_DIR}/${f} (authored by another agent)."
fi
done
chown -R www-data:www-data "${STATUS_WEBROOT_PARENT}"
info "Status webroot ready; copied ${copied} file(s); chowned to www-data."
}
# -----------------------------------------------------------------------------
# 8. TLS via certbot (nginx plugin). Re-run-safe.
# -----------------------------------------------------------------------------
step_tls() {
section "8/10 TLS certificate via certbot (${DOMAIN}, ${WWW_DOMAIN})"
if ! command -v certbot >/dev/null 2>&1; then
err "certbot not found — skipping TLS."
return 1
fi
# certbot --nginx is re-run-safe: if a cert already covers these domains it
# will reuse/expand rather than fail. --non-interactive prevents prompts.
if certbot --nginx \
-d "${DOMAIN}" -d "${WWW_DOMAIN}" \
--non-interactive --agree-tos \
-m "${CERTBOT_EMAIL}" \
--redirect \
--keep-until-expiring; then
info "certbot succeeded (cert obtained or already present)."
systemctl reload nginx
info "nginx reloaded with TLS."
else
warn "certbot did not complete successfully. This is usually because DNS for"
warn "${DOMAIN}/${WWW_DOMAIN} is not yet pointing at this box, or port 80 is"
warn "unreachable. Provisioning continues; re-run this script after DNS is live."
fi
}
# -----------------------------------------------------------------------------
# 9. systemd unit (pm2) + logrotate.
# -----------------------------------------------------------------------------
step_systemd_logrotate() {
section "9/10 systemd unit (pm2-pyre) + logrotate"
# App log directory — pm2 processes and scripts/backup.sh write here, and
# infra/logrotate/pyre rotates it. Created up-front so those don't fail.
install -d -m 0755 -o "${PYRE_USER}" -g "${PYRE_USER}" "${REPO_DIR}/logs"
info "Ensured app log dir ${REPO_DIR}/logs (owner ${PYRE_USER})."
# --- systemd pm2 unit ---
if [[ -f "${SYSTEMD_UNIT_SRC}" ]]; then
install -m 0644 "${SYSTEMD_UNIT_SRC}" "${SYSTEMD_UNIT_DST}"
systemctl daemon-reload
# Enable so it starts on boot. Do NOT fail if pm2 currently has no apps:
# 'enable' only registers the unit; we deliberately do not start it here.
if systemctl enable pm2-pyre.service 2>/dev/null; then
info "Installed + enabled pm2-pyre.service (not started here; ok if pm2 has no apps yet)."
else
warn "Could not enable pm2-pyre.service (continuing; check the unit later)."
fi
else
warn "systemd unit source missing: ${SYSTEMD_UNIT_SRC} (authored by another agent)."
fi
# --- logrotate ---
if [[ -f "${LOGROTATE_SRC}" ]]; then
install -m 0644 "${LOGROTATE_SRC}" "${LOGROTATE_DST}"
info "Installed logrotate config -> ${LOGROTATE_DST}."
else
warn "logrotate source missing: ${LOGROTATE_SRC} (authored by another agent)."
fi
}
# -----------------------------------------------------------------------------
# 10. Final summary.
# -----------------------------------------------------------------------------
svc_status() { systemctl is-active "$1" 2>/dev/null || echo "unknown"; }
step_summary() {
section "10/10 Summary"
echo " Versions:"
command -v nginx >/dev/null 2>&1 && info "nginx: $(nginx -v 2>&1)"
command -v psql >/dev/null 2>&1 && info "postgres: $(psql --version 2>/dev/null)"
command -v redis-server >/dev/null 2>&1 && info "redis-server: $(redis-server --version 2>/dev/null)"
command -v certbot >/dev/null 2>&1 && info "certbot: $(certbot --version 2>&1)"
echo
echo " Service status:"
info "postgresql: $(svc_status postgresql)"
info "redis-server: $(svc_status redis-server)"
info "nginx: $(svc_status nginx)"
info "ufw: $(ufw status 2>/dev/null | head -n1 || echo unknown)"
info "pm2-pyre: enabled=$(systemctl is-enabled pm2-pyre.service 2>/dev/null || echo no) active=$(svc_status pm2-pyre)"
echo
echo " Checklist (this run):"
info "[x] base packages installed (nginx, postgres, redis, certbot, ufw)"
info "[x] postgresql + redis-server enabled & started"
info "[x] postgres role '${PYRE_DB_ROLE}' + db '${PYRE_DB_NAME}' ensured (localhost-only)"
info "[x] redis bound to 127.0.0.1/::1, maxmemory-policy noeviction"
info "[x] UFW: 22, 2222, 80, 443 allowed (22 & 2222 MUST stay open)"
info "[x] nginx vhost installed + default site removed (if sources present)"
info "[x] status page webroot ${STATUS_WEBROOT} (if sources present)"
info "[x] TLS via certbot (skipped gracefully if DNS not ready)"
info "[x] pm2-pyre.service + logrotate installed (if sources present)"
echo
warn "REMINDER: set REAL secrets in the per-app .env files (copy from .env.example):"
warn " - DATABASE_URL (with the real PYRE_DB_PASSWORD if you changed it)"
warn " - ANTHROPIC_API_KEY / OPENAI_API_KEY / IMAGE_GEN_API_KEY"
warn " - ADMIN_API_TOKEN, SOLANA_RPC_URL, etc."
warn " PYRE never stores wallet private keys — there is intentionally no key var."
echo
info "Done. Safe to re-run this script any time."
}
# -----------------------------------------------------------------------------
# Main
# -----------------------------------------------------------------------------
main() {
require_root "$@"
section "PYRE Phase 0 provisioning — $(date -u '+%Y-%m-%dT%H:%M:%SZ')"
info "Repo: ${REPO_DIR} (owner: ${PYRE_USER}) Domain: ${DOMAIN}"
step_apt
step_services
step_postgres
step_redis
step_ufw
step_nginx
step_status_page
step_tls
step_systemd_logrotate
step_summary
}
main "$@"