diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 36e6273..60ecfc4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -36,6 +36,15 @@ jobs: pip install -r python-requirements.txt working-directory: test/requirements + - name: Login to DockerHub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Pull nginx:alpine image + run: docker pull nginx:alpine + - name: Build Docker web server image run: make build-webserver diff --git a/docs/README.md b/docs/README.md index 3d6776a..97483f9 100644 --- a/docs/README.md +++ b/docs/README.md @@ -618,7 +618,19 @@ If the default certificate is also missing, nginx-proxy will: ## IPv6 Support -You can activate the IPv6 support for the nginx-proxy container by passing the value `true` to the `ENABLE_IPV6` environment variable: +### IPv6 Docker Networks + +nginx-proxy support both IPv4 and IPv6 on Docker networks. + +By default nginx-proxy will prefer IPv4: if a container can be reached over both IPv4 and IPv6, only its IPv4 will be used. + +This can be changed globally by setting the environment variable `PREFER_IPV6_NETWORK` to `true` on the proxy container: with this setting the proxy will only use IPv6 for containers that can be reached over both IPv4 and IPv6. + +IPv4 and IPv6 are never both used at the same time on containers that use both IP stacks to avoid artificially inflating the effective round robin weight of those containers. + +### Listening on IPv6 + +By default the nginx-proxy container will only listen on IPv4. To enable listening on IPv6 too, set the `ENABLE_IPV6` environment variable to `true`: ```console docker run -d -p 80:80 -e ENABLE_IPV6=true -v /var/run/docker.sock:/tmp/docker.sock:ro nginxproxy/nginx-proxy diff --git a/nginx.tmpl b/nginx.tmpl index 06d7b58..7cb4114 100644 --- a/nginx.tmpl +++ b/nginx.tmpl @@ -23,6 +23,7 @@ {{- $_ := set $config "trust_downstream_proxy" ($globals.Env.TRUST_DOWNSTREAM_PROXY | default "true" | parseBool) }} {{- $_ := set $config "enable_access_log" ($globals.Env.DISABLE_ACCESS_LOGS | default "false" | parseBool | not) }} {{- $_ := set $config "enable_ipv6" ($globals.Env.ENABLE_IPV6 | default "false" | parseBool) }} +{{- $_ := set $config "prefer_ipv6_network" ($globals.Env.PREFER_IPV6_NETWORK | default "false" | parseBool) }} {{- $_ := set $config "ssl_policy" ($globals.Env.SSL_POLICY | default "Mozilla-Intermediate") }} {{- $_ := set $config "enable_debug_endpoint" ($globals.Env.DEBUG_ENDPOINT | default "false") }} {{- $_ := set $config "hsts" ($globals.Env.HSTS | default "max-age=31536000") }} @@ -76,7 +77,8 @@ * The return value will be added to the dot dict with key "ip". */}} {{- define "container_ip" }} - {{- $ip := "" }} + {{- $ipv4 := "" }} + {{- $ipv6 := "" }} # networks: {{- range sortObjectsByKeysAsc $.container.Networks "Name" }} {{- /* @@ -91,17 +93,17 @@ {{- /* Handle containers in host nework mode */}} {{- if (index $.globals.networks "host") }} # both container and proxy are in host network mode, using localhost IP - {{- $ip = "127.0.0.1" }} + {{- $ipv4 = "127.0.0.1" }} {{- continue }} {{- end }} {{- range sortObjectsByKeysAsc $.globals.CurrentContainer.Networks "Name" }} {{- if and . .Gateway (not .Internal) }} # container is in host network mode, using {{ .Name }} gateway IP - {{- $ip = .Gateway }} + {{- $ipv4 = .Gateway }} {{- break }} {{- end }} {{- end }} - {{- if $ip }} + {{- if $ipv4 }} {{- continue }} {{- end }} {{- end }} @@ -111,26 +113,41 @@ {{- end }} {{- /* * Do not emit multiple `server` directives for this container if it - * is reachable over multiple networks. This avoids accidentally - * inflating the effective round-robin weight of a server due to the - * redundant upstream addresses that nginx sees as belonging to + * is reachable over multiple networks or multiple IP stacks. This avoids + * accidentally inflating the effective round-robin weight of a server due + * to the redundant upstream addresses that nginx sees as belonging to * distinct servers. */}} - {{- if $ip }} + {{- if or $ipv4 $ipv6 }} # {{ .Name }} (ignored; reachable but redundant) {{- continue }} {{- end }} # {{ .Name }} (reachable) {{- if and . .IP }} - {{- $ip = .IP }} - {{- else }} - # /!\ No IP for this network! + {{- $ipv4 = .IP }} + {{- end }} + {{- if and . .GlobalIPv6Address }} + {{- $ipv6 = .GlobalIPv6Address }} + {{- end }} + {{- if and (empty $ipv4) (empty $ipv6) }} + # /!\ No IPv4 or IPv6 for this network! {{- end }} {{- else }} # (none) {{- end }} - # IP address: {{ if $ip }}{{ $ip }}{{ else }}(none usable){{ end }} - {{- $_ := set $ "ip" $ip }} + {{ if and $ipv6 $.globals.config.prefer_ipv6_network }} + # IPv4 address: {{ if $ipv4 }}{{ $ipv4 }} (ignored; reachable but IPv6 prefered){{ else }}(none usable){{ end }} + # IPv6 address: {{ $ipv6 }} + {{- $_ := set $ "ip" (printf "[%s]" $ipv6) }} + {{- else }} + # IPv4 address: {{ if $ipv4 }}{{ $ipv4 }}{{ else }}(none usable){{ end }} + # IPv6 address: {{ if $ipv6 }}{{ $ipv6 }}{{ if $ipv4 }} (ignored; reachable but IPv4 prefered){{ end }}{{ else }}(none usable){{ end }} + {{- if $ipv4 }} + {{- $_ := set $ "ip" $ipv4 }} + {{- else if $ipv6}} + {{- $_ := set $ "ip" (printf "[%s]" $ipv6) }} + {{- end }} + {{- end }} {{- end }} {{- /* diff --git a/test/test_ipv6.py b/test/test_ipv6/test_ipv6.py similarity index 100% rename from test/test_ipv6.py rename to test/test_ipv6/test_ipv6.py diff --git a/test/test_ipv6.yml b/test/test_ipv6/test_ipv6.yml similarity index 100% rename from test/test_ipv6.yml rename to test/test_ipv6/test_ipv6.yml diff --git a/test/test_ipv6/test_ipv6_prefer_ipv4_network.py b/test/test_ipv6/test_ipv6_prefer_ipv4_network.py new file mode 100644 index 0000000..9a11fac --- /dev/null +++ b/test/test_ipv6/test_ipv6_prefer_ipv4_network.py @@ -0,0 +1,19 @@ +import pytest + + +def test_forwards_to_ipv4_only_network(docker_compose, nginxproxy): + r = nginxproxy.get("http://ipv4only.nginx-proxy.tld/port") + assert r.status_code == 200 + assert r.text == "answer from port 80\n" + + +def test_forwards_to_dualstack_network(docker_compose, nginxproxy): + r = nginxproxy.get("http://dualstack.nginx-proxy.tld") + assert r.status_code == 200 + assert "Welcome to nginx!" in r.text + + +def test_dualstack_network_prefer_ipv4_config(docker_compose, nginxproxy): + conf = nginxproxy.get_conf().decode('ASCII') + assert "IPv6 address: fd00:cafe:face:feed::2 (ignored; reachable but IPv4 prefered)" in conf + assert "server 172.16.20.2:80;" in conf diff --git a/test/test_ipv6/test_ipv6_prefer_ipv4_network.yml b/test/test_ipv6/test_ipv6_prefer_ipv4_network.yml new file mode 100644 index 0000000..8c36dc5 --- /dev/null +++ b/test/test_ipv6/test_ipv6_prefer_ipv4_network.yml @@ -0,0 +1,45 @@ +version: "2" + +networks: + ipv4net: + ipam: + config: + - subnet: 172.16.10.0/24 + dualstacknet: + enable_ipv6: true + ipam: + config: + - subnet: 172.16.20.0/24 + - subnet: fd00:cafe:face:feed::/64 + +services: + ipv4only: + image: web + expose: + - "80" + environment: + WEB_PORTS: 80 + VIRTUAL_HOST: ipv4only.nginx-proxy.tld + networks: + ipv4net: + ipv4_address: 172.16.10.2 + + dualstack: + image: nginx:alpine + environment: + VIRTUAL_HOST: dualstack.nginx-proxy.tld + networks: + dualstacknet: + ipv4_address: 172.16.20.2 + ipv6_address: fd00:cafe:face:feed::2 + + sut: + image: nginxproxy/nginx-proxy:test + volumes: + - /var/run/docker.sock:/tmp/docker.sock:ro + networks: + ipv4net: + ipv4_address: 172.16.10.3 + dualstacknet: + ipv4_address: 172.16.20.3 + ipv6_address: fd00:cafe:face:feed::3 diff --git a/test/test_ipv6/test_ipv6_prefer_ipv6_network.py b/test/test_ipv6/test_ipv6_prefer_ipv6_network.py new file mode 100644 index 0000000..09a8dde --- /dev/null +++ b/test/test_ipv6/test_ipv6_prefer_ipv6_network.py @@ -0,0 +1,19 @@ +import pytest + + +def test_forwards_to_ipv4_only_network(docker_compose, nginxproxy): + r = nginxproxy.get("http://ipv4only.nginx-proxy.tld/port") + assert r.status_code == 200 + assert r.text == "answer from port 80\n" + + +def test_forwards_to_dualstack_network(docker_compose, nginxproxy): + r = nginxproxy.get("http://dualstack.nginx-proxy.tld") + assert r.status_code == 200 + assert "Welcome to nginx!" in r.text + + +def test_dualstack_network_prefer_ipv6_config(docker_compose, nginxproxy): + conf = nginxproxy.get_conf().decode('ASCII') + assert "IPv4 address: 172.16.20.2 (ignored; reachable but IPv6 prefered)" in conf + assert "server [fd00:cafe:face:feed::2]:80;" in conf diff --git a/test/test_ipv6/test_ipv6_prefer_ipv6_network.yml b/test/test_ipv6/test_ipv6_prefer_ipv6_network.yml new file mode 100644 index 0000000..236d8d8 --- /dev/null +++ b/test/test_ipv6/test_ipv6_prefer_ipv6_network.yml @@ -0,0 +1,47 @@ +version: "2" + +networks: + ipv4net: + ipam: + config: + - subnet: 172.16.10.0/24 + dualstacknet: + enable_ipv6: true + ipam: + config: + - subnet: 172.16.20.0/24 + - subnet: fd00:cafe:face:feed::/64 + +services: + ipv4only: + image: web + expose: + - "80" + environment: + WEB_PORTS: 80 + VIRTUAL_HOST: ipv4only.nginx-proxy.tld + networks: + ipv4net: + ipv4_address: 172.16.10.2 + + dualstack: + image: nginx:alpine + environment: + VIRTUAL_HOST: dualstack.nginx-proxy.tld + networks: + dualstacknet: + ipv4_address: 172.16.20.2 + ipv6_address: fd00:cafe:face:feed::2 + + sut: + image: nginxproxy/nginx-proxy:test + volumes: + - /var/run/docker.sock:/tmp/docker.sock:ro + environment: + PREFER_IPV6_NETWORK: "true" + networks: + ipv4net: + ipv4_address: 172.16.10.3 + dualstacknet: + ipv4_address: 172.16.20.3 + ipv6_address: fd00:cafe:face:feed::3