diff --git a/README.md b/README.md index 6d918e0..30bf591 100644 --- a/README.md +++ b/README.md @@ -388,6 +388,35 @@ If the default certificate is also missing, nginx-proxy will configure nginx to > > Error code: `SSL_ERROR_INTERNAL_ERROR_ALERT` "TLS error". +### HTTP/2 support + +HTTP/2 is enabled by default and can be disabled if necessary either per-proxied container or globally: + +To disable HTTP/2 for a single proxied container, set the `com.github.nginx-proxy.nginx-proxy.http2.enable` label to `false` on this container. + +To disable HTTP/2 globally set the environment variable `ENABLE_HTTP2` to `false` on the nginx-proxy container. + +More reading on the potential TCP head-of-line blocking issue with HTTP/2: [HTTP/2 Issues](https://www.twilio.com/blog/2017/10/http2-issues.html), [Comparing HTTP/3 vs HTTP/2](https://blog.cloudflare.com/http-3-vs-http-2/) + +### HTTP/3 support + +> **Warning** +> HTTP/3 support [is still considered experimental in nginx](https://www.nginx.com/blog/binary-packages-for-preview-nginx-quic-http3-implementation/) and as such is considered experimental in nginx-proxy too and is disabled by default. [Feedbacks for the HTTP/3 support are welcome in #2271.](https://github.com/nginx-proxy/nginx-proxy/discussions/2271) + +HTTP/3 use the QUIC protocol over UDP (unlike HTTP/1.1 and HTTP/2 which work over TCP), so if you want to use HTTP/3 you'll have to explicitely publish the 443/udp port of the proxy in addition to the 443/tcp port: + +```console +docker run -d -p 80:80 -p 443:443/tcp -p 443:443/udp \ + -v /var/run/docker.sock:/tmp/docker.sock:ro \ + nginxproxy/nginx-proxy +``` + +HTTP/3 can be enabled either per-proxied container or globally: + +To enable HTTP/3 for a single proxied container, set the `com.github.nginx-proxy.nginx-proxy.http3.enable` label to `true` on this container. + +To enable HTTP/3 globally set the environment variable `ENABLE_HTTP3` to `true` on the nginx-proxy container. + ### Basic Authentication Support In order to be able to secure your virtual host, you have to create a file named as its equivalent VIRTUAL_HOST variable on directory diff --git a/nginx.tmpl b/nginx.tmpl index fb0766b..db2d54b 100644 --- a/nginx.tmpl +++ b/nginx.tmpl @@ -277,8 +277,8 @@ map $http_x_forwarded_proto $proxy_x_forwarded_proto { } map $http_x_forwarded_host $proxy_x_forwarded_host { - default {{ if $globals.trust_downstream_proxy }}$http_x_forwarded_host{{ else }}$http_host{{ end }}; - '' $http_host; + default {{ if $globals.trust_downstream_proxy }}$http_x_forwarded_host{{ else }}$host{{ end }}; + '' $host; } # If we receive X-Forwarded-Port, pass it through; otherwise, pass along the @@ -350,7 +350,7 @@ include /etc/nginx/proxy.conf; # HTTP 1.1 support proxy_http_version 1.1; proxy_buffering off; -proxy_set_header Host $http_host; +proxy_set_header Host $host; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $proxy_connection; proxy_set_header X-Real-IP $remote_addr; @@ -384,7 +384,15 @@ proxy_set_header Proxy ""; {{- $cert_ok := and (ne $cert "") (exists (printf "/etc/nginx/certs/%s.crt" $cert)) (exists (printf "/etc/nginx/certs/%s.key" $cert)) }} {{- $default := eq $globals.Env.DEFAULT_HOST $vhost }} {{- $https_method := or (first (groupByKeys $containers "Env.HTTPS_METHOD")) $globals.Env.HTTPS_METHOD "redirect" }} - {{- $_ := set $globals.vhosts $vhost (dict "cert" $cert "cert_ok" $cert_ok "containers" $containers "default" $default "https_method" $https_method) }} + {{- $http3 := parseBool (or (first (keys (groupByLabel $containers "com.github.nginx-proxy.nginx-proxy.http3.enable"))) $globals.Env.ENABLE_HTTP3 "false")}} + {{- $_ := set $globals.vhosts $vhost (dict + "cert" $cert + "cert_ok" $cert_ok + "containers" $containers + "default" $default + "https_method" $https_method + "http3" $http3 + ) }} {{- end }} {{- /* @@ -406,6 +414,7 @@ proxy_set_header Proxy ""; {{- $https_exists := false }} {{- $default_http_exists := false }} {{- $default_https_exists := false }} + {{- $http3 := false }} {{- range $vhost := $globals.vhosts }} {{- $http := or (ne $vhost.https_method "nohttp") (not $vhost.cert_ok) }} {{- $https := ne $vhost.https_method "nohttps" }} @@ -413,6 +422,7 @@ proxy_set_header Proxy ""; {{- $https_exists = or $https_exists $https }} {{- $default_http_exists = or $default_http_exists (and $http $vhost.default) }} {{- $default_https_exists = or $default_https_exists (and $https $vhost.default) }} + {{- $http3 = or $http3 $vhost.http3 }} {{- end }} {{- $fallback_http := and $http_exists (not $default_http_exists) }} {{- $fallback_https := and $https_exists (not $default_https_exists) }} @@ -429,6 +439,7 @@ proxy_set_header Proxy ""; server { server_name _; # This is just an invalid value which will never trigger on a real hostname. server_tokens off; + {{ $globals.access_log }} http2 on; {{- if $fallback_http }} listen {{ $globals.external_http_port }}; {{- /* Do not add `default_server` (see comment above). */}} @@ -441,10 +452,16 @@ server { {{- if $globals.enable_ipv6 }} listen [::]:{{ $globals.external_https_port }} ssl; {{- /* Do not add `default_server` (see comment above). */}} {{- end }} + {{- if $http3 }} + http3 on; + listen {{ $globals.external_https_port }} quic reuseport; {{- /* Do not add `default_server` (see comment above). */}} + {{- if $globals.enable_ipv6 }} + listen [::]:{{ $globals.external_https_port }} quic reuseport; {{- /* Do not add `default_server` (see comment above). */}} + {{- end }} + {{- end }} ssl_session_cache shared:SSL:50m; ssl_session_tickets off; {{- end }} - {{ $globals.access_log }} {{- if $globals.default_cert_ok }} ssl_certificate /etc/nginx/certs/default.crt; ssl_certificate_key /etc/nginx/certs/default.key; @@ -471,6 +488,8 @@ server { {{- $containers := $vhost.containers }} {{- $default_server := when $vhost.default "default_server" "" }} {{- $https_method := $vhost.https_method }} + {{- $http2 := parseBool (or (first (keys (groupByLabel $containers "com.github.nginx-proxy.nginx-proxy.http2.enable"))) $globals.Env.ENABLE_HTTP2 "true")}} + {{- $http3 := parseBool (or (first (keys (groupByLabel $containers "com.github.nginx-proxy.nginx-proxy.http3.enable"))) $globals.Env.ENABLE_HTTP3 "false")}} {{- $is_regexp := hasPrefix "~" $host }} {{- $upstream_name := when (or $is_regexp $globals.sha1_upstream_name) (sha1 $host) $host }} @@ -518,11 +537,11 @@ server { {{- if $server_tokens }} server_tokens {{ $server_tokens }}; {{- end }} + {{ $globals.access_log }} listen {{ $globals.external_http_port }} {{ $default_server }}; {{- if $globals.enable_ipv6 }} listen [::]:{{ $globals.external_http_port }} {{ $default_server }}; {{- end }} - {{ $globals.access_log }} # Do not HTTPS redirect Let's Encrypt ACME challenge location ^~ /.well-known/acme-challenge/ { @@ -549,8 +568,10 @@ server { {{- if $server_tokens }} server_tokens {{ $server_tokens }}; {{- end }} - http2 on; {{ $globals.access_log }} + {{- if $http2 }} + http2 on; + {{- end }} {{- if or (eq $https_method "nohttps") (not $cert_ok) (eq $https_method "noredirect") }} listen {{ $globals.external_http_port }} {{ $default_server }}; {{- if $globals.enable_ipv6 }} @@ -563,6 +584,15 @@ server { listen [::]:{{ $globals.external_https_port }} ssl {{ $default_server }}; {{- end }} + {{- if $http3 }} + http3 on; + add_header alt-svc 'h3=":{{ $globals.external_https_port }}"; ma=86400;'; + listen {{ $globals.external_https_port }} quic {{ $default_server }}; + {{- if $globals.enable_ipv6 }} + listen [::]:{{ $globals.external_https_port }} quic {{ $default_server }}; + {{- end }} + {{- end }} + {{- if $cert_ok }} {{- template "ssl_policy" (dict "ssl_policy" $ssl_policy) }} @@ -645,7 +675,16 @@ server { {{- $upstream = printf "%s-%s" $upstream $sum }} {{- $dest = (or (first (groupByKeys $containers "Env.VIRTUAL_DEST")) "") }} {{- end }} - {{- template "location" (dict "Path" $path "Proto" $proto "Upstream" $upstream "Host" $host "VhostRoot" $vhost_root "Dest" $dest "NetworkTag" $network_tag "Containers" $containers) }} + {{- template "location" (dict + "Path" $path + "Proto" $proto + "Upstream" $upstream + "Host" $host + "VhostRoot" $vhost_root + "Dest" $dest + "NetworkTag" $network_tag + "Containers" $containers + ) }} {{- end }} {{- if and (not (contains $paths "/")) (ne $globals.default_root_response "none")}} location / { diff --git a/test/test_http2/test_http2_global_disabled.py b/test/test_http2/test_http2_global_disabled.py new file mode 100644 index 0000000..42e102d --- /dev/null +++ b/test/test_http2/test_http2_global_disabled.py @@ -0,0 +1,8 @@ +import pytest +import re + +def test_http2_global_disabled_config(docker_compose, nginxproxy): + conf = nginxproxy.get_conf().decode('ASCII') + r = nginxproxy.get("http://http2-global-disabled.nginx-proxy.tld") + assert r.status_code == 200 + assert not re.search(r"(?s)http2-global-disabled\.nginx-proxy\.tld.*http2 on", conf) diff --git a/test/test_http2/test_http2_global_disabled.yml b/test/test_http2/test_http2_global_disabled.yml new file mode 100644 index 0000000..5dffa19 --- /dev/null +++ b/test/test_http2/test_http2_global_disabled.yml @@ -0,0 +1,15 @@ +services: + http2-global-disabled: + image: web + expose: + - "80" + environment: + WEB_PORTS: 80 + VIRTUAL_HOST: http2-global-disabled.nginx-proxy.tld + + sut: + image: nginxproxy/nginx-proxy:test + volumes: + - /var/run/docker.sock:/tmp/docker.sock:ro + environment: + ENABLE_HTTP2: "false" diff --git a/test/test_http3/test_http3_global_disabled.py b/test/test_http3/test_http3_global_disabled.py new file mode 100644 index 0000000..508823e --- /dev/null +++ b/test/test_http3/test_http3_global_disabled.py @@ -0,0 +1,19 @@ +import pytest +import re + + #Python Requests is not able to do native http3 requests. + #We only check for directives which should enable http3. + +def test_http3_global_disabled_ALTSVC_header(docker_compose, nginxproxy): + r = nginxproxy.get("http://http3-global-disabled.nginx-proxy.tld/headers") + assert r.status_code == 200 + assert "Host: http3-global-disabled.nginx-proxy.tld" in r.text + assert not "alt-svc" in r.headers + +def test_http3_global_disabled_config(docker_compose, nginxproxy): + conf = nginxproxy.get_conf().decode('ASCII') + r = nginxproxy.get("http://http3-global-disabled.nginx-proxy.tld") + assert r.status_code == 200 + assert not re.search(r"(?s)listen 443 quic", conf) + assert not re.search(r"(?s)http3 on", conf) + assert not re.search(r"(?s)add_header alt-svc \'h3=\":443\"; ma=86400;\'", conf) diff --git a/test/test_http3/test_http3_global_disabled.yml b/test/test_http3/test_http3_global_disabled.yml new file mode 100644 index 0000000..66530a4 --- /dev/null +++ b/test/test_http3/test_http3_global_disabled.yml @@ -0,0 +1,15 @@ +services: + http3-global-disabled: + image: web + expose: + - "80" + environment: + WEB_PORTS: 80 + VIRTUAL_HOST: http3-global-disabled.nginx-proxy.tld + + sut: + image: nginxproxy/nginx-proxy:test + volumes: + - /var/run/docker.sock:/tmp/docker.sock:ro + #environment: + #ENABLE_HTTP3: "false" #Disabled by default diff --git a/test/test_http3/test_http3_global_enabled.py b/test/test_http3/test_http3_global_enabled.py new file mode 100644 index 0000000..c678ab6 --- /dev/null +++ b/test/test_http3/test_http3_global_enabled.py @@ -0,0 +1,21 @@ +import pytest +import re + + #Python Requests is not able to do native http3 requests. + #We only check for directives which should enable http3. + +def test_http3_global_enabled_ALTSVC_header(docker_compose, nginxproxy): + r = nginxproxy.get("http://http3-global-enabled.nginx-proxy.tld/headers") + assert r.status_code == 200 + assert "Host: http3-global-enabled.nginx-proxy.tld" in r.text + assert "alt-svc" in r.headers + assert r.headers["alt-svc"] == 'h3=":443"; ma=86400;' + +def test_http3_global_enabled_config(docker_compose, nginxproxy): + conf = nginxproxy.get_conf().decode('ASCII') + r = nginxproxy.get("http://http3-global-enabled.nginx-proxy.tld") + assert r.status_code == 200 + assert re.search(r"listen 443 quic reuseport\;", conf) + assert re.search(r"(?s)http3-global-enabled\.nginx-proxy\.tld;.*listen 443 quic", conf) + assert re.search(r"(?s)http3-global-enabled\.nginx-proxy\.tld;.*http3 on\;", conf) + assert re.search(r"(?s)http3-global-enabled\.nginx-proxy\.tld;.*add_header alt-svc \'h3=\":443\"; ma=86400;\'", conf) diff --git a/test/test_http3/test_http3_global_enabled.yml b/test/test_http3/test_http3_global_enabled.yml new file mode 100644 index 0000000..0825469 --- /dev/null +++ b/test/test_http3/test_http3_global_enabled.yml @@ -0,0 +1,15 @@ +services: + http3-global-enabled: + image: web + expose: + - "80" + environment: + WEB_PORTS: 80 + VIRTUAL_HOST: http3-global-enabled.nginx-proxy.tld + + sut: + image: nginxproxy/nginx-proxy:test + volumes: + - /var/run/docker.sock:/tmp/docker.sock:ro + environment: + ENABLE_HTTP3: "true" diff --git a/test/test_http3/test_http3_vhost.py b/test/test_http3/test_http3_vhost.py new file mode 100644 index 0000000..93a217c --- /dev/null +++ b/test/test_http3/test_http3_vhost.py @@ -0,0 +1,49 @@ +import pytest +import re + + #Python Requests is not able to do native http3 requests. + #We only check for directives which should enable http3. + +def test_http3_vhost_enabled_ALTSVC_header(docker_compose, nginxproxy): + r = nginxproxy.get("http://http3-vhost-enabled.nginx-proxy.tld/headers") + assert r.status_code == 200 + assert "Host: http3-vhost-enabled.nginx-proxy.tld" in r.text + assert "alt-svc" in r.headers + assert r.headers["alt-svc"] == 'h3=":443"; ma=86400;' + +def test_http3_vhost_enabled_config(docker_compose, nginxproxy): + conf = nginxproxy.get_conf().decode('ASCII') + r = nginxproxy.get("http://http3-vhost-enabled.nginx-proxy.tld") + assert r.status_code == 200 + assert re.search(r"listen 443 quic reuseport\;", conf) + assert re.search(r"(?s)http3-vhost-enabled\.nginx-proxy\.tld;.*listen 443 quic", conf) + assert re.search(r"(?s)http3-vhost-enabled\.nginx-proxy\.tld;.*http3 on\;", conf) + assert re.search(r"(?s)http3-vhost-enabled\.nginx-proxy\.tld;.*add_header alt-svc \'h3=\":443\"; ma=86400;\'", conf) + +def test_http3_vhost_disabled_ALTSVC_header(docker_compose, nginxproxy): + r = nginxproxy.get("http://http3-vhost-disabled.nginx-proxy.tld/headers") + assert r.status_code == 200 + assert "Host: http3-vhost-disabled.nginx-proxy.tld" in r.text + assert not "alt-svc" in r.headers + +def test_http3_vhost_disabled_config(docker_compose, nginxproxy): + conf = nginxproxy.get_conf().decode('ASCII') + r = nginxproxy.get("http://http3-vhost-disabled.nginx-proxy.tld") + assert r.status_code == 200 + assert not re.search(r"(?s)http3-vhost-disabled\.nginx-proxy\.tld.*listen 443 quic.*\# http3-vhost-enabled\.nginx-proxy\.tld", conf) + assert not re.search(r"(?s)http3-vhost-disabled\.nginx-proxy\.tld.*http3 on.*\# http3-vhost-enabled\.nginx-proxy\.tld", conf) + assert not re.search(r"(?s)http3-vhost-disabled\.nginx-proxy\.tld;.*add_header alt-svc \'h3=\":443\"; ma=86400;\'.*\# http3-vhost-enabled\.nginx-proxy\.tld", conf) + +def test_http3_vhost_disabledbydefault_ALTSVC_header(docker_compose, nginxproxy): + r = nginxproxy.get("http://http3-vhost-default-disabled.nginx-proxy.tld/headers") + assert r.status_code == 200 + assert "Host: http3-vhost-default-disabled.nginx-proxy.tld" in r.text + assert not "alt-svc" in r.headers + +def test_http3_vhost_disabledbydefault_config(docker_compose, nginxproxy): + conf = nginxproxy.get_conf().decode('ASCII') + r = nginxproxy.get("http://http3-vhost-default-disabled.nginx-proxy.tld") + assert r.status_code == 200 + assert not re.search(r"(?s)http3-vhost-default-disabled\.nginx-proxy\.tld.*listen 443 quic.*\# http3-vhost-disabled\.nginx-proxy\.tld", conf) + assert not re.search(r"(?s)http3-vhost-default-disabled\.nginx-proxy\.tld.*http3 on.*\# http3-vhost-disabled\.nginx-proxy\.tld", conf) + assert not re.search(r"(?s)http3-vhost-default-disabled\.nginx-proxy\.tld;.*add_header alt-svc \'h3=\":443\"; ma=86400;\'.*\# http3-vhost-disabled\.nginx-proxy\.tld", conf) diff --git a/test/test_http3/test_http3_vhost.yml b/test/test_http3/test_http3_vhost.yml new file mode 100644 index 0000000..1d5cdf2 --- /dev/null +++ b/test/test_http3/test_http3_vhost.yml @@ -0,0 +1,33 @@ +services: + http3-vhost-enabled: + image: web + expose: + - "80" + environment: + WEB_PORTS: 80 + VIRTUAL_HOST: http3-vhost-enabled.nginx-proxy.tld + labels: + com.github.nginx-proxy.nginx-proxy.http3.enable: "true" + + http3-vhost-disabled: + image: web + expose: + - "80" + environment: + WEB_PORTS: 80 + VIRTUAL_HOST: http3-vhost-disabled.nginx-proxy.tld + labels: + com.github.nginx-proxy.nginx-proxy.http3.enable: "false" + + http3-vhost-default-disabled: + image: web + expose: + - "80" + environment: + WEB_PORTS: 80 + VIRTUAL_HOST: http3-vhost-default-disabled.nginx-proxy.tld + + sut: + image: nginxproxy/nginx-proxy:test + volumes: + - /var/run/docker.sock:/tmp/docker.sock:ro diff --git a/test/test_ssl/test_hsts.py b/test/test_ssl/test_hsts.py index 16dffd2..890c4ad 100644 --- a/test/test_ssl/test_hsts.py +++ b/test/test_ssl/test_hsts.py @@ -31,3 +31,11 @@ def test_web4_HSTS_off_noredirect(docker_compose, nginxproxy): r = nginxproxy.get("https://web4.nginx-proxy.tld/port", allow_redirects=False) assert "answer from port 81\n" in r.text assert "Strict-Transport-Security" not in r.headers + +def test_http3_vhost_enabled_HSTS_default(docker_compose, nginxproxy): + r = nginxproxy.get("https://http3-vhost-enabled.nginx-proxy.tld/port", allow_redirects=False) + assert "answer from port 81\n" in r.text + assert "Strict-Transport-Security" in r.headers + assert "max-age=31536000" == r.headers["Strict-Transport-Security"] + assert "alt-svc" in r.headers + assert r.headers["alt-svc"] == 'h3=":443"; ma=86400;' diff --git a/test/test_ssl/test_hsts.yml b/test/test_ssl/test_hsts.yml index b4af3b6..da3b629 100644 --- a/test/test_ssl/test_hsts.yml +++ b/test/test_ssl/test_hsts.yml @@ -34,6 +34,16 @@ web4: HSTS: "off" HTTPS_METHOD: "noredirect" +web5: + image: web + expose: + - "81" + environment: + WEB_PORTS: "81" + VIRTUAL_HOST: http3-vhost-enabled.nginx-proxy.tld + labels: + com.github.nginx-proxy.nginx-proxy.http3.enable: "true" + sut: image: nginxproxy/nginx-proxy:test volumes: