diff --git a/debian/postinst b/debian/postinst index 06860f8d0..6edbbed79 100755 --- a/debian/postinst +++ b/debian/postinst @@ -171,8 +171,28 @@ setup-default-site() { return fi + local nginx_bin nginx_version major minor patch + nginx_bin="$(command -v nginx || true)" + if [ -z "$nginx_bin" ]; then + return + fi + + nginx_version="$("$nginx_bin" -v 2>&1 | cut -d'/' -f 2 | awk '{print $1}')" + major="$(echo "$nginx_version" | awk -F. '{print $1}')" + minor="$(echo "$nginx_version" | awk -F. '{print $2}')" + patch="$(echo "$nginx_version" | awk -F. '{print $3}')" + + # ssl_reject_handshake requires nginx >= 1.19.4; older nginx gets the + # HTTP-only catch-all so the SSL listen lines do not require a cert. + local default_vhost_basename="default-site.conf" + if [ "${major:-0}" -lt 2 ]; then + if [ "${major:-0}" -lt 1 ] || [ "${minor:-0}" -lt 19 ] || { [ "${minor:-0}" -eq 19 ] && [ "${patch:-0}" -lt 4 ]; }; then + default_vhost_basename="default-site-legacy.conf" + fi + fi + local default_vhost_target="/etc/nginx/conf.d/00-default-vhost.conf" - local default_vhost_source="${DOKKU_LIB_ROOT}/core-plugins/available/nginx-vhosts/templates/default-site.conf" + local default_vhost_source="${DOKKU_LIB_ROOT}/core-plugins/available/nginx-vhosts/templates/${default_vhost_basename}" if [ -e "$default_vhost_target" ]; then return diff --git a/docs/appendices/0.38.0-migration-guide.md b/docs/appendices/0.38.0-migration-guide.md index c67db9a83..6b01d0620 100644 --- a/docs/appendices/0.38.0-migration-guide.md +++ b/docs/appendices/0.38.0-migration-guide.md @@ -17,7 +17,7 @@ - The path on disk to both the global `ENV` file and app `ENV` files have been moved. Users should reference environment variables via the provided plugin triggers rather than directly sourcing the ENV files. Existing ENV files are left untouched and will be removed on the subsequent Dokku install. - During a fresh apt install, the upstream nginx default vhost files (`/etc/nginx/sites-enabled/default`, `/etc/nginx/sites-available/default`, and `/etc/nginx/conf.d/default.conf`) are renamed to `${path}.dokku-disabled` (not deleted) to avoid a `duplicate default server for 0.0.0.0:80` error. Operators with local customizations can recover them by inspecting the `.dokku-disabled` siblings. Upgrade-in-place installs do not touch any existing nginx files. -- Fresh apt installs now ship a catch-all default site at `/etc/nginx/conf.d/00-default-vhost.conf` that rejects requests with unknown Host headers using `ssl_reject_handshake on` (HTTPS) and `return 444` (HTTP). This replaces the manual workaround previously documented in the nginx docs. The behavior can be opted out at install time via the `dokku/install_default_site` debconf prompt. See the [Default site documentation](/docs/networking/proxies/nginx.md#default-site). +- Fresh apt installs now ship a catch-all default site at `/etc/nginx/conf.d/00-default-vhost.conf` that rejects requests with unknown Host headers using `ssl_reject_handshake on` (HTTPS) and `return 444` (HTTP). This replaces the manual workaround previously documented in the nginx docs. The behavior can be opted out at install time via the `dokku/install_default_site` debconf prompt. On nginx older than 1.19.4 (e.g., Debian Bullseye's nginx 1.18.0), the postinst installs an HTTP-only variant of the catch-all that omits the SSL listener and `ssl_reject_handshake`, since that directive is unsupported on those versions. See the [Default site documentation](/docs/networking/proxies/nginx.md#default-site). - The `docker-local` scheduler now sends `SIGTERM` to old containers immediately after a successful deploy, rather than waiting `wait-to-retire` seconds before signaling. This matches Heroku's graceful-shutdown contract and lets applications begin draining in-flight work as soon as proxy traffic switches. The `wait-to-retire` grace period and `stop-timeout-seconds` hard-stop continue to apply as before. See the [zero downtime deploys documentation](/docs/deployment/zero-downtime-deploys.md#wait-to-retire) for more details. - The `docker-local` scheduler no longer queues an image for retirement when another running container of the same app still uses it. This fixes the case where a `ps:rebuild` against an image-based deploy (`git:from-image`) produced an identical-SHA image and the `dokku-retire` cron timer would log `Image ... has running containers, skipping rm` on every run. Stuck entries from prior versions are pruned automatically on the next `ps:retire` run. - All `:report` subcommands now accept the `--global` flag, which scopes the report to globally-configured properties. The flag composes with `--format json`, so a JSON report of global properties can be obtained via, for example, `dokku scheduler:report --global --format json`. Previously, combining `--global` with `--format json` was rejected with an "info flag" error, and `--global` on its own was treated as an unknown flag. @@ -26,7 +26,9 @@ ### TLS handshake behavior change -With the new catch-all installed, an HTTPS request to a hostname that matches a configured dokku app but where the app has no TLS certificate configured will have its TLS handshake rejected by the catch-all (via `ssl_reject_handshake on`). Previously, nginx fell through to the lexicographically first port-443 server block and presented that block's certificate, producing a cert-mismatch error on the client. The new behavior is a correctness improvement, but operators who deliberately relied on the old fall-through certificate (for monitoring probes, for example) need to either configure a certificate for the target app or remove the catch-all on that host. Existing apps that already have certificates configured are unaffected: nginx selects the right server block via SNI before TLS completion, so the catch-all is never consulted for legitimate requests. +With the new catch-all installed on nginx 1.19.4+, an HTTPS request to a hostname that matches a configured dokku app but where the app has no TLS certificate configured will have its TLS handshake rejected by the catch-all (via `ssl_reject_handshake on`). Previously, nginx fell through to the lexicographically first port-443 server block and presented that block's certificate, producing a cert-mismatch error on the client. The new behavior is a correctness improvement, but operators who deliberately relied on the old fall-through certificate (for monitoring probes, for example) need to either configure a certificate for the target app or remove the catch-all on that host. Existing apps that already have certificates configured are unaffected: nginx selects the right server block via SNI before TLS completion, so the catch-all is never consulted for legitimate requests. + +This change does not apply to nginx older than 1.19.4 (e.g., Debian Bullseye's nginx 1.18.0), where the catch-all is installed as an HTTP-only variant. On those systems, HTTPS handshakes to unknown hosts continue to fall through to the first port-443 server block as before. ### Environment variables migrated to plugin properties diff --git a/docs/networking/proxies/nginx.md b/docs/networking/proxies/nginx.md index 0e5a256bc..16a91837a 100644 --- a/docs/networking/proxies/nginx.md +++ b/docs/networking/proxies/nginx.md @@ -208,6 +208,22 @@ server { The `00-` prefix forces nginx to load this file before `/etc/nginx/conf.d/dokku.conf`, so its `default_server` markers establish the default for each port before any per-app server blocks are loaded. +On systems running nginx older than 1.19.4 (e.g., Debian Bullseye, which ships nginx 1.18.0), `ssl_reject_handshake` is not available. The postinst detects this and installs an HTTP-only variant instead: + +```nginx +server { + listen 80 default_server; + listen [::]:80 default_server; + + server_name _; + access_log off; + + return 444; +} +``` + +On these systems, HTTPS requests to unknown hosts are not rejected by the catch-all - nginx falls through to the lexicographically first port-443 server block and presents that block's certificate, the same behavior as before 0.38.0. + #### TLS handshake behavior The catch-all does not affect TLS handshakes for legitimate apps. nginx selects the matching server block via SNI before completing the handshake; only requests that have no matching app fall through to the catch-all. @@ -215,8 +231,8 @@ The catch-all does not affect TLS handshakes for legitimate apps. nginx selects | Request | Result | | --- | --- | | HTTPS to a configured app's hostname (with matching SNI) and the app has a cert | Handshake completes with the app's cert. Catch-all not consulted. | -| HTTPS to a configured app's hostname when the app has no cert configured | Handshake rejected by the catch-all. (Previously: nginx fell through to the lexicographically first port-443 server block and presented its cert, producing a confusing cert-mismatch error.) | -| HTTPS to the server's IP with no SNI, or with an SNI matching no app | Handshake rejected. | +| HTTPS to a configured app's hostname when the app has no cert configured | Handshake rejected by the catch-all on nginx 1.19.4+. On older nginx, falls through to the lexicographically first port-443 server block and presents its cert, producing a cert-mismatch error. | +| HTTPS to the server's IP with no SNI, or with an SNI matching no app | Handshake rejected on nginx 1.19.4+. On older nginx, falls through to the first port-443 server block. | | HTTP to a configured app's hostname | Routed normally to that app. Catch-all not consulted. | | HTTP to a hostname matching no app | Catch-all `return 444`. | diff --git a/plugins/nginx-vhosts/templates/default-site-legacy.conf b/plugins/nginx-vhosts/templates/default-site-legacy.conf new file mode 100644 index 000000000..db19098e4 --- /dev/null +++ b/plugins/nginx-vhosts/templates/default-site-legacy.conf @@ -0,0 +1,13 @@ +# Catch-all server block for requests with unknown Host headers. +# Installed on fresh apt installs of dokku when nginx < 1.19.4 +# (which lacks ssl_reject_handshake support); safe to edit. +# See /etc/nginx/conf.d/dokku.conf for the per-app server blocks. +server { + listen 80 default_server; + listen [::]:80 default_server; + + server_name _; + access_log off; + + return 444; +} diff --git a/tests/unit/nginx-vhosts_14.bats b/tests/unit/nginx-vhosts_14.bats index 51046b609..4825bdaf6 100644 --- a/tests/unit/nginx-vhosts_14.bats +++ b/tests/unit/nginx-vhosts_14.bats @@ -10,7 +10,34 @@ load test_helper NGINX_DEFAULT_VHOST_PATH="/etc/nginx/conf.d/00-default-vhost.conf" -NGINX_DEFAULT_VHOST_SOURCE="/var/lib/dokku/core-plugins/available/nginx-vhosts/templates/default-site.conf" +NGINX_DEFAULT_VHOST_SOURCE_MODERN="/var/lib/dokku/core-plugins/available/nginx-vhosts/templates/default-site.conf" +NGINX_DEFAULT_VHOST_SOURCE_LEGACY="/var/lib/dokku/core-plugins/available/nginx-vhosts/templates/default-site-legacy.conf" + +fn-nginx-supports-ssl-reject-handshake() { + local nginx_version major minor patch + nginx_version="$(nginx -v 2>&1 | cut -d'/' -f 2 | awk '{print $1}')" + major="$(echo "$nginx_version" | awk -F. '{print $1}')" + minor="$(echo "$nginx_version" | awk -F. '{print $2}')" + patch="$(echo "$nginx_version" | awk -F. '{print $3}')" + if [ "${major:-0}" -ge 2 ]; then + return 0 + fi + if [ "${major:-0}" -eq 1 ] && [ "${minor:-0}" -gt 19 ]; then + return 0 + fi + if [ "${major:-0}" -eq 1 ] && [ "${minor:-0}" -eq 19 ] && [ "${patch:-0}" -ge 4 ]; then + return 0 + fi + return 1 +} + +fn-default-site-source() { + if fn-nginx-supports-ssl-reject-handshake; then + echo "$NGINX_DEFAULT_VHOST_SOURCE_MODERN" + else + echo "$NGINX_DEFAULT_VHOST_SOURCE_LEGACY" + fi +} setup() { global_setup @@ -51,10 +78,11 @@ teardown() { sudo rm -f "$invalid_conf" } -@test "(nginx-vhosts) [default-site] shipped template installs and validates" { - [[ -f "$NGINX_DEFAULT_VHOST_SOURCE" ]] || skip "default-site template not installed; run 'sudo make copyfiles'" +@test "(nginx-vhosts) [default-site] modern template installs and validates on nginx >= 1.19.4" { + fn-nginx-supports-ssl-reject-handshake || skip "nginx < 1.19.4 does not support ssl_reject_handshake" + [[ -f "$NGINX_DEFAULT_VHOST_SOURCE_MODERN" ]] || skip "default-site template not installed; run 'sudo make copyfiles'" - sudo install -m 0644 -o root -g root "$NGINX_DEFAULT_VHOST_SOURCE" "$NGINX_DEFAULT_VHOST_PATH" + sudo install -m 0644 -o root -g root "$NGINX_DEFAULT_VHOST_SOURCE_MODERN" "$NGINX_DEFAULT_VHOST_PATH" run /bin/bash -c "sudo grep -F 'ssl_reject_handshake on' '$NGINX_DEFAULT_VHOST_PATH'" assert_success @@ -69,8 +97,33 @@ teardown() { assert_success } +@test "(nginx-vhosts) [default-site] legacy template installs and validates on nginx < 1.19.4" { + if fn-nginx-supports-ssl-reject-handshake; then + skip "nginx >= 1.19.4 uses the modern default-site template" + fi + [[ -f "$NGINX_DEFAULT_VHOST_SOURCE_LEGACY" ]] || skip "default-site-legacy template not installed; run 'sudo make copyfiles'" + + sudo install -m 0644 -o root -g root "$NGINX_DEFAULT_VHOST_SOURCE_LEGACY" "$NGINX_DEFAULT_VHOST_PATH" + + run /bin/bash -c "sudo grep -F 'return 444' '$NGINX_DEFAULT_VHOST_PATH'" + assert_success + run /bin/bash -c "sudo grep -F 'default_server' '$NGINX_DEFAULT_VHOST_PATH'" + assert_success + run /bin/bash -c "sudo grep -F 'ssl_reject_handshake' '$NGINX_DEFAULT_VHOST_PATH'" + assert_failure + run /bin/bash -c "sudo grep -E 'listen[[:space:]]+(\\[::\\]:)?443' '$NGINX_DEFAULT_VHOST_PATH'" + assert_failure + + run /bin/bash -c "sudo nginx -t" + echo "output: $output" + echo "status: $status" + assert_success +} + @test "(nginx-vhosts) [default-site] coexists with stock sites-enabled/default" { - [[ -f "$NGINX_DEFAULT_VHOST_SOURCE" ]] || skip "default-site template not installed; run 'sudo make copyfiles'" + local default_vhost_source + default_vhost_source="$(fn-default-site-source)" + [[ -f "$default_vhost_source" ]] || skip "default-site template not installed; run 'sudo make copyfiles'" sudo mkdir -p /etc/nginx/sites-enabled sudo tee /etc/nginx/sites-enabled/default.bats-stub >/dev/null <<'STOCK' @@ -82,7 +135,7 @@ server { } STOCK - sudo install -m 0644 -o root -g root "$NGINX_DEFAULT_VHOST_SOURCE" "$NGINX_DEFAULT_VHOST_PATH" + sudo install -m 0644 -o root -g root "$default_vhost_source" "$NGINX_DEFAULT_VHOST_PATH" run /bin/bash -c "sudo nginx -t" echo "output: $output"