Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .github/workflows/docker-buildx.yml
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,27 @@ jobs:
chmod +x tests/verify-drupal.sh
./tests/verify-drupal.sh ${{ matrix.variant }}

- name: Verify FrankenPHP defaults (opt-in features off)
if: matrix.variant == 'frankenphp-trixie'
run: ./tests/verify-frankenphp-optin.sh default

- name: Verify FrankenPHP opt-in features
if: matrix.variant == 'frankenphp-trixie'
env:
# Special characters on purpose: $, quotes and braces must survive
# hashing and the Caddyfile.
BASIC_AUTH_PASSWORD: 'S3cret$pa"ss{env.HOME}'
run: |
# Recreate the container on the same installed site with the
# variables from the override file set.
docker compose -f tests/docker-compose.yml \
-f tests/docker-compose.frankenphp-optin.yml up -d --force-recreate
./tests/verify-frankenphp-optin.sh optin

- name: Verify FrankenPHP basic auth fails closed
if: matrix.variant == 'frankenphp-trixie'
run: ./tests/verify-frankenphp-optin.sh failclosed "drupal-test:${{ matrix.php_version }}-${{ matrix.variant }}"

- name: Capture logs
if: failure()
run: |
Expand Down
45 changes: 44 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -217,14 +217,21 @@ The image ships with a Drupal-tuned Caddyfile that blocks access to sensitive pa

```caddyfile
{
frankenphp
frankenphp {
php_ini memory_limit {$PHP_MEMORY_LIMIT:128M}
}
order php_server before file_server
}

:80 {
encode zstd br gzip
root * /app/web

# Opt-in features, off by default (see README). Noindex comes first so the
# 401 from basic auth carries the X-Robots-Tag header too.
import {$NOINDEX_SNIPPET:/etc/frankenphp/noindex/disabled.caddy}
import {$BASIC_AUTH_SNIPPET:/etc/frankenphp/basic-auth/disabled.caddy}

# Block hidden PHP files
@hiddenPhp path_regexp \..*/.*.php$
error @hiddenPhp 403
Expand Down Expand Up @@ -259,6 +266,42 @@ The image ships with a Drupal-tuned Caddyfile that blocks access to sensitive pa
}
```

#### Basic auth, noindex and PHP memory limit

Three features are driven by environment variables, so a private staging or demo site can run on the stock Caddyfile. All are off (or unchanged) by default.

| Variable | Default | Purpose |
| --- | --- | --- |
| `BASIC_AUTH_USER` | unset | User name for HTTP basic auth. |
| `BASIC_AUTH_PASSWORD` | unset | Plaintext password. The entrypoint hashes it at start. |
| `BASIC_AUTH_HASH` | unset | A ready-made hash (from `frankenphp hash-password`) instead of a password. Wins over `BASIC_AUTH_PASSWORD` if both are set. |
| `BASIC_AUTH_SNIPPET` | `/etc/frankenphp/basic-auth/disabled.caddy` | Caddy snippet imported for basic auth. Set automatically to `/etc/frankenphp/basic-auth/enabled.caddy` when the user and a password or hash are given. |
| `NOINDEX_SNIPPET` | `/etc/frankenphp/noindex/disabled.caddy` | Set to `/etc/frankenphp/noindex/enabled.caddy` to send `X-Robots-Tag: noindex, nofollow, noarchive` on every response (the 401 included) and to serve a disallow-all `/robots.txt`. |
| `PHP_MEMORY_LIMIT` | `128M` | `memory_limit` for the web server. `128M` is PHP's built-in default, so nothing changes unless you set it. The CLI keeps `memory_limit = -1`, so drush and composer are unaffected. |

```yaml
services:
drupal:
image: hussainweb/drupal-base:php8.5-frankenphp-trixie
volumes:
- ./path/to/your/drupal/root:/app
ports:
- "8080:80"
environment:
BASIC_AUTH_USER: demo
BASIC_AUTH_PASSWORD: ${BASIC_AUTH_PASSWORD}
NOINDEX_SNIPPET: /etc/frankenphp/noindex/enabled.caddy
PHP_MEMORY_LIMIT: 512M
```

Basic auth protects every path except `/robots.txt`, so crawlers can still read the disallow rule. The password is hashed by `docker-drupal-entrypoint` (a bcrypt hash via `frankenphp hash-password`, with the password on stdin), so you never write a hash containing `$` into a compose file. `BASIC_AUTH_PASSWORD` is unset before the server starts, so it does not reach PHP's environment. The user name must not contain spaces.

**Fails closed.** If basic auth is requested (`BASIC_AUTH_SNIPPET` points at the enabled file) but the user name or the password and hash are missing, the container exits with an error instead of starting an open site.

**Downstream entrypoints.** The image sets `ENTRYPOINT ["docker-drupal-entrypoint"]` and re-declares the upstream `CMD`. If your image has its own entrypoint, end it with `exec docker-drupal-entrypoint "$@"` to keep these features (it hands over to `docker-php-entrypoint`). If you replace the entrypoint without doing so, the variables have no effect, and basic auth is never enabled.

If you mount your own Caddyfile, it only gets these features if it contains the same `import` lines (and the `php_ini` line in the global block).

#### Custom Caddyfile

To customize the Caddy configuration, mount your own Caddyfile:
Expand Down
9 changes: 8 additions & 1 deletion php8/frankenphp-trixie/Caddyfile
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
{
frankenphp
frankenphp {
php_ini memory_limit {$PHP_MEMORY_LIMIT:128M}
}
order php_server before file_server
}

:80 {
encode zstd br gzip
root * /app/web

# Opt-in features, off by default (see README). Noindex comes first so the
# 401 from basic auth carries the X-Robots-Tag header too.
import {$NOINDEX_SNIPPET:/etc/frankenphp/noindex/disabled.caddy}
import {$BASIC_AUTH_SNIPPET:/etc/frankenphp/basic-auth/disabled.caddy}

# Block hidden PHP files
@hiddenPhp path_regexp \..*/.*.php$
error @hiddenPhp 403
Expand Down
9 changes: 9 additions & 0 deletions php8/frankenphp-trixie/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,15 @@ COPY --from=composer:2 /usr/bin/composer /usr/local/bin/
# FrankenPHP configuration
# Copy Caddyfile for Drupal configuration
COPY Caddyfile /etc/frankenphp/Caddyfile
# Opt-in snippets imported by the Caddyfile; the disabled ones are the default
COPY noindex/ /etc/frankenphp/noindex/
COPY basic-auth/ /etc/frankenphp/basic-auth/

# Prepares basic auth from environment variables, then runs the upstream
# entrypoint. Setting ENTRYPOINT resets CMD, so re-declare the upstream one.
COPY --chmod=755 docker-drupal-entrypoint /usr/local/bin/docker-drupal-entrypoint
ENTRYPOINT ["docker-drupal-entrypoint"]
CMD ["--config", "/etc/frankenphp/Caddyfile", "--adapter", "caddyfile"]

WORKDIR /app
ENV PATH=${PATH}:/app/vendor/bin
Expand Down
2 changes: 2 additions & 0 deletions php8/frankenphp-trixie/basic-auth/disabled.caddy
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Basic auth is disabled (default). Nothing to configure.
# Set BASIC_AUTH_USER and BASIC_AUTH_PASSWORD to enable it.
7 changes: 7 additions & 0 deletions php8/frankenphp-trixie/basic-auth/enabled.caddy
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Opt-in: HTTP basic auth on every path except /robots.txt.
# docker-drupal-entrypoint enables this when BASIC_AUTH_USER and
# BASIC_AUTH_PASSWORD (or BASIC_AUTH_HASH) are set.
@drupal_base_basic_auth not path /robots.txt
basic_auth @drupal_base_basic_auth {
{$BASIC_AUTH_USER} {$BASIC_AUTH_HASH}
}
41 changes: 41 additions & 0 deletions php8/frankenphp-trixie/docker-drupal-entrypoint
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
#!/bin/sh
# Prepares the opt-in FrankenPHP features, then hands over to the upstream
# entrypoint. Downstream entrypoints should end with:
# exec docker-drupal-entrypoint "$@"
set -eu

enabled=/etc/frankenphp/basic-auth/enabled.caddy

# Credentials given: turn basic auth on unless a snippet was chosen.
if [ -n "${BASIC_AUTH_USER:-}" ] \
&& { [ -n "${BASIC_AUTH_PASSWORD:-}" ] || [ -n "${BASIC_AUTH_HASH:-}" ]; } \
&& [ -z "${BASIC_AUTH_SNIPPET:-}" ]; then
BASIC_AUTH_SNIPPET=$enabled
export BASIC_AUTH_SNIPPET
fi

# Basic auth requested: fail closed rather than serve an open site.
if [ "${BASIC_AUTH_SNIPPET:-}" = "$enabled" ]; then
if [ -z "${BASIC_AUTH_USER:-}" ]; then
echo "docker-drupal-entrypoint: basic auth is enabled but BASIC_AUTH_USER is not set." >&2
exit 1
fi
if [ -z "${BASIC_AUTH_HASH:-}" ]; then
if [ -z "${BASIC_AUTH_PASSWORD:-}" ]; then
echo "docker-drupal-entrypoint: basic auth is enabled but neither BASIC_AUTH_PASSWORD nor BASIC_AUTH_HASH is set." >&2
exit 1
fi
# The password goes in on stdin so it never shows up in a process list.
if ! BASIC_AUTH_HASH=$(printf '%s\n' "$BASIC_AUTH_PASSWORD" | frankenphp hash-password) \
|| [ -z "$BASIC_AUTH_HASH" ]; then
echo "docker-drupal-entrypoint: failed to hash BASIC_AUTH_PASSWORD." >&2
exit 1
fi
export BASIC_AUTH_HASH
fi
fi

# The plaintext is no longer needed; keep it out of PHP's environment.
unset BASIC_AUTH_PASSWORD

exec docker-php-entrypoint "$@"
2 changes: 2 additions & 0 deletions php8/frankenphp-trixie/noindex/disabled.caddy
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Noindex is disabled (default). Nothing to configure.
# Set NOINDEX_SNIPPET=/etc/frankenphp/noindex/enabled.caddy to enable it.
9 changes: 9 additions & 0 deletions php8/frankenphp-trixie/noindex/enabled.caddy
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Opt-in: discourage search engines from indexing the site.
# Enable with NOINDEX_SNIPPET=/etc/frankenphp/noindex/enabled.caddy
header X-Robots-Tag "noindex, nofollow, noarchive"
handle /robots.txt {
header Content-Type "text/plain; charset=utf-8"
respond "User-agent: *
Disallow: /
" 200
}
9 changes: 9 additions & 0 deletions tests/docker-compose.frankenphp-optin.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Override for the FrankenPHP variant: turns on the opt-in features.
# Layered over tests/docker-compose.yml by the CI step that runs tests/verify-frankenphp-optin.sh optin.
services:
drupal:
environment:
BASIC_AUTH_USER: demo
BASIC_AUTH_PASSWORD: ${BASIC_AUTH_PASSWORD}
NOINDEX_SNIPPET: /etc/frankenphp/noindex/enabled.caddy
PHP_MEMORY_LIMIT: 512M
103 changes: 103 additions & 0 deletions tests/verify-frankenphp-optin.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
#!/bin/bash
# Checks the opt-in FrankenPHP features (basic auth, noindex, PHP memory limit).
#
# Usage: verify-frankenphp-optin.sh default|optin|failclosed <image>
#
# default run against the site started with none of the variables set
# optin run against the site started with docker-compose.frankenphp-optin.yml
# (needs BASIC_AUTH_PASSWORD in the environment)
# failclosed run the image directly with incomplete basic auth settings
set -u

MODE=${1:-}
IMAGE=${2:-}
BASE_URL="http://localhost:8080"
SERVICE="drupal"
FAILED=0

pass() { echo "PASSED: $1"; }
fail() { echo "FAILED: $1"; FAILED=1; }

check() { # description, expected, actual
if [ "$2" = "$3" ]; then pass "$1 ($3)"; else fail "$1 (expected '$2', got '$3')"; fi
}

check_contains() { # description, needle, haystack
if printf '%s' "$3" | grep -qF -- "$2"; then pass "$1"; else fail "$1 (missing '$2')"; fi
}

check_not_contains() { # description, needle, haystack
if printf '%s' "$3" | grep -qF -- "$2"; then fail "$1 (found '$2')"; else pass "$1"; fi
}

wait_for_web() {
for _ in $(seq 1 30); do
code=$(curl -s -o /dev/null -w '%{http_code}' "$BASE_URL/" || true)
[ "$code" != "000" ] && return 0
sleep 2
done
echo "Web server did not come up"
docker compose logs "$SERVICE" || true
exit 1
}

# memory_limit as the web server sees it (a request), not the CLI.
web_memory_limit() { # extra curl args
docker compose exec -T "$SERVICE" sh -c \
"echo '<?php echo ini_get(\"memory_limit\");' > /app/web/memlimit.php"
curl -s "$@" "$BASE_URL/memlimit.php"
}

case "$MODE" in
default)
wait_for_web
check "/ without credentials" 200 "$(curl -s -o /dev/null -w '%{http_code}' -L "$BASE_URL/")"
headers=$(curl -si "$BASE_URL/")
check_not_contains "/ has no X-Robots-Tag" "X-Robots-Tag: noindex" "$headers"
robots=$(curl -s "$BASE_URL/robots.txt")
check_contains "/robots.txt is Drupal's own" "Disallow: /core/" "$robots"
check "web memory_limit is PHP's default" 128M "$(web_memory_limit)"
check "CLI memory_limit" -1 "$(docker compose exec -T "$SERVICE" php -r 'echo ini_get("memory_limit");')"
;;
optin)
: "${BASIC_AUTH_PASSWORD:?BASIC_AUTH_PASSWORD must be set}"
wait_for_web
check "/ without credentials" 401 "$(curl -s -o /dev/null -w '%{http_code}' "$BASE_URL/")"
headers=$(curl -si "$BASE_URL/")
check_contains "401 carries X-Robots-Tag" "X-Robots-Tag: noindex, nofollow, noarchive" "$headers"
check "/ with wrong credentials" 401 "$(curl -s -o /dev/null -w '%{http_code}' -u "demo:wrong" "$BASE_URL/")"
check "/ with wrong user" 401 "$(curl -s -o /dev/null -w '%{http_code}' -u "other:${BASIC_AUTH_PASSWORD}" "$BASE_URL/")"
check "/ with right credentials" 200 "$(curl -s -o /dev/null -w '%{http_code}' -L -u "demo:${BASIC_AUTH_PASSWORD}" "$BASE_URL/")"
check "/user/login without credentials" 401 "$(curl -s -o /dev/null -w '%{http_code}' "$BASE_URL/user/login")"
check "/robots.txt without credentials" 200 "$(curl -s -o /dev/null -w '%{http_code}' "$BASE_URL/robots.txt")"
robots=$(curl -s "$BASE_URL/robots.txt")
check_contains "/robots.txt disallows all" "Disallow: /" "$robots"
check_not_contains "/robots.txt is not Drupal's" "Disallow: /core/" "$robots"
check_contains "/robots.txt carries X-Robots-Tag" "X-Robots-Tag: noindex" "$(curl -si "$BASE_URL/robots.txt")"
check "web memory_limit" 512M "$(web_memory_limit -u "demo:${BASIC_AUTH_PASSWORD}")"
check "CLI memory_limit" -1 "$(docker compose exec -T "$SERVICE" php -r 'echo ini_get("memory_limit");')"
# PID 1 is the web server after the entrypoint's exec; its own environment
# is what PHP would inherit.
if docker compose exec -T "$SERVICE" sh -c "tr '\0' '\n' < /proc/1/environ | grep -q '^BASIC_AUTH_PASSWORD='"; then
fail "BASIC_AUTH_PASSWORD is unset in the server process"
else
pass "BASIC_AUTH_PASSWORD is unset in the server process"
fi
;;
failclosed)
[ -n "$IMAGE" ] || { echo "image required"; exit 2; }
enabled=/etc/frankenphp/basic-auth/enabled.caddy
for envs in "BASIC_AUTH_USER=demo" "BASIC_AUTH_PASSWORD=secret"; do
out=$(docker run --rm -e "$envs" -e "BASIC_AUTH_SNIPPET=$enabled" "$IMAGE" 2>&1)
rc=$?
if [ "$rc" -ne 0 ]; then pass "exits non-zero with only $envs (rc=$rc)"; else fail "exits non-zero with only $envs"; fi
check_contains "message for only $envs" "docker-drupal-entrypoint: basic auth is enabled" "$out"
done
;;
*)
echo "Usage: $0 default|optin|failclosed <image>"
exit 2
;;
esac

exit "$FAILED"
Loading