diff --git a/.markdownlint.json b/.markdownlint.json new file mode 100644 index 0000000..e4d2730 --- /dev/null +++ b/.markdownlint.json @@ -0,0 +1,5 @@ +{ + // Line length is not enforced: this README uses wide tables and long + // inline-command lines that would be unreadable if wrapped. + "MD013": false +} diff --git a/README.md b/README.md index 2d609e9..da59dde 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ Work proceeds in two phases: `--bootstrap` hardens the host and stands up the pl **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). | @@ -58,7 +58,7 @@ Supporting design documents live in [`docs/`](docs/): ## 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.). | @@ -105,6 +105,7 @@ 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@ > ``` @@ -113,7 +114,7 @@ chmod +x /usr/local/bin/wp-engine.sh ## 4. CLI Reference & Options -``` +```text Usage: Interactive Bootstrap: ./wp-engine.sh --bootstrap @@ -132,7 +133,7 @@ Examples: ### 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. | @@ -140,7 +141,7 @@ Examples: ### 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. | @@ -150,7 +151,7 @@ Examples: ### `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. | @@ -176,13 +177,14 @@ Additional `create` examples: 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`: -``` +```text Port PermitRootLogin prohibit-password PasswordAuthentication yes @@ -195,12 +197,14 @@ 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: @@ -215,7 +219,7 @@ Also re-points `ssh.socket`'s `ListenStream` to the new port when socket activat 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 | @@ -264,7 +268,7 @@ A random Grafana admin password is generated (`openssl rand`) and appended to th ### 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) | @@ -273,7 +277,7 @@ A random Grafana admin password is generated (`openssl rand`) and appended to th ### 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 | @@ -329,7 +333,7 @@ High-level traffic flow (see `docs/` for the full Mermaid diagrams): ### 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 | @@ -337,7 +341,7 @@ High-level traffic flow (see `docs/` for the full Mermaid diagrams): ### 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` | @@ -347,7 +351,7 @@ High-level traffic flow (see `docs/` for the full Mermaid diagrams): ### Filesystem layout (`/server/websites/`) -``` +```text /server/websites/ ├── .engine-settings # Persisted choices: ENGINE, PROXY_TYPE, SSH_PORT, GRAFANA_ADMIN_PASS ├── core/ # Shared data tier @@ -384,7 +388,7 @@ Host-level configs written by the script: ## 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 | @@ -403,13 +407,15 @@ 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 | @@ -467,7 +473,7 @@ fail2ban-client get ingress-wordpress banned ## 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 | @@ -503,7 +509,7 @@ The script expects **root + systemd + apt + a privileged container runtime**, so ### 14.1 Test stages — `tests/smoke.sh` | `STAGE=` | Needs | What it verifies | -|---|---|---| +| --- | --- | --- | | `static` *(default)* | Linux (WSL is fine), **no root** | `bash -n` syntax on both scripts, `shellcheck -S error`, usage/argument handling, non-root rejection via `setpriv` | | `bootstrap` | root + systemd + privileged container | Full unattended `--bootstrap` (Docker + Caddy), then asserts: state file, UFW active, both Fail2ban jails, all 5 containers running, Grafana health, proxy on :80, SSH on the hardened port | | `site` | same as `bootstrap` | Auto-bootstraps if needed, runs `create smoketest smoketest.test`, then asserts: site dir, WP container, `wp-config.php`, scoped DB `wp_smoketest`, Caddyfile route, HTTP response through the proxy, `wp core is-installed` | @@ -608,10 +614,7 @@ Harness fixes (in `tests/smoke.sh`): Grafana/proxy assertions got 60 s retry win ## Repository Contents - - - -``` +```text wp-engine.sh # The installer/provisioner script (executable) README.md # This file .gitignore / .gitattributes # Secrets excluded; *.sh forced to LF @@ -624,11 +627,3 @@ docs/ │ Runtime_Architecture_Network_Topology.md # Architecture & network Mermaid diagram │ Filesystem_and_Directory_Structure.md # Filesystem layout Mermaid diagram ``` - - - - - - - -