Add dev container and staged test harness for wp-engine.sh

This commit is contained in:
Mike Kell 2026-10-03 14:00:53 -04:00
parent 65e10343b2
commit fd6810ca26
5 changed files with 395 additions and 2 deletions

54
.devcontainer/Dockerfile Normal file
View File

@ -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"]

View File

@ -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"]
}
}
}

View File

@ -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) 11. [Day-2 Operations](#11-day-2-operations)
12. [Security Hardening Summary](#12-security-hardening-summary) 12. [Security Hardening Summary](#12-security-hardening-summary)
13. [Known Limitations & Post-Install TODOs](#13-known-limitations--post-install-todos) 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 ## Repository Contents
``` ```
wp-engine.sh # The installer/provisioner script (executable) 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 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
``` ```

239
tests/smoke.sh Executable file
View File

@ -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() { # <description> <command...>
local desc="$1"; shift
if "$@" >/dev/null 2>&1; then pass "$desc"; else fail "$desc"; fi
}
assert_contains() { # <description> <grep-pattern> <haystack>
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() { # <seconds> <command...>
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 <site-slug>" "${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

0
wp-engine.sh Normal file → Executable file
View File