From 7594dab91e9207dbe4d1a40d06e6c54d56512d05 Mon Sep 17 00:00:00 2001 From: Alexander Lieret Date: Mon, 5 Apr 2021 15:15:15 +0200 Subject: [PATCH] Support for path-based routing This is a rebase of #1083 with some improvements. - VIRTUAL_PATH: route using this path - VIRTUAL_DEST: rewrite the query path (optional) - Support for custom config snippets files - Add test cases --- README.md | 27 ++++++ nginx.tmpl | 87 ++++++++++++++++++-- test/test_custom/test_location-per-vpath.py | 22 +++++ test/test_custom/test_location-per-vpath.yml | 28 +++++++ test/test_custom/test_per-vpath.py | 26 ++++++ test/test_custom/test_per-vpath.yml | 36 ++++++++ test/test_paths.py | 26 ++++++ test/test_paths.yml | 38 +++++++++ 8 files changed, 284 insertions(+), 6 deletions(-) create mode 100644 test/test_custom/test_location-per-vpath.py create mode 100644 test/test_custom/test_location-per-vpath.yml create mode 100644 test/test_custom/test_per-vpath.py create mode 100644 test/test_custom/test_per-vpath.yml create mode 100644 test/test_paths.py create mode 100644 test/test_paths.yml diff --git a/README.md b/README.md index 5b87195..27da09a 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,21 @@ You can activate the IPv6 support for the nginx-proxy container by passing the v $ docker run -d -p 80:80 -e ENABLE_IPV6=true -v /var/run/docker.sock:/tmp/docker.sock:ro jwilder/nginx-proxy +### Path-based Routing + +You can proxy multiple containers on a single `VIRTUAL_HOST` by specifying a `VIRTUAL_PATH` environment variable. This will route requests to `http://CONTAINER/VIRTUAL_PATH` to this container. +The optional environment variable `VIRTUAL_DEST` will modify the request URI to `http://CONTAINER/VIRTUAL_DEST` if necessary. + +The full request URI will be forwarded to the serving container in the `X-Original-URI` header. + +For example, if you want to run multiple containers on the same host name under different subdirectories, you can set it up as follows: + + $ docker run -d -p 80:80 -v /var/run/docker.sock:/tmp/docker.sock:ro nginxproxy/nginx-proxy + $ docker run -d -name service1 -e VIRTUAL_HOST=example.tld -e VIRTUAL_PATH=/foo jwilder/whoami + $ docker run -d -name service2 -e VIRTUAL_HOST=example.tld -e VIRTUAL_PATH=/bar -e VIRTUAL_DEST=/ jwilder/whoami + +In this example, request to `http://example.tld/foo` are proxied to `http://service1/foo` and request to `http://example.tld/bar` to `http://service2/`. + ### Multiple Ports If your container exposes multiple ports, nginx-proxy will default to the service running on port 80. If you need to specify a different port, you can set a VIRTUAL_PORT env var to select a different one. If your container only exposes one port and it has a VIRTUAL_HOST env var set, that port will be selected. @@ -395,6 +410,18 @@ If you are using multiple hostnames for a single container (e.g. `VIRTUAL_HOST=e $ { echo 'proxy_cache my-cache;'; echo 'proxy_cache_valid 200 302 60m;'; echo 'proxy_cache_valid 404 1m;' } > /path/to/vhost.d/app.example.com_location $ ln -s /path/to/vhost.d/www.example.com /path/to/vhost.d/example.com +#### Per-VIRTUAL_HOST VIRTUAL_PATH location configuration + +To add settings to the "location" block on a per-`VIRTUAL_HOST/VIRTUAL_PATH` basis, add your configuration file under `/etc/nginx/vhost.d` +just like the previous section except with the `_$(echo -n $VIRTUAL_PATH | sha1sum | awk '{ print $1 }')_location` + +For example, if you have a container proxied under `example.tld/hidden` and you want to add custon configuration to the location block, you could append it as follows: + + $ docker run -d -p 80:80 -p 443:443 -v /path/to/vhost.d:/etc/nginx/vhost.d:ro -v /var/run/docker.sock:/tmp/docker.sock:ro nginxproxy/nginx-proxy + $ docker run -d -e VIRTUAL_HOST=example.tld -e VIRTUAL_PATH=/hidden jwilder/whoami + $ { echo '# custom config block' } > /path/to/vhost.d/example.tld_$(echo -n /hidden | sha1sum | awk '{ print $1 }')_location + + #### Per-VIRTUAL_HOST location default configuration If you want most of your virtual hosts to use a default single `location` block configuration and then override on a few specific ones, add those settings to the `/etc/nginx/vhost.d/default_location` file. This file diff --git a/nginx.tmpl b/nginx.tmpl index 23d9c62..97de054 100644 --- a/nginx.tmpl +++ b/nginx.tmpl @@ -72,6 +72,65 @@ location {{ .Path }} { } {{ end }} +{{ define "location" }} +location {{ .Path }} { + {{ if eq .Proto "uwsgi" }} + include uwsgi_params; + uwsgi_pass {{ trim .Proto }}://{{ trim .Upstream }}; + {{ else if eq .Proto "fastcgi" }} + root {{ trim .Vhostroot }}; + include fastcgi.conf; + 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_location" .Host)) }} + include {{ printf "/etc/nginx/vhost.d/%s_location" .Host}}; + {{ else 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 "/etc/nginx/vhost.d/default_location") }} + include /etc/nginx/vhost.d/default_location; + {{ end }} +} +{{ end }} + +{{ define "upstream-definition" }} + {{ $networks := .Networks }} + upstream {{ .Upstream }} { + {{ range $container := .Containers }} + {{ $addrLen := len $container.Addresses }} + {{ 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 only 1 port exposed, use that */}} + {{ if eq $addrLen 1 }} + {{ $address := index $container.Addresses 0 }} + {{ template "upstream" (dict "Container" $container "Address" $address "Network" $containerNetwork) }} + {{/* If more than one port exposed, use the one matching VIRTUAL_PORT env var, falling back to standard web port 80 */}} + {{ else }} + {{ $port := coalesce $container.Env.VIRTUAL_PORT "80" }} + {{ $address := where $container.Addresses "Port" $port | first }} + {{ template "upstream" (dict "Container" $container "Address" $address "Network" $containerNetwork) }} + {{ end }} + {{ else }} + # Cannot connect to network of this container + server 127.0.0.1 down; + {{ end }} + {{ end }} + {{ end }} + {{ end }} + } +{{ end }} + # If we receive X-Forwarded-Proto, pass it through; otherwise, pass along the # scheme used to connect to this server map $http_x_forwarded_proto $proxy_x_forwarded_proto { @@ -114,6 +173,10 @@ log_format vhost '$host $remote_addr - $remote_user [$time_local] ' '"$http_referer" "$http_user_agent"'; 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 }} @@ -313,12 +376,18 @@ server { {{ end }} {{ if eq $nPaths 0 }} - {{ template "location" (dict "Path" "/" "Proto" $proto "Upstream" $upstream_name "Host" $host "VHostRoot" $vhost_root) }} + {{ template "location" (dict "Path" "/" "Proto" $proto "Upstream" $upstream_name "Host" $host "VHostRoot" $vhost_root "Dest" "") }} {{ else }} - {{ range $path, $containers := $paths }} + {{ range $path, $container := $paths }} {{ $sum := sha1 $path }} {{ $upstream := printf "%s-%s" $upstream_name $sum }} - {{ template "location" (dict "Path" $path "Proto" $proto "Upstream" $upstream "Host" $host "VHostRoot" $vhost_root) }} + {{ $dest := (or (first (groupByKeys $container "Env.VIRTUAL_DEST")) "") }} + {{ template "location" (dict "Path" $path "Proto" $proto "Upstream" $upstream "Host" $host "VHostRoot" $vhost_root "Dest" $dest) }} + {{ end }} + {{ if (not (contains $paths "/")) }} + location / { + return 503 + } {{ end }} {{ end }} } @@ -347,12 +416,18 @@ server { {{ end }} {{ if eq $nPaths 0 }} - {{ template "location" (dict "Path" "/" "Proto" $proto "Upstream" $upstream_name "Host" $host "VHostRoot" $vhost_root) }} + {{ template "location" (dict "Path" "/" "Proto" $proto "Upstream" $upstream_name "Host" $host "VHostRoot" $vhost_root "Dest" "") }} {{ else }} - {{ range $path, $containers := $paths }} + {{ range $path, $container := $paths }} {{ $sum := sha1 $path }} {{ $upstream := printf "%s-%s" $upstream_name $sum }} - {{ template "location" (dict "Path" $path "Proto" $proto "Upstream" $upstream "Host" $host "VHostRoot" $vhost_root) }} + {{ $dest := (or (first (groupByKeys $container "Env.VIRTUAL_DEST")) "") }} + {{ template "location" (dict "Path" $path "Proto" $proto "Upstream" $upstream "Host" $host "VHostRoot" $vhost_root "Dest" $dest) }} + {{ end }} + {{ if (not (contains $paths "/")) }} + location / { + return 503 + } {{ end }} {{ end }} } diff --git a/test/test_custom/test_location-per-vpath.py b/test/test_custom/test_location-per-vpath.py new file mode 100644 index 0000000..48c93c7 --- /dev/null +++ b/test/test_custom/test_location-per-vpath.py @@ -0,0 +1,22 @@ +import pytest + +def test_custom_conf_does_not_apply_to_unknown_vpath(docker_compose, nginxproxy): + r = nginxproxy.get("http://nginx-proxy.local/") + assert r.status_code == 503 + assert "X-test" not in r.headers + +def test_custom_conf_applies_to_path1(docker_compose, nginxproxy): + r = nginxproxy.get("http://nginx-proxy.local/path1/port") + assert r.status_code == 200 + assert r.text == "answer from port 81\n" + assert "X-test" in r.headers + assert "f00" == r.headers["X-test"] + +def test_custom_conf_does_not_apply_to_path2(docker_compose, nginxproxy): + r = nginxproxy.get("http://nginx-proxy.local/path2/port") + assert r.status_code == 200 + assert r.text == "answer from port 82\n" + assert "X-test" not in r.headers + +def test_custom_block_is_present_in_nginx_generated_conf(docker_compose, nginxproxy): + assert "include /etc/nginx/vhost.d/nginx-proxy.local_faeee25c67f4f2196a5cf9c7b87b970ed63140de_location;" in nginxproxy.get_conf() diff --git a/test/test_custom/test_location-per-vpath.yml b/test/test_custom/test_location-per-vpath.yml new file mode 100644 index 0000000..0d92513 --- /dev/null +++ b/test/test_custom/test_location-per-vpath.yml @@ -0,0 +1,28 @@ +version: "2" +services: + nginx-proxy: + image: nginxproxy/nginx-proxy:test + volumes: + - /var/run/docker.sock:/tmp/docker.sock:ro + - ../lib/ssl/dhparam.pem:/etc/nginx/dhparam/dhparam.pem:ro + - ./my_custom_proxy_settings.conf:/etc/nginx/vhost.d/nginx-proxy.local_faeee25c67f4f2196a5cf9c7b87b970ed63140de_location:ro + + web1: + image: web + expose: + - "81" + environment: + WEB_PORTS: 81 + VIRTUAL_HOST: nginx-proxy.local + VIRTUAL_PATH: /path1/ + VIRTUAL_DEST: / + + web2: + image: web + expose: + - "82" + environment: + WEB_PORTS: 82 + VIRTUAL_HOST: nginx-proxy.local + VIRTUAL_PATH: /path2/ + VIRTUAL_DEST: / diff --git a/test/test_custom/test_per-vpath.py b/test/test_custom/test_per-vpath.py new file mode 100644 index 0000000..940c7b4 --- /dev/null +++ b/test/test_custom/test_per-vpath.py @@ -0,0 +1,26 @@ +import pytest + +def test_custom_conf_does_not_apply_to_unknown_vhost(docker_compose, nginxproxy): + r = nginxproxy.get("http://nginx-proxy/") + assert r.status_code == 503 + assert "X-test" not in r.headers + +def test_custom_conf_applies_to_web1_path1(docker_compose, nginxproxy): + r = nginxproxy.get("http://web1.nginx-proxy.local/path1/port") + assert r.status_code == 200 + assert r.text == "answer from port 81\n" + assert "X-test" in r.headers + assert "f00" == r.headers["X-test"] + +def test_custom_conf_applies_to_web1_path2(docker_compose, nginxproxy): + r = nginxproxy.get("http://web1.nginx-proxy.local/path2/port") + assert r.status_code == 200 + assert r.text == "answer from port 82\n" + assert "X-test" in r.headers + assert "f00" == r.headers["X-test"] + +def test_custom_conf_does_not_apply_to_web2(docker_compose, nginxproxy): + r = nginxproxy.get("http://web2.nginx-proxy.local/port") + assert r.status_code == 200 + assert r.text == "answer from port 83\n" + assert "X-test" not in r.headers diff --git a/test/test_custom/test_per-vpath.yml b/test/test_custom/test_per-vpath.yml new file mode 100644 index 0000000..d2a0a8d --- /dev/null +++ b/test/test_custom/test_per-vpath.yml @@ -0,0 +1,36 @@ +version: "2" +services: + nginx-proxy: + image: nginxproxy/nginx-proxy:test + volumes: + - /var/run/docker.sock:/tmp/docker.sock:ro + - ../lib/ssl/dhparam.pem:/etc/nginx/dhparam/dhparam.pem:ro + - ./my_custom_proxy_settings.conf:/etc/nginx/vhost.d/web1.nginx-proxy.local:ro + + web1: + image: web + expose: + - "81" + environment: + WEB_PORTS: 81 + VIRTUAL_HOST: web1.nginx-proxy.local + VIRTUAL_PATH: /path1/ + VIRTUAL_DEST: / + + web2: + image: web + expose: + - "82" + environment: + WEB_PORTS: 82 + VIRTUAL_HOST: web1.nginx-proxy.local + VIRTUAL_PATH: /path2/ + VIRTUAL_DEST: / + + web3: + image: web + expose: + - "83" + environment: + WEB_PORTS: 83 + VIRTUAL_HOST: web2.nginx-proxy.local diff --git a/test/test_paths.py b/test/test_paths.py new file mode 100644 index 0000000..1822f63 --- /dev/null +++ b/test/test_paths.py @@ -0,0 +1,26 @@ +import pytest + +def test_forwards_to_whoami1(docker_compose, nginxproxy): + r = nginxproxy.get("http://web.nginx-proxy.local/web1") + assert r.status_code == 200 + whoami_container = docker_compose.containers.get("whoami1") + assert r.text == "I'm %s\n" % whoami_container.id[:12] + +def test_forwards_to_whoami2(docker_compose, nginxproxy): + r = nginxproxy.get("http://web.nginx-proxy.local/web2") + assert r.status_code == 200 + whoami_container = docker_compose.containers.get("whoami2") + assert r.text == "I'm %s\n" % whoami_container.id[:12] + +def test_forwards_to_status(docker_compose, nginxproxy): + r = nginxproxy.get("http://status.nginx-proxy.local/status/418") + assert r.status_code == 418 + assert r.text == "answer with response code 418\n" + +def test_forwards_to_unknown1(docker_compose, nginxproxy): + r = nginxproxy.get("http://web.nginx-proxy.local/foo") + assert r.status_code == 503 + +def test_forwards_to_unknown2(docker_compose, nginxproxy): + r = nginxproxy.get("http://web.nginx-proxy.local/") + assert r.status_code == 503 diff --git a/test/test_paths.yml b/test/test_paths.yml new file mode 100644 index 0000000..3a9cede --- /dev/null +++ b/test/test_paths.yml @@ -0,0 +1,38 @@ +version: "2" +services: + nginx-proxy: + image: nginxproxy/nginx-proxy:test + volumes: + - /var/run/docker.sock:/tmp/docker.sock:ro + - ./lib/ssl/dhparam.pem:/etc/nginx/dhparam/dhparam.pem:ro + + web1: + image: web + container_name: whoami1 + expose: + - "80" + environment: + WEB_PORTS: 80 + VIRTUAL_HOST: web.nginx-proxy.local + VIRTUAL_PATH: /web1 + VIRTUAL_DEST: / + + web2: + image: web + container_name: whoami2 + expose: + - "80" + environment: + WEB_PORTS: 80 + VIRTUAL_HOST: web.nginx-proxy.local + VIRTUAL_PATH: /web2 + VIRTUAL_DEST: / + + web3: + image: web + expose: + - "80" + environment: + WEB_PORTS: 80 + VIRTUAL_HOST: status.nginx-proxy.local + VIRTUAL_PATH: /status