diff --git a/.github/workflows/dockerhub.yml b/.github/workflows/dockerhub.yml index cb3635e..74283b7 100644 --- a/.github/workflows/dockerhub.yml +++ b/.github/workflows/dockerhub.yml @@ -7,6 +7,7 @@ on: push: branches: - main + - dev tags: - '*.*.*' paths-ignore: @@ -42,7 +43,8 @@ jobs: tags: | type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} - type=raw,value=latest,enable=${{ endsWith(github.ref, github.event.repository.default_branch) }} + type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} + type=raw,value=dev,enable=${{ github.ref == 'refs/heads/dev' }} labels: | org.opencontainers.image.authors=Nicolas Duchon (@buchdag), Jason Wilder org.opencontainers.image.version=${{ env.GIT_DESCRIBE }} @@ -60,6 +62,7 @@ jobs: password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Build and push the Debian based image + if: github.ref == 'refs/heads/main' id: docker_build_debian uses: docker/build-push-action@v2 with: @@ -72,8 +75,25 @@ jobs: labels: ${{ steps.docker_meta_debian.outputs.labels }} - name: Images digests + if: github.ref == 'refs/heads/main' run: echo ${{ steps.docker_build_debian.outputs.digest }} + - name: Build and push the Debian based dev image + if: github.ref == 'refs/heads/dev' + id: docker_build_debian_dev + uses: docker/build-push-action@v2 + with: + file: Dockerfile + build-args: DOCKER_GEN_VERSION=main + platforms: linux/amd64,linux/arm64,linux/arm/v7 + push: true + tags: ${{ steps.docker_meta_debian.outputs.tags }} + labels: ${{ steps.docker_meta_debian.outputs.labels }} + + - name: Images digests + if: github.ref == 'refs/heads/dev' + run: echo ${{ steps.docker_build_debian_dev.outputs.digest }} + multiarch-build-alpine: runs-on: ubuntu-latest steps: @@ -96,7 +116,8 @@ jobs: tags: | type=semver,suffix=-alpine,pattern={{version}} type=semver,suffix=-alpine,pattern={{major}}.{{minor}} - type=raw,value=alpine,enable=${{ endsWith(github.ref, github.event.repository.default_branch) }} + type=raw,value=alpine,enable=${{ github.ref == 'refs/heads/main' }} + type=raw,value=dev-alpine,enable=${{ github.ref == 'refs/heads/dev' }} labels: | org.opencontainers.image.authors=Nicolas Duchon (@buchdag), Jason Wilder org.opencontainers.image.version=${{ env.GIT_DESCRIBE }} @@ -115,6 +136,7 @@ jobs: password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Build and push the Alpine based image + if: github.ref == 'refs/heads/main' id: docker_build_alpine uses: docker/build-push-action@v2 with: @@ -127,4 +149,21 @@ jobs: labels: ${{ steps.docker_meta_alpine.outputs.labels }} - name: Images digests + if: github.ref == 'refs/heads/main' run: echo ${{ steps.docker_build_alpine.outputs.digest }} + + - name: Build and push the Alpine based dev image + if: github.ref == 'refs/heads/dev' + id: docker_build_alpine_dev + uses: docker/build-push-action@v2 + with: + file: Dockerfile.alpine + build-args: DOCKER_GEN_VERSION=main + platforms: linux/amd64,linux/arm64,linux/arm/v7 + push: true + tags: ${{ steps.docker_meta_alpine.outputs.tags }} + labels: ${{ steps.docker_meta_alpine.outputs.labels }} + + - name: Images digests + if: github.ref == 'refs/heads/dev' + run: echo ${{ steps.docker_build_alpine_dev.outputs.digest }} diff --git a/Dockerfile b/Dockerfile index d5c71bc..bc4093d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # setup build arguments for version of dependencies to use -ARG DOCKER_GEN_VERSION=0.7.7 +ARG DOCKER_GEN_VERSION=main ARG FOREGO_VERSION=v0.17.0 # Use a specific version of golang to build both binaries diff --git a/Dockerfile.alpine b/Dockerfile.alpine index 2552615..98e9bc5 100644 --- a/Dockerfile.alpine +++ b/Dockerfile.alpine @@ -1,5 +1,5 @@ # setup build arguments for version of dependencies to use -ARG DOCKER_GEN_VERSION=0.7.7 +ARG DOCKER_GEN_VERSION=main ARG FOREGO_VERSION=v0.17.0 # Use a specific version of golang to build both binaries diff --git a/README.md b/README.md index 556cf5c..880d0e8 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,50 @@ For each host defined into `VIRTUAL_HOST`, the associated virtual port is retrie You can also use wildcards at the beginning and the end of host name, like `*.bar.com` or `foo.bar.*`. Or even a regular expression, which can be very useful in conjunction with a wildcard DNS service like [nip.io](https://nip.io) or [sslip.io](https://sslip.io), using `~^foo\.bar\..*\.nip\.io` will match `foo.bar.127.0.0.1.nip.io`, `foo.bar.10.0.2.2.nip.io` and all other given IPs. More information about this topic can be found in the nginx documentation about [`server_names`](http://nginx.org/en/docs/http/server_names.html). +### Path-based Routing + +You can have multiple containers proxied by the same `VIRTUAL_HOST` by adding a `VIRTUAL_PATH` environment variable containing the absolute path to where the container should be mounted. For example with `VIRTUAL_HOST=foo.example.com` and `VIRTUAL_PATH=/api/v2/service`, then requests to http://foo.example.com/api/v2/service will be routed to the container. If you wish to have a container serve the root while other containers serve other paths, give the root container a `VIRTUAL_PATH` of `/`. Unmatched paths will be served by the container at `/` or will return the default nginx error page if no container has been assigned `/`. +It is also possible to specify multiple paths with regex locations like `VIRTUAL_PATH=~^/(app1|alternative1)/`. For further details see the nginx documentation on location blocks. This is not compatible with `VIRTUAL_DEST`. + +The full request URI will be forwarded to the serving container in the `X-Forwarded-Path` header. + +**NOTE**: Your application needs to be able to generate links starting with `VIRTUAL_PATH`. This can be achieved by it being natively on this path or having an option to prepend this path. The application does not need to expect this path in the request. + +#### VIRTUAL_DEST + +This environment variable can be used to rewrite the `VIRTUAL_PATH` part of the requested URL to proxied application. The default value is empty (off). +Make sure that your settings won't result in the slash missing or being doubled. Both these versions can cause troubles. + +If the application runs natively on this sub-path or has a setting to do so, `VIRTUAL_DEST` should not be set or empty. +If the requests are expected to not contain a sub-path and the generated links contain the sub-path, `VIRTUAL_DEST=/` should be used. + +```console +$ docker run -d -e VIRTUAL_HOST=example.tld -e VIRTUAL_PATH=/app1/ -e VIRTUAL_DEST=/ --name app1 app +``` + +In this example, the incoming request `http://example.tld/app1/foo` will be proxied as `http://app1/foo` instead of `http://app1/app1/foo`. + +#### Per-VIRTUAL_PATH location configuration + +The same options as from [Per-VIRTUAL_HOST location configuration](#Per-VIRTUAL_HOST-location-configuration) are available on a `VIRTUAL_PATH` basis. +The only difference is that the filename gets an additional block `HASH=$(echo -n $VIRTUAL_PATH | sha1sum | awk '{ print $1 }')`. This is the sha1-hash of the `VIRTUAL_PATH` (no newline). This is done filename sanitization purposes. +The used filename is `${VIRTUAL_HOST}_${HASH}_location` + +The filename of the previous example would be `example.tld_8610f6c344b4096614eab6e09d58885349f42faf_location`. + +#### DEFAULT_ROOT + +This environment variable of the nginx proxy container can be used to customize the return error page if no matching path is found. Furthermore it is possible to use anything which is compatible with the `return` statement of nginx. + +For example `DEFAUL_ROOT=418` will return a 418 error page instead of the normal 404 one. +Another example is `DEFAULT_ROOT="301 https://github.com/nginx-proxy/nginx-proxy/blob/main/README.md"` which would redirect an invalid request to this documentation. +Nginx variables such as $scheme, $host, and $request_uri can be used. However, care must be taken to make sure the $ signs are escaped properly. +If you want to use `301 $scheme://$host/myapp1$request_uri` you should use: + +* Bash: `DEFAULT_ROOT='301 $scheme://$host/myapp1$request_uri'` +* Docker Compose yaml: `- DEFAULT_ROOT: 301 $$scheme://$$host/myapp1$$request_uri` + + ### Multiple Networks With the addition of [overlay networking](https://docs.docker.com/engine/userguide/networking/get-started-overlay/) in Docker 1.9, your `nginx-proxy` container may need to connect to backend containers on multiple networks. By default, if you don't pass the `--net` flag when your `nginx-proxy` container is created, it will only be attached to the default `bridge` network. This means that it will not be able to connect to containers on networks other than `bridge`. @@ -337,6 +381,7 @@ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; proxy_set_header X-Forwarded-Ssl $proxy_x_forwarded_ssl; proxy_set_header X-Forwarded-Port $proxy_x_forwarded_port; +proxy_set_header X-Forwarded-Path $request_uri; # Mitigate httpoxy attack (see README for details) proxy_set_header Proxy ""; diff --git a/network_internal.conf b/network_internal.conf index cdf3c9c..bacceb1 100644 --- a/network_internal.conf +++ b/network_internal.conf @@ -3,4 +3,5 @@ allow 127.0.0.0/8; allow 10.0.0.0/8; allow 192.168.0.0/16; allow 172.16.0.0/12; +allow fc00::/7; # IPv6 local address range deny all; diff --git a/nginx.tmpl b/nginx.tmpl index 2414633..351cf88 100644 --- a/nginx.tmpl +++ b/nginx.tmpl @@ -5,6 +5,7 @@ {{ $external_https_port := coalesce $.Env.HTTPS_PORT "443" }} {{ $debug_all := $.Env.DEBUG }} {{ $sha1_upstream_name := parseBool (coalesce $.Env.SHA1_UPSTREAM_NAME "false") }} +{{ $default_root_response := coalesce $.Env.DEFAULT_ROOT "404" }} {{ define "ssl_policy" }} {{ if eq .ssl_policy "Mozilla-Modern" }} @@ -49,6 +50,99 @@ {{ end }} {{ end }} +{{ define "location" }} +location {{ .Path }} { + {{ if eq .NetworkTag "internal" }} + # Only allow traffic from internal clients + include /etc/nginx/network_internal.conf; + {{ end }} + + {{ if eq .Proto "uwsgi" }} + include uwsgi_params; + uwsgi_pass {{ trim .Proto }}://{{ trim .Upstream }}; + {{ else if eq .Proto "fastcgi" }} + root {{ trim .VhostRoot }}; + include fastcgi_params; + fastcgi_pass {{ trim .Upstream }}; + {{ else if eq .Proto "grpc" }} + grpc_pass {{ trim .Proto }}://{{ trim .Upstream }}; + {{ else }} + proxy_pass {{ trim .Proto }}://{{ trim .Upstream }}{{ trim .Dest }}; + {{ end }} + + {{ if (exists (printf "/etc/nginx/htpasswd/%s" .Host)) }} + auth_basic "Restricted {{ .Host }}"; + auth_basic_user_file {{ (printf "/etc/nginx/htpasswd/%s" .Host) }}; + {{ end }} + + {{ if (exists (printf "/etc/nginx/vhost.d/%s_%s_location" .Host (sha1 .Path) )) }} + include {{ printf "/etc/nginx/vhost.d/%s_%s_location" .Host (sha1 .Path) }}; + {{ else if (exists (printf "/etc/nginx/vhost.d/%s_location" .Host)) }} + include {{ printf "/etc/nginx/vhost.d/%s_location" .Host}}; + {{ else if (exists "/etc/nginx/vhost.d/default_location") }} + include /etc/nginx/vhost.d/default_location; + {{ end }} +} +{{ end }} + +{{ define "upstream" }} + {{ $networks := .Networks }} + {{ $debug_all := .Debug }} + upstream {{ .Upstream }} { + {{ $server_found := "false" }} + {{ range $container := .Containers }} + {{ $debug := (eq (coalesce $container.Env.DEBUG $debug_all "false") "true") }} + {{/* If only 1 port exposed, use that as a default, else 80 */}} + {{ $defaultPort := (when (eq (len $container.Addresses) 1) (first $container.Addresses) (dict "Port" "80")).Port }} + {{ $port := (coalesce $container.Env.VIRTUAL_PORT $defaultPort) }} + {{ $address := where $container.Addresses "Port" $port | first }} + {{ if $debug }} + # Exposed ports: {{ $container.Addresses }} + # Default virtual port: {{ $defaultPort }} + # VIRTUAL_PORT: {{ $container.Env.VIRTUAL_PORT }} + {{ if not $address }} + # /!\ Virtual port not exposed + {{ end }} + {{ end }} + {{ range $knownNetwork := $networks }} + {{ range $containerNetwork := $container.Networks }} + {{ if (and (ne $containerNetwork.Name "ingress") (or (eq $knownNetwork.Name $containerNetwork.Name) (eq $knownNetwork.Name "host"))) }} + ## Can be connected with "{{ $containerNetwork.Name }}" network + {{ if $address }} + {{/* If we got the containers from swarm and this container's port is published to host, use host IP:PORT */}} + {{ if and $container.Node.ID $address.HostPort }} + {{ $server_found = "true" }} + # {{ $container.Node.Name }}/{{ $container.Name }} + server {{ $container.Node.Address.IP }}:{{ $address.HostPort }}; + {{/* If there is no swarm node or the port is not published on host, use container's IP:PORT */}} + {{ else if $containerNetwork }} + {{ $server_found = "true" }} + # {{ $container.Name }} + server {{ $containerNetwork.IP }}:{{ $address.Port }}; + {{ end }} + {{ else if $containerNetwork }} + # {{ $container.Name }} + {{ if $containerNetwork.IP }} + {{ $server_found = "true" }} + server {{ $containerNetwork.IP }}:{{ $port }}; + {{ else }} + # /!\ No IP for this network! + {{ end }} + {{ end }} + {{ else }} + # Cannot connect to network '{{ $containerNetwork.Name }}' of this container + {{ end }} + {{ end }} + {{ end }} + {{ end }} + {{/* nginx-proxy/nginx-proxy#1105 */}} + {{ if (eq $server_found "false") }} + # Fallback entry + server 127.0.0.1 down; + {{ end }} + } +{{ end }} + {{ if ne $nginx_proxy_version "" }} # nginx-proxy version : {{ $nginx_proxy_version }} {{ end }} @@ -100,6 +194,7 @@ access_log off; {{/* Get the SSL_POLICY defined by this container, falling back to "Mozilla-Intermediate" */}} {{ $ssl_policy := or ($.Env.SSL_POLICY) "Mozilla-Intermediate" }} {{ template "ssl_policy" (dict "ssl_policy" $ssl_policy) }} +error_log /dev/stderr; {{ if $.Env.RESOLVERS }} resolver {{ $.Env.RESOLVERS }}; @@ -119,6 +214,7 @@ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; proxy_set_header X-Forwarded-Ssl $proxy_x_forwarded_ssl; proxy_set_header X-Forwarded-Port $proxy_x_forwarded_port; +proxy_set_header X-Original-URI $request_uri; # Mitigate httpoxy attack (see README for details) proxy_set_header Proxy ""; @@ -162,73 +258,27 @@ server { {{ $is_regexp := hasPrefix "~" $host }} {{ $upstream_name := when (or $is_regexp $sha1_upstream_name) (sha1 $host) $host }} -# {{ $host }} -upstream {{ $upstream_name }} { +{{ $paths := groupBy $containers "Env.VIRTUAL_PATH" }} +{{ $nPaths := len $paths }} -{{ $server_found := "false" }} -{{ range $container := $containers }} - {{ $debug := (eq (coalesce $container.Env.DEBUG $debug_all "false") "true") }} - {{/* If only 1 port exposed, use that as a default, else 80 */}} - {{ $defaultPort := (when (eq (len $container.Addresses) 1) (first $container.Addresses) (dict "Port" "80")).Port }} - {{ $port := (coalesce $container.Env.VIRTUAL_PORT $defaultPort) }} - {{ $address := where $container.Addresses "Port" $port | first }} - {{ if $debug }} - # Exposed ports: {{ $container.Addresses }} - # Default virtual port: {{ $defaultPort }} - # VIRTUAL_PORT: {{ $container.Env.VIRTUAL_PORT }} - {{ if not $address }} - # /!\ Virtual port not exposed - {{ end }} - {{ end }} - {{ range $knownNetwork := $CurrentContainer.Networks }} - {{ range $containerNetwork := $container.Networks }} - {{ if (and (ne $containerNetwork.Name "ingress") (or (eq $knownNetwork.Name $containerNetwork.Name) (eq $knownNetwork.Name "host"))) }} - ## Can be connected with "{{ $containerNetwork.Name }}" network - {{ if $address }} - {{/* If we got the containers from swarm and this container's port is published to host, use host IP:PORT */}} - {{ if and $container.Node.ID $address.HostPort }} - {{ $server_found = "true" }} - # {{ $container.Node.Name }}/{{ $container.Name }} - server {{ $container.Node.Address.IP }}:{{ $address.HostPort }}; - {{/* If there is no swarm node or the port is not published on host, use container's IP:PORT */}} - {{ else if $containerNetwork }} - {{ $server_found = "true" }} - # {{ $container.Name }} - server {{ $containerNetwork.IP }}:{{ $address.Port }}; - {{ end }} - {{ else if $containerNetwork }} - # {{ $container.Name }} - {{ if $containerNetwork.IP }} - {{ $server_found = "true" }} - server {{ $containerNetwork.IP }}:{{ $port }}; - {{ else }} - # /!\ No IP for this network! - {{ end }} - {{ end }} - {{ else }} - # Cannot connect to network '{{ $containerNetwork.Name }}' of this container - {{ end }} - {{ end }} +{{ if eq $nPaths 0 }} + # {{ $host }} + {{ template "upstream" (dict "Upstream" $upstream_name "Containers" $containers "Networks" $CurrentContainer.Networks "Debug" $debug_all) }} +{{ else }} + {{ range $path, $containers := $paths }} + {{ $sum := sha1 $path }} + {{ $upstream := printf "%s-%s" $upstream_name $sum }} + # {{ $host }}{{ $path }} + {{ template "upstream" (dict "Upstream" $upstream "Containers" $containers "Networks" $CurrentContainer.Networks "Debug" $debug_all) }} {{ end }} {{ end }} -{{/* nginx-proxy/nginx-proxy#1105 */}} -{{ if (eq $server_found "false") }} - # Fallback entry - server 127.0.0.1 down; -{{ end }} -} {{ $default_host := or ($.Env.DEFAULT_HOST) "" }} {{ $default_server := index (dict $host "" $default_host "default_server") $host }} -{{/* Get the VIRTUAL_PROTO defined by containers w/ the same vhost, falling back to "http" */}} -{{ $proto := trim (or (first (groupByKeys $containers "Env.VIRTUAL_PROTO")) "http") }} - {{/* Get the SERVER_TOKENS defined by containers w/ the same vhost, falling back to "" */}} {{ $server_tokens := trim (or (first (groupByKeys $containers "Env.SERVER_TOKENS")) "") }} -{{/* Get the NETWORK_ACCESS defined by containers w/ the same vhost, falling back to "external" */}} -{{ $network_tag := or (first (groupByKeys $containers "Env.NETWORK_ACCESS")) "external" }} {{/* Get the HTTPS_METHOD defined by containers w/ the same vhost, falling back to "redirect" */}} {{ $https_method := or (first (groupByKeys $containers "Env.HTTPS_METHOD")) (or $.Env.HTTPS_METHOD "redirect") }} @@ -303,11 +353,6 @@ server { {{ end }} {{ $access_log }} - {{ if eq $network_tag "internal" }} - # Only allow traffic from internal clients - include /etc/nginx/network_internal.conf; - {{ end }} - {{ template "ssl_policy" (dict "ssl_policy" $ssl_policy) }} ssl_session_timeout 5m; @@ -337,30 +382,31 @@ server { include /etc/nginx/vhost.d/default; {{ end }} - location / { - {{ if eq $proto "uwsgi" }} - include uwsgi_params; - uwsgi_pass {{ trim $proto }}://{{ trim $upstream_name }}; - {{ else if eq $proto "fastcgi" }} - root {{ trim $vhost_root }}; - include fastcgi_params; - fastcgi_pass {{ trim $upstream_name }}; - {{ else if eq $proto "grpc" }} - grpc_pass {{ trim $proto }}://{{ trim $upstream_name }}; - {{ else }} - proxy_pass {{ trim $proto }}://{{ trim $upstream_name }}; - {{ end }} + {{ if eq $nPaths 0 }} + {{/* Get the VIRTUAL_PROTO defined by containers w/ the same vhost, falling back to "http" */}} + {{ $proto := trim (or (first (groupByKeys $containers "Env.VIRTUAL_PROTO")) "http") }} - {{ if (exists (printf "/etc/nginx/htpasswd/%s" $host)) }} - auth_basic "Restricted {{ $host }}"; - auth_basic_user_file {{ (printf "/etc/nginx/htpasswd/%s" $host) }}; + {{/* Get the NETWORK_ACCESS defined by containers w/ the same vhost, falling back to "external" */}} + {{ $network_tag := or (first (groupByKeys $containers "Env.NETWORK_ACCESS")) "external" }} + {{ template "location" (dict "Path" "/" "Proto" $proto "Upstream" $upstream_name "Host" $host "VhostRoot" $vhost_root "Dest" "" "NetworkTag" $network_tag) }} + {{ else }} + {{ range $path, $container := $paths }} + {{/* Get the VIRTUAL_PROTO defined by containers w/ the same vhost-vpath, falling back to "http" */}} + {{ $proto := trim (or (first (groupByKeys $container "Env.VIRTUAL_PROTO")) "http") }} + + {{/* Get the NETWORK_ACCESS defined by containers w/ the same vhost, falling back to "external" */}} + {{ $network_tag := or (first (groupByKeys $container "Env.NETWORK_ACCESS")) "external" }} + {{ $sum := sha1 $path }} + {{ $upstream := printf "%s-%s" $upstream_name $sum }} + {{ $dest := (or (first (groupByKeys $container "Env.VIRTUAL_DEST")) "") }} + {{ template "location" (dict "Path" $path "Proto" $proto "Upstream" $upstream "Host" $host "VhostRoot" $vhost_root "Dest" $dest "NetworkTag" $network_tag) }} {{ end }} - {{ if (exists (printf "/etc/nginx/vhost.d/%s_location" $host)) }} - include {{ printf "/etc/nginx/vhost.d/%s_location" $host}}; - {{ else if (exists "/etc/nginx/vhost.d/default_location") }} - include /etc/nginx/vhost.d/default_location; + {{ if (not (contains $paths "/")) }} + location / { + return {{ $default_root_response }}; + } {{ end }} - } + {{ end }} } {{ end }} @@ -378,40 +424,37 @@ server { {{ end }} {{ $access_log }} - {{ if eq $network_tag "internal" }} - # Only allow traffic from internal clients - include /etc/nginx/network_internal.conf; - {{ end }} - {{ if (exists (printf "/etc/nginx/vhost.d/%s" $host)) }} include {{ printf "/etc/nginx/vhost.d/%s" $host }}; {{ else if (exists "/etc/nginx/vhost.d/default") }} include /etc/nginx/vhost.d/default; {{ end }} - location / { - {{ if eq $proto "uwsgi" }} - include uwsgi_params; - uwsgi_pass {{ trim $proto }}://{{ trim $upstream_name }}; - {{ else if eq $proto "fastcgi" }} - root {{ trim $vhost_root }}; - include fastcgi_params; - fastcgi_pass {{ trim $upstream_name }}; - {{ else if eq $proto "grpc" }} - grpc_pass {{ trim $proto }}://{{ trim $upstream_name }}; - {{ else }} - proxy_pass {{ trim $proto }}://{{ trim $upstream_name }}; + {{ if eq $nPaths 0 }} + {{/* Get the VIRTUAL_PROTO defined by containers w/ the same vhost, falling back to "http" */}} + {{ $proto := trim (or (first (groupByKeys $containers "Env.VIRTUAL_PROTO")) "http") }} + + {{/* Get the NETWORK_ACCESS defined by containers w/ the same vhost, falling back to "external" */}} + {{ $network_tag := or (first (groupByKeys $containers "Env.NETWORK_ACCESS")) "external" }} + {{ template "location" (dict "Path" "/" "Proto" $proto "Upstream" $upstream_name "Host" $host "VhostRoot" $vhost_root "Dest" "" "NetworkTag" $network_tag) }} + {{ else }} + {{ range $path, $container := $paths }} + {{/* Get the VIRTUAL_PROTO defined by containers w/ the same vhost-vpath, falling back to "http" */}} + {{ $proto := trim (or (first (groupByKeys $container "Env.VIRTUAL_PROTO")) "http") }} + + {{/* Get the NETWORK_ACCESS defined by containers w/ the same vhost, falling back to "external" */}} + {{ $network_tag := or (first (groupByKeys $container "Env.NETWORK_ACCESS")) "external" }} + {{ $sum := sha1 $path }} + {{ $upstream := printf "%s-%s" $upstream_name $sum }} + {{ $dest := (or (first (groupByKeys $container "Env.VIRTUAL_DEST")) "") }} + {{ template "location" (dict "Path" $path "Proto" $proto "Upstream" $upstream "Host" $host "VhostRoot" $vhost_root "Dest" $dest "NetworkTag" $network_tag) }} {{ end }} - {{ if (exists (printf "/etc/nginx/htpasswd/%s" $host)) }} - auth_basic "Restricted {{ $host }}"; - auth_basic_user_file {{ (printf "/etc/nginx/htpasswd/%s" $host) }}; + {{ if (not (contains $paths "/")) }} + location / { + return {{ $default_root_response }}; + } {{ end }} - {{ if (exists (printf "/etc/nginx/vhost.d/%s_location" $host)) }} - include {{ printf "/etc/nginx/vhost.d/%s_location" $host}}; - {{ else if (exists "/etc/nginx/vhost.d/default_location") }} - include /etc/nginx/vhost.d/default_location; - {{ end }} - } + {{ end }} } {{ if (and (not $is_https) (exists "/etc/nginx/certs/default.crt") (exists "/etc/nginx/certs/default.key")) }} diff --git a/test/test_events.py b/test/test_events.py index 201917f..b5da3dd 100644 --- a/test/test_events.py +++ b/test/test_events.py @@ -29,13 +29,36 @@ def web1(docker_compose): except NotFound: pass +@pytest.fixture() +def web2(docker_compose): + """ + pytest fixture creating a web container with `VIRTUAL_HOST=nginx-proxy`, `VIRTUAL_PATH=/web2/` and `VIRTUAL_DEST=/` listening on port 82. + """ + container = docker_compose.containers.run( + name="web2", + image="web", + detach=True, + environment={ + "WEB_PORTS": "82", + "VIRTUAL_HOST": "nginx-proxy", + "VIRTUAL_PATH": "/web2/", + "VIRTUAL_DEST": "/", + }, + ports={"82/tcp": None} + ) + sleep(2) # give it some time to initialize and for docker-gen to detect it + yield container + try: + docker_compose.containers.get("web2").remove(force=True) + except NotFound: + pass def test_nginx_proxy_behavior_when_alone(docker_compose, nginxproxy): r = nginxproxy.get("http://nginx-proxy/") assert r.status_code == 503 -def test_new_container_is_detected(web1, nginxproxy): +def test_new_container_is_detected_vhost(web1, nginxproxy): r = nginxproxy.get("http://web1.nginx-proxy/port") assert r.status_code == 200 assert "answer from port 81\n" == r.text @@ -44,3 +67,16 @@ def test_new_container_is_detected(web1, nginxproxy): sleep(2) r = nginxproxy.get("http://web1.nginx-proxy/port") assert r.status_code == 503 + +def test_new_container_is_detected_vpath(web2, nginxproxy): + r = nginxproxy.get("http://nginx-proxy/web2/port") + assert r.status_code == 200 + assert "answer from port 82\n" == r.text + r = nginxproxy.get("http://nginx-proxy/port") + assert r.status_code in [404, 503] + + web2.remove(force=True) + sleep(2) + r = nginxproxy.get("http://nginx-proxy/web2/port") + assert r.status_code == 503 + diff --git a/test/test_internal/network_internal.conf b/test/test_internal/network_internal.conf new file mode 100644 index 0000000..496e569 --- /dev/null +++ b/test/test_internal/network_internal.conf @@ -0,0 +1,11 @@ +# Only allow traffic from internal clients +allow 127.0.0.0/8; +allow 10.0.0.0/8; +allow 192.168.0.0/16; +allow 172.16.0.0/12; +allow fc00::/7; # IPv6 local address range +deny all; + +# Dummy header for testing +add_header X-network internal; + diff --git a/test/test_internal/test_internal-per-vhost.py b/test/test_internal/test_internal-per-vhost.py new file mode 100644 index 0000000..4586cc0 --- /dev/null +++ b/test/test_internal/test_internal-per-vhost.py @@ -0,0 +1,14 @@ +import pytest + +def test_network_web1(docker_compose, nginxproxy): + r = nginxproxy.get("http://web1.nginx-proxy.local/port") + assert r.status_code == 200 + assert r.text == "answer from port 81\n" + assert "X-network" in r.headers + assert "internal" == r.headers["X-network"] + +def test_network_web2(docker_compose, nginxproxy): + r = nginxproxy.get("http://web2.nginx-proxy.local/port") + assert r.status_code == 200 + assert r.text == "answer from port 82\n" + assert "X-network" not in r.headers diff --git a/test/test_internal/test_internal-per-vhost.yml b/test/test_internal/test_internal-per-vhost.yml new file mode 100644 index 0000000..5c732ee --- /dev/null +++ b/test/test_internal/test_internal-per-vhost.yml @@ -0,0 +1,23 @@ +web1: + image: web + expose: + - "81" + environment: + WEB_PORTS: 81 + VIRTUAL_HOST: web1.nginx-proxy.local + NETWORK_ACCESS: internal + +web2: + image: web + expose: + - "82" + environment: + WEB_PORTS: 82 + VIRTUAL_HOST: web2.nginx-proxy.local + +sut: + image: nginxproxy/nginx-proxy:test + volumes: + - /var/run/docker.sock:/tmp/docker.sock:ro + - ./network_internal.conf:/etc/nginx/network_internal.conf:ro + diff --git a/test/test_internal/test_internal-per-vpath.py b/test/test_internal/test_internal-per-vpath.py new file mode 100644 index 0000000..e95fe00 --- /dev/null +++ b/test/test_internal/test_internal-per-vpath.py @@ -0,0 +1,14 @@ +import pytest + +def test_network_web1(docker_compose, nginxproxy): + r = nginxproxy.get("http://nginx-proxy.local/web1/port") + assert r.status_code == 200 + assert r.text == "answer from port 81\n" + assert "X-network" in r.headers + assert "internal" == r.headers["X-network"] + +def test_network_web2(docker_compose, nginxproxy): + r = nginxproxy.get("http://nginx-proxy.local/web2/port") + assert r.status_code == 200 + assert r.text == "answer from port 82\n" + assert "X-network" not in r.headers diff --git a/test/test_internal/test_internal-per-vpath.yml b/test/test_internal/test_internal-per-vpath.yml new file mode 100644 index 0000000..f5bac55 --- /dev/null +++ b/test/test_internal/test_internal-per-vpath.yml @@ -0,0 +1,27 @@ +web1: + image: web + expose: + - "81" + environment: + WEB_PORTS: 81 + VIRTUAL_HOST: nginx-proxy.local + VIRTUAL_PATH: /web1/ + VIRTUAL_DEST: / + NETWORK_ACCESS: internal + +web2: + image: web + expose: + - "82" + environment: + WEB_PORTS: 82 + VIRTUAL_HOST: nginx-proxy.local + VIRTUAL_PATH: /web2/ + VIRTUAL_DEST: / + +sut: + image: nginxproxy/nginx-proxy:test + volumes: + - /var/run/docker.sock:/tmp/docker.sock:ro + - ./network_internal.conf:/etc/nginx/network_internal.conf:ro + diff --git a/test/test_ssl/test_virtual_path.py b/test/test_ssl/test_virtual_path.py new file mode 100644 index 0000000..508653f --- /dev/null +++ b/test/test_ssl/test_virtual_path.py @@ -0,0 +1,15 @@ +import pytest + +@pytest.mark.parametrize("path", ["web1", "web2"]) +def test_web1_http_redirects_to_https(docker_compose, nginxproxy, path): + r = nginxproxy.get("http://www.nginx-proxy.tld/%s/port" % path, allow_redirects=False) + assert r.status_code == 301 + assert "Location" in r.headers + assert "https://www.nginx-proxy.tld/%s/port" % path == r.headers['Location'] + +@pytest.mark.parametrize("path,port", [("web1", 81), ("web2", 82)]) +def test_web1_https_is_forwarded(docker_compose, nginxproxy, path, port): + r = nginxproxy.get("https://www.nginx-proxy.tld/%s/port" % path, allow_redirects=False) + assert r.status_code == 200 + assert "answer from port %d\n" % port in r.text + diff --git a/test/test_ssl/test_virtual_path.yml b/test/test_ssl/test_virtual_path.yml new file mode 100644 index 0000000..2260321 --- /dev/null +++ b/test/test_ssl/test_virtual_path.yml @@ -0,0 +1,26 @@ +web1: + image: web + expose: + - "81" + environment: + WEB_PORTS: "81" + VIRTUAL_HOST: "www.nginx-proxy.tld" + VIRTUAL_PATH: "/web1/" + VIRTUAL_DEST: "/" + +web2: + image: web + expose: + - "82" + environment: + WEB_PORTS: "82" + VIRTUAL_HOST: "www.nginx-proxy.tld" + VIRTUAL_PATH: "/web2/" + VIRTUAL_DEST: "/" + +sut: + image: nginxproxy/nginx-proxy:test + volumes: + - /var/run/docker.sock:/tmp/docker.sock:ro + - ./certs:/etc/nginx/certs:ro + diff --git a/test/test_virtual-path/alternate.conf b/test/test_virtual-path/alternate.conf new file mode 100644 index 0000000..541332e --- /dev/null +++ b/test/test_virtual-path/alternate.conf @@ -0,0 +1 @@ +rewrite ^/(web3|alt)/(.*) /$2 break; diff --git a/test/test_virtual-path/bar.conf b/test/test_virtual-path/bar.conf new file mode 100644 index 0000000..e8b0827 --- /dev/null +++ b/test/test_virtual-path/bar.conf @@ -0,0 +1 @@ +add_header X-test bar; diff --git a/test/test_virtual-path/default.conf b/test/test_virtual-path/default.conf new file mode 100644 index 0000000..087e66c --- /dev/null +++ b/test/test_virtual-path/default.conf @@ -0,0 +1 @@ +add_header X-test-default true; diff --git a/test/test_virtual-path/foo.conf b/test/test_virtual-path/foo.conf new file mode 100644 index 0000000..8d8502d --- /dev/null +++ b/test/test_virtual-path/foo.conf @@ -0,0 +1 @@ +add_header X-test f00; \ No newline at end of file diff --git a/test/test_virtual-path/host.conf b/test/test_virtual-path/host.conf new file mode 100644 index 0000000..fe05265 --- /dev/null +++ b/test/test_virtual-path/host.conf @@ -0,0 +1 @@ +add_header X-test-host true; diff --git a/test/test_virtual-path/path.conf b/test/test_virtual-path/path.conf new file mode 100644 index 0000000..6c23b9a --- /dev/null +++ b/test/test_virtual-path/path.conf @@ -0,0 +1 @@ +add_header X-test-path true; diff --git a/test/test_virtual-path/test_custom_conf.py b/test/test_virtual-path/test_custom_conf.py new file mode 100644 index 0000000..eec149f --- /dev/null +++ b/test/test_virtual-path/test_custom_conf.py @@ -0,0 +1,38 @@ +import pytest + +def test_default_root_response(docker_compose, nginxproxy): + r = nginxproxy.get("http://nginx-proxy.test/") + assert r.status_code == 418 + +@pytest.mark.parametrize("stub,header", [ + ("nginx-proxy.test/web1", "bar"), + ("foo.nginx-proxy.test", "f00"), +]) +def test_custom_applies(docker_compose, nginxproxy, stub, header): + r = nginxproxy.get(f"http://{stub}/port") + assert r.status_code == 200 + assert "X-test" in r.headers + assert header == r.headers["X-test"] + +@pytest.mark.parametrize("stub,code", [ + ("nginx-proxy.test/foo", 418), + ("nginx-proxy.test/web2", 200), + ("nginx-proxy.test/web3", 200), + ("bar.nginx-proxy.test", 503), +]) +def test_custom_does_not_apply(docker_compose, nginxproxy, stub, code): + r = nginxproxy.get(f"http://{stub}/port") + assert r.status_code == code + assert "X-test" not in r.headers + +@pytest.mark.parametrize("stub,port", [ + ("nginx-proxy.test/web1", 81), + ("nginx-proxy.test/web2", 82), + ("nginx-proxy.test/web3", 83), + ("nginx-proxy.test/alt", 83), +]) +def test_alternate(docker_compose, nginxproxy, stub, port): + r = nginxproxy.get(f"http://{stub}/port") + assert r.status_code == 200 + assert r.text == f"answer from port {port}\n" + diff --git a/test/test_virtual-path/test_custom_conf.yml b/test/test_virtual-path/test_custom_conf.yml new file mode 100644 index 0000000..40ab512 --- /dev/null +++ b/test/test_virtual-path/test_custom_conf.yml @@ -0,0 +1,48 @@ + +foo: + image: web + expose: + - "42" + environment: + WEB_PORTS: "42" + VIRTUAL_HOST: "foo.nginx-proxy.test" + +web1: + image: web + expose: + - "81" + environment: + WEB_PORTS: "81" + VIRTUAL_HOST: "nginx-proxy.test" + VIRTUAL_PATH: "/web1/" + VIRTUAL_DEST: "/" + +web2: + image: web + expose: + - "82" + environment: + WEB_PORTS: "82" + VIRTUAL_HOST: "nginx-proxy.test" + VIRTUAL_PATH: "/web2/" + VIRTUAL_DEST: "/" + +web3: + image: web + expose: + - "83" + environment: + WEB_PORTS: "83" + VIRTUAL_HOST: "nginx-proxy.test" + VIRTUAL_PATH: "~ ^/(web3|alt)/" + +sut: + image: nginxproxy/nginx-proxy:test + environment: + DEFAULT_ROOT: 418 + volumes: + - /var/run/docker.sock:/tmp/docker.sock:ro + - ./foo.conf:/etc/nginx/vhost.d/foo.nginx-proxy.test:ro + - ./bar.conf:/etc/nginx/vhost.d/nginx-proxy.test_918d687a929083edd0c7224ee2293e0e7c062ab4_location:ro + - ./alternate.conf:/etc/nginx/vhost.d/nginx-proxy.test_7fb22b74bbdf907425dbbad18e4462565cada230_location:ro + diff --git a/test/test_virtual-path/test_forwarding.py b/test/test_virtual-path/test_forwarding.py new file mode 100644 index 0000000..062dd6c --- /dev/null +++ b/test/test_virtual-path/test_forwarding.py @@ -0,0 +1,18 @@ +import pytest + +def test_root_redirects_to_web1(docker_compose, nginxproxy): + r = nginxproxy.get("http://www.nginx-proxy.tld/port", allow_redirects=False) + assert r.status_code == 301 + assert "Location" in r.headers + assert "http://www.nginx-proxy.tld/web1/port" == r.headers['Location'] + +def test_direct_access(docker_compose, nginxproxy): + r = nginxproxy.get("http://www.nginx-proxy.tld/web1/port", allow_redirects=False) + assert r.status_code == 200 + assert "answer from port 81\n" in r.text + +def test_root_is_forwarded(docker_compose, nginxproxy): + r = nginxproxy.get("http://www.nginx-proxy.tld/port", allow_redirects=True) + assert r.status_code == 200 + assert "answer from port 81\n" in r.text + diff --git a/test/test_virtual-path/test_forwarding.yml b/test/test_virtual-path/test_forwarding.yml new file mode 100644 index 0000000..ee87e8d --- /dev/null +++ b/test/test_virtual-path/test_forwarding.yml @@ -0,0 +1,17 @@ +web1: + image: web + expose: + - "81" + environment: + WEB_PORTS: "81" + VIRTUAL_HOST: "www.nginx-proxy.tld" + VIRTUAL_PATH: "/web1/" + VIRTUAL_DEST: "/" + +sut: + image: nginxproxy/nginx-proxy:test + volumes: + - /var/run/docker.sock:/tmp/docker.sock:ro + - ./certs:/etc/nginx/certs:ro + environment: + - DEFAULT_ROOT=301 http://$$host/web1$$request_uri diff --git a/test/test_virtual-path/test_location_precedence.py b/test/test_virtual-path/test_location_precedence.py new file mode 100644 index 0000000..415c6c1 --- /dev/null +++ b/test/test_virtual-path/test_location_precedence.py @@ -0,0 +1,32 @@ +import pytest + +def test_location_precedence_case1(docker_compose, nginxproxy): + r = nginxproxy.get(f"http://foo.nginx-proxy.test/web1/port") + assert r.status_code == 200 + + assert "X-test-default" in r.headers + assert "X-test-host" not in r.headers + assert "X-test-path" not in r.headers + + assert r.headers["X-test-default"] == "true" + +def test_location_precedence_case2(docker_compose, nginxproxy): + r = nginxproxy.get(f"http://bar.nginx-proxy.test/web2/port") + assert r.status_code == 200 + + assert "X-test-default" not in r.headers + assert "X-test-host" in r.headers + assert "X-test-path" not in r.headers + + assert r.headers["X-test-host"] == "true" + +def test_location_precedence_case3(docker_compose, nginxproxy): + r = nginxproxy.get(f"http://bar.nginx-proxy.test/web3/port") + assert r.status_code == 200 + + assert "X-test-default" not in r.headers + assert "X-test-host" not in r.headers + assert "X-test-path" in r.headers + + assert r.headers["X-test-path"] == "true" + diff --git a/test/test_virtual-path/test_location_precedence.yml b/test/test_virtual-path/test_location_precedence.yml new file mode 100644 index 0000000..be3248c --- /dev/null +++ b/test/test_virtual-path/test_location_precedence.yml @@ -0,0 +1,37 @@ +web1: + image: web + expose: + - "81" + environment: + WEB_PORTS: "81" + VIRTUAL_HOST: "foo.nginx-proxy.test" + VIRTUAL_PATH: "/web1/" + VIRTUAL_DEST: "/" + +web2: + image: web + expose: + - "82" + environment: + WEB_PORTS: "82" + VIRTUAL_HOST: "bar.nginx-proxy.test" + VIRTUAL_PATH: "/web2/" + VIRTUAL_DEST: "/" + +web3: + image: web + expose: + - "83" + environment: + WEB_PORTS: "83" + VIRTUAL_HOST: "bar.nginx-proxy.test" + VIRTUAL_PATH: "/web3/" + VIRTUAL_DEST: "/" + +sut: + image: nginxproxy/nginx-proxy:test + volumes: + - /var/run/docker.sock:/tmp/docker.sock:ro + - ./default.conf:/etc/nginx/vhost.d/default_location:ro + - ./host.conf:/etc/nginx/vhost.d/bar.nginx-proxy.test_location:ro + - ./path.conf:/etc/nginx/vhost.d/bar.nginx-proxy.test_99f2db0ed8aa95dbb5b87fca79c7eff2ff6bb8bd_location:ro diff --git a/test/test_virtual-path/test_virtual_paths.py b/test/test_virtual-path/test_virtual_paths.py new file mode 100644 index 0000000..115d47f --- /dev/null +++ b/test/test_virtual-path/test_virtual_paths.py @@ -0,0 +1,59 @@ +from time import sleep + +import pytest +from docker.errors import NotFound + +@pytest.mark.parametrize("stub,expected_port", [ + ("nginx-proxy.test/web1", 81), + ("nginx-proxy.test/web2", 82), + ("nginx-proxy.test", 83), + ("foo.nginx-proxy.test", 42), +]) +def test_valid_path(docker_compose, nginxproxy, stub, expected_port): + r = nginxproxy.get(f"http://{stub}/port") + assert r.status_code == 200 + assert r.text == f"answer from port {expected_port}\n" + +@pytest.mark.parametrize("stub", [ + "nginx-proxy.test/foo", + "bar.nginx-proxy.test", +]) +def test_invalid_path(docker_compose, nginxproxy, stub): + r = nginxproxy.get(f"http://{stub}/port") + assert r.status_code in [404, 503] + +@pytest.fixture() +def web4(docker_compose): + """ + pytest fixture creating a web container with `VIRTUAL_HOST=nginx-proxy.test`, `VIRTUAL_PATH=/web4/` and `VIRTUAL_DEST=/` listening on port 84. + """ + container = docker_compose.containers.run( + name="web4", + image="web", + detach=True, + environment={ + "WEB_PORTS": "84", + "VIRTUAL_HOST": "nginx-proxy.test", + "VIRTUAL_PATH": "/web4/", + "VIRTUAL_DEST": "/", + }, + ports={"84/tcp": None} + ) + sleep(2) # give it some time to initialize and for docker-gen to detect it + yield container + try: + docker_compose.containers.get("web4").remove(force=True) + except NotFound: + pass + +""" +Test if we can add and remove a single virtual_path from multiple ones on the same subdomain. +""" +def test_container_hotplug(web4, nginxproxy): + r = nginxproxy.get(f"http://nginx-proxy.test/web4/port") + assert r.status_code == 200 + assert r.text == f"answer from port 84\n" + web4.remove(force=True) + sleep(2) + r = nginxproxy.get(f"http://nginx-proxy.test/web4/port") + assert r.status_code == 404 diff --git a/test/test_virtual-path/test_virtual_paths.yml b/test/test_virtual-path/test_virtual_paths.yml new file mode 100644 index 0000000..9f6a54f --- /dev/null +++ b/test/test_virtual-path/test_virtual_paths.yml @@ -0,0 +1,42 @@ + +foo: + image: web + expose: + - "42" + environment: + WEB_PORTS: "42" + VIRTUAL_HOST: "foo.nginx-proxy.test" + +web1: + image: web + expose: + - "81" + environment: + WEB_PORTS: "81" + VIRTUAL_HOST: "nginx-proxy.test" + VIRTUAL_PATH: "/web1/" + VIRTUAL_DEST: "/" + +web2: + image: web + expose: + - "82" + environment: + WEB_PORTS: "82" + VIRTUAL_HOST: "nginx-proxy.test" + VIRTUAL_PATH: "/web2/" + VIRTUAL_DEST: "/" + +web3: + image: web + expose: + - "83" + environment: + WEB_PORTS: "83" + VIRTUAL_HOST: "nginx-proxy.test" + VIRTUAL_PATH: "/" + +sut: + image: nginxproxy/nginx-proxy:test + volumes: + - /var/run/docker.sock:/tmp/docker.sock:ro