From 571e5d04d288e8def22435dde6c336b23d115c22 Mon Sep 17 00:00:00 2001 From: RogueWave Date: Sun, 31 May 2026 02:34:13 +0000 Subject: [PATCH] feat(infra): Phase 0 provisioning + dev status dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- README.md | 2 + ecosystem.config.cjs | 74 ++++++ infra/logrotate/pyre | 34 +++ infra/nginx/README.md | 98 ++++++++ infra/nginx/feedthepyre.com.conf | 109 +++++++++ infra/status/README.md | 38 +++ infra/status/index.html | 306 ++++++++++++++++++++++++ infra/status/status.json | 128 ++++++++++ infra/systemd/pm2-pyre.service | 40 ++++ scripts/README.md | 102 ++++++++ scripts/backup.sh | 48 ++++ scripts/gen-status.mjs | 256 ++++++++++++++++++++ scripts/phase0-provision.sh | 394 +++++++++++++++++++++++++++++++ 13 files changed, 1629 insertions(+) create mode 100644 ecosystem.config.cjs create mode 100644 infra/logrotate/pyre create mode 100644 infra/nginx/README.md create mode 100644 infra/nginx/feedthepyre.com.conf create mode 100644 infra/status/README.md create mode 100644 infra/status/index.html create mode 100644 infra/status/status.json create mode 100644 infra/systemd/pm2-pyre.service create mode 100644 scripts/README.md create mode 100644 scripts/backup.sh create mode 100644 scripts/gen-status.mjs create mode 100755 scripts/phase0-provision.sh diff --git a/README.md b/README.md index d5711c0..2413d74 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ > **Burn the dead. Feed the PYRE. Claim the Spawn.** +**Links:** [feedthepyre.com](https://feedthepyre.com) · repo: `git.lumiai.dev/RogueWave/pyre` · dev status: [feedthepyre.com](https://feedthepyre.com) (status dashboard) + PYRE is a **Solana wallet-cleanup and ritual meme-rebirth protocol**. You connect a wallet; PYRE scans your SPL token accounts, classifies them conservatively, and helps you safely close empty associated token accounts (ATAs) and burn obvious diff --git a/ecosystem.config.cjs b/ecosystem.config.cjs new file mode 100644 index 0000000..b2351f8 --- /dev/null +++ b/ecosystem.config.cjs @@ -0,0 +1,74 @@ +// PYRE / Prometheus Protocol — PM2 ecosystem (process manager) config +// +// ⚠️ INERT / NOT YET RUNNABLE: the apps are NOT implemented yet. This file +// defines how the three PYRE processes WILL run once apps/web, apps/api, +// and apps/worker are built/deployed. Starting it now will fail because +// the apps (and their builds) do not exist yet. +// +// Process names match docs/PYRE_MVP_DESIGN.md §12: pyre-web, pyre-api, pyre-worker. +// +// Once apps exist, start with: +// pm2 start ecosystem.config.cjs +// pm2 save # persist process list so `pm2 resurrect` works on boot +// +// PM2 is installed at user level: ~/.local/share/pnpm/bin/pm2 +// Logs go to /home/pyre/pyre/logs/ (rotated by infra/logrotate/pyre). +// +// Note on memory: the 8GB VPS is shared with postgres, redis, nginx, etc., +// so each process is capped at 400M via max_memory_restart. + +module.exports = { + apps: [ + { + // Next.js frontend (production) — port 3000 per .env.example (WEB_PORT). + name: "pyre-web", + cwd: "apps/web", + script: "pnpm", + args: "start", // runs `next start` (requires a prior `pnpm build`) + instances: 1, + autorestart: true, + max_memory_restart: "400M", + env: { + NODE_ENV: "production", + PORT: 3000, + }, + out_file: "/home/pyre/pyre/logs/pyre-web-out.log", + error_file: "/home/pyre/pyre/logs/pyre-web-err.log", + }, + { + // Fastify HTTP API — port 4000 per .env.example (API_PORT). + // Runs the compiled server. Until a build exists you can temporarily + // swap to a dev runner: script: "pnpm", args: "dev" + name: "pyre-api", + cwd: "apps/api", + script: "node", + args: "dist/index.js", + instances: 1, + autorestart: true, + max_memory_restart: "400M", + env: { + NODE_ENV: "production", + PORT: 4000, + }, + out_file: "/home/pyre/pyre/logs/pyre-api-out.log", + error_file: "/home/pyre/pyre/logs/pyre-api-err.log", + }, + { + // BullMQ background worker (no HTTP port). + // Runs the compiled worker. Until a build exists you can temporarily + // swap to a dev runner: script: "pnpm", args: "dev" + name: "pyre-worker", + cwd: "apps/worker", + script: "node", + args: "dist/index.js", + instances: 1, + autorestart: true, + max_memory_restart: "400M", + env: { + NODE_ENV: "production", + }, + out_file: "/home/pyre/pyre/logs/pyre-worker-out.log", + error_file: "/home/pyre/pyre/logs/pyre-worker-err.log", + }, + ], +}; diff --git a/infra/logrotate/pyre b/infra/logrotate/pyre new file mode 100644 index 0000000..653651e --- /dev/null +++ b/infra/logrotate/pyre @@ -0,0 +1,34 @@ +# PYRE / Prometheus Protocol — logrotate config. +# +# INERT until logs are actually being produced (PM2 apps running / nginx +# serving). The paths below will simply be skipped (missingok) until then. +# +# Install (run as a privileged user): +# sudo cp /home/pyre/pyre/infra/logrotate/pyre /etc/logrotate.d/pyre +# sudo logrotate --debug /etc/logrotate.d/pyre # dry-run to validate +# +# copytruncate is used so PM2 and nginx keep writing to the same file handle +# (no restart/reopen needed after rotation). + +# ---- PYRE app logs (written by PM2) ---------------------------------------- +/home/pyre/pyre/logs/*.log { + su pyre pyre + daily + rotate 14 + compress + delaycompress + missingok + notifempty + copytruncate +} + +# ---- nginx logs for feedthepyre --------------------------------------------- +/var/log/nginx/feedthepyre.*.log { + daily + rotate 14 + compress + delaycompress + missingok + notifempty + copytruncate +} diff --git a/infra/nginx/README.md b/infra/nginx/README.md new file mode 100644 index 0000000..76bc089 --- /dev/null +++ b/infra/nginx/README.md @@ -0,0 +1,98 @@ +# nginx — feedthepyre.com virtual host + +This directory holds the nginx virtual host config for **feedthepyre.com**, the +public domain for the PYRE / Prometheus Protocol MVP. + +- [`feedthepyre.com.conf`](feedthepyre.com.conf) — the vhost. + +## What it does now + +Right now the vhost serves a **static status dashboard** (a holding/status page) +for both `feedthepyre.com` and `www.feedthepyre.com`. + +- Site root (`location /`) serves files from the webroot + `/var/www/feedthepyre/status` (with `index.html`), using + `try_files $uri $uri/ /index.html`. +- The Next.js web app and Fastify API are **not** proxied yet — those blocks are + present but commented out. +- An explicit `/.well-known/acme-challenge/` location is served from the same + webroot so Let's Encrypt HTTP-01 validation works even before certbot applies + its `--nginx` changes. +- gzip is enabled for common text content types. + +Ports (per `.env.example`; design §11 names the processes but not their ports): + +| service | bind | env var | +| -------------- | --------------- | ---------- | +| web (Next.js) | 127.0.0.1:3000 | `WEB_PORT` | +| api (Fastify) | 127.0.0.1:4000 | `API_PORT` | + +## How the provision script installs it + +The provisioning script (run with sudo on the PYRE VPS) is expected to: + +1. Copy `feedthepyre.com.conf` to `/etc/nginx/sites-available/feedthepyre.com`. +2. Symlink it into the enabled set: + `ln -s /etc/nginx/sites-available/feedthepyre.com /etc/nginx/sites-enabled/feedthepyre.com` +3. Ensure the webroot exists and has an index page: + `mkdir -p /var/www/feedthepyre/status` (drop an `index.html` in it). +4. Validate and reload: `nginx -t && systemctl reload nginx`. + +These exact paths are a contract the script relies on — do not rename the +install path or the webroot without updating the script too. + +> This config is file-only. Do not run nginx/sudo from this repo; the provision +> script owns that. + +## How certbot adds TLS + +After the HTTP vhost is installed and nginx is reloaded, obtain certificates: + +```bash +sudo certbot --nginx -d feedthepyre.com -d www.feedthepyre.com +``` + +certbot will **edit `feedthepyre.com.conf` in place**, adding: + +- a `listen 443 ssl;` server block, +- `ssl_certificate` / `ssl_certificate_key` directives (and Let's Encrypt + includes), +- an HTTP->HTTPS redirect on the `listen 80;` server. + +That is why the `:80` server is written as a plain `listen 80;` block with both +domains in `server_name` — it is the shape certbot expects to augment. Renewals +are handled automatically by certbot's systemd timer/cron. + +## Flipping from the static status page to the live app + API proxy + +Once `apps/web` (Next.js, port 3000) and `apps/api` (Fastify, port 4000) are +deployed and running on the VPS: + +1. Edit `/etc/nginx/sites-available/feedthepyre.com`. +2. **Enable the API proxy:** uncomment the `location /api/ { ... }` block. The + trailing slash on `proxy_pass http://127.0.0.1:4000/;` strips the `/api/` + prefix so the backend sees `/scan`, `/receipt`, etc. +3. **Switch the root to the web app:** in `location / { ... }`, replace the + `try_files ...` line with the `proxy_pass http://127.0.0.1:3000;` block shown + in the inline comment (Host / X-Real-IP / X-Forwarded-For / X-Forwarded-Proto + plus the websocket Upgrade/Connection headers). +4. **Add the websocket map** (one time) to the `http{}` block of + `/etc/nginx/nginx.conf`: + + ```nginx + map $http_upgrade $connection_upgrade { + default upgrade; + '' close; + } + ``` + +5. **Optional global hardening:** add `server_tokens off;` to the same `http{}` + block (kept out of the vhost on purpose so it is not duplicated). +6. Validate and reload: + + ```bash + sudo nginx -t && sudo systemctl reload nginx + ``` + +Keep the `/.well-known/acme-challenge/` location in place so certificate +renewals continue to work after the cutover. diff --git a/infra/nginx/feedthepyre.com.conf b/infra/nginx/feedthepyre.com.conf new file mode 100644 index 0000000..a3fbb11 --- /dev/null +++ b/infra/nginx/feedthepyre.com.conf @@ -0,0 +1,109 @@ +# ============================================================================ +# PYRE / Prometheus Protocol — nginx virtual host for feedthepyre.com +# ---------------------------------------------------------------------------- +# Install path: /etc/nginx/sites-available/feedthepyre.com +# (the provision script symlinks this into sites-enabled/) +# +# TLS: Managed by certbot. Run `certbot --nginx` AFTER this config is +# installed — it will inject the listen 443 ssl server block, +# the ssl_certificate / ssl_certificate_key lines, and the +# HTTP->HTTPS redirect automatically. Do NOT hand-edit those in. +# +# App ports (see docs/PYRE_MVP_DESIGN.md §11 and .env.example): +# web (Next.js) -> 127.0.0.1:3000 (WEB_PORT) +# api (Fastify) -> 127.0.0.1:4000 (API_PORT) +# +# Current behaviour: serves the static status dashboard from +# /var/www/feedthepyre/status. The reverse-proxy blocks below +# are commented out until the apps are deployed. +# ============================================================================ + +server { + listen 80; + listen [::]:80; + server_name feedthepyre.com www.feedthepyre.com; + + # --- Static status site (current site root) ----------------------------- + root /var/www/feedthepyre/status; + index index.html; + + # --- Logging ------------------------------------------------------------ + access_log /var/log/nginx/feedthepyre.access.log; + error_log /var/log/nginx/feedthepyre.error.log; + + # --- ACME HTTP-01 challenge -------------------------------------------- + # Explicit so certbot's HTTP-01 validation works even before its --nginx + # tweaks are applied. ^~ ensures this wins over the regex/proxy locations. + location ^~ /.well-known/acme-challenge/ { + root /var/www/feedthepyre/status; + allow all; + } + + # --- Basic hardening ---------------------------------------------------- + # gzip for text-ish content types. + gzip on; + gzip_comp_level 5; + gzip_min_length 256; + gzip_proxied any; + gzip_vary on; + gzip_types + text/plain + text/css + text/xml + text/javascript + application/javascript + application/json + application/xml + application/rss+xml + image/svg+xml; + + # NOTE: `server_tokens off;` is intentionally NOT set here — it belongs in + # the http{} block of /etc/nginx/nginx.conf so it applies globally. Set it + # there once rather than duplicating it per-vhost. + + # --- Site root ---------------------------------------------------------- + # Serve the static status dashboard for now. + # + # LATER: when apps/web (Next.js) is deployed, switch this location from the + # static status page to a reverse proxy. Replace the try_files body with: + # + # proxy_pass http://127.0.0.1:3000; + # proxy_http_version 1.1; + # proxy_set_header Host $host; + # proxy_set_header X-Real-IP $remote_addr; + # proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + # proxy_set_header X-Forwarded-Proto $scheme; + # proxy_set_header Upgrade $http_upgrade; + # proxy_set_header Connection $connection_upgrade; + # + location / { + try_files $uri $uri/ /index.html; + } + + # ------------------------------------------------------------------------ + # REVERSE-PROXY BLOCKS — enable when apps are running + # ------------------------------------------------------------------------ + # Uncomment the /api/ block below once apps/api (Fastify, port 4000) is up. + # The trailing slash on proxy_pass strips the /api/ prefix so the backend + # sees /scan, /receipt, etc. + # + # location /api/ { + # proxy_pass http://127.0.0.1:4000/; + # proxy_http_version 1.1; + # proxy_set_header Host $host; + # proxy_set_header X-Real-IP $remote_addr; + # proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + # proxy_set_header X-Forwarded-Proto $scheme; + # proxy_set_header Upgrade $http_upgrade; + # proxy_set_header Connection $connection_upgrade; + # } + # + # The websocket Upgrade/Connection headers above rely on a $connection_upgrade + # map. Add this once in the http{} block of /etc/nginx/nginx.conf: + # + # map $http_upgrade $connection_upgrade { + # default upgrade; + # '' close; + # } + # ------------------------------------------------------------------------ +} diff --git a/infra/status/README.md b/infra/status/README.md new file mode 100644 index 0000000..f881c35 --- /dev/null +++ b/infra/status/README.md @@ -0,0 +1,38 @@ +# PYRE Status Dashboard + +A static, self-contained dark "ember"-themed page the team uses to track PYRE MVP +progress. It is a **snapshot**, not live telemetry. + +## Files + +- **`status.json`** — the single source of truth. All content on the page + (phases, checklists, infra, dates, links) comes from here. +- **`index.html`** — the prebuilt rendered page. Committed so the page works even + before anyone runs the generator. Self-contained: inline CSS, no external + requests, no JS. +- **`README.md`** — this file. + +## Editing & regenerating + +1. Edit **`status.json`** — flip an item's `"done"` to `true`, update a phase + `"state"` (`todo` / `in_progress` / `done`), or bump `"updated"`. +2. Regenerate the page from the repo root: + + ```bash + node scripts/gen-status.mjs + ``` + + The generator is dependency-free (plain Node ESM, no npm install). It reads + `infra/status/status.json` and rewrites `infra/status/index.html`, recomputing + the overall % complete from the item done-counts. It prints the output path + when finished. + +3. Commit the regenerated `index.html` alongside the `status.json` change so the + prebuilt page stays consistent with the data. + +## Deployment + +The provision script deploys `infra/status/*` to `/var/www/feedthepyre/status`, +and nginx serves it as the site root (`feedthepyre.com`) until the real PYRE app +ships. Because `index.html` is prebuilt and self-contained, deployment is a plain +file copy — no build step or generator run is required on the server. diff --git a/infra/status/index.html b/infra/status/index.html new file mode 100644 index 0000000..a17946f --- /dev/null +++ b/infra/status/index.html @@ -0,0 +1,306 @@ + + + + + + PYRE / Prometheus Protocol — Status Dashboard + + + +
+
+

PYRE / Prometheus Protocol

+

Burn the dead. Feed the PYRE. Claim the Spawn.

+ +
+ +
+
+

Overall MVP Progress

+ 12% +
+
+

6 of 50 phase deliverables complete

+
+ +

Development Phases

+
+
+
+

Phase 0 Server & Repo Setup

+ IN PROGRESS +
+

6 / 8 complete

+
    +
  • VPS configured (pyre user, SSH key, root disabled, UFW, Fail2ban)
  • +
  • Claude Code installed
  • +
  • Repo initialized
  • +
  • pnpm workspace created
  • +
  • web/api/worker skeleton
  • +
  • Postgres + Redis running
  • +
  • nginx configured
  • +
  • Environment templates
  • +
+
+
+
+

Phase 1 Wallet Scanner

+ TODO +
+

0 / 6 complete

+
    +
  • Wallet connect frontend
  • +
  • Scan endpoint
  • +
  • Token account fetch
  • +
  • Basic classification
  • +
  • Scan results UI
  • +
  • Protected/skipped UI
  • +
+
+
+
+

Phase 2 Close Empty ATAs

+ TODO +
+

0 / 6 complete

+
    +
  • Identify empty token accounts
  • +
  • Build close-account tx
  • +
  • Decode tx preview
  • +
  • Wallet signing
  • +
  • Confirmation tracking
  • +
  • Receipt page
  • +
+
+
+
+

Phase 3 Burn Junk

+ TODO +
+

0 / 5 complete

+
    +
  • Incinerate-only classification
  • +
  • Burn transaction builder
  • +
  • Burn-then-close flow
  • +
  • Stronger confirmations
  • +
  • Receipt update
  • +
+
+
+
+

Phase 4 Prometheus Generator

+ TODO +
+

0 / 6 complete

+
    +
  • Generation input from receipt
  • +
  • Meta mixer
  • +
  • Spawn name/ticker/lore generation
  • +
  • Image prompt generation
  • +
  • Safety checks
  • +
  • Admin approval UI
  • +
+
+
+
+

Phase 5 Manual Pump.fun Launch Workflow

+ TODO +
+

0 / 5 complete

+
    +
  • Approved Spawn package
  • +
  • Metadata JSON
  • +
  • Operator launch checklist
  • +
  • Mint/url/tx record input
  • +
  • Public Spawn record page
  • +
+
+
+
+

Phase 6 Essence / Round Prototype

+ TODO +
+

0 / 6 complete

+
    +
  • Safe swap candidate detection
  • +
  • Route quote preview
  • +
  • Net Essence estimate
  • +
  • Round dashboard
  • +
  • Contribution database record
  • +
  • No claim promises until on-chain logic exists
  • +
+
+
+
+

Phase 7 PYRE Core Program

+ TODO +
+

0 / 8 complete

+
    +
  • Anchor program — create round
  • +
  • Contribute Essence
  • +
  • Contribution receipt PDA
  • +
  • Lock round
  • +
  • Register Spawn
  • +
  • Claim Spawn
  • +
  • Refund failed round
  • +
  • Tests
  • +
+
+
+ +

Infrastructure

+
+

6 / 11 provisioned

+
    +
  • Node.js 22
  • +
  • pnpm
  • +
  • Git + Gitea remote
  • +
  • DNS (feedthepyre.com)
  • +
  • Monorepo scaffold + docs
  • +
  • pnpm install + typecheck clean
  • +
  • nginx
  • +
  • PostgreSQL
  • +
  • Redis
  • +
  • PM2
  • +
  • TLS (Let's Encrypt)
  • +
+
+ +
+
Last updated: 2026-05-31
+
Static snapshot generated from status.json — not live telemetry.
+
+
+ + diff --git a/infra/status/status.json b/infra/status/status.json new file mode 100644 index 0000000..6a78791 --- /dev/null +++ b/infra/status/status.json @@ -0,0 +1,128 @@ +{ + "project": "PYRE / Prometheus Protocol", + "tagline": "Burn the dead. Feed the PYRE. Claim the Spawn.", + "repo": "https://git.lumiai.dev/RogueWave/pyre", + "domain": "https://feedthepyre.com", + "updated": "2026-05-31", + "phases": [ + { + "id": 0, + "name": "Server & Repo Setup", + "state": "in_progress", + "items": [ + { "label": "VPS configured (pyre user, SSH key, root disabled, UFW, Fail2ban)", "done": true }, + { "label": "Claude Code installed", "done": true }, + { "label": "Repo initialized", "done": true }, + { "label": "pnpm workspace created", "done": true }, + { "label": "web/api/worker skeleton", "done": true }, + { "label": "Postgres + Redis running", "done": false }, + { "label": "nginx configured", "done": false }, + { "label": "Environment templates", "done": true } + ] + }, + { + "id": 1, + "name": "Wallet Scanner", + "state": "todo", + "items": [ + { "label": "Wallet connect frontend", "done": false }, + { "label": "Scan endpoint", "done": false }, + { "label": "Token account fetch", "done": false }, + { "label": "Basic classification", "done": false }, + { "label": "Scan results UI", "done": false }, + { "label": "Protected/skipped UI", "done": false } + ] + }, + { + "id": 2, + "name": "Close Empty ATAs", + "state": "todo", + "items": [ + { "label": "Identify empty token accounts", "done": false }, + { "label": "Build close-account tx", "done": false }, + { "label": "Decode tx preview", "done": false }, + { "label": "Wallet signing", "done": false }, + { "label": "Confirmation tracking", "done": false }, + { "label": "Receipt page", "done": false } + ] + }, + { + "id": 3, + "name": "Burn Junk", + "state": "todo", + "items": [ + { "label": "Incinerate-only classification", "done": false }, + { "label": "Burn transaction builder", "done": false }, + { "label": "Burn-then-close flow", "done": false }, + { "label": "Stronger confirmations", "done": false }, + { "label": "Receipt update", "done": false } + ] + }, + { + "id": 4, + "name": "Prometheus Generator", + "state": "todo", + "items": [ + { "label": "Generation input from receipt", "done": false }, + { "label": "Meta mixer", "done": false }, + { "label": "Spawn name/ticker/lore generation", "done": false }, + { "label": "Image prompt generation", "done": false }, + { "label": "Safety checks", "done": false }, + { "label": "Admin approval UI", "done": false } + ] + }, + { + "id": 5, + "name": "Manual Pump.fun Launch Workflow", + "state": "todo", + "items": [ + { "label": "Approved Spawn package", "done": false }, + { "label": "Metadata JSON", "done": false }, + { "label": "Operator launch checklist", "done": false }, + { "label": "Mint/url/tx record input", "done": false }, + { "label": "Public Spawn record page", "done": false } + ] + }, + { + "id": 6, + "name": "Essence / Round Prototype", + "state": "todo", + "items": [ + { "label": "Safe swap candidate detection", "done": false }, + { "label": "Route quote preview", "done": false }, + { "label": "Net Essence estimate", "done": false }, + { "label": "Round dashboard", "done": false }, + { "label": "Contribution database record", "done": false }, + { "label": "No claim promises until on-chain logic exists", "done": false } + ] + }, + { + "id": 7, + "name": "PYRE Core Program", + "state": "todo", + "items": [ + { "label": "Anchor program — create round", "done": false }, + { "label": "Contribute Essence", "done": false }, + { "label": "Contribution receipt PDA", "done": false }, + { "label": "Lock round", "done": false }, + { "label": "Register Spawn", "done": false }, + { "label": "Claim Spawn", "done": false }, + { "label": "Refund failed round", "done": false }, + { "label": "Tests", "done": false } + ] + } + ], + "infra": [ + { "label": "Node.js 22", "done": true }, + { "label": "pnpm", "done": true }, + { "label": "Git + Gitea remote", "done": true }, + { "label": "DNS (feedthepyre.com)", "done": true }, + { "label": "Monorepo scaffold + docs", "done": true }, + { "label": "pnpm install + typecheck clean", "done": true }, + { "label": "nginx", "done": false }, + { "label": "PostgreSQL", "done": false }, + { "label": "Redis", "done": false }, + { "label": "PM2", "done": false }, + { "label": "TLS (Let's Encrypt)", "done": false } + ] +} diff --git a/infra/systemd/pm2-pyre.service b/infra/systemd/pm2-pyre.service new file mode 100644 index 0000000..ce2445b --- /dev/null +++ b/infra/systemd/pm2-pyre.service @@ -0,0 +1,40 @@ +# PYRE / Prometheus Protocol — systemd unit to resurrect PM2 on boot. +# +# INERT until apps are deployed AND `pm2 save` has been run at least once: +# `pm2 resurrect` only restores processes from the saved dump +# (~/.pm2/dump.pm2). Without that dump it starts nothing. +# +# Install (run as a privileged user, once): +# sudo cp /home/pyre/pyre/infra/systemd/pm2-pyre.service /etc/systemd/system/pm2-pyre.service +# sudo systemctl daemon-reload +# sudo systemctl enable pm2-pyre +# +# Then, after the apps are built and started: +# pm2 start /home/pyre/pyre/ecosystem.config.cjs +# pm2 save # <-- REQUIRED: writes the dump that resurrect reads +# +# This unit runs PM2 as the unprivileged `pyre` user (no root daemon). + +[Unit] +Description=PM2 process manager for PYRE (user pyre) +Documentation=https://pm2.keymetrics.io/ +After=network-online.target postgresql.service redis-server.service +Wants=network-online.target + +[Service] +Type=forking +User=pyre +LimitNOFILE=infinity +LimitNPROC=infinity +LimitCORE=infinity +Environment=PATH=/home/pyre/.local/share/pnpm:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin +Environment=PM2_HOME=/home/pyre/.pm2 +PIDFile=/home/pyre/.pm2/pm2.pid +Restart=on-failure + +ExecStart=/home/pyre/.local/share/pnpm/bin/pm2 resurrect +ExecReload=/home/pyre/.local/share/pnpm/bin/pm2 reload all +ExecStop=/home/pyre/.local/share/pnpm/bin/pm2 kill + +[Install] +WantedBy=multi-user.target diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..2f59c25 --- /dev/null +++ b/scripts/README.md @@ -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`. diff --git a/scripts/backup.sh b/scripts/backup.sh new file mode 100644 index 0000000..0cc07fe --- /dev/null +++ b/scripts/backup.sh @@ -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" diff --git a/scripts/gen-status.mjs b/scripts/gen-status.mjs new file mode 100644 index 0000000..fb040e3 --- /dev/null +++ b/scripts/gen-status.mjs @@ -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, "'"); +} + +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 `
  • ${mark}${esc(item.label)}
  • `; + }) + .join("\n"); +} + +function renderPhase(phase) { + const doneCount = phase.items.filter((i) => i.done).length; + const stateClass = esc(phase.state); + return `
    +
    +

    Phase ${esc(phase.id)} ${esc(phase.name)}

    + ${esc(stateLabel(phase.state))} +
    +

    ${doneCount} / ${phase.items.length} complete

    +
      +${renderItems(phase.items)} +
    +
    `; +} + +const phasesHtml = data.phases.map(renderPhase).join("\n"); +const infraHtml = renderItems(data.infra); +const infraDone = data.infra.filter((i) => i.done).length; + +const html = ` + + + + + ${esc(data.project)} — Status Dashboard + + + +
    +
    +

    ${esc(data.project)}

    +

    ${esc(data.tagline)}

    + +
    + +
    +
    +

    Overall MVP Progress

    + ${overallPct}% +
    +
    +

    ${doneItems} of ${totalItems} phase deliverables complete

    +
    + +

    Development Phases

    +
    +${phasesHtml} +
    + +

    Infrastructure

    +
    +

    ${infraDone} / ${data.infra.length} provisioned

    +
      +${infraHtml} +
    +
    + +
    +
    Last updated: ${esc(data.updated)}
    +
    Static snapshot generated from status.json — not live telemetry.
    +
    +
    + + +`; + +writeFileSync(outPath, html, "utf8"); +console.log(`Wrote ${outPath} (${overallPct}% complete, ${doneItems}/${totalItems} items)`); diff --git a/scripts/phase0-provision.sh b/scripts/phase0-provision.sh new file mode 100755 index 0000000..fa00a9b --- /dev/null +++ b/scripts/phase0-provision.sh @@ -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}:@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 + # 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 "$@"