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
This commit is contained in:
Alexander Lieret 2021-04-05 15:15:15 +02:00
parent 6613e272eb
commit 050f7ec687
No known key found for this signature in database
GPG key ID: F595A2E96992CF30
8 changed files with 302 additions and 69 deletions

View file

@ -70,6 +70,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 nginxproxy/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.
@ -341,6 +356,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 "";
@ -404,6 +420,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

View file

@ -68,6 +68,65 @@
{{ end }}
{{ 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,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 }};
@ -133,6 +193,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 "";
@ -174,35 +235,20 @@ server {
{{ $is_regexp := hasPrefix "~" $host }}
{{ $upstream_name := when $is_regexp (sha1 $host) $host }}
# {{ $host }}
upstream {{ $upstream_name }} {
{{ $paths := groupBy $containers "Env.VIRTUAL_PATH" }}
{{ $nPaths := len $paths }}
{{ range $container := $containers }}
{{ $addrLen := len $container.Addresses }}
{{ 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 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 }}
{{ if eq $nPaths 0 }}
# {{ $host }}
{{ template "upstream-definition" (dict "Upstream" $upstream_name "Containers" $containers "Networks" $CurrentContainer.Networks) }}
{{ else }}
{{ range $path, $containers := $paths }}
{{ $sum := sha1 $path }}
{{ $upstream := printf "%s-%s" $upstream_name $sum }}
# {{ $host }}{{ $path }}
{{ template "upstream-definition" (dict "Upstream" $upstream "Containers" $containers "Networks" $CurrentContainer.Networks) }}
{{ end }}
{{ end }}
}
{{ $default_host := or ($.Env.DEFAULT_HOST) "" }}
{{ $default_server := index (dict $host "" $default_host "default_server") $host }}
@ -310,30 +356,21 @@ 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 }};
{{ if eq $nPaths 0 }}
{{ template "location" (dict "Path" "/" "Proto" $proto "Upstream" $upstream_name "Host" $host "Vhostroot" $vhost_root "Dest" "") }}
{{ else }}
{{ range $path, $container := $paths }}
{{ $sum := sha1 $path }}
{{ $upstream := printf "%s-%s" $host $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) }}
{{ 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 503
}
{{ 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 }}
}
{{ end }}
@ -359,29 +396,21 @@ 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 }};
{{ if eq $nPaths 0 }}
{{ template "location" (dict "Path" "/" "Proto" $proto "Upstream" $upstream_name "Host" $host "Vhostroot" $vhost_root "Dest" "") }}
{{ else }}
{{ range $path, $container := $paths }}
{{ $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) }}
{{ 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 503
}
{{ 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")) }}

View file

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

View file

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

View file

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

View file

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

26
test/test_paths.py Normal file
View file

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

38
test/test_paths.yml Normal file
View file

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