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
67 changes: 67 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ jobs:
shellcheck --severity=warning \
install.sh install_cert.sh setup-local-dns.sh aliases.bash \
lib/menu.sh lib/compose.sh bin/forge \
docker-library/nginx/config_files/docker-entrypoint.sh \
docker-library/php/config_files/docker-php-entrypoint \
docker-library/php/config_files/xdebug \
docker-library/httpd/config_files/docker-entrypoint.sh
Expand Down Expand Up @@ -523,6 +524,72 @@ jobs:
forge profile off nginx
docker compose ps --services | grep -qx apachedev

# Neither fallback had ever been exercised: CI runs install_cert.sh first, so
# the certificate always existed. nginx died outright without one, and
# Apache's safety net -- written in task 7 -- had never been proven to work.
- name: a missing certificate degrades instead of killing the server
run: |
set -euo pipefail
export PATH="$HOME/.local/bin:$PATH"
mkdir -p /tmp/certs-aside
mv certificates/php-devforge.pem certificates/php-devforge.key /tmp/certs-aside/
# Unconditional: a failure halfway through would otherwise leave the
# certificates aside, and every later step -- including the log dump on
# failure -- would be looking at a degraded stack.
trap 'mv /tmp/certs-aside/* certificates/ 2>/dev/null || true' EXIT

# `curl -k` accepts any certificate, so it cannot tell a generated
# fallback from a stale file. Comparing issuer to subject can: a
# self-signed certificate signs itself, mkcert's does not. Comparing the
# two beats grepping for a name -- OpenSSL prints `CN = x` on some builds
# and `CN=x` on others, which is what broke the first version of this.
cert_field() {
echo | openssl s_client -connect 127.0.0.1:443 -servername welcome.phpforge.dev 2>/dev/null \
| openssl x509 -noout "-$1" | sed "s/^$1=//" | tr -d ' '
}
self_signed() { [ "$(cert_field issuer)" = "$(cert_field subject)" ]; }

docker compose up -d --force-recreate apachedev
for i in $(seq 1 30); do
curl -fsS -k --max-time 5 --resolve welcome.phpforge.dev:443:127.0.0.1 \
https://welcome.phpforge.dev/ >/dev/null 2>&1 && break
sleep 1
done
curl -fsS -k --resolve welcome.phpforge.dev:443:127.0.0.1 \
https://welcome.phpforge.dev/ | grep -q "PHP DevForge is running"
self_signed || {
echo "apache is not serving a generated certificate: $(cert_field issuer)"; exit 1; }
docker compose logs apachedev | grep -qi 'self-signed'
echo "apache degrades"

forge profile on nginx
for i in $(seq 1 30); do
curl -fsS -k --max-time 5 --resolve welcome.phpforge.dev:443:127.0.0.1 \
https://welcome.phpforge.dev/ >/dev/null 2>&1 && break
sleep 1
done
docker compose ps --services | grep -qx nginxdev || {
echo "nginxdev is not running"; docker compose logs --tail=20 nginxdev; exit 1; }
curl -fsS -k --resolve welcome.phpforge.dev:443:127.0.0.1 \
https://welcome.phpforge.dev/ | grep -q "PHP DevForge is running"
self_signed || {
echo "nginx is not serving a generated certificate: $(cert_field issuer)"; exit 1; }
echo "nginx degrades too"

# Put it back, or later steps would be testing the wrong certificate.
forge profile off nginx
mv /tmp/certs-aside/php-devforge.pem /tmp/certs-aside/php-devforge.key certificates/
docker compose up -d --force-recreate apachedev
for i in $(seq 1 30); do
curl -fsS --max-time 5 --resolve welcome.phpforge.dev:443:127.0.0.1 \
https://welcome.phpforge.dev/ >/dev/null 2>&1 && break
sleep 1
done
if self_signed; then echo "the real certificate did not come back"; exit 1; fi
cert_field issuer | grep -qi mkcert || {
echo "unexpected issuer after restore: $(cert_field issuer)"; exit 1; }
echo "and the real one comes back"

- name: container logs on failure
if: failure()
run: docker compose logs --tail=50
38 changes: 37 additions & 1 deletion TASKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -1292,7 +1292,7 @@ what the README explains first.
Both surfaced from running the CI steps against running containers rather than
reasoning about them.

- [ ] **39. nginx dies when there is no certificate; Apache degrades**
- [x] **39. nginx dies when there is no certificate; Apache degrades** — FIXED
A checkout without `certificates/php-devforge.pem` — a fresh clone, or one where
`install_cert.sh` was skipped — cannot run the nginx variant at all:

Expand All @@ -1318,6 +1318,42 @@ reasoning about them.
CI never caught this because it runs `install_cert.sh` before the nginx step,
so the certificate always exists there.

Fixed with an entrypoint mirroring Apache's. nginx has no `<IfFile>`, so the
choice is made in the script and handed to the template as `$SSL_CERT` /
`$SSL_KEY` — the templates were already rendered with `envsubst`, so this adds
two variables to a list that exists to keep nginx's own `$mail` and
`$subdomains` from being substituted. The entrypoint also absorbed the
`envsubst` calls that were inlined in `CMD`, which is now just
`openresty -g 'daemon off;'`.

Kept as a second copy rather than shared with Apache's: the common part is
twelve lines of `openssl`, and sharing means widening both build contexts to
`./docker-library`, shipping every other image's files as context.

**The planning turned up a second hole: Apache's fallback had never been
tested either.** Nothing in CI removed the certificates, so the safety net
written in task 7 had been assumed to work for weeks. It does — verified for
the first time here — but that is luck, not coverage. The new step exercises
both servers.

It checks the certificate's **issuer**, not just that HTTPS answers: `curl -k`
accepts anything, so an assertion built on it would pass even if the fallback
were never generated and a stale file were being served. A self-signed
certificate has issuer equal to subject; mkcert's does not.

Two things proven rather than assumed:

| | |
|---|---|
| the step fails without the fix | reverted the entrypoint, got `nginxdev is not running` |
| the cleanup survives a failure | a `trap ... EXIT` puts the certificates back even when the step dies halfway, which the first version did not — it left every later step testing a degraded stack |

One more thing the local run could not have caught: the first version grepped
the issuer for `CN=phpforge.dev`, and the runner's OpenSSL prints `CN = x`
while this machine's prints `CN=x`. It now compares issuer to subject, which
is what "self-signed" actually means and does not depend on how a build
formats a name.

- [ ] **40. Two checkouts of the project fight over the same containers**
`COMPOSE_PROJECT_NAME=php-devforge` is a fixed literal in `.env.example:4`, so
every clone claims the same compose project. Seen for real with a second
Expand Down
19 changes: 8 additions & 11 deletions docker-library/nginx/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,11 @@ COPY config_files/config.main /etc/nginx/conf.d/config.main
# 3. Crear carpeta de certificados
RUN mkdir -p /etc/nginx/ssl/

# 4. CMD: Procesa ambos templates con envsubst y luego inicia OpenResty
CMD ["/bin/bash", "-c", " \
# Procesa el archivo principal de Nginx
envsubst '$DEV_DOMAIN $PHP_VERSION' < /etc/nginx/conf.d/site.conf.tpl > /etc/nginx/conf.d/site.conf && \
\
# Procesa el fragmento de configuración común y lo guarda donde se incluye
envsubst '$DEV_DOMAIN $PHP_VERSION' < /etc/nginx/conf.d/common_server_config.conf.tpl > /etc/nginx/snippets/common_server_config.conf && \
\
# Inicia OpenResty
openresty -g 'daemon off;' \
"]
# The entrypoint renders the templates and picks the certificate: the real one if
# ./certificates has it, a self-signed one it generates otherwise. Without it a
# missing certificate stopped nginx from starting at all.
COPY config_files/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh

ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
CMD ["openresty", "-g", "daemon off;"]
48 changes: 48 additions & 0 deletions docker-library/nginx/config_files/docker-entrypoint.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
#!/bin/bash
#
# Generates a temporary self-signed certificate when the real one is missing, so
# nginx still starts. The browser will warn, but the environment stays usable.
#
# nginx has no equivalent of Apache's <IfFile>, so the choice is made here and
# handed to the template as $SSL_CERT / $SSL_KEY.
#
# docker-library/httpd/config_files/docker-entrypoint.sh does the same job for
# Apache. The two are kept separate on purpose: sharing twelve lines of openssl
# would mean widening both build contexts to ./docker-library, which ships every
# other image's files as context and churns the cache on unrelated edits.
#
set -e

REAL_CERT="/etc/nginx/ssl/php-devforge.pem"
REAL_KEY="/etc/nginx/ssl/php-devforge.key"
FALLBACK_DIR="/etc/nginx/ssl-fallback"
DOMAIN="${DEV_DOMAIN:-localhost}"

if [ -s "$REAL_CERT" ]; then
export SSL_CERT="$REAL_CERT"
export SSL_KEY="$REAL_KEY"
else
echo "⚠️ $REAL_CERT not found"
echo " Generating a temporary self-signed certificate for *.${DOMAIN}"
echo " Your browser will warn that it is not trusted."
echo " Run ./install_cert.sh on the host to get the real one."

mkdir -p "$FALLBACK_DIR"
openssl req -x509 -newkey rsa:2048 -nodes -days 365 \
-subj "/CN=${DOMAIN}" \
-addext "subjectAltName=DNS:${DOMAIN},DNS:*.${DOMAIN}" \
-keyout "${FALLBACK_DIR}/fallback.key" \
-out "${FALLBACK_DIR}/fallback.pem" >/dev/null 2>&1
chmod 600 "${FALLBACK_DIR}/fallback.key"

export SSL_CERT="${FALLBACK_DIR}/fallback.pem"
export SSL_KEY="${FALLBACK_DIR}/fallback.key"
fi

# The variable list is explicit so nginx's own $mail and $subdomains survive.
envsubst '$DEV_DOMAIN $PHP_VERSION $SSL_CERT $SSL_KEY' \
< /etc/nginx/conf.d/site.conf.tpl > /etc/nginx/conf.d/site.conf
envsubst '$DEV_DOMAIN $PHP_VERSION $SSL_CERT $SSL_KEY' \
< /etc/nginx/conf.d/common_server_config.conf.tpl > /etc/nginx/snippets/common_server_config.conf

exec "$@"
8 changes: 4 additions & 4 deletions docker-library/nginx/config_files/site.conf.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@ server {
listen 443 ssl;
http2 on;
server_name mail.${DEV_DOMAIN};
ssl_certificate /etc/nginx/ssl/php-devforge.pem;
ssl_certificate_key /etc/nginx/ssl/php-devforge.key;
ssl_certificate ${SSL_CERT};
ssl_certificate_key ${SSL_KEY};
location / {
resolver 127.0.0.11 ipv6=off valid=10s;
set $mail "mailpit:8025";
Expand All @@ -50,7 +50,7 @@ server {
listen 443 ssl;
http2 on;
server_name ~^(?<subdomains>.+)\.${DEV_DOMAIN}$;
ssl_certificate /etc/nginx/ssl/php-devforge.pem;
ssl_certificate_key /etc/nginx/ssl/php-devforge.key;
ssl_certificate ${SSL_CERT};
ssl_certificate_key ${SSL_KEY};
include /etc/nginx/snippets/common_server_config.conf;
}