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>
This commit is contained in:
394
scripts/phase0-provision.sh
Executable file
394
scripts/phase0-provision.sh
Executable file
@@ -0,0 +1,394 @@
|
||||
#!/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 "$@"
|
||||
Reference in New Issue
Block a user