commit 08fc8f66d087840718a9ac084c71317e08633cfa Author: Mike Kell Date: Sat Oct 3 13:39:34 2026 -0400 Initial commit: wp-engine hardening script, README, and architecture docs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..228d24c --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +# OS / editor noise +.DS_Store +Thumbs.db +*.swp +*~ +.vscode/ +.idea/ + +# Secrets & runtime state — never commit +.engine-settings +.env +*.env +*.log + +# Transient +*.tmp diff --git a/README.md b/README.md new file mode 100644 index 0000000..3af91ea --- /dev/null +++ b/README.md @@ -0,0 +1,513 @@ +# SecureServer — `wp-engine.sh` + +**Also known as WP-Engine — Multi-Tenant Hardened WordPress Infrastructure.** + +A single, self-contained Bash script that takes a **bare-metal server, Proxmox VM, Proxmox LXC, or base container** from an open, unhardened state to a **secure, multi-tenant WordPress hosting platform** with **observability dashboards** — in two commands: `--bootstrap` and `create`. + +It eliminates container sprawl by combining a **centralized, tuned MariaDB 11.4 instance** and a **centralized Redis 7 object cache** with completely isolated tenant environments — alongside a host-level security perimeter (hardened high-port SSH, UFW with container-bypass prevention, and Fail2ban) and a full Prometheus + Grafana observability stack. + +Work proceeds in two phases: `--bootstrap` hardens the host and stands up the platform, then `create` spins up unlimited WordPress sites — each with its own database, credentials, Redis cache index, and TLS routing. + +--- + +## Table of Contents + +1. [Project Overview](#1-project-overview) +2. [Requirements](#2-requirements) +3. [Quick Start](#3-quick-start) +4. [CLI Reference & Options](#4-cli-reference--options) +5. [Step-by-Step: What `--bootstrap` Does](#5-step-by-step-what---bootstrap-does) +6. [Step-by-Step: What `create` Does](#6-step-by-step-what-create-does) +7. [Option Guides](#7-option-guides) +8. [Architecture: Networks, Ports, Layout](#8-architecture-networks-ports-layout) +9. [Credentials & State Files](#9-credentials--state-files) +10. [Monitoring Dashboards](#10-monitoring-dashboards) +11. [Day-2 Operations](#11-day-2-operations) +12. [Security Hardening Summary](#12-security-hardening-summary) +13. [Known Limitations & Post-Install TODOs](#13-known-limitations--post-install-todos) + +--- + +## 1. Project Overview + +**Problem:** A freshly provisioned Linux server is open to the internet — SSH brute force, WordPress exploit scanners, open databases, no firewall, no visibility. + +**Solution:** `wp-engine.sh` performs two phases: + +| Phase | Command | Purpose | +|---|---|---| +| **Phase 1 — Host Hardening & Platform** | `./wp-engine.sh --bootstrap` | Installs packages, container runtime, hardened SSH, UFW firewall, Fail2ban jails, container networks, ingress reverse proxy, and the Grafana/Prometheus monitoring stack. | +| **Phase 2 — Site Provisioning** | `./wp-engine.sh create ...` | Creates an isolated WordPress site (container, database, DB user, Redis cache index, TLS route, WP-CLI automated install). | + +**Key design properties:** + +- **Multi-tenant** — many WordPress sites share one host, one MariaDB instance (isolated DB per site), and one Redis instance (isolated DB index per site). +- **Proxy-agnostic** — choose Nginx Proxy Manager, Traefik v3, or Caddy at bootstrap time. +- **Runtime-agnostic** — Docker (official packages) or Podman (distro packages). +- **Persistent state** — choices are persisted to `/server/websites/.engine-settings`, so subsequent `create` runs need no re-prompting. +- **Automated WordPress install** — `wp core install`, permalinks, and Redis object cache are wired up via WP-CLI containers; no browser wizard required. + +Supporting design documents live in [`docs/`](docs/): + +- [`docs/Runtime_Architecture_Network_Topology.md`](docs/Runtime_Architecture_Network_Topology.md) — Mermaid diagram of traffic flow, networks, and security layers. +- [`docs/Filesystem_and_Directory_Structure.md`](docs/Filesystem_and_Directory_Structure.md) — Mermaid diagram of `/server/websites/` layout. + +--- + +## 2. Requirements + +| Requirement | Detail | +|---|---| +| **OS** | Clean Debian 11/12 or Ubuntu 22.04/24.04 LTS installation (bare-metal, Proxmox VM, or Proxmox LXC) with `apt` and `systemd` (the script calls `apt-get`, `systemctl`, `ufw`). Not compatible with RHEL/Alpine/Windows hosts. | +| **Privileges** | Must be run as `root` (the script exits if `EUID != 0`). | +| **Network** | Outbound internet access to pull packages and container images (Docker Hub, download.docker.com, etc.). | +| **Inbound ports** | `80`/`443` reachable from the internet (for HTTP/HTTPS + ACME TLS challenges), plus your chosen SSH port. | +| **DNS** | `A`/`AAAA` records for each site domain pointing at the server's public IP **before** running `create` (needed for certificate issuance). | +| **Resources** | Recommended ≥ 2 GB RAM (MariaDB buffer pool is pre-set to `2G` in `99-performance.cnf`) and ≥ 20 GB disk. | + +--- + +## 3. Quick Start + +```bash +# 0. Copy the script to the target host and make it executable +chmod +x wp-engine.sh + +# 1. Harden the host + deploy proxy & monitoring (interactive prompts) +./wp-engine.sh --bootstrap + +# 2. Provision your first WordPress site +./wp-engine.sh create teetimeprints teetimeprints.com mkell admin@teetimeprints.com +``` + +**Fully unattended bootstrap** (no prompts — ideal for cloud-init / Ansible / automation): + +```bash +# Docker + Caddy on custom SSH port 48222 +./wp-engine.sh --bootstrap --ssh-port 48222 --engine docker --proxy caddy + +# Docker + Traefik v3 on custom SSH port 52822 +./wp-engine.sh --bootstrap --ssh-port 52822 --engine docker --proxy traefik + +# Podman + Nginx Proxy Manager (NPM) on custom SSH port 48222 +./wp-engine.sh --bootstrap --ssh-port 48222 --engine podman --proxy npm +``` + +**Install system-wide** so it can be run from anywhere: + +```bash +install -m 0755 wp-engine.sh /usr/local/bin/wp-engine.sh +# ...or download it directly: +curl -fsSL https://your-repo/wp-engine.sh -o /usr/local/bin/wp-engine.sh +chmod +x /usr/local/bin/wp-engine.sh +``` + +> [!WARNING] +> **Do not close your current SSH terminal** after running `--bootstrap`. Open a secondary terminal and verify that you can connect over your chosen port before disconnecting: +> ```bash +> ssh -p user@ +> ``` + +--- + +## 4. CLI Reference & Options + +``` +Usage: + Interactive Bootstrap: + ./wp-engine.sh --bootstrap + + Unattended Bootstrap: + ./wp-engine.sh --bootstrap --ssh-port --engine --proxy + + Provision Site: + ./wp-engine.sh create [admin_user] [admin_email] + +Examples: + ./wp-engine.sh --bootstrap --ssh-port 48222 --engine docker --proxy caddy + ./wp-engine.sh create teetimeprints teetimeprints.com mkell admin@teetimeprints.com +``` + +### Actions + +| Action | Description | +|---|---| +| `--bootstrap` | Runs the 6-step host provisioning sequence, then deploys the monitoring stack. Prompts for any option not supplied via flags. | +| `create [admin_user] [admin_email]` | Provisions a new WordPress site. If the ingress proxy isn't running yet, bootstrap runs automatically; if the shared DB/cache core isn't up, it is initialized automatically. | +| *(no/unknown action)* | Prints the usage text above and exits with code 1. | + +### Flags (used with `--bootstrap`) + +| Flag | Values | Default | Effect | +|---|---|---|---| +| `--ssh-port` | `1–65535` | `48222` | Moves SSH off port 22; written to `sshd_config.d/99-hardened.conf`, the `ssh.socket` unit (if present), UFW, and the Fail2ban `sshd` jail. | +| `--engine` | `docker` \| `podman` | `docker` | Container runtime. Docker installs from Docker's official apt repo; Podman installs `podman`, `podman-compose`, `podman-docker` from distro repos. | +| `--proxy` | `npm` \| `traefik` \| `caddy` | `npm` | Ingress reverse proxy. Determines the proxy compose file, extra firewall openings, TLS automation, and how sites get routed. | + +> **Note:** Flags are read before the persisted state is loaded, so they take precedence for that run and are saved into `/server/websites/.engine-settings`. Subsequent `create` commands reuse the saved choices without prompting. + +### `create` arguments + +| Position | Argument | Default | Description | +|---|---|---|---| +| 1 | `slug` | *(required)* | Directory/container name, e.g. `teetimeprints`. Hyphens are converted to underscores for DB identifiers. | +| 2 | `domain` | *(required)* | Public FQDN, e.g. `teetimeprints.com`. Used for TLS routing and the WP site URL. | +| 3 | `admin_user` | `admin` | WordPress administrator username. | +| 4 | `admin_email` | `admin@` | WordPress administrator e-mail. | + +Additional `create` examples: + +```bash +# Minimal — admin user defaults to `admin`, e-mail to admin@myblog.com +./wp-engine.sh create myblog myblog.com + +# Custom admin credentials +./wp-engine.sh create teetimeprints teetimeprints.com mkell admin@teetimeprints.com +``` + +--- + +## 5. Step-by-Step: What `--bootstrap` Does + +### Phase A — Bare-host provisioning & hardening (6 steps) + +**`[1/6] Installing Host Packages`** +Installs `apt-transport-https ca-certificates curl gnupg lsb-release ufw fail2ban nftables openssl jq`. + +**`[2/6] Setting up Container Engine`** +- *Docker:* adds Docker's official GPG key + apt repo, installs `docker-ce`, `docker-ce-cli`, `containerd.io`, `docker-buildx-plugin`, `docker-compose-plugin`, enables the service (skipped if Docker already present). +- *Podman:* `apt-get install podman podman-compose podman-docker`, enables the service. + +**`[3/6] Hardening SSH on Port `** +Writes `/etc/ssh/sshd_config.d/99-hardened.conf`: + +``` +Port +PermitRootLogin prohibit-password +PasswordAuthentication yes +X11Forwarding no +MaxAuthTries 4 +ClientAliveInterval 300 +ClientAliveCountMax 2 +``` + +Also re-points `ssh.socket`'s `ListenStream` to the new port when socket activation is in use, then restarts SSH. + +**`[4/6] Configuring Firewall & Isolation Rules`** +- UFW policy: **deny incoming / allow outgoing**. +- Allowed inbound: SSH port, `80`, `443`, `3000` (Grafana), plus `81` if NPM or `8080` if Traefik. +- For Docker, appends a **`DOCKER-USER` chain** to `/etc/ufw/after.rules` so UFW rules actually apply to container traffic (blocks direct SYN scans into `172.16.0.0/12`, rate-limited logging). +- `ufw --force enable && ufw reload`. + +**`[5/6] Scaffolding Networks & Fail2ban Jails`** +- Creates the directory skeleton (`/server/websites/{proxy,core,template,sites,monitoring}`). +- Creates three external container networks: `proxy-net`, `shared-backend`, `monitoring-net`. +- Writes Fail2ban jails: + - **`sshd`** — your SSH port, `maxretry 3`, `findtime 600`, `bantime 3600`, `banaction ufw`. + - **`ingress-wordpress`** — watches `/server/websites/proxy/logs/*.log` for brute force on `/wp-login.php` and `/xmlrpc.php`, plus scans for `.env`, `.git`, `wp-config.php`, `eval-stdin.php`; `maxretry 4`, `bantime 86400` (24 h), `banaction ufw`. +- Enables and restarts Fail2ban. + +**`[6/6] Launching Ingress Proxy`** — writes and starts the proxy compose file (differences per proxy are covered in [Option Guides](#7-option-guides)), then deploys monitoring. + +### Phase B — Observability stack (`deploy_monitoring`) + +Deploys `/server/websites/monitoring/docker-compose.yml` on the isolated `monitoring-net`: + +| Service | Image | Purpose | Exposure | +|---|---|---|---| +| `prometheus` | `prom/prometheus:v2.54.0` | Time-series DB; scrapes node_exporter + cAdvisor every 15 s | internal | +| `node_exporter` | `prom/node-exporter:v1.8.2` | Host CPU/RAM/disk metrics (`/proc`, `/sys`, `/` mounted read-only) | internal | +| `cadvisor` | `gcr.io/cadvisor/cadvisor:v0.49.1` | Per-container cgroup resource usage | internal | +| `grafana` | `grafana/grafana:11.2.0` | Dashboards; Prometheus datasource auto-provisioned; sign-up disabled | **`:3000`** | + +A random Grafana admin password is generated (`openssl rand`) and appended to the state file as `GRAFANA_ADMIN_PASS`. + +--- + +## 6. Step-by-Step: What `create` Does + +`./wp-engine.sh create [admin_user] [admin_email]` + +1. **Load state** — reads `/server/websites/.engine-settings` (runs bootstrap prompts only if it doesn't exist yet). +2. **Auto-heal** — if no `ingress_proxy` container is running, bootstrap runs automatically; if `core_mariadb` is missing, the shared core is initialized. +3. **Allocate resources** + - Next Redis DB index from `NEXT_REDIS_INDEX` (incremented in `core/.env`). + - DB name `wp_` and DB user `usr_` (hyphens → underscores). + - Random DB password and random WordPress admin password (`openssl rand`). +4. **Create the database** — `CREATE DATABASE`, `CREATE USER`, `GRANT ALL` scoped to that site's DB only, executed inside `core_mariadb`. +5. **Write site files** + - `config/wordpress.ini` — PHP limits: `memory_limit 512M`, `upload_max_filesize/post_max_size 128M`, `max_execution_time 300`, `max_input_vars 5000`, OPcache enabled (256 MB, 20 000 files). + - `docker-compose.yml` — `wordpress:6.7-apache` container attached to `proxy-net` **and** `shared-backend`, with `WORDPRESS_CONFIG_EXTRA` injecting: + - Redis connection (`core_redis`, port 6379, per-site password, per-site DB index, per-site cache key salt) + - `DISALLOW_FILE_EDIT` (no theme/plugin editing from wp-admin) + - `FORCE_SSL_ADMIN` + - `X-Forwarded-Proto` HTTPS detection behind the proxy + - Traefik routing labels (only when `--proxy traefik`) +6. **Start the site** — `chown 33:33` on the docroot, then `compose up -d`. +7. **Wire up routing** + - *Caddy:* appends a `domain { reverse_proxy slug_wp:80 ... }` block to the `Caddyfile` and hot-reloads Caddy. + - *Traefik:* handled by the labels from step 5 (router on `websecure`, cert resolver `myresolver`). + - *NPM:* prints a reminder — create the Proxy Host manually in the NPM UI. +8. **Automated WordPress bootstrap (WP-CLI container)** + - Waits for `wp-config.php` to appear in the docroot. + - `wp core install --url=https:// --admin_user=... --admin_password=... --admin_email=... --skip-email` + - `wp rewrite structure '/%postname%/' --hard` + - `wp plugin install redis-cache --activate` → `wp redis enable` + - `chown -R 33:33 data/wp/wp-content` +9. **Prints a summary** — domain, admin user, **admin password (shown only once — copy it now)**, DB name, DB user, Redis index, directory path, plus any proxy-specific next step. + +--- + +## 7. Option Guides + +### 7.1 Container runtime + +| | **Docker** (default) | **Podman** | +|---|---|---| +| Install source | Docker official apt repo (`docker-ce` + compose plugin) | Distro repos (`podman`, `podman-compose`, `podman-docker`) | +| Compose command | `docker compose` | `podman-compose` | +| UFW integration | Gets the `DOCKER-USER` after.rules patch (required, since Docker bypasses UFW by default) | Not applied (Podman networking works differently with nftables/UFW) | +| Root | Script still requires root for host hardening | Same — host services are hardened system-wide | + +### 7.2 Ingress proxy + +| | **NPM** (default) | **Traefik v3** | **Caddy** | +|---|---|---|---| +| Image | `jc21/nginx-proxy-manager` | `traefik:v3.1` | `caddy:2-alpine` | +| Management | Web UI on **`:81`** (opened in UFW) | Docker labels + insecure API dashboard on **`:8080`** (opened in UFW) | Text `Caddyfile` | +| TLS certificates | Requested per host in the UI (Let's Encrypt) | ACME **TLS-ALPN challenge** via `myresolver` labels | **Fully automatic** Let's Encrypt | +| Site onboarding | **Manual**: create Proxy Host → `https://domain` → forward to `_wp:80` | **Automatic**: labels emitted by `create` | **Automatic**: `create` appends a site block + reloads | +| Access log | bind-mounted `proxy/logs/` | `proxy/logs/access.log` (`--accesslog`) | `proxy/logs/access.log` via `log {}` block | +| Extra ports opened | `81` | `8080` | none | +| Best for | Users who want a GUI | Label-driven, declarative setups | Minimal, zero-touch SSL | + +The proxy choice affects the generated `proxy/docker-compose.yml`, the UFW allow rules, and how `create` wires routing — all persisted to the state file. + +### 7.3 SSH port + +Any non-standard port (default `48222`). Applied consistently in four places: `sshd_config.d`, `ssh.socket`, the UFW allow rule, and the Fail2ban `sshd` jail — so ban actions target the right port. + +--- + +## 8. Architecture: Networks, Ports, Layout + +High-level traffic flow (see `docs/` for the full Mermaid diagrams): + +```text + Internet / WAN + │ + ▼ + ┌──────────────────────────────────┐ + │ Host Firewall (UFW / nftables) │ + │ + Fail2ban Bare-Host Jails │ + └─────────────────┬────────────────┘ + │ Ports 80 / 443 + ▼ + ┌──────────────────────────────────┐ + │ Ingress: Caddy / Traefik / NPM │ ── Access Logs ──► Host Fail2ban + └──────────────┬───────────────────┘ + │ + ┌───────────────────┴───────────────────┐ + ▼ ▼ +┌──────────────┐ ┌──────────────┐ +│ WordPress │ │ Observability│ +│ - PHP 8.3 │ │ - Grafana │ (Port 3000) +│ - WP-CLI │ │ - Prometheus│ +│ - OPcache │ │ - Node Exp │ +└──────┬───────┘ │ - cAdvisor │ + │ └──────────────┘ + ┌────┴────────────┐ + ▼ ▼ +┌───────────┐ ┌───────────┐ +│ MariaDB │ │ Redis │ +│ (Shared │ │ (Shared │ +│ Core) │ │ Cache) │ +└───────────┘ └───────────┘ +``` + +### Networks + +| Network | Scope | Members | +|---|---|---| +| `proxy-net` | external | ingress proxy + all WordPress containers (public-facing side) | +| `shared-backend` | external, isolated | all WordPress containers + `core_mariadb` + `core_redis` (no host ports published) | +| `monitoring-net` | external, isolated | Prometheus, node_exporter, cAdvisor, Grafana | + +### Ports + +| Port | Service | Firewall | +|---|---|---| +| 22 → **48222** (default) | SSH | allowed (custom port) | +| 80 / 443 | Ingress proxy (HTTP/HTTPS + ACME) | allowed | +| 81 | Nginx Proxy Manager admin UI | allowed **only if** `--proxy npm` | +| 8080 | Traefik dashboard | allowed **only if** `--proxy traefik` | +| 3000 | Grafana | allowed | +| 3306 / 6379 | MariaDB / Redis | **not published** — container network only | + +### Filesystem layout (`/server/websites/`) + +``` +/server/websites/ +├── .engine-settings # Persisted choices: ENGINE, PROXY_TYPE, SSH_PORT, GRAFANA_ADMIN_PASS +├── core/ # Shared data tier +│ ├── .env # DB_ROOT_PASSWORD, REDIS_PASSWORD, NEXT_REDIS_INDEX (chmod 600) +│ ├── docker-compose.yml # core_mariadb (MariaDB 11.4) + core_redis (Redis 7) +│ ├── config/mariadb/99-performance.cnf +│ └── data/{mariadb,redis}/ +├── proxy/ # Ingress tier +│ ├── docker-compose.yml # ingress_proxy container +│ ├── Caddyfile # (Caddy only) auto-appended per site +│ ├── letsencrypt/ # acme.json / certs +│ └── logs/access.log # scanned by Fail2ban `ingress-wordpress` +├── monitoring/ # Observability tier +│ ├── docker-compose.yml # prometheus, node_exporter, cadvisor, grafana +│ ├── prometheus/prometheus.yml +│ └── grafana/provisioning/datasources/prometheus.yaml +├── sites// # One directory per WordPress tenant +│ ├── docker-compose.yml # _wp container (proxy-net + shared-backend) +│ ├── config/wordpress.ini # OPcache & upload limits +│ └── data/wp/ # Full WordPress docroot (uid 33) +└── template/ # Reserved for site templates +``` + +Host-level configs written by the script: + +- `/etc/ssh/sshd_config.d/99-hardened.conf` +- `/etc/ufw/after.rules` (DOCKER-USER protection block) +- `/etc/fail2ban/jail.d/sshd.local` +- `/etc/fail2ban/jail.d/ingress-wordpress.local` +- `/etc/fail2ban/filter.d/ingress-wordpress.conf` + +--- + +## 9. Credentials & State Files + +| Secret | Where it lives | Notes | +|---|---|---| +| Engine / proxy / SSH port | `/server/websites/.engine-settings` | plain, non-secret | +| Grafana admin password | `/server/websites/.engine-settings` → `GRAFANA_ADMIN_PASS` | login: `admin` @ `http://:3000` | +| MariaDB **root** password | `/server/websites/core/.env` → `DB_ROOT_PASSWORD` | `chmod 600`; used for creating per-site DBs | +| Redis password | `/server/websites/core/.env` → `REDIS_PASSWORD` | injected into every site's `wp-config` | +| Per-site DB password | injected into the site's compose environment | random per site | +| **WordPress admin password** | **printed to stdout only, at the end of `create`** | **not persisted anywhere — save it immediately** | +| Redis DB index | `core/.env` → `NEXT_REDIS_INDEX` counter | one index allocated per site (0, 1, 2, …) | + +> ⚠️ Re-running `--bootstrap` appends a **new** `GRAFANA_ADMIN_PASS` line; when the state file is sourced, the **last** line wins, effectively rotating the Grafana password. + +--- + +## 10. Monitoring Dashboards + +After bootstrap: + +1. Open `http://:3000` (Grafana). +2. Log in with `admin` / the password generated during bootstrap and saved in the state file: + ```bash + grep GRAFANA_ADMIN_PASS /server/websites/.engine-settings + ``` +3. The **Prometheus** datasource is pre-provisioned and set as default. Import dashboards via **Dashboards → Import** — recommended IDs: + + | ID | Dashboard | Provides | + |---|---|---| + | `1860` | Node Exporter Full | Real-time CPU, RAM, disk I/O, network throughput, load averages | + | `14282` | cAdvisor Docker Monitoring | Per-container resource usage; spot CPU/RAM throttling across tenant sites | + +Data collected: + +- **node_exporter** → host CPU, memory, disk I/O, network. +- **cAdvisor** → per-container CPU/memory of every WordPress, DB, proxy, and monitoring container. +- **Prometheus** → 15 s scrape interval, persistent `prometheus_data` volume. + +--- + +## 11. Day-2 Operations + +```bash +# Add another site (no prompts — state already saved) +./wp-engine.sh create kellcreations kellcreations.com admin admin@kellcreations.com + +# Check running containers +docker ps # or: podman ps + +# Site lifecycle +cd /server/websites/sites/ +docker compose up -d / stop / restart / logs -f + +# Core stack & proxy +cd /server/websites/core && docker compose ps +cd /server/websites/proxy && docker compose logs -f ingress + +# WP-CLI inside a site (one-off) +docker run --rm --user 33:33 --network shared-backend \ + -v /server/websites/sites//data/wp:/var/www/html wordpress:cli wp plugin list + +# Logs for a specific site +docker logs -f _wp # or: podman logs -f _wp + +# Unban an IP address +fail2ban-client set ingress-wordpress unbanip + +# Security status +ufw status verbose +fail2ban-client status +fail2ban-client status ingress-wordpress +fail2ban-client status sshd +fail2ban-client get ingress-wordpress banned +``` + +**Backups** (not automated by the script — recommended): + +- `/server/websites/core/data/mariadb/` — all site databases (stop MariaDB or use `mariadb-dump` for consistency). +- `/server/websites/sites/*/data/wp/` — WordPress docroots (uploads, themes, plugins). +- `/server/websites/core/.env` and `.engine-settings` — credentials/state. + +--- + +## 12. Security Hardening Summary + +| Layer | Control | +|---|---| +| **Access** | SSH moved off port 22; root login limited to key auth (`PermitRootLogin prohibit-password`); `MaxAuthTries 4`; X11 forwarding off; idle timeout via `ClientAliveInterval`/`ClientAliveCountMax` | +| **Perimeter** | UFW default-deny inbound; explicit allow-list (SSH, 80, 443, 3000, +81/8080 per proxy) | +| **Container escape path** | `DOCKER-USER` nftables chain in `/etc/ufw/after.rules` so UFW governs Docker-bridged traffic; direct scans into RFC1918 container ranges are logged + dropped | +| **Brute force** | Fail2ban `sshd` jail (3 attempts → 1 h ban) and `ingress-wordpress` jail (4 attempts on `wp-login.php`/`xmlrpc.php`/sensitive-file scans → 24 h ban via UFW), fed by proxy access logs | +| **WordPress** | `DISALLOW_FILE_EDIT`, `FORCE_SSL_ADMIN`, HTTPS detection behind proxy, randomized admin/DB passwords, per-site scoped DB grants (no shared credentials) | +| **Secrets** | Root/Redis passwords generated with `openssl rand`; `core/.env` set to `chmod 600`; Grafana sign-up disabled | +| **Isolation** | Each site gets its own DB, DB user, Redis index, cache key salt, docroot, and compose project — on a shared-but-segmented backend network with **no published host ports** | +| **Observability** | Centralized access logs → Fail2ban; Prometheus metrics for capacity/anomaly detection | + +--- + +## 13. Known Limitations & Post-Install TODOs + +Behaviors of the current script — review and adjust to your threat model: + +1. **NPM requires a manual step** — after `create`, log in to `http://:81` (NPM default login) and add a Proxy Host forwarding `https://` → `_wp:80` with an SSL certificate. Change the NPM default password immediately. +2. **Traefik ACME email is a placeholder** — the generated compose file hardcodes `admin@example.com`. Edit `/server/websites/proxy/docker-compose.yml` before the first certificate request. +3. **Traefik dashboard is exposed** — `--api.insecure=true` on `:8080` and UFW allows it publicly. Restrict or close it if you don't need it. +4. **NPM admin UI (`:81`) is exposed publicly** when `--proxy npm` is selected. Consider restricting it to your IP. +5. **Grafana (`:3000`) is served over plain HTTP** with no TLS and is firewall-open. Put it behind the proxy with HTTPS, or restrict the firewall rule to your IP. +6. **`PasswordAuthentication yes`** remains enabled in the SSH hardening block — convenient for first-time migration, but switch to keys-only (`no`) once your key is installed. +7. **Single MariaDB/Redis instance** — `innodb_buffer_pool_size = 2G` is a fixed default; tune `core/config/mariadb/99-performance.cnf` for smaller/larger hosts. +8. **Distro scope** — apt/systemd only. Non-Debian hosts need manual adaptation of steps 1 and 4. +9. **No automated backups or WAF** — add ModSecurity/Cloudflare or scheduled backups per your requirements. +10. **WP admin password is ephemeral** — captured only in the terminal output of `create`; store it in your password manager right away. + +--- + +## Repository Contents + +``` +wp-engine.sh # The installer/provisioner script (executable) +docs/Runtime_Architecture_Network_Topology.md # Architecture & network Mermaid diagram +docs/Filesystem_and_Directory_Structure.md # Filesystem layout Mermaid diagram +README.md # This file +``` + + + + + + + + diff --git a/docs/Filesystem_and_Directory_Structure.md b/docs/Filesystem_and_Directory_Structure.md new file mode 100644 index 0000000..e5d64f2 --- /dev/null +++ b/docs/Filesystem_and_Directory_Structure.md @@ -0,0 +1,99 @@ +# Filesystem & Directory Structure + +```mermaid +graph TD + Root["/server/websites/"] + + %% Root Configuration & State Files + StateFile[".engine-settings\n(Persisted: RUNTIME, PROXY_TYPE, SSH_PORT, GRAFANA_PASS)"] + ScriptExec["/usr/local/bin/wp-engine.sh\n(Self-contained installer & provisioner)"] + Root --- StateFile + Root --- ScriptExec + + %% Core Shared Stack + subgraph CoreDir["/server/websites/core/"] + CoreEnv[".env (DB_ROOT_PASSWORD, REDIS_PASSWORD, NEXT_REDIS_INDEX)"] + CoreCompose["docker-compose.yml (core_mariadb + core_redis)"] + CoreConfig["config/mariadb/99-performance.cnf"] + CoreDataMariaDB[("data/mariadb/ (Persistent MySQL data)")] + CoreDataRedis[("data/redis/ (Persistent Redis dump)")] + + CoreEnv --- CoreCompose + CoreCompose --- CoreConfig + CoreCompose --- CoreDataMariaDB + CoreCompose --- CoreDataRedis + end + Root --> CoreDir + + %% Proxy Stack + subgraph ProxyDir["/server/websites/proxy/"] + ProxyCompose["docker-compose.yml (ingress_proxy)"] + ProxyData["data/ (NPM state / Caddy data)"] + ProxyLE["letsencrypt/ (acme.json / certs)"] + ProxyLogs["logs/access.log (Scanned by bare-host Fail2ban)"] + ProxyCaddy["Caddyfile (If Caddy selected; dynamically updated)"] + + ProxyCompose --- ProxyData + ProxyCompose --- ProxyLE + ProxyCompose --- ProxyLogs + ProxyCompose --- ProxyCaddy + end + Root --> ProxyDir + + %% Monitoring Stack + subgraph MonDir["/server/websites/monitoring/"] + MonCompose["docker-compose.yml (Prometheus, Node Exp, cAdvisor, Grafana)"] + PromConfig["prometheus/prometheus.yml"] + GrafanaProv["grafana/provisioning/datasources/prometheus.yaml"] + MonData["prometheus_data / grafana_data (Named Volumes)"] + + MonCompose --- PromConfig + MonCompose --- GrafanaProv + MonCompose --- MonData + end + Root --> MonDir + + %% Template Stack + subgraph TemplateDir["/server/websites/template/"] + SiteTemplate["docker-compose.site.yml (Standard WP Compose definition)"] + end + Root --> TemplateDir + + %% Tenant Sites + subgraph SitesDir["/server/websites/sites/"] + subgraph SiteA["mysite/"] + SiteAEnv[".env (Scoped DB user, password, Redis DB index)"] + SiteACompose["docker-compose.yml"] + SiteAConfig["config/wordpress.ini (OPcache, upload limits)"] + SiteAData[("data/wp/ (Full WordPress document root)")] + + SiteAEnv --- SiteACompose + SiteACompose --- SiteAConfig + SiteACompose --- SiteAData + end + + subgraph SiteB["teetimeprints/"] + SiteBCompose["... (Identical isolated structure)"] + end + end + Root --> SitesDir + + %% Host-level configurations + subgraph HostConfig["Host-Level Configs (/etc/)"] + F2BJail["/etc/fail2ban/jail.d/ingress-wordpress.local"] + F2BFilter["/etc/fail2ban/filter.d/ingress-wordpress.conf"] + UFWChain["/etc/ufw/after.rules (DOCKER-USER isolation chain)"] + SSHDDropin["/etc/ssh/sshd_config.d/99-hardened.conf"] + end + + ScriptExec -.->|Deploys & Controls| HostConfig + + %% Styling + classDef dir fill:#eceff1,stroke:#455a64,stroke-width:2px,color:#263238; + classDef compose fill:#e1f5fe,stroke:#0288d1,stroke-width:1px,color:#01579b; + classDef data fill:#fff8e1,stroke:#ffa000,stroke-width:1px,color:#ff6f00; + + class Root,CoreDir,ProxyDir,MonDir,TemplateDir,SitesDir,SiteA,SiteB dir; + class CoreCompose,ProxyCompose,MonCompose,SiteTemplate,SiteACompose compose; + class CoreDataMariaDB,CoreDataRedis,SiteAData,MonData data; + ``` diff --git a/docs/Runtime_Architecture_Network_Topology.md b/docs/Runtime_Architecture_Network_Topology.md new file mode 100644 index 0000000..457ac61 --- /dev/null +++ b/docs/Runtime_Architecture_Network_Topology.md @@ -0,0 +1,84 @@ +# Runtime Architecture & Network Topology + +```mermaid +flowchart TD + %% Internet & External Ingress + User([WAN / Client Traffic]) -->|Port 80 / 443| IngressRule + Attacker([Malicious Actor / Brute Force]) -.->|wp-login / xmlrpc / exploits| IngressRule + + %% Bare Host Hardening & Security Layer + subgraph BareHost["Bare Proxmox / Debian Host"] + IngressRule["Host Firewall: nftables / UFW\n(Deny All Incoming except 80, 443, 3000, 48222)"] + SSHD["Hardened OpenSSH\n(Port 48222)"] + F2B["Fail2ban Engine\n(jail: ingress-wordpress, sshd)"] + LogWatcher[("/server/websites/proxy/logs/*.log")] + + IngressRule -->|Port 48222| SSHD + LogWatcher -->|Inspects Access Logs| F2B + F2B -->|Bans IP via UFW / nftables| IngressRule + end + + %% Ingress Proxy Tier + subgraph NetProxy["Docker / Podman Network: proxy-net (External)"] + IngressProxy["Ingress Reverse Proxy\n(Caddy / Traefik v3 / NPM)"] + IngressRule -->|Forward 80/443| IngressProxy + IngressProxy -.->|Writes Client Access Logs| LogWatcher + end + + %% WordPress Multi-Tenant Tier + subgraph NetBackend["Docker / Podman Network: shared-backend (Isolated)"] + subgraph Tenant1["Tenant: Site 1 (e.g. teetimeprints)"] + WP1["WordPress 6.7 Container\n(PHP 8.3 / Apache)"] + RedisDropin1["Redis Object Cache Drop-in\n(Key Salt: teetimeprints_)"] + WP1 --- RedisDropin1 + end + + subgraph Tenant2["Tenant: Site N (e.g. kellcreations)"] + WPN["WordPress Container\n(PHP 8.3 / Apache)"] + RedisDropinN["Redis Object Cache Drop-in\n(Key Salt: siteN_)"] + WPN --- RedisDropinN + end + + %% Centralized Core Data Tier + subgraph CoreData["Centralized Core Stack"] + CoreDB[("core_mariadb\n(MariaDB 11.4)\n- Isolated DB per site\n- Scoped DB user per site\n- Buffer Pool: 2GB")] + CoreRedis[("core_redis\n(Redis 7 Alpine)\n- DB Index 0..15 per site\n- Maxmemory: 512MB / LRU")] + end + + WP1 -->|MySQL: Port 3306| CoreDB + WP1 -->|TCP: Port 6379 / DB 0| CoreRedis + WPN -->|MySQL: Port 3306| CoreDB + WPN -->|TCP: Port 6379 / DB N| CoreRedis + end + + %% Proxy to WP App Connections + IngressProxy -->|Reverse Proxy / HTTPS Term| WP1 + IngressProxy -->|Reverse Proxy / HTTPS Term| WPN + + %% Observability Stack + subgraph NetMon["Docker / Podman Network: monitoring-net (Isolated)"] + NodeExp["node_exporter:9100\n(Host CPU, RAM, Disk I/O)"] + CAdvisor["cadvisor:8080\n(Per-container cgroup usage)"] + Prometheus[("prometheus:9090\n(Time Series DB)")] + Grafana["grafana:3000\n(Pre-provisioned Dashboards)"] + + NodeExp -->|Scraped| Prometheus + CAdvisor -->|Scraped| Prometheus + Prometheus -->|Datasource| Grafana + end + + IngressRule -->|Port 3000| Grafana + + %% Styling + classDef security fill:#ffebee,stroke:#c62828,stroke-width:2px,color:#b71c1c; + classDef proxy fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px,color:#1b5e20; + classDef app fill:#e3f2fd,stroke:#1565c0,stroke-width:2px,color:#0d47a1; + classDef db fill:#fff3e0,stroke:#e65100,stroke-width:2px,color:#e65100; + classDef monitor fill:#f3e5f5,stroke:#6a1b9a,stroke-width:2px,color:#4a148c; + + class F2B,IngressRule,SSHD security; + class IngressProxy proxy; + class WP1,WPN app; + class CoreDB,CoreRedis db; + class Grafana,Prometheus,NodeExp,CAdvisor monitor; + ``` diff --git a/wp-engine.sh b/wp-engine.sh new file mode 100644 index 0000000..4b6111f --- /dev/null +++ b/wp-engine.sh @@ -0,0 +1,794 @@ +#!/usr/bin/env bash +set -euo pipefail + +BASE_DIR="/server/websites" +PROXY_DIR="${BASE_DIR}/proxy" +CORE_DIR="${BASE_DIR}/core" +TEMPLATE_DIR="${BASE_DIR}/template" +SITES_DIR="${BASE_DIR}/sites" +MONITORING_DIR="${BASE_DIR}/monitoring" + +STATE_FILE="${BASE_DIR}/.engine-settings" +PROXY_NETWORK="proxy-net" +BACKEND_NETWORK="shared-backend" +MONITORING_NETWORK="monitoring-net" + +if [ "$EUID" -ne 0 ]; then + echo "[!] Error: Root privileges required." >&2 + exit 1 +fi + +# ============================================================================== +# PROMPT & CONFIGURATION HELPERS +# ============================================================================== +prompt_config() { + if [ -z "${SSH_PORT_ARG:-}" ]; then + read -rp "Enter non-standard SSH port [Default: 48222]: " SSH_PORT_ARG + SSH_PORT_ARG=${SSH_PORT_ARG:-48222} + fi + + if [ -z "${ENGINE_ARG:-}" ]; then + echo "Select Container Runtime:" + echo " 1) Docker" + echo " 2) Podman" + read -rp "Choice [1-2, Default: 1]: " engine_choice + case "${engine_choice}" in + 2) ENGINE_ARG="podman" ;; + *) ENGINE_ARG="docker" ;; + esac + fi + + if [ -z "${PROXY_ARG:-}" ]; then + echo "Select Ingress Proxy:" + echo " 1) Nginx Proxy Manager (NPM - Web UI on :81)" + echo " 2) Traefik v3 (Label discovery)" + echo " 3) Caddy (Automated SSL & lightweight Caddyfile)" + read -rp "Choice [1-3, Default: 1]: " proxy_choice + case "${proxy_choice}" in + 2) PROXY_ARG="traefik" ;; + 3) PROXY_ARG="caddy" ;; + *) PROXY_ARG="npm" ;; + esac + fi + + mkdir -p "${BASE_DIR}" + cat < "${STATE_FILE}" +ENGINE="${ENGINE_ARG}" +PROXY_TYPE="${PROXY_ARG}" +SSH_PORT="${SSH_PORT_ARG}" +EOF +} + +load_state() { + if [ -f "${STATE_FILE}" ]; then + # shellcheck source=/dev/null + source "${STATE_FILE}" + else + echo "[!] System uninitialized. Running setup prompts..." + prompt_config + # shellcheck source=/dev/null + source "${STATE_FILE}" + fi +} + +compose_exec() { + local dir="$1" + shift + if [ "${ENGINE}" = "podman" ]; then + (cd "${dir}" && podman-compose "$@") + else + (cd "${dir}" && docker compose "$@") + fi +} + +cli_exec() { + if [ "${ENGINE}" = "podman" ]; then + podman "$@" + else + docker "$@" + fi +} + +# ============================================================================== +# 1. BARE-HOST PROVISIONING & HARDENING +# ============================================================================== +run_host_bootstrap() { + echo "==========================================================" + echo " [1/6] Installing Host Packages" + echo "==========================================================" + apt-get update -y + apt-get install -y apt-transport-https ca-certificates curl gnupg \ + lsb-release ufw fail2ban nftables openssl jq + + echo "==========================================================" + echo " [2/6] Setting up Container Engine: ${ENGINE}" + echo "==========================================================" + if [ "${ENGINE}" = "podman" ]; then + apt-get install -y podman podman-compose podman-docker + systemctl enable --now podman + else + if ! command -v docker &>/dev/null; then + install -m 0755 -d /etc/apt/keyrings + curl -fsSL "https://download.docker.com/linux/$(. /etc/os-release && echo "$ID")/gpg" | gpg --dearmor --yes -o /etc/apt/keyrings/docker.gpg + chmod a+r /etc/apt/keyrings/docker.gpg + echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/$(. /etc/os-release && echo "$ID") $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null + apt-get update -y + apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin + systemctl enable --now docker + fi + fi + + echo "==========================================================" + echo " [3/6] Hardening SSH on Port ${SSH_PORT}" + echo "==========================================================" + if systemctl is-active --quiet ssh.socket; then + sed -i -E "s/^ListenStream=.*/ListenStream=${SSH_PORT}/" /lib/systemd/system/ssh.socket || true + systemctl daemon-reload + systemctl restart ssh.socket + fi + + mkdir -p /etc/ssh/sshd_config.d/ + cat < /etc/ssh/sshd_config.d/99-hardened.conf +Port ${SSH_PORT} +PermitRootLogin prohibit-password +PasswordAuthentication yes +X11Forwarding no +MaxAuthTries 4 +ClientAliveInterval 300 +ClientAliveCountMax 2 +EOF + systemctl restart ssh || systemctl restart sshd + + echo "==========================================================" + echo " [4/6] Configuring Firewall & Isolation Rules" + echo "==========================================================" + ufw default deny incoming + ufw default allow outgoing + ufw allow "${SSH_PORT}"/tcp comment 'Hardened SSH' + ufw allow 80/tcp comment 'HTTP Ingress' + ufw allow 443/tcp comment 'HTTPS Ingress' + [ "${PROXY_TYPE}" = "npm" ] && ufw allow 81/tcp comment 'NPM Admin' + [ "${PROXY_TYPE}" = "traefik" ] && ufw allow 8080/tcp comment 'Traefik Dashboard' + ufw allow 3000/tcp comment 'Grafana Dashboard' + + if [ "${ENGINE}" = "docker" ] && ! grep -q "BEGIN UFW AND DOCKER" /etc/ufw/after.rules; then + cat <<'EOF' >> /etc/ufw/after.rules + +# ================================ +# BEGIN UFW AND DOCKER PROTECTION +# ================================ +*filter +:ufw-user-forward - [0:0] +:DOCKER-USER - [0:0] +-A DOCKER-USER -j ufw-user-forward +-A DOCKER-USER -j RETURN -s 10.0.0.0/8 +-A DOCKER-USER -j RETURN -s 172.16.0.0/12 +-A DOCKER-USER -j RETURN -s 192.168.0.0/16 +-A DOCKER-USER -p udp -m udp --sport 53 --dport 1024:65535 -j RETURN +-A DOCKER-USER -j ufw-docker-logging-deny -p tcp -m tcp --tcp-flags FIN,SYN,RST,ACK SYN -d 172.16.0.0/12 +-A DOCKER-USER -j RETURN +-A ufw-docker-logging-deny -m limit --limit 3/min --limit-burst 10 -j LOG --log-prefix "[UFW DOCKER BLOCK] " +-A ufw-docker-logging-deny -j DROP +COMMIT +# ================================ +# END UFW AND DOCKER PROTECTION +# ================================ +EOF + fi + + ufw --force enable + ufw reload + + echo "==========================================================" + echo " [5/6] Scaffolding Networks & Fail2ban Jails" + echo "==========================================================" + mkdir -p "${PROXY_DIR}/logs" "${CORE_DIR}/data/mariadb" "${CORE_DIR}/data/redis" "${CORE_DIR}/config/mariadb" + mkdir -p "${TEMPLATE_DIR}" "${SITES_DIR}" "${MONITORING_DIR}" + + cli_exec network inspect "${PROXY_NETWORK}" >/dev/null 2>&1 || cli_exec network create "${PROXY_NETWORK}" + cli_exec network inspect "${BACKEND_NETWORK}" >/dev/null 2>&1 || cli_exec network create "${BACKEND_NETWORK}" + cli_exec network inspect "${MONITORING_NETWORK}" >/dev/null 2>&1 || cli_exec network create "${MONITORING_NETWORK}" + + touch "${PROXY_DIR}/logs/access.log" + + cat < /etc/fail2ban/jail.d/sshd.local +[sshd] +enabled = true +port = ${SSH_PORT} +filter = sshd +maxretry = 3 +findtime = 600 +bantime = 3600 +banaction = ufw +EOF + + cat <<'EOF' > /etc/fail2ban/filter.d/ingress-wordpress.conf +[Definition] +failregex = ^.*"(POST|GET) /wp-login\.php.*" (401|403|200) + ^.*"(POST|GET) /xmlrpc\.php.*" (401|403|200) + ^.*"GET .*(?:\.env|\.git|wp-config\.php|eval-stdin\.php).*".* +ignoreregex = +EOF + + cat < /etc/fail2ban/jail.d/ingress-wordpress.local +[ingress-wordpress] +enabled = true +port = http,https +filter = ingress-wordpress +logpath = ${PROXY_DIR}/logs/*.log +maxretry = 4 +findtime = 600 +bantime = 86400 +banaction = ufw +EOF + + systemctl enable --now fail2ban + systemctl restart fail2ban + + echo "==========================================================" + echo " [6/6] Launching Ingress Proxy: ${PROXY_TYPE}" + echo "==========================================================" + if [ "${PROXY_TYPE}" = "npm" ]; then + mkdir -p "${PROXY_DIR}/data" "${PROXY_DIR}/letsencrypt" + cat < "${PROXY_DIR}/docker-compose.yml" +services: + ingress: + image: 'jc21/nginx-proxy-manager:latest' + container_name: ingress_proxy + restart: unless-stopped + ports: + - '80:80' + - '81:81' + - '443:443' + volumes: + - ./data:/data + - ./letsencrypt:/etc/letsencrypt + - ./logs:/data/logs + networks: + - ${PROXY_NETWORK} + +networks: + ${PROXY_NETWORK}: + external: true +EOF + elif [ "${PROXY_TYPE}" = "traefik" ]; then + mkdir -p "${PROXY_DIR}/letsencrypt" + touch "${PROXY_DIR}/letsencrypt/acme.json" + chmod 600 "${PROXY_DIR}/letsencrypt/acme.json" + cat < "${PROXY_DIR}/docker-compose.yml" +services: + ingress: + image: traefik:v3.1 + container_name: ingress_proxy + restart: unless-stopped + command: + - "--api.insecure=true" + - "--providers.docker=true" + - "--providers.docker.exposedbydefault=false" + - "--entryPoints.web.address=:80" + - "--entryPoints.websecure.address=:443" + - "--certificatesresolvers.myresolver.acme.tlschallenge=true" + - "--certificatesresolvers.myresolver.acme.email=admin@example.com" + - "--certificatesresolvers.myresolver.acme.storage=/letsencrypt/acme.json" + - "--accesslog=true" + - "--accesslog.filepath=/logs/access.log" + ports: + - "80:80" + - "443:443" + - "8080:8080" + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + - ./letsencrypt:/letsencrypt + - ./logs:/logs + networks: + - ${PROXY_NETWORK} + +networks: + ${PROXY_NETWORK}: + external: true +EOF + elif [ "${PROXY_TYPE}" = "caddy" ]; then + mkdir -p "${PROXY_DIR}/data" "${PROXY_DIR}/config" + touch "${PROXY_DIR}/Caddyfile" + cat < "${PROXY_DIR}/docker-compose.yml" +services: + ingress: + image: caddy:2-alpine + container_name: ingress_proxy + restart: unless-stopped + ports: + - "80:80" + - "443:443" + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile + - ./data:/data + - ./config:/config + - ./logs:/logs + networks: + - ${PROXY_NETWORK} + +networks: + ${PROXY_NETWORK}: + external: true +EOF + fi + + compose_exec "${PROXY_DIR}" up -d + deploy_monitoring +} + +# ============================================================================== +# 2. GRAFANA & OBSERVABILITY DEPLOYMENT +# ============================================================================== +deploy_monitoring() { + echo "==========================================================" + echo " Deploying Observability Stack (Grafana + Prometheus + cAdvisor)" + echo "==========================================================" + mkdir -p "${MONITORING_DIR}/prometheus" "${MONITORING_DIR}/grafana/provisioning/datasources" + + # Prometheus Scrape Configuration + cat <<'EOF' > "${MONITORING_DIR}/prometheus/prometheus.yml" +global: + scrape_interval: 15s + evaluation_interval: 15s + +scrape_configs: + - job_name: 'node_exporter' + static_configs: + - targets: ['node_exporter:9100'] + + - job_name: 'cadvisor' + static_configs: + - targets: ['cadvisor:8080'] +EOF + + # Grafana Datasource Auto-provisioning + cat <<'EOF' > "${MONITORING_DIR}/grafana/provisioning/datasources/prometheus.yaml" +apiVersion: 1 +datasources: + - name: Prometheus + type: prometheus + access: proxy + url: http://prometheus:9090 + isDefault: true + editable: false +EOF + + GRAFANA_SEC_PASS=$(openssl rand -base64 16 | tr -dc 'a-zA-Z0-9') + echo "GRAFANA_ADMIN_PASS=${GRAFANA_SEC_PASS}" >> "${STATE_FILE}" + + cat < "${MONITORING_DIR}/docker-compose.yml" +services: + prometheus: + image: prom/prometheus:v2.54.0 + container_name: prometheus + restart: unless-stopped + volumes: + - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro + - prometheus_data:/prometheus + networks: + - ${MONITORING_NETWORK} + + node_exporter: + image: prom/node-exporter:v1.8.2 + container_name: node_exporter + restart: unless-stopped + volumes: + - /proc:/host/proc:ro + - /sys:/host/sys:ro + - /:/rootfs:ro + command: + - '--path.procfs=/host/proc' + - '--path.rootfs=/rootfs' + - '--path.sysfs=/host/sys' + networks: + - ${MONITORING_NETWORK} + + cadvisor: + image: gcr.io/cadvisor/cadvisor:v0.49.1 + container_name: cadvisor + restart: unless-stopped + privileged: true + volumes: + - /:/rootfs:ro + - /var/run:/var/run:ro + - /sys:/sys:ro + - /var/lib/docker/:/var/lib/docker:ro + - /dev/disk/:/dev/disk:ro + networks: + - ${MONITORING_NETWORK} + + grafana: + image: grafana/grafana:11.2.0 + container_name: grafana + restart: unless-stopped + ports: + - "3000:3000" + environment: + - GF_SECURITY_ADMIN_USER=admin + - GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_SEC_PASS} + - GF_USERS_ALLOW_SIGN_UP=false + volumes: + - grafana_data:/var/lib/grafana + - ./grafana/provisioning:/etc/grafana/provisioning:ro + networks: + - ${MONITORING_NETWORK} + +volumes: + prometheus_data: + grafana_data: + +networks: + ${MONITORING_NETWORK}: + external: true +EOF + + compose_exec "${MONITORING_DIR}" up -d + echo "==> Monitoring stack operational. Grafana UI: http://:3000" +} + +# ============================================================================== +# 3. SHARED DATABASE & OBJECT CACHE CORE +# ============================================================================== +init_shared_core() { + echo "==> Initializing centralized MariaDB & Redis services..." + mkdir -p "${CORE_DIR}/data/mariadb" "${CORE_DIR}/data/redis" "${CORE_DIR}/config/mariadb" + + CORE_DB_ROOT_PW=$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9') + CORE_REDIS_PW=$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9') + + cat < "${CORE_DIR}/.env" +DB_ROOT_PASSWORD=${CORE_DB_ROOT_PW} +REDIS_PASSWORD=${CORE_REDIS_PW} +NEXT_REDIS_INDEX=0 +EOF + chmod 600 "${CORE_DIR}/.env" + + cat <<'EOF' > "${CORE_DIR}/config/mariadb/99-performance.cnf" +[mysqld] +character-set-server = utf8mb4 +collation-server = utf8mb4_unicode_ci +innodb_buffer_pool_size = 2G +innodb_log_file_size = 256M +innodb_flush_log_at_trx_commit = 2 +innodb_flush_method = O_DIRECT +max_connections = 300 +query_cache_type = 0 +query_cache_size = 0 +EOF + + cat < "${CORE_DIR}/docker-compose.yml" +services: + core_db: + image: mariadb:11.4 + container_name: core_mariadb + restart: unless-stopped + environment: + MYSQL_ROOT_PASSWORD: \${DB_ROOT_PASSWORD} + volumes: + - ./data/mariadb:/var/lib/mysql + - ./config/mariadb/99-performance.cnf:/etc/mysql/conf.d/99-performance.cnf:ro + networks: + - ${BACKEND_NETWORK} + healthcheck: + test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] + interval: 10s + timeout: 5s + retries: 5 + + core_redis: + image: redis:7-alpine + container_name: core_redis + restart: unless-stopped + command: > + redis-server + --requirepass \${REDIS_PASSWORD} + --maxmemory 512mb + --maxmemory-policy allkeys-lru + --save "" + volumes: + - ./data/redis:/data + networks: + - ${BACKEND_NETWORK} + +networks: + ${BACKEND_NETWORK}: + external: true +EOF + + compose_exec "${CORE_DIR}" up -d + echo "==> Waiting for MariaDB health status..." + until cli_exec exec core_mariadb mariadb-admin ping -u root -p"${CORE_DB_ROOT_PW}" --silent >/dev/null 2>&1; do + sleep 2 + done + echo "==> Core database and cache online." +} + +# ============================================================================== +# 4. WORDPRESS SITE SCALING & PROVISIONING +# ============================================================================== +provision_site() { + local SITE_SLUG="${1:-}" + local DOMAIN="${2:-}" + local ADMIN_USER="${3:-admin}" + local ADMIN_EMAIL="${4:-admin@${DOMAIN}}" + + if [ -z "${SITE_SLUG}" ] || [ -z "${DOMAIN}" ]; then + echo "Usage: $0 create [admin_user] [admin_email]" + exit 1 + fi + + load_state + + if ! cli_exec ps --format '{{.Names}}' | grep -q "ingress_proxy"; then + run_host_bootstrap + fi + + if [ ! -f "${CORE_DIR}/.env" ] || ! cli_exec ps --format '{{.Names}}' | grep -q "core_mariadb"; then + init_shared_core + fi + + # shellcheck source=/dev/null + source "${CORE_DIR}/.env" + + local SITE_DIR="${SITES_DIR}/${SITE_SLUG}" + if [ -d "${SITE_DIR}" ]; then + echo "[!] Error: Destination directory '${SITE_DIR}' already exists." >&2 + exit 1 + fi + + local REDIS_INDEX="${NEXT_REDIS_INDEX:-0}" + local NEW_NEXT_INDEX=$((REDIS_INDEX + 1)) + sed -i "s/^NEXT_REDIS_INDEX=.*/NEXT_REDIS_INDEX=${NEW_NEXT_INDEX}/" "${CORE_DIR}/.env" + + local DB_NAME="wp_${SITE_SLUG//-/_}" + local DB_USER="usr_${SITE_SLUG//-/_}" + local DB_PASS=$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9') + local WP_ADMIN_PASS=$(openssl rand -base64 18 | tr -dc 'a-zA-Z0-9!@#%^&*') + + echo "==> Provisioning database credentials..." + cli_exec exec -i core_mariadb mariadb -u root -p"${DB_ROOT_PASSWORD}" < "${SITE_DIR}/config/wordpress.ini" +file_uploads = On +memory_limit = 512M +upload_max_filesize = 128M +post_max_size = 128M +max_execution_time = 300 +max_input_vars = 5000 +opcache.enable = 1 +opcache.memory_consumption = 256 +opcache.max_accelerated_files = 20000 +opcache.revalidate_freq = 60 +EOF + + # Generate compose file based on chosen proxy + if [ "${PROXY_TYPE}" = "traefik" ]; then + cat < "${SITE_DIR}/docker-compose.yml" +services: + wordpress: + image: wordpress:6.7-apache + container_name: ${SITE_SLUG}_wp + restart: unless-stopped + environment: + WORDPRESS_DB_HOST: core_mariadb:3306 + WORDPRESS_DB_NAME: ${DB_NAME} + WORDPRESS_DB_USER: ${DB_USER} + WORDPRESS_DB_PASSWORD: ${DB_PASS} + WORDPRESS_TABLE_PREFIX: wp_ + WORDPRESS_CONFIG_EXTRA: | + define('WP_REDIS_HOST', 'core_redis'); + define('WP_REDIS_PORT', 6379); + define('WP_REDIS_PASSWORD', '${REDIS_PASSWORD}'); + define('WP_REDIS_DATABASE', ${REDIS_INDEX}); + define('WP_CACHE_KEY_SALT', '${SITE_SLUG}_'); + define('WP_CACHE', true); + define('DISALLOW_FILE_EDIT', true); + define('FORCE_SSL_ADMIN', true); + if (isset(\$_SERVER['HTTP_X_FORWARDED_PROTO']) && \$_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https') { + \$_SERVER['HTTPS'] = 'on'; + } + labels: + - "traefik.enable=true" + - "traefik.http.routers.${SITE_SLUG}.rule=Host(\`${DOMAIN}\`)" + - "traefik.http.routers.${SITE_SLUG}.entrypoints=websecure" + - "traefik.http.routers.${SITE_SLUG}.tls.certresolver=myresolver" + - "traefik.http.services.${SITE_SLUG}.loadbalancer.server.port=80" + volumes: + - ./data/wp:/var/www/html + - ./config/wordpress.ini:/usr/local/etc/php/conf.d/wordpress.ini:ro + networks: + - ${PROXY_NETWORK} + - ${BACKEND_NETWORK} + +networks: + ${PROXY_NETWORK}: + external: true + ${BACKEND_NETWORK}: + external: true +EOF + else + # NPM and Caddy use standard ingress-connected containers + cat < "${SITE_DIR}/docker-compose.yml" +services: + wordpress: + image: wordpress:6.7-apache + container_name: ${SITE_SLUG}_wp + restart: unless-stopped + environment: + WORDPRESS_DB_HOST: core_mariadb:3306 + WORDPRESS_DB_NAME: ${DB_NAME} + WORDPRESS_DB_USER: ${DB_USER} + WORDPRESS_DB_PASSWORD: ${DB_PASS} + WORDPRESS_TABLE_PREFIX: wp_ + WORDPRESS_CONFIG_EXTRA: | + define('WP_REDIS_HOST', 'core_redis'); + define('WP_REDIS_PORT', 6379); + define('WP_REDIS_PASSWORD', '${REDIS_PASSWORD}'); + define('WP_REDIS_DATABASE', ${REDIS_INDEX}); + define('WP_CACHE_KEY_SALT', '${SITE_SLUG}_'); + define('WP_CACHE', true); + define('DISALLOW_FILE_EDIT', true); + define('FORCE_SSL_ADMIN', true); + if (isset(\$_SERVER['HTTP_X_FORWARDED_PROTO']) && \$_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https') { + \$_SERVER['HTTPS'] = 'on'; + } + volumes: + - ./data/wp:/var/www/html + - ./config/wordpress.ini:/usr/local/etc/php/conf.d/wordpress.ini:ro + networks: + - ${PROXY_NETWORK} + - ${BACKEND_NETWORK} + +networks: + ${PROXY_NETWORK}: + external: true + ${BACKEND_NETWORK}: + external: true +EOF + fi + + chown -R 33:33 "${SITE_DIR}/data/wp" + compose_exec "${SITE_DIR}" up -d + + # Automatic Caddy routing generation + if [ "${PROXY_TYPE}" = "caddy" ]; then + echo "==> Injecting ${DOMAIN} entry into Caddyfile..." + cat <> "${PROXY_DIR}/Caddyfile" + +${DOMAIN} { + reverse_proxy ${SITE_SLUG}_wp:80 + log { + output file /logs/access.log + } +} +EOF + compose_exec "${PROXY_DIR}" exec ingress caddy reload --config /etc/caddy/Caddyfile || true + fi + + echo "==> Awaiting core WordPress file deployment..." + until [ -f "${SITE_DIR}/data/wp/wp-config.php" ]; do + sleep 2 + done + + echo "==> Running automated WP-CLI bootstrap..." + cli_exec run --rm \ + --user 33:33 \ + --network "${BACKEND_NETWORK}" \ + -v "${SITE_DIR}/data/wp:/var/www/html" \ + wordpress:cli \ + core install \ + --url="https://${DOMAIN}" \ + --title="${SITE_SLUG}" \ + --admin_user="${ADMIN_USER}" \ + --admin_password="${WP_ADMIN_PASS}" \ + --admin_email="${ADMIN_EMAIL}" \ + --skip-email + + cli_exec run --rm \ + --user 33:33 \ + --network "${BACKEND_NETWORK}" \ + -v "${SITE_DIR}/data/wp:/var/www/html" \ + wordpress:cli \ + rewrite structure '/%postname%/' --hard + + cli_exec run --rm \ + --user 33:33 \ + --network "${BACKEND_NETWORK}" \ + -v "${SITE_DIR}/data/wp:/var/www/html" \ + wordpress:cli \ + plugin install redis-cache --activate + + cli_exec run --rm \ + --user 33:33 \ + --network "${BACKEND_NETWORK}" \ + -v "${SITE_DIR}/data/wp:/var/www/html" \ + wordpress:cli \ + redis enable + + chown -R 33:33 "${SITE_DIR}/data/wp/wp-content" + + echo "" + echo "==========================================================" + echo " Site Provisioned Successfully!" + echo "==========================================================" + echo " Domain: https://${DOMAIN}" + echo " Administrator: ${ADMIN_USER}" + echo " Password: ${WP_ADMIN_PASS}" + echo " Database: ${DB_NAME}" + echo " DB User: ${DB_USER}" + echo " Redis Index: ${REDIS_INDEX}" + echo " Directory: ${SITE_DIR}" + if [ "${PROXY_TYPE}" = "npm" ]; then + echo " Action Required in NPM: Forward https://${DOMAIN} ->${SITE_SLUG}_wp:80" + elif [ "${PROXY_TYPE}" = "traefik" ]; then + echo " Traefik Status: Automatically routed via Docker labels." + elif [ "${PROXY_TYPE}" = "caddy" ]; then + echo " Caddy Status: Block appended to Caddyfile and reloaded automatically." + fi + echo "==========================================================" +} + +# ============================================================================== +# CLI DISPATCHER +# ============================================================================== +ACTION="${1:-}" +shift || true + +SSH_PORT_ARG="" +ENGINE_ARG="" +PROXY_ARG="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --ssh-port) + SSH_PORT_ARG="$2" + shift 2 + ;; + --engine) + ENGINE_ARG="$2" + shift 2 + ;; + --proxy) + PROXY_ARG="$2" + shift 2 + ;; + *) + break + ;; + esac +done + +case "${ACTION}" in + --bootstrap) + prompt_config + load_state + run_host_bootstrap + ;; + create) + provision_site "$@" + ;; + *) + echo "Usage:" + echo " Interactive Bootstrap:" + echo " $0 --bootstrap" + echo "" + echo " Unattended Bootstrap:" + echo " $0 --bootstrap --ssh-port --engine --proxy " + echo "" + echo " Provision Site:" + echo " $0 create [admin_user] [admin_email]" + echo "" + echo "Examples:" + echo " $0 --bootstrap --ssh-port 48222 --engine docker --proxy caddy" + echo " $0 create teetimeprints teetimeprints.com mkell admin@teetimeprints.com" + exit 1 + ;; +esac \ No newline at end of file