- 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>
395 lines
16 KiB
Bash
Executable File
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 "$@"
|