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:
102
scripts/README.md
Normal file
102
scripts/README.md
Normal 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
48
scripts/backup.sh
Normal 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
256
scripts/gen-status.mjs
Normal 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
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
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