diff --git a/README.md b/README.md index 813fdda..ec29380 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/nginx.tmpl b/nginx.tmpl index f18aa21..ce4b545 100644 --- a/nginx.tmpl +++ b/nginx.tmpl @@ -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")) }} 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