From fd6810ca2675b76f1ff7087e558356ab1684f7be Mon Sep 17 00:00:00 2001 From: Mike Kell Date: Sat, 3 Oct 2026 14:00:53 -0400 Subject: [PATCH] Add dev container and staged test harness for wp-engine.sh --- .devcontainer/Dockerfile | 54 ++++++++ .devcontainer/devcontainer.json | 33 +++++ README.md | 71 +++++++++- tests/smoke.sh | 239 ++++++++++++++++++++++++++++++++ wp-engine.sh | 0 5 files changed, 395 insertions(+), 2 deletions(-) create mode 100644 .devcontainer/Dockerfile create mode 100644 .devcontainer/devcontainer.json create mode 100755 tests/smoke.sh mode change 100644 => 100755 wp-engine.sh diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 0000000..aca259c --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,54 @@ +# Dev/test container for wp-engine.sh +# +# Provides the root + systemd + apt environment the script expects on a bare +# host. Must run --privileged (set in devcontainer.json) so UFW, Fail2ban and a +# nested Docker daemon behave like they would on real hardware. +# +# Manual (non-VS Code) usage: +# docker build -t wp-engine-dev .devcontainer +# docker run --rm -d --name wp-engine-dev --privileged \ +# -p 80:80 -p 443:443 -p 81:81 -p 3000:3000 -p 48222:48222 \ +# -v "$PWD":/workspaces/SecureServer wp-engine-dev +# docker exec -it wp-engine-dev bash + +FROM ubuntu:24.04 + +ENV DEBIAN_FRONTEND=noninteractive \ + container=docker + +# systemd as PID 1, plus tools the harness and the script's hardening path need. +# (ufw/fail2ban/docker are deliberately NOT preinstalled -- wp-engine.sh installs +# them itself during --bootstrap, which is exactly what we want to test.) +RUN apt-get update && apt-get install -y --no-install-recommends \ + systemd \ + systemd-sysv \ + openssh-server \ + openssh-client \ + sudo \ + ca-certificates \ + curl \ + gnupg \ + lsb-release \ + apt-transport-https \ + git \ + jq \ + shellcheck \ + iproute2 \ + procps \ + && rm -rf /var/lib/apt/lists/* \ + && mkdir -p /run/sshd /run/lock + +# Silence units that cannot work inside a container so boot completes cleanly. +RUN systemctl set-default multi-user.target \ + && systemctl mask --no-reload -- \ + systemd-remount-fs.service \ + getty.target \ + systemd-logind.service \ + dev-hugepages.mount \ + sys-fs-fuse-connections.mount \ + 2>/dev/null || true + +# Container-friendly init: systemd wants SIGRTMIN+3 to shut down. +STOPSIGNAL SIGRTMIN+3 + +CMD ["/sbin/init"] diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..5886c67 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,33 @@ +{ + "name": "wp-engine-test", + "build": { + "dockerfile": "Dockerfile", + "context": "." + }, + + // systemd as PID 1 requires a privileged container (UFW/Fail2ban/DinD too) + "runArgs": ["--privileged"], + + // Let /sbin/init be PID 1 instead of the default sleep-loop + "overrideCommand": false, + "remoteUser": "root", + + // Ports wp-engine.sh opens, surfaced on the host for manual verification + "forwardPorts": [80, 443, 81, 8080, 3000, 48222], + "portsAttributes": { + "80": { "label": "HTTP (ingress)" }, + "443": { "label": "HTTPS (ingress)" }, + "81": { "label": "Nginx Proxy Manager admin" }, + "8080": { "label": "Traefik dashboard" }, + "3000": { "label": "Grafana" }, + "48222": { "label": "SSH (hardened port)" } + }, + + "postCreateCommand": "chmod +x wp-engine.sh tests/smoke.sh && bash tests/smoke.sh static; echo ''; echo 'Dev container ready. Run: STAGE=all bash tests/smoke.sh'", + + "customizations": { + "vscode": { + "extensions": ["timonwong.shellcheck"] + } + } +} diff --git a/README.md b/README.md index 3af91ea..25c73a4 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ Work proceeds in two phases: `--bootstrap` hardens the host and stands up the pl 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) +14. [Testing the Script](#14-testing-the-script) --- @@ -495,13 +496,79 @@ Behaviors of the current script — review and adjust to your threat model: --- +## 14. Testing the Script + +The script expects **root + systemd + apt + a privileged container runtime**, so it can't execute directly on Windows. The repo ships a staged test harness plus a dev container that reproduces a real host. + +### 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` | +| `all` | same as `bootstrap` | `static` + `bootstrap` + `site` | +| `cleanup` | root | Removes the disposable test site (container, DB, user, directory) | + +```bash +bash tests/smoke.sh # safe static checks (works in WSL / CI) +STAGE=all bash tests/smoke.sh # full end-to-end run (dev container) +STAGE=cleanup bash tests/smoke.sh # remove the test site +SSH_PORT=2222 TEST_SLUG=demo bash tests/smoke.sh # override defaults +``` + +Exit codes: `0` all passed · `1` failures (each printed as `[FAIL]`) · `2` wrong environment for that stage. Long stages are wrapped in `timeout` guards (`BOOTSTRAP_TIMEOUT=900`, `SITE_TIMEOUT=900` — override via env). + +### 14.2 Dev container — `.devcontainer/` + +A **privileged Ubuntu 24.04 container with systemd as PID 1**, preloaded with `shellcheck`, `openssh-server`, and the tooling the script's hardening path exercises. Docker/UFW/Fail2ban are deliberately **not** preinstalled — `--bootstrap` installs them itself, which is part of what's being tested. Ports 80, 443, 81, 8080, 3000, and 48222 are forwarded to the host for manual poking. + +**Option A — VS Code Dev Containers** (requires Docker Desktop or any Docker engine + the *Dev Containers* extension): + +1. Open the folder in VS Code +2. Command Palette → **Dev Containers: Reopen in Container** +3. The `postCreateCommand` runs the static suite automatically +4. In the terminal: `STAGE=all bash tests/smoke.sh` + +**Option B — plain Docker CLI:** + +```bash +docker build -t wp-engine-dev .devcontainer +docker run --rm -d --name wp-engine-dev --privileged \ + -p 80:80 -p 443:443 -p 81:81 -p 8080:8080 -p 3000:3000 -p 48222:48222 \ + -v "$PWD":/workspaces/SecureServer wp-engine-dev +docker exec -it wp-engine-dev bash + +# inside the container: +STAGE=all bash tests/smoke.sh +STAGE=cleanup bash tests/smoke.sh +``` + +### 14.3 Notes & caveats + +- **First full run pulls ~1–2 GB** of images (Docker CE, WordPress, MariaDB, Grafana, Prometheus, …) and takes several minutes. +- The `site` stage uses `smoketest.test` (RFC 2606 reserved TLD) — no real DNS needed; checks send `Host:` headers directly to the proxy, so ACME issuance is never triggered. +- A full `STAGE=all` run **reconfigures the container's SSH port and firewall** — that's expected inside the throwaway container; never run it on a production host. +- Rebuild the container for a clean slate: `docker rm -f wp-engine-dev` and re-run (host state lives in the container's writable layer; the mounted repo is untouched). +- CI option: the `static` stage runs anywhere with `bash` + `shellcheck` and could be wired into Forgejo Actions later. + +--- + ## 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 +.gitignore / .gitattributes # Secrets excluded; *.sh forced to LF +.devcontainer/ +│ Dockerfile # Privileged Ubuntu 24.04 + systemd test host +│ devcontainer.json # VS Code Dev Container config (ports, shellcheck) +tests/ +│ smoke.sh # Staged test harness (static|bootstrap|site|all|cleanup) +docs/ +│ Runtime_Architecture_Network_Topology.md # Architecture & network Mermaid diagram +│ Filesystem_and_Directory_Structure.md # Filesystem layout Mermaid diagram ``` diff --git a/tests/smoke.sh b/tests/smoke.sh new file mode 100755 index 0000000..20f8894 --- /dev/null +++ b/tests/smoke.sh @@ -0,0 +1,239 @@ +#!/usr/bin/env bash +# ============================================================================= +# smoke.sh — test harness for wp-engine.sh +# +# Stages (select with STAGE=...): +# static syntax + lint + argument handling. Safe anywhere (root NOT +# required; works in WSL / Git Bash / CI). +# bootstrap full unattended `--bootstrap` (requires root + systemd + +# a privileged container: the dev container). +# site provision a disposable WordPress site and smoke-test it +# (runs bootstrap first if the platform isn't up). +# all static + bootstrap + site. +# cleanup remove the disposable test site. +# +# Examples: +# bash tests/smoke.sh +# STAGE=bootstrap bash tests/smoke.sh +# STAGE=all bash tests/smoke.sh +# STAGE=cleanup TEST_SLUG=smoketest bash tests/smoke.sh +# +# Tunables (env): +# SSH_PORT=48222 TEST_SLUG=smoketest TEST_DOMAIN=smoketest.test +# BOOTSTRAP_TIMEOUT=900 SITE_TIMEOUT=900 +# +# Exit codes: 0 = all checks passed, 1 = failures, 2 = bad environment/usage. +# ============================================================================= +set -uo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SCRIPT="${ROOT_DIR}/wp-engine.sh" +STAGE="${STAGE:-static}" +SSH_PORT="${SSH_PORT:-48222}" +TEST_SLUG="${TEST_SLUG:-smoketest}" +TEST_DOMAIN="${TEST_DOMAIN:-smoketest.test}" +BOOTSTRAP_TIMEOUT="${BOOTSTRAP_TIMEOUT:-900}" +SITE_TIMEOUT="${SITE_TIMEOUT:-900}" + +BASE_DIR="/server/websites" +PASS=0; FAIL=0; SKIP=0 + +pass() { printf ' [PASS] %s\n' "$1"; PASS=$((PASS + 1)); } +fail() { printf ' [FAIL] %s\n' "$1"; FAIL=$((FAIL + 1)); } +skip() { printf ' [SKIP] %s\n' "$1"; SKIP=$((SKIP + 1)); } + +assert_cmd() { # + local desc="$1"; shift + if "$@" >/dev/null 2>&1; then pass "$desc"; else fail "$desc"; fi +} + +assert_contains() { # + if grep -q -- "$2" <<<"$3"; then pass "$1"; else fail "$1 (pattern not found: $2)"; fi +} + +need_linux() { + [ "$(uname -s)" = "Linux" ] || { echo "[!] Stage '${STAGE}' needs Linux — use the dev container or WSL."; exit 2; } +} + +need_root() { + [ "${EUID}" -eq 0 ] || { echo "[!] Stage '${STAGE}' needs root."; exit 2; } +} + +run_with_timeout() { # + local secs="$1"; shift + if command -v timeout >/dev/null 2>&1; then + timeout "${secs}" "$@" + else + "$@" + fi +} + +# ---------------------------------------------------------------- static ----- +stage_static() { + echo "== [static] syntax, lint, argument handling ==" + assert_cmd "bash -n syntax check (wp-engine.sh)" bash -n "${SCRIPT}" + assert_cmd "bash -n syntax check (smoke.sh)" bash -n "${BASH_SOURCE[0]}" + + if command -v shellcheck >/dev/null 2>&1; then + assert_cmd "shellcheck -S error (wp-engine.sh)" shellcheck -S error "${SCRIPT}" + assert_cmd "shellcheck -S error (smoke.sh)" shellcheck -S error "${BASH_SOURCE[0]}" + else + skip "shellcheck not installed" + fi + + local out + # Root gate: running as non-root must always be refused. + if [ "${EUID}" -eq 0 ]; then + if command -v setpriv >/dev/null 2>&1; then + out="$(setpriv --reuid 65534 --regid 65534 --clear-groups bash "${SCRIPT}" 2>&1 || true)" + assert_contains "non-root invocation rejected" "Root privileges required" "${out}" + else + skip "setpriv unavailable — non-root check skipped" + fi + else + out="$(bash "${SCRIPT}" 2>&1 || true)" + assert_contains "non-root invocation rejected" "Root privileges required" "${out}" + fi + + # Usage/argument handling only reachable past the root gate. + if [ "${EUID}" -eq 0 ]; then + out="$(bash "${SCRIPT}" 2>&1 || true)" + assert_contains "usage shown for unknown action" "Usage:" "${out}" + + out="$(bash "${SCRIPT}" create 2>&1 || true)" + assert_contains "create without args prints usage" "create " "${out}" + + out="$(bash "${SCRIPT}" --definitely-invalid 2>&1 || true)" + assert_contains "unknown flag falls back to usage" "Usage:" "${out}" + else + skip "usage checks require root (run as root in WSL / dev container)" + fi +} + +# ------------------------------------------------------------- bootstrap ----- +stage_bootstrap() { + need_linux; need_root + echo "== [bootstrap] full unattended bootstrap (docker + caddy) ==" + + local rc=0 + run_with_timeout "${BOOTSTRAP_TIMEOUT}" \ + bash "${SCRIPT}" --bootstrap --ssh-port "${SSH_PORT}" --engine docker --proxy caddy || rc=$? + if [ "${rc}" -eq 0 ]; then + pass "bootstrap exited 0" + else + fail "bootstrap exited ${rc} (124 = timeout after ${BOOTSTRAP_TIMEOUT}s?)" + fi + + local state; state="$(cat "${BASE_DIR}/.engine-settings" 2>/dev/null || true)" + assert_contains "state: engine=docker" 'ENGINE="docker"' "${state}" + assert_contains "state: proxy=caddy" 'PROXY_TYPE="caddy"' "${state}" + assert_contains "state: ssh port saved" "SSH_PORT=\"${SSH_PORT}\"" "${state}" + + assert_contains "ufw active" "Status: active" "$(ufw status 2>&1 || true)" + assert_cmd "fail2ban sshd jail running" fail2ban-client status sshd + assert_cmd "fail2ban ingress-wordpress jail" fail2ban-client status ingress-wordpress + assert_cmd "ingress_proxy container running" docker inspect -f '{{.State.Running}}' ingress_proxy + assert_cmd "prometheus container running" docker inspect -f '{{.State.Running}}' prometheus + assert_cmd "node_exporter container running" docker inspect -f '{{.State.Running}}' node_exporter + assert_cmd "cadvisor container running" docker inspect -f '{{.State.Running}}' cadvisor + assert_cmd "grafana container running" docker inspect -f '{{.State.Running}}' grafana + assert_cmd "grafana health endpoint" curl -fsS http://localhost:3000/api/health + assert_cmd "proxy access log created" test -f "${BASE_DIR}/proxy/logs/access.log" + + local code + code="$(curl -sS -o /dev/null -w '%{http_code}' http://localhost/ 2>/dev/null || echo 000)" + if [ "${code}" != "000" ]; then + pass "proxy answers on :80 (HTTP ${code})" + else + fail "proxy answers on :80" + fi + + if ss -tln 2>/dev/null | grep -q ":${SSH_PORT} "; then + pass "SSH listening on ${SSH_PORT}" + else + fail "SSH listening on ${SSH_PORT}" + fi +} + +# ------------------------------------------------------------------ site ----- +stage_site() { + need_linux; need_root + echo "== [site] provision disposable WordPress site '${TEST_SLUG}' ==" + + if ! docker inspect ingress_proxy >/dev/null 2>&1; then + echo " (platform not up — running bootstrap first)" + stage_bootstrap + fi + + local rc=0 slug="${TEST_SLUG//-/_}" + run_with_timeout "${SITE_TIMEOUT}" \ + bash "${SCRIPT}" create "${TEST_SLUG}" "${TEST_DOMAIN}" admin "admin@${TEST_DOMAIN}" || rc=$? + if [ "${rc}" -eq 0 ]; then + pass "create exited 0" + else + fail "create exited ${rc} (124 = timeout after ${SITE_TIMEOUT}s?)" + fi + + assert_cmd "site directory created" test -d "${BASE_DIR}/sites/${TEST_SLUG}" + assert_cmd "wp container running" docker inspect -f '{{.State.Running}}' "${TEST_SLUG}_wp" + assert_cmd "wp-config.php written" test -f "${BASE_DIR}/sites/${TEST_SLUG}/data/wp/wp-config.php" + assert_contains "database wp_${slug} exists" "wp_${slug}" \ + "$(docker exec core_mariadb mariadb -N -e 'SHOW DATABASES' 2>/dev/null || true)" + assert_contains "Caddyfile route appended" "${TEST_DOMAIN}" \ + "$(cat "${BASE_DIR}/proxy/Caddyfile" 2>/dev/null || true)" + + # Site must answer through the ingress proxy (200 or any redirect is fine) + local code + code="$(curl -sS -o /dev/null -w '%{http_code}' -H "Host: ${TEST_DOMAIN}" http://localhost/ 2>/dev/null || echo 000)" + case "${code}" in + 200|301|302|303|307|308) pass "site answers via proxy (HTTP ${code})" ;; + *) fail "site answers via proxy (HTTP ${code})" ;; + esac + + # WordPress reports itself installed + if docker run --rm --user 33:33 --network shared-backend \ + -v "${BASE_DIR}/sites/${TEST_SLUG}/data/wp:/var/www/html" \ + wordpress:cli wp core is-installed >/dev/null 2>&1; then + pass "wp core is-installed" + else + fail "wp core is-installed" + fi +} + +# --------------------------------------------------------------- cleanup ----- +stage_cleanup() { + need_linux; need_root + echo "== [cleanup] removing disposable site '${TEST_SLUG}' ==" + local slug="${TEST_SLUG//-/_}" + + docker rm -f "${TEST_SLUG}_wp" >/dev/null 2>&1 || true + + if [ -f "${BASE_DIR}/core/.env" ] && docker inspect core_mariadb >/dev/null 2>&1; then + # shellcheck source=/dev/null + source "${BASE_DIR}/core/.env" + docker exec core_mariadb mariadb -u root -p"${DB_ROOT_PASSWORD}" \ + -e "DROP DATABASE IF EXISTS \`${DB_NAME_OVERRIDE:-wp_${slug}}\`;" \ + -e "DROP USER IF EXISTS 'usr_${slug}'@'%';" >/dev/null 2>&1 || true + fi + + rm -rf "${BASE_DIR}/sites/${TEST_SLUG}" + pass "test site '${TEST_SLUG}' removed (rerun with STAGE=site to recreate)" +} + +# --------------------------------------------------------------- dispatch ----- +case "${STAGE}" in + static) stage_static ;; + bootstrap) stage_bootstrap ;; + site) stage_site ;; + all) stage_static; stage_bootstrap; stage_site ;; + cleanup) stage_cleanup ;; + *) + echo "[!] Unknown STAGE='${STAGE}'. Use: static | bootstrap | site | all | cleanup" + exit 2 + ;; +esac + +echo "" +echo "Result: ${PASS} passed, ${FAIL} failed, ${SKIP} skipped" +[ "${FAIL}" -eq 0 ] || exit 1 +exit 0 diff --git a/wp-engine.sh b/wp-engine.sh old mode 100644 new mode 100755