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:
2026-05-31 02:34:13 +00:00
parent c20094ab56
commit 571e5d04d2
13 changed files with 1629 additions and 0 deletions

102
scripts/README.md Normal file
View File

@@ -0,0 +1,102 @@
# PYRE — `scripts/`
Operational scripts for the PYRE VPS (Ubuntu 24.04). These cover **Phase 0**
(server + repo setup) per `docs/PYRE_MVP_DESIGN.md` §12, §18, §19.
| Script | Purpose | Who runs it |
| --- | --- | --- |
| [`phase0-provision.sh`](./phase0-provision.sh) | Idempotent **root** provisioning: nginx, PostgreSQL, Redis, certbot/TLS, UFW, systemd pm2 unit, logrotate, status page. | root (`sudo`) |
| `gen-status.mjs` | Generates the public `status.json` for the status page. *(Authored by another agent — see that file's header for usage.)* | `pyre` user |
| `backup.sh` | Database / config backup routine. *(Authored by another agent — see that file's header for usage.)* | `pyre` user / cron |
---
## `phase0-provision.sh`
Provisions all **system-level** services PYRE depends on. It is
**idempotent and safe to re-run** — every step checks current state before
changing anything.
### What it does NOT do
- Does **not** install Node.js 22, pnpm, the repo, or app `.env` files
(those are handled separately / by the `pyre` user).
- Does **not** run a Solana validator/RPC node or any local LLM/image model
(explicitly out of scope per design §11/§12).
- Does **not** change Postgres `listen_addresses` or expose Redis — both stay
**localhost-only**.
- Does **not** store any wallet private key. PYRE never holds keys; there is
intentionally no key/mnemonic variable anywhere.
### Steps
1. `apt-get update` + install `nginx postgresql postgresql-contrib redis-server certbot python3-certbot-nginx ufw`.
2. Enable + start `postgresql` and `redis-server`.
3. Create Postgres role `pyre` (LOGIN, NOCREATEDB) and database `pyre` owned by it — only if absent. Matches `DATABASE_URL=postgresql://pyre:pyre@localhost:5432/pyre` from `.env.example`.
4. Harden Redis: `bind 127.0.0.1 ::1` and `maxmemory-policy noeviction`, then restart.
5. UFW: allow `22`, `2222`, `80`, `443` **before** enabling the firewall.
6. Install the nginx vhost, remove the default site, validate, reload.
7. Create the status-page webroot and copy in `index.html` / `status.json`.
8. Obtain/renew the TLS certificate via certbot (nginx plugin), with redirect.
9. Install the `pm2-pyre` systemd unit (enable, don't start) and the logrotate config.
10. Print versions, service statuses, a checklist, and a reminder to set real secrets.
### Prerequisites
- Ubuntu 24.04 with the base setup already done (`pyre` user, SSH key auth,
root login disabled, Fail2ban) — per design §12.
- The repo present at **`/home/pyre/pyre`**, owned by `pyre`.
- The `infra/` config sources present (authored by other agents):
- `infra/nginx/feedthepyre.com.conf`
- `infra/status/index.html`, `infra/status/status.json`
- `infra/systemd/pm2-pyre.service`
- `infra/logrotate/pyre`
- If any are missing the script **warns and continues** rather than failing,
so you can re-run it once the sources exist.
- **DNS must already point `feedthepyre.com` and `www.feedthepyre.com` at this
box** before the TLS step can succeed. If DNS isn't live yet, certbot is
skipped gracefully — just re-run the script later.
### How to run
```bash
# Default (dev) — uses DB password "pyre" and the built-in certbot email:
sudo bash scripts/phase0-provision.sh
```
It must run as **root**. If you forget `sudo`, it re-execs itself under `sudo`
(or errors with instructions if `sudo` is unavailable).
### Overridable environment variables
| Variable | Default | Notes |
| --- | --- | --- |
| `PYRE_DB_PASSWORD` | `pyre` | Postgres password for role `pyre`. The default is **dev-only**; the script prints a loud warning when it's used. Set a real one for production. Only applied when the role is first created — re-runs do not change an existing role's password. |
| `CERTBOT_EMAIL` | `a31s15.roguewave@gmail.com` | Let's Encrypt registration / expiry-notice email. |
Pass overrides via `sudo -E` so the environment is preserved:
```bash
PYRE_DB_PASSWORD='a-strong-secret' \
CERTBOT_EMAIL='ops@feedthepyre.com' \
sudo -E bash scripts/phase0-provision.sh
```
If you set a non-default `PYRE_DB_PASSWORD`, update `DATABASE_URL` in the
per-app `.env` files to match.
### ⚠️ UFW / SSH warning
This script runs **over SSH** and enables the firewall. It deliberately allows
the SSH ports **before** enabling UFW:
- **`22/tcp` (system SSH)** and **`2222/tcp` (Gitea git-SSH)** MUST stay open.
If you remove these allow rules, you will **permanently lose remote access**
to the box. The script never removes existing allow rules; if you edit UFW
by hand, keep 22 and 2222 open.
### After provisioning
Set **real secrets** in the per-app `.env` files (copy from `.env.example`):
`DATABASE_URL`, `ANTHROPIC_API_KEY` / `OPENAI_API_KEY` / `IMAGE_GEN_API_KEY`,
`ADMIN_API_TOKEN`, `SOLANA_RPC_URL`, etc. Never commit a real `.env`.

48
scripts/backup.sh Normal file
View File

@@ -0,0 +1,48 @@
#!/usr/bin/env bash
#
# PYRE / Prometheus Protocol — PostgreSQL backup script.
#
# INERT until the `pyre` database exists and has data. Until then a run will
# simply fail at pg_dump (no DB), which is harmless — nothing is deleted unless
# pg_dump succeeds.
#
# What it does:
# - dumps the `pyre` database (gzip) to /home/pyre/backups/
# - prunes backups older than 14 days
# Idempotent and safe to run from cron.
#
# DATABASE_URL is read from the environment if set, otherwise defaults to the
# local pyre DB. Do NOT hardcode real passwords here — for non-trivial passwords
# prefer a ~/.pgpass file (chmod 600) so the URL can omit the password.
#
# Example crontab (run as the `pyre` user, daily at 03:30):
# 30 3 * * * /home/pyre/pyre/scripts/backup.sh >> /home/pyre/pyre/logs/backup.log 2>&1
set -euo pipefail
DATABASE_URL="${DATABASE_URL:-postgresql://pyre:pyre@localhost:5432/pyre}"
BACKUP_DIR="${BACKUP_DIR:-/home/pyre/backups}"
RETENTION_DAYS="${RETENTION_DAYS:-14}"
TIMESTAMP="$(date +%Y%m%d-%H%M%S)"
OUTFILE="${BACKUP_DIR}/pyre-${TIMESTAMP}.sql.gz"
mkdir -p "${BACKUP_DIR}"
echo "[backup] $(date -Is) dumping database -> ${OUTFILE}"
# --no-owner / --no-acl keeps the dump portable across roles when restoring.
# Write to a temp file first, then atomically rename, so cron never prunes or
# leaves behind a half-written archive.
TMPFILE="${OUTFILE}.partial"
pg_dump --no-owner --no-acl --dbname="${DATABASE_URL}" | gzip -c > "${TMPFILE}"
mv "${TMPFILE}" "${OUTFILE}"
echo "[backup] $(date -Is) wrote $(du -h "${OUTFILE}" | cut -f1) backup"
# Prune backups older than RETENTION_DAYS (only PYRE dumps).
echo "[backup] $(date -Is) pruning backups older than ${RETENTION_DAYS} days"
find "${BACKUP_DIR}" -maxdepth 1 -type f -name 'pyre-*.sql.gz' \
-mtime "+${RETENTION_DAYS}" -print -delete || true
echo "[backup] $(date -Is) done"

256
scripts/gen-status.mjs Normal file
View File

@@ -0,0 +1,256 @@
#!/usr/bin/env node
// PYRE status dashboard generator.
// Dependency-free: reads ../infra/status/status.json (relative to this file)
// and renders ../infra/status/index.html.
//
// Usage (from repo root): node scripts/gen-status.mjs
import { readFileSync, writeFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
const __dirname = dirname(fileURLToPath(import.meta.url));
const dataPath = resolve(__dirname, "../infra/status/status.json");
const outPath = resolve(__dirname, "../infra/status/index.html");
/** Escape a string for safe insertion into HTML text/attribute context. */
function esc(value) {
return String(value)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
const data = JSON.parse(readFileSync(dataPath, "utf8"));
// --- compute overall % complete from item done-counts ---
let totalItems = 0;
let doneItems = 0;
for (const phase of data.phases) {
for (const item of phase.items) {
totalItems += 1;
if (item.done) doneItems += 1;
}
}
const overallPct = totalItems === 0 ? 0 : Math.round((doneItems / totalItems) * 100);
// --- state badge helpers ---
const STATE_LABEL = {
done: "DONE",
in_progress: "IN PROGRESS",
todo: "TODO",
};
function stateLabel(state) {
return STATE_LABEL[state] || String(state).toUpperCase();
}
function renderItems(items) {
return items
.map((item) => {
const cls = item.done ? "item done" : "item";
const mark = item.done ? "✓" : "○"; // ✓ / ○
return ` <li class="${cls}"><span class="mark">${mark}</span><span>${esc(item.label)}</span></li>`;
})
.join("\n");
}
function renderPhase(phase) {
const doneCount = phase.items.filter((i) => i.done).length;
const stateClass = esc(phase.state);
return ` <article class="card ${stateClass}">
<header class="card-head">
<h3><span class="phase-id">Phase ${esc(phase.id)}</span> ${esc(phase.name)}</h3>
<span class="badge ${stateClass}">${esc(stateLabel(phase.state))}</span>
</header>
<p class="count">${doneCount} / ${phase.items.length} complete</p>
<ul class="checklist">
${renderItems(phase.items)}
</ul>
</article>`;
}
const phasesHtml = data.phases.map(renderPhase).join("\n");
const infraHtml = renderItems(data.infra);
const infraDone = data.infra.filter((i) => i.done).length;
const html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>${esc(data.project)} — Status Dashboard</title>
<style>
:root {
--bg: #0a0a0a;
--panel: #141210;
--panel-2: #1b1815;
--border: #2a241e;
--ember: #ff5a1f;
--ember-soft: #ff8a4f;
--text: #ede6df;
--muted: #9a8f84;
--ok: #6ad17a;
--todo: #6b6258;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: radial-gradient(1200px 600px at 50% -200px, #1a0f08 0%, var(--bg) 60%);
color: var(--text);
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
a { color: var(--ember-soft); text-decoration: none; }
a:hover { text-decoration: underline; }
.wrap { max-width: 1040px; margin: 0 auto; padding: 2.5rem 1.25rem 4rem; }
header.site {
border-bottom: 1px solid var(--border);
padding-bottom: 1.5rem;
margin-bottom: 2rem;
}
.title {
font-size: clamp(1.8rem, 5vw, 2.8rem);
margin: 0;
letter-spacing: 0.5px;
background: linear-gradient(90deg, var(--ember), var(--ember-soft));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.tagline { color: var(--muted); font-style: italic; margin: 0.4rem 0 1rem; }
.links a {
display: inline-block;
margin-right: 1rem;
font-size: 0.9rem;
border: 1px solid var(--border);
padding: 0.35rem 0.7rem;
border-radius: 6px;
background: var(--panel);
}
.links a:hover { border-color: var(--ember); text-decoration: none; }
.overall {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 12px;
padding: 1.25rem 1.5rem;
margin-bottom: 2rem;
}
.overall-head {
display: flex; justify-content: space-between; align-items: baseline;
margin-bottom: 0.75rem; flex-wrap: wrap; gap: 0.5rem;
}
.overall-head h2 { margin: 0; font-size: 1.05rem; letter-spacing: 1px; text-transform: uppercase; color: var(--muted); }
.overall-pct { font-size: 1.6rem; font-weight: 700; color: var(--ember); }
.bar {
height: 16px; width: 100%;
background: var(--panel-2);
border: 1px solid var(--border);
border-radius: 999px; overflow: hidden;
}
.bar > span {
display: block; height: 100%;
background: linear-gradient(90deg, var(--ember), var(--ember-soft));
box-shadow: 0 0 18px rgba(255, 90, 31, 0.6);
}
h2.section {
font-size: 1rem; letter-spacing: 1.5px; text-transform: uppercase;
color: var(--muted); margin: 2.25rem 0 1rem;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1rem;
}
.card {
background: var(--panel);
border: 1px solid var(--border);
border-left: 3px solid var(--todo);
border-radius: 10px;
padding: 1.1rem 1.25rem;
}
.card.in_progress { border-left-color: var(--ember); }
.card.done { border-left-color: var(--ok); }
.card-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 0.75rem; }
.card-head h3 { margin: 0; font-size: 1.05rem; font-weight: 600; }
.phase-id { color: var(--ember); font-weight: 700; display: block; font-size: 0.75rem; letter-spacing: 1px; text-transform: uppercase; }
.badge {
flex: none;
font-size: 0.65rem; letter-spacing: 1px; font-weight: 700;
padding: 0.25rem 0.55rem; border-radius: 999px;
border: 1px solid var(--border); color: var(--muted);
text-transform: uppercase; white-space: nowrap;
}
.badge.in_progress { color: #0a0a0a; background: var(--ember); border-color: var(--ember); }
.badge.done { color: #0a0a0a; background: var(--ok); border-color: var(--ok); }
.badge.todo { color: var(--todo); }
.count { color: var(--muted); font-size: 0.8rem; margin: 0.5rem 0 0.75rem; }
ul.checklist { list-style: none; margin: 0; padding: 0; }
.item { display: flex; gap: 0.55rem; align-items: flex-start; padding: 0.2rem 0; font-size: 0.92rem; color: var(--muted); }
.item.done { color: var(--text); }
.item .mark { color: var(--todo); font-weight: 700; flex: none; width: 1rem; text-align: center; }
.item.done .mark { color: var(--ok); }
.infra-panel {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
padding: 1.25rem 1.5rem;
}
.infra-panel .count { margin-top: 0; }
.infra-grid { columns: 2; column-gap: 2rem; }
@media (max-width: 520px) { .infra-grid { columns: 1; } }
footer.site {
margin-top: 3rem; padding-top: 1.5rem;
border-top: 1px solid var(--border);
color: var(--muted); font-size: 0.85rem;
}
footer.site .disclaimer { color: var(--ember-soft); margin-top: 0.35rem; }
</style>
</head>
<body>
<div class="wrap">
<header class="site">
<h1 class="title">${esc(data.project)}</h1>
<p class="tagline">${esc(data.tagline)}</p>
<nav class="links">
<a href="${esc(data.repo)}">Repository</a>
<a href="${esc(data.domain)}">${esc(data.domain.replace(/^https?:\/\//, ""))}</a>
<a href="../../docs/PYRE_MVP_DESIGN.md">Design Doc</a>
</nav>
</header>
<section class="overall">
<div class="overall-head">
<h2>Overall MVP Progress</h2>
<span class="overall-pct">${overallPct}%</span>
</div>
<div class="bar"><span style="width: ${overallPct}%"></span></div>
<p class="count">${doneItems} of ${totalItems} phase deliverables complete</p>
</section>
<h2 class="section">Development Phases</h2>
<div class="grid">
${phasesHtml}
</div>
<h2 class="section">Infrastructure</h2>
<div class="infra-panel">
<p class="count">${infraDone} / ${data.infra.length} provisioned</p>
<ul class="checklist infra-grid">
${infraHtml}
</ul>
</div>
<footer class="site">
<div>Last updated: ${esc(data.updated)}</div>
<div class="disclaimer">Static snapshot generated from status.json — not live telemetry.</div>
</footer>
</div>
</body>
</html>
`;
writeFileSync(outPath, html, "utf8");
console.log(`Wrote ${outPath} (${overallPct}% complete, ${doneItems}/${totalItems} items)`);

394
scripts/phase0-provision.sh Executable file
View 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 "$@"