Merge pull request #1607 from nginx-proxy/virtual-path

Support for path-based routing
This commit is contained in:
Nicolas Duchon 2021-07-15 21:28:29 +02:00 committed by GitHub
commit 6c4b4a4c2d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 260 additions and 98 deletions

View file

@ -103,6 +103,12 @@ 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 [xip.io](http://xip.io), using `~^foo\.bar\..*\.xip\.io` will match `foo.bar.127.0.0.1.xip.io`, `foo.bar.10.0.2.2.xip.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, make 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 `/`.
The full request URI will be forwarded to the serving container in the `X-Forwarded-Path` header.
### 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`.
@ -365,6 +371,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 "";

View file

@ -47,6 +47,92 @@
{{ 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 }}/;
{{ 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 "/etc/nginx/vhost.d/default_location") }}
include /etc/nginx/vhost.d/default_location;
{{ end }}
}
{{ end }}
{{ define "upstream-definition" }}
{{ $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 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 {
@ -94,6 +180,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 }};
@ -113,6 +200,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 "";
@ -156,61 +244,20 @@ server {
{{ $is_regexp := hasPrefix "~" $host }}
{{ $upstream_name := (print (when $is_regexp (sha1 $host) $host) "-upstream") }}
# {{ $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-definition" (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-definition" (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 }}
@ -331,30 +378,15 @@ 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) }}
{{ else }}
{{ range $path, $container := $paths }}
{{ $sum := sha1 $path }}
{{ $upstream := printf "%s-%s" $host $sum }}
{{ template "location" (dict "Path" $path "Proto" $proto "Upstream" $upstream "Host" $host "Vhostroot" $vhost_root) }}
{{ 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 "/etc/nginx/vhost.d/default_location") }}
include /etc/nginx/vhost.d/default_location;
{{ end }}
}
{{ end }}
}
{{ end }}
@ -383,29 +415,15 @@ 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) }}
{{ else }}
{{ 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) }}
{{ 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 "/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

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

View file

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

View file

@ -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/"
web2:
image: web
expose:
- "82"
environment:
WEB_PORTS: "82"
VIRTUAL_HOST: "nginx-proxy.test"
VIRTUAL_PATH: "/web2/"
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
- ../lib/ssl/dhparam.pem:/etc/nginx/dhparam/dhparam.pem:ro