From e6ec3c94dca9b2f24b0239ec28f9d696bbd0cff9 Mon Sep 17 00:00:00 2001 From: bisco Date: Thu, 23 Jul 2026 23:40:44 +0200 Subject: [PATCH] feat: add internal WordPress cron runner --- .env.example | 1 + README.md | 15 ++++-- docker-compose.test.yml | 7 +++ docker-compose.yml | 41 ++++++++++++++ docs/adr/0001-wordpress-single-page.md | 17 +++--- docs/architecture.md | 11 ++-- docs/deployment.md | 5 ++ docs/operations.md | 16 +++--- docs/runbook.md | 33 ++++++++++++ docs/security.md | 6 ++- docs/testing.md | 3 +- tests/security/check-compose.sh | 74 ++++++++++++++++++++++++++ wordpress/Dockerfile | 1 + wordpress/entrypoint-wrapper.sh | 3 +- wp-cli/cron.sh | 36 +++++++++++++ 15 files changed, 245 insertions(+), 24 deletions(-) create mode 100644 wp-cli/cron.sh diff --git a/.env.example b/.env.example index 6088d03..a8369ec 100644 --- a/.env.example +++ b/.env.example @@ -11,6 +11,7 @@ WP_TITLE=Azione!Lab WP_ADMIN_USER=azionelab-admin WP_ADMIN_PASSWORD=replace-with-a-local-admin-password WP_ADMIN_EMAIL=admin@example.org +WP_CRON_INTERVAL_SECONDS=300 NGINX_BIND_ADDRESS=127.0.0.1 NGINX_HTTP_PORT=8080 NGINX_HTTPS_PORT=8443 diff --git a/README.md b/README.md index e90aab6..d2fc240 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,8 @@ A warm, editorial single-page website for the Azione!Lab contemporary theatre workshop. WordPress manages the content, a custom theme owns the visual system, and -Docker Compose provides MariaDB, NGINX, optional Let's Encrypt, WP-CLI, and isolated -browser tests. +Docker Compose provides MariaDB, NGINX, an internal WordPress cron runner, optional +Let's Encrypt, WP-CLI, and isolated browser tests. ## Architecture @@ -12,6 +12,7 @@ browser tests. - `db`: MariaDB 11.8 LTS, available only on the internal data network. - `proxy`: the only published service; routes `azionelab.org` to WordPress and applies edge security controls. +- `wp-cron`: internal WP-CLI runner for due scheduled WordPress events. - `certbot`: optional HTTP-01 certificate issue/renewal service. - `wp-cli`: opt-in bootstrap and maintenance service. - `tests/functional`: Playwright tests running only through the public virtual host. @@ -61,6 +62,7 @@ Use this checklist for the first run of a new environment. - set strong `MARIADB_PASSWORD`, `MARIADB_ROOT_PASSWORD`, and `WP_ADMIN_PASSWORD`; - set `WP_URL` to the public URL, for example `https://lab.younerd.org`; + - keep `WP_CRON_INTERVAL_SECONDS=300` unless scheduled jobs need a different cadence; - set `LETSENCRYPT_DOMAIN` to the same hostname when direct Let's Encrypt is used; - keep `LETSENCRYPT_STAGING=1` for the first certificate test; - configure `DB_DATA_PATH`, `WORDPRESS_DATA_PATH`, `LETSENCRYPT_DATA_PATH`, and @@ -127,7 +129,7 @@ Use this checklist for the first run of a new environment. ```bash docker compose ps - docker compose logs --tail=100 proxy wordpress db certbot + docker compose logs --tail=100 proxy wordpress wp-cron db certbot ``` Open the configured `WP_URL` and `/wp-admin/`. @@ -212,6 +214,10 @@ docker compose --profile tools run --rm wp-cli /scripts/bootstrap.sh The `docker-compose.test.yml` override intentionally uses disposable test volumes; do not use it for production or staging bootstrap commands. +`wp-cron` runs internally through WP-CLI every `WP_CRON_INTERVAL_SECONDS` seconds. It +does not publish ports and does not join the public `web` network. Public +`/wp-cron.php` requests remain blocked intentionally. + ## Maintenance commands with internet access The default `wp-cli` service joins only the internal database network, so it cannot @@ -232,8 +238,9 @@ Compose file. ## Useful commands ```bash -docker compose logs -f proxy wordpress db certbot +docker compose logs -f proxy wordpress wp-cron db certbot docker compose --profile tools run --rm wp-cli -c 'wp plugin list' +docker compose --profile tools run --rm wp-cli -c 'wp cron event list' docker compose --profile tools run --rm wp-cli -c 'wp core version' docker compose -f docker-compose.yml -f docker-compose.maintenance.yml --profile tools run --rm wp-cli -c 'wp core update --version=7.0.2 --force' diff --git a/docker-compose.test.yml b/docker-compose.test.yml index 620f3ae..94d8d22 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -17,6 +17,13 @@ services: - test_wordpress_data:/var/www/html - ./wp-cli/bootstrap.sh:/scripts/bootstrap.sh:ro + wp-cron: + environment: + WP_CRON_INTERVAL_SECONDS: 300 + volumes: + - test_wordpress_data:/var/www/html + - ./wp-cli/cron.sh:/scripts/cron.sh:ro + proxy: networks: web: diff --git a/docker-compose.yml b/docker-compose.yml index f07e41d..b2e7efe 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -81,6 +81,47 @@ services: - no-new-privileges:true pids_limit: 300 + wp-cron: + image: wordpress:cli-2.12.0-php8.3 + restart: unless-stopped + init: true + user: "33:33" + working_dir: /var/www/html + environment: + WORDPRESS_DB_HOST: db:3306 + WORDPRESS_DB_NAME: ${MARIADB_DATABASE:-azionelab} + WORDPRESS_DB_USER: ${MARIADB_USER:-azionelab} + WORDPRESS_DB_PASSWORD: ${MARIADB_PASSWORD:-replace-with-a-local-password} + WORDPRESS_TABLE_PREFIX: ${WORDPRESS_TABLE_PREFIX:-azl_} + WP_CRON_INTERVAL_SECONDS: ${WP_CRON_INTERVAL_SECONDS:-300} + WP_ENVIRONMENT_TYPE: ${WP_ENVIRONMENT_TYPE:-local} + WP_URL: ${WP_URL:-http://azionelab.org:8080} + HOME: /tmp + HTTP_HOST: ${LETSENCRYPT_DOMAIN:-azionelab.org} + entrypoint: ["/bin/sh"] + command: ["/scripts/cron.sh"] + volumes: + - type: bind + source: ${WORDPRESS_DATA_PATH:-./runtime/wordpress} + target: /var/www/html + bind: + create_host_path: true + - ./wp-cli/cron.sh:/scripts/cron.sh:ro + networks: + - data + depends_on: + db: + condition: service_healthy + wordpress: + condition: service_healthy + tmpfs: + - /tmp + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + pids_limit: 100 + proxy: build: context: ./nginx diff --git a/docs/adr/0001-wordpress-single-page.md b/docs/adr/0001-wordpress-single-page.md index 6dbcdaf..be06dcd 100644 --- a/docs/adr/0001-wordpress-single-page.md +++ b/docs/adr/0001-wordpress-single-page.md @@ -17,10 +17,12 @@ classic theme for exact semantic markup and responsive design. Store homepage fi sanitized theme modifications, while a must-use plugin owns Shows and Gallery custom post types so structured content is not lost when changing themes. -NGINX is the only public entry point. WP-CLI provides an idempotent opt-in bootstrap; -Certbot provides opt-in direct TLS. Docker networks isolate the database. Runtime state -uses host-based bind mounts with a preparation script for ownership/mode, while -functional tests use separate Docker volumes. +NGINX is the only public entry point. Public `wp-cron.php` requests stay blocked, while +an internal WP-CLI based `wp-cron` service runs due scheduled events over the private +data network. WP-CLI provides an idempotent opt-in bootstrap; Certbot provides opt-in +direct TLS. Docker networks isolate the database. Runtime state uses host-based bind +mounts with a preparation script for ownership/mode, while functional tests use +separate Docker volumes. ## Consequences @@ -30,6 +32,8 @@ functional tests use separate Docker volumes. - Core/theme/plugin patches require an image rebuild in production. - Fixed participation cards remain code until editorial requirements justify another structured content type. +- Scheduled WordPress jobs depend on the internal `wp-cron` service instead of visitor + traffic. ## Alternatives considered @@ -43,8 +47,9 @@ functional tests use separate Docker volumes. The architecture inherits WordPress's public CMS attack surface. The implementation reduces it with network isolation, no direct application port, disabled file editing -and XML-RPC, production immutability, sanitization/escaping, rate limiting, security -headers, and fail-closed TLS. Admin MFA and allowlisting remain external controls. +and XML-RPC, production immutability, sanitized/escaped rendering, no bundled demo +plugins, internal-only scheduled jobs, rate limiting, security headers, and fail-closed +TLS. Admin MFA and allowlisting remain external controls. ## Operational impact diff --git a/docs/architecture.md b/docs/architecture.md index 23be5de..5ce8e48 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -12,10 +12,13 @@ structured editorial content survives a theme change. These custom post types ar editorial data sources for the homepage, not standalone public routes or REST collections. Images use WordPress featured images with local SVG fallbacks. -WP-CLI is an opt-in tools-profile service. Its idempotent bootstrap installs WordPress, -activates the theme, configures the site, and creates realistic demo content. Certbot is -another optional service, enabled only for direct deployments. It shares challenge and -certificate volumes with NGINX but has no container-control access. +`wp-cron` is an internal WP-CLI runner on the `data` network. It executes due scheduled +events with `wp cron event run --due-now` and keeps public `wp-cron.php` requests +blocked. WP-CLI is also available as an opt-in tools-profile service. Its idempotent +bootstrap installs WordPress, activates the theme, configures the site, and creates +realistic demo content. Certbot is another optional service, enabled only for direct +deployments. It shares challenge and certificate volumes with NGINX but has no +container-control access. Apache includes a small defense-in-depth hardening file that denies uploaded PHP files, direct `wp-config.php` requests, and direct access to selected internal WordPress PHP diff --git a/docs/deployment.md b/docs/deployment.md index 48249d0..47b2340 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -11,6 +11,8 @@ docker compose --profile tools run --rm wp-cli /scripts/bootstrap.sh NGINX binds to loopback ports 8080/8443. WordPress and MariaDB remain private. The bootstrap is safe to rerun and does not duplicate demo records. +The internal `wp-cron` service runs scheduled WordPress events after the bootstrap +completes. The default persistent paths are host-based bind mounts under `./runtime`. Before the first start, run: @@ -37,6 +39,9 @@ Required controls: - either direct Let's Encrypt termination or a trusted external load balancer; - off-host database/file backups and monitoring. +The default `wp-cron` service handles scheduled WordPress events internally and should +remain enabled unless another controlled cron runner replaces it. + When a load balancer terminates TLS, Certbot stays disabled. `TRUST_PROXY_HEADERS=1` is safe only when firewall/network policy makes the load balancer the sole NGINX caller and it overwrites `X-Forwarded-*` headers. diff --git a/docs/operations.md b/docs/operations.md index 1f765d9..149fff2 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -5,9 +5,10 @@ ```bash docker compose up --build -d docker compose ps -docker compose logs -f proxy wordpress db certbot +docker compose logs -f proxy wordpress wp-cron db certbot ./scripts/prepare-host-volumes.sh docker compose --profile tools run --rm wp-cli -c 'wp core version' +docker compose --profile tools run --rm wp-cli -c 'wp cron event list' docker compose down ``` @@ -37,10 +38,11 @@ docker compose restart wordpress proxy Do not use the maintenance override for routine bootstrap or inspection commands that do not need outbound network access. -External `wp-cron.php` requests are blocked to reduce public attack surface. If future -content scheduling, maintenance jobs, or plugin features require WordPress cron, run it -from a controlled host/container cron against the private WordPress service instead of -leaving the public trigger enabled. +External `wp-cron.php` requests are blocked to reduce public attack surface. The +default `wp-cron` service runs due scheduled events internally through WP-CLI every +`WP_CRON_INTERVAL_SECONDS` seconds, defaults to 300, and joins only the internal `data` +network. If a scheduled job is late, inspect `docker compose logs wp-cron wordpress db` +and run `docker compose --profile tools run --rm wp-cli -c 'wp cron event list'`. ## Backup and restore @@ -61,8 +63,8 @@ the database and file volume together, restart, and verify the homepage, media, owner or mode; run `./scripts/prepare-host-volumes.sh` after changing paths or image user IDs. - SMTP is not configured; WordPress password-reset email needs an external mail service. -- WordPress cron is not publicly triggerable; scheduled jobs need an operator-managed - cron runner if that feature becomes necessary. +- WordPress cron is not publicly triggerable; scheduled jobs depend on the internal + `wp-cron` runner being healthy. - Admin MFA and network allowlisting are deployment concerns and are not bundled. - WordPress plugins expand the attack surface; install only reviewed, maintained, necessary plugins. diff --git a/docs/runbook.md b/docs/runbook.md index 27eba25..6883512 100644 --- a/docs/runbook.md +++ b/docs/runbook.md @@ -50,6 +50,39 @@ docker compose restart wordpress proxy If DNS still fails with the maintenance override, inspect the host/container DNS configuration and firewall rules before retrying the update. +## Scheduled WordPress events are late + +The public `/wp-cron.php` trigger is intentionally blocked. Scheduled jobs are executed +by the internal `wp-cron` service. + +1. Confirm the runner is present: + + ```bash + docker compose ps wp-cron + ``` + +2. Inspect due events: + + ```bash + docker compose --profile tools run --rm wp-cli -c 'wp cron event list' + ``` + +3. Inspect runner logs without printing `.env` values: + + ```bash + docker compose logs --tail=100 wp-cron wordpress db + ``` + +4. If needed, run due events manually: + + ```bash + docker compose --profile tools run --rm wp-cli -c 'wp cron event run --due-now' + ``` + +The Site Health loopback test may still report a blocked public loopback while the edge +continues to deny `/wp-cron.php`; that is an expected trade-off of the hardened public +configuration. + ## Uploaded image does not appear on the public page 1. Confirm the image was selected in **Appearance > Customize**, not only uploaded in diff --git a/docs/security.md b/docs/security.md index 58c23cd..93daec0 100644 --- a/docs/security.md +++ b/docs/security.md @@ -11,6 +11,8 @@ collections. - File editing is always disabled. Production also disables web-based core, theme, and plugin changes; patched images are rebuilt and redeployed instead. +- The WordPress image removes the bundled Akismet and Hello Dolly plugins, and the + entrypoint also removes them from existing persistent volumes on startup. - XML-RPC and comments are disabled. NGINX blocks PHP execution below uploads, dotfiles, direct `wp-config.php` requests, the WordPress readme/license files, direct installation entry points, public mu-plugin directory probing, and external @@ -28,7 +30,9 @@ WordPress/Apache retains the capabilities needed by the official image internally, but no WordPress port is published. A containerized security test fails if the WordPress service is configured with host-published ports. -- WP-CLI normally joins only the internal data network. The maintenance override +- `wp-cron` runs WordPress scheduled events through WP-CLI on the internal data network + only; public `wp-cron.php` remains blocked. WP-CLI normally joins only the internal + data network. The maintenance override attaches WP-CLI to the web network for operator-triggered commands that require outbound internet access, such as WordPress core downloads; do not use it for routine bootstrap or inspection commands. diff --git a/docs/testing.md b/docs/testing.md index 50e00dc..0d22e5d 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -23,7 +23,8 @@ cover content and section order, contact actions, mobile overflow/navigation, se landmarks, image alternatives, admin routing, security headers, blocked sensitive routes, blocked uploaded PHP requests, and unknown virtual hosts. Security checks also assert that WordPress does not publish host ports and that Apache hardening remains -installed in the WordPress image. +installed in the WordPress image. They also assert that the default demo plugins are +removed and that the internal `wp-cron` service has no public network exposure. Subjective visual review and a real-device accessibility audit remain manual release checks. diff --git a/tests/security/check-compose.sh b/tests/security/check-compose.sh index be57ac3..cb10275 100755 --- a/tests/security/check-compose.sh +++ b/tests/security/check-compose.sh @@ -27,6 +27,26 @@ grep -q '^FROM wordpress:7\.0\.2-php8\.3-apache$' /workspace/wordpress/Dockerfil exit 1 } +grep -q 'wp-content/plugins/akismet' /workspace/wordpress/Dockerfile || { + echo "The WordPress image must remove the bundled Akismet plugin." >&2 + exit 1 +} + +grep -q 'wp-content/plugins/hello.php' /workspace/wordpress/Dockerfile || { + echo "The WordPress image must remove the bundled Hello Dolly plugin." >&2 + exit 1 +} + +grep -q 'wp-content/plugins/akismet' /workspace/wordpress/entrypoint-wrapper.sh || { + echo "The WordPress entrypoint must remove Akismet from existing persistent volumes." >&2 + exit 1 +} + +grep -q 'wp-content/plugins/hello.php' /workspace/wordpress/entrypoint-wrapper.sh || { + echo "The WordPress entrypoint must remove Hello Dolly from existing persistent volumes." >&2 + exit 1 +} + grep -q 'wp-content/(uploads|files)' /workspace/wordpress/apache-hardening.conf || { echo "Apache hardening must block PHP execution below uploads/files." >&2 exit 1 @@ -76,3 +96,57 @@ grep -q "^[[:space:]]*-[[:space:]]*web$" /workspace/docker-compose.maintenance.y echo "The maintenance wp-cli service must explicitly opt into the web network." >&2 exit 1 } + +grep -Eq "^[[:space:]]{2}wp-cron:" "$compose_file" || { + echo "Compose must define an internal wp-cron service." >&2 + exit 1 +} + +grep -q "wp cron event run --due-now" /workspace/wp-cli/cron.sh || { + echo "The wp-cron runner must execute due WordPress cron events via WP-CLI." >&2 + exit 1 +} + +awk ' + /^ wp-cron:/ { + in_wp_cron = 1 + has_data_network = 0 + has_db_host = 0 + next + } + in_wp_cron && /^ [A-Za-z0-9_-]+:/ { + if (!has_data_network) { + print "The wp-cron service must join the internal data network." > "/dev/stderr" + exit 1 + } + if (!has_db_host) { + print "The wp-cron service must receive WordPress database environment variables." > "/dev/stderr" + exit 1 + } + in_wp_cron = 0 + } + in_wp_cron && /^ ports:/ { + print "The wp-cron service must not publish host ports." > "/dev/stderr" + exit 1 + } + in_wp_cron && /^[[:space:]]+WORDPRESS_DB_HOST:/ { + has_db_host = 1 + } + in_wp_cron && /^ - data$/ { + has_data_network = 1 + } + in_wp_cron && /^ - web$/ { + print "The wp-cron service must not join the public web network." > "/dev/stderr" + exit 1 + } + END { + if (in_wp_cron && !has_data_network) { + print "The wp-cron service must join the internal data network." > "/dev/stderr" + exit 1 + } + if (in_wp_cron && !has_db_host) { + print "The wp-cron service must receive WordPress database environment variables." > "/dev/stderr" + exit 1 + } + } +' "$compose_file" diff --git a/wordpress/Dockerfile b/wordpress/Dockerfile index 62c620c..d07c197 100644 --- a/wordpress/Dockerfile +++ b/wordpress/Dockerfile @@ -9,6 +9,7 @@ COPY theme/azionelab /opt/azionelab/theme COPY mu-plugins/azionelab-content.php /opt/azionelab/azionelab-content.php RUN sed -ri 's!^[[:space:]]*CustomLog .*!CustomLog /dev/null combined!' /etc/apache2/sites-available/000-default.conf \ + && rm -rf /usr/src/wordpress/wp-content/plugins/akismet /usr/src/wordpress/wp-content/plugins/hello.php \ && a2enconf azionelab-apache-hardening \ && chmod 755 /usr/local/bin/azionelab-entrypoint \ && chmod 644 /usr/local/bin/azionelab-healthcheck.php \ diff --git a/wordpress/entrypoint-wrapper.sh b/wordpress/entrypoint-wrapper.sh index 59df7eb..04d9cc7 100644 --- a/wordpress/entrypoint-wrapper.sh +++ b/wordpress/entrypoint-wrapper.sh @@ -24,7 +24,8 @@ case "$environment" in esac if [ "${1:-}" = "apache2-foreground" ]; then - mkdir -p /var/www/html/wp-content/themes /var/www/html/wp-content/mu-plugins + mkdir -p /var/www/html/wp-content/themes /var/www/html/wp-content/mu-plugins /var/www/html/wp-content/plugins + rm -rf /var/www/html/wp-content/plugins/akismet /var/www/html/wp-content/plugins/hello.php rm -rf /var/www/html/wp-content/themes/azionelab cp -a /opt/azionelab/theme /var/www/html/wp-content/themes/azionelab cp /opt/azionelab/azionelab-content.php /var/www/html/wp-content/mu-plugins/azionelab-content.php diff --git a/wp-cli/cron.sh b/wp-cli/cron.sh new file mode 100644 index 0000000..aa8508f --- /dev/null +++ b/wp-cli/cron.sh @@ -0,0 +1,36 @@ +#!/bin/sh +set -eu + +cd /var/www/html + +trap 'exit 0' INT TERM + +interval="${WP_CRON_INTERVAL_SECONDS:-300}" +case "$interval" in + "" | *[!0-9]*) + echo "WP_CRON_INTERVAL_SECONDS must be a positive integer." >&2 + exit 1 + ;; +esac + +if [ "$interval" -lt 60 ]; then + echo "WP_CRON_INTERVAL_SECONDS must be at least 60 seconds." >&2 + exit 1 +fi + +while [ ! -f /var/www/html/wp-load.php ]; do + sleep 10 +done + +until wp core is-installed >/dev/null 2>&1; do + sleep 10 +done + +while :; do + if ! wp cron event run --due-now >/dev/null 2>&1; then + echo "Warning: failed to run due WordPress cron events." >&2 + fi + + sleep "$interval" & + wait "$!" +done