Merge pull request #8593 from dokku/fix-debian-bullseye-ssl-reject-handshake

This commit is contained in:
Jose Diaz-Gonzalez
2026-05-10 16:16:16 -04:00
committed by GitHub
5 changed files with 115 additions and 11 deletions

22
debian/postinst vendored
View File

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

View File

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

View File

@@ -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`. |

View File

@@ -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;
}

View File

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