From 1e4e1948aba26225223e431cfc7cc4ecf80cbf68 Mon Sep 17 00:00:00 2001 From: Greg Symons Date: Thu, 6 Oct 2016 21:08:47 -0500 Subject: [PATCH 1/3] Add support for path-based routing --- nginx.tmpl | 133 +++++++++++++++++++++++++++++++---------------------- 1 file changed, 79 insertions(+), 54 deletions(-) diff --git a/nginx.tmpl b/nginx.tmpl index 9eb9520..d14b917 100644 --- a/nginx.tmpl +++ b/nginx.tmpl @@ -17,6 +17,55 @@ {{ end }} {{ end }} +{{ define "location" }} +location {{ .Path }} { + {{ if eq .Proto "uwsgi" }} + include uwsgi_params; + uwsgi_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" }} + upstream {{ .Upstream }} { + {{ range $container := .Containers }} + {{ $addrLen := len $container.Addresses }} + {{ $networks := .Networks }} + {{ range $knownNetwork := $networks }} + {{ range $containerNetwork := $container.Networks }} + {{ if eq $knownNetwork.Name $containerNetwork.Name }} + ## Can be connect 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 }} + {{ 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 { @@ -38,6 +87,7 @@ log_format vhost '$host $remote_addr - $remote_user [$time_local] ' '"$http_referer" "$http_user_agent"'; access_log off; +error_log /dev/stderr; {{ if (exists "/etc/nginx/proxy.conf") }} include /etc/nginx/proxy.conf; @@ -51,6 +101,7 @@ proxy_set_header Connection $proxy_connection; proxy_set_header X-Real-IP $remote_addr; 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-Path $request_uri; # Mitigate httpoxy attack (see README for details) proxy_set_header Proxy ""; @@ -78,30 +129,20 @@ server { {{ range $host, $containers := groupByMulti $ "Env.VIRTUAL_HOST" "," }} -upstream {{ $host }} { -{{ range $container := $containers }} - {{ $addrLen := len $container.Addresses }} +{{ $paths := groupBy $containers "Env.VIRTUAL_PATH" }} +{{ $nPaths := len $paths }} - {{ range $knownNetwork := $CurrentContainer.Networks }} - {{ range $containerNetwork := $container.Networks }} - {{ if eq $knownNetwork.Name $containerNetwork.Name }} - ## Can be connect 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 }} - {{ end }} - {{ end }} +{{ if eq $nPaths 0 }} + # {{ $host }} + {{ template "upstream-definition" (dict "Upstream" $host "Containers" $containers "Networks" (json $CurrentContainer.Networks)) }} +{{ else }} + {{ range $path, $containers := $paths }} + {{ $sum := sha1 $path }} + {{ $upstream := printf "%s-%s" $host $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 }} @@ -167,24 +208,16 @@ server { {{ 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 $host }}; - {{ else }} - proxy_pass {{ trim $proto }}://{{ trim $host }}; + + {{ if eq $nPaths 0 }} + {{ template "location" (dict "Path" "/" "Proto" $proto "Upstream" $host "Host" $host) }} + {{ else }} + {{ range $path, $containers := $paths }} + {{ $sum := sha1 $path }} + {{ $upstream := printf "%s-%s" $host $sum }} + {{ template "location" (dict "Path" $path "Proto" $proto "Upstream" $upstream "Host" $host) }} {{ 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 }} @@ -202,23 +235,15 @@ server { include /etc/nginx/vhost.d/default; {{ end }} - location / { - {{ if eq $proto "uwsgi" }} - include uwsgi_params; - uwsgi_pass {{ trim $proto }}://{{ trim $host }}; - {{ else }} - proxy_pass {{ trim $proto }}://{{ trim $host }}; + {{ if eq $nPaths 0 }} + {{ template "location" (dict "Path" "/" "Proto" $proto "Upstream" $host "Host" $host) }} + {{ else }} + {{ range $path, $containers := $paths }} + {{ $sum := sha1 $path }} + {{ $upstream := printf "%s-%s" $host $sum }} + {{ template "location" (dict "Path" $path "Proto" $proto "Upstream" $upstream "Host" $host) }} {{ 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")) }} From dd0f51a714eb501cf53f421e32a0aa2f9bd9c6d1 Mon Sep 17 00:00:00 2001 From: Greg Symons Date: Thu, 6 Oct 2016 21:31:57 -0500 Subject: [PATCH 2/3] Update README to document the path-based routing feature. --- README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.md b/README.md index 52ab6e4..d54966f 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,12 @@ If you need to support multiple virtual hosts for a container, you can separate 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`. @@ -219,6 +225,7 @@ proxy_set_header Connection $proxy_connection; proxy_set_header X-Real-IP $remote_addr; 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-Path $request_uri; # Mitigate httpoxy attack (see README for details) proxy_set_header Proxy ""; From 189ed4b29baccabee241d6f57cf2a69c897a7e0f Mon Sep 17 00:00:00 2001 From: Greg Symons Date: Thu, 6 Oct 2016 22:50:12 -0500 Subject: [PATCH 3/3] Add some tests --- test/path-based-routes.bats | 69 +++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 test/path-based-routes.bats diff --git a/test/path-based-routes.bats b/test/path-based-routes.bats new file mode 100644 index 0000000..c8e7a8b --- /dev/null +++ b/test/path-based-routes.bats @@ -0,0 +1,69 @@ +#!/usr/bin/env bats + +load test_helpers +SUT_CONTAINER=bats-nginx-proxy-${TEST_FILE} + +function setup { + stop_bats_containers web +} + +@test "[$TEST_FILE] start a nginx-proxy container" { + run nginxproxy $SUT_CONTAINER -v /var/run/docker.sock:/tmp/docker.sock:ro + assert_success + docker_wait_for_log $SUT_CONTAINER 9 "Watching docker events" +} + +@test "[$TEST_FILE] nginx-proxy can put a container at a specific path" { + #WHEN a container is run with VIRTUAL_PATH set + prepare_web_container bats-virtual-path-1 80 -e VIRTUAL_HOST=virtual-path-1.bats -e VIRTUAL_PATH=/virtual_path + dockergen_wait_for_event $SUT_CONTAINER start bats-virtual-path-1 + sleep 1 + + #THEN querying the root -> 404 + run curl_container $SUT_CONTAINER / --head --header "Host: virtual-path-1.bats" + assert_output -l 0 $'HTTP/1.1 404 Not Found\r' + + #THEN querying an unmatched path -> 503 + run curl_container $SUT_CONTAINER /some_other_path --head --header "Host: virtual-path-1.bats" + assert_output -l 0 $'HTTP/1.1 404 Not Found\r' + + #THEN + run curl_container $SUT_CONTAINER /virtual_path/data --header "Host: virtual-path-1.bats" + assert_output "answer from port 80" +} + +@test "[$TEST_FILE] nginx-proxy can put a container at the root" { + #WHEN a container is run with VIRTUAL_PATH set to / + prepare_web_container bats-virtual-path-1 80 -e VIRTUAL_HOST=virtual-path-1.bats -e VIRTUAL_PATH=/ + dockergen_wait_for_event $SUT_CONTAINER start bats-virtual-path-1 + sleep 1 + + #THEN + run curl_container $SUT_CONTAINER /data --header "Host: virtual-path-1.bats" + assert_output "answer from port 80" +} + +@test "[$TEST_FILE] nginx-proxy can put multiple containers at different paths" { + #WHEN a multiple containers are run with VIRTUAL_PATH set + prepare_web_container bats-virtual-path-1 80 -e VIRTUAL_HOST=virtual-path-1.bats -e VIRTUAL_PATH=/at-80 + prepare_web_container bats-virtual-path-2 90 -e VIRTUAL_HOST=virtual-path-1.bats -e VIRTUAL_PATH=/at-90 + prepare_web_container bats-virtual-path-3 100 -e VIRTUAL_HOST=virtual-path-1.bats -e VIRTUAL_PATH=/ + dockergen_wait_for_event $SUT_CONTAINER start bats-virtual-path-1 + sleep 1 + + #THEN + run curl_container $SUT_CONTAINER /data --header "Host: virtual-path-1.bats" + assert_output "answer from port 100" + + #THEN + run curl_container $SUT_CONTAINER /at-80/data --header "Host: virtual-path-1.bats" + assert_output "answer from port 80" + + #THEN + run curl_container $SUT_CONTAINER /at-90/data --header "Host: virtual-path-1.bats" + assert_output "answer from port 90" +} + +@test "[$TEST_FILE] stop all bats containers" { + stop_bats_containers +}