├── .dockerignore ├── Dockerfile ├── LICENSE ├── Makefile ├── Procfile ├── README.md ├── circle.yml ├── install_simp_le.sh ├── letsencrypt_service ├── letsencrypt_service_data.tmpl ├── nginx.tmpl ├── test ├── README.md ├── cleanup_test_containers.sh ├── default-host.bats ├── docker.bats ├── lib │ ├── README.md │ ├── bats │ │ ├── batslib.bash │ │ └── batslib │ │ │ └── output.bash │ ├── docker_helpers.bash │ └── helpers.bash ├── multiple-hosts.bats ├── multiple-ports.bats ├── test_helpers.bash └── wildcard-hosts.bats ├── update_certs └── update_nginx /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | .dockerignore 3 | circle.yml 4 | Dockerfile 5 | LICENSE 6 | Makefile 7 | README.md 8 | test 9 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM jwilder/nginx-proxy 2 | 3 | MAINTAINER David Parrish 4 | MAINTAINER Yves Blusseau <90z7oey02@sneakemail.com> 5 | MAINTAINER Hadrien Mary 6 | 7 | # Install simp_le program 8 | COPY /install_simp_le.sh /app/install_simp_le.sh 9 | RUN chmod +rx /app/install_simp_le.sh && sync && /app/install_simp_le.sh && rm -f /app/install_simp_le.sh 10 | 11 | COPY letsencrypt_service letsencrypt_service_data.tmpl nginx.tmpl Procfile update_certs update_nginx /app/ 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Jason Wilder 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .SILENT : 2 | .PHONY : test 3 | 4 | update-dependencies: 5 | docker pull jwilder/docker-gen:latest 6 | docker pull nginx:latest 7 | docker pull python:3 8 | docker pull rancher/socat-docker:latest 9 | docker pull appropriate/curl:latest 10 | docker pull docker:1.9.1 11 | 12 | test: 13 | docker build -t dmp1ce/nginx-proxy-letsencrypt:bats . 14 | bats test 15 | 16 | test-clean: 17 | ./test/cleanup_test_containers.sh 18 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | nginx: /usr/sbin/nginx 2 | dockergen: /usr/local/bin/docker-gen -watch -only-exposed -notify "/app/update_nginx" /app/nginx.tmpl /etc/nginx/conf.d/default.conf 3 | letsencrypt: /app/letsencrypt_service 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | **Development efforts have moved to the [docker-letsencrypt-nginx-proxy-companion](https://github.com/JrCs/docker-letsencrypt-nginx-proxy-companion) project because docker-letsencrypt-nginx-proxy-companion doesn't require a fork of nginx-proxy in order to register Let's Encrypt certificates.** 2 | 3 | **Currently, this project is unsupported. Contact daveparrish@tutanota.com if you want to take over support of this project.** 4 | 5 | ![nginx 1.9.6](https://img.shields.io/badge/nginx-1.9.6-brightgreen.svg) ![License MIT](https://img.shields.io/badge/license-MIT-blue.svg) [![Build](https://circleci.com/gh/dmp1ce/nginx-proxy-letsencrypt.svg?&style=shield&circle-token=cd873b9ebad6424218c4dee8e8e2344366920dde)](https://circleci.com/gh/dmp1ce/nginx-proxy-letsencrypt) [![](https://badge.imagelayers.io/dmp1ce/nginx-proxy-letsencrypt:latest.svg)](https://imagelayers.io/?images=dmp1ce/nginx-proxy-letsencrypt:latest 'Get your own badge on imagelayers.io') 6 | 7 | nginx-proxy sets up a container running nginx and [docker-gen][1]. docker-gen generates reverse proxy configs for nginx and reloads nginx when containers are started and stopped. 8 | 9 | See [Automated Nginx Reverse Proxy for Docker][2] for why you might want to use this. 10 | 11 | nginx-proxy-letsencrypt is a fork of nginx-proxy which adds Let's Encrypt support. Let's Encrypt allows multiple virtual hosts to have TLS certificates automatically created and renewed! The reason the jwilder/nginx-proxy was forked is because it seemed unlikely that the specific Let's Encrypt use case world be added to the more generic nginx-proxy project and the Let's Encrypt client does add some storage overhead. See [pull request](https://github.com/jwilder/nginx-proxy/pull/300) for details about fork. See [Let's Encrypt section](#lets-encrypt) for configuration details. 12 | 13 | ### Usage 14 | 15 | To run it: 16 | 17 | $ docker run -d -p 80:80 -v /var/run/docker.sock:/tmp/docker.sock:ro dmp1ce/nginx-proxy-letsencrypt 18 | 19 | Then start any containers you want proxied with an env var `VIRTUAL_HOST=subdomain.youdomain.com` 20 | 21 | $ docker run -e VIRTUAL_HOST=foo.bar.com ... 22 | 23 | The containers being proxied must [expose](https://docs.docker.com/reference/run/#expose-incoming-ports) the port to be proxied, either by using the `EXPOSE` directive in their `Dockerfile` or by using the `--expose` flag to `docker run` or `docker create`. 24 | 25 | Provided your DNS is setup to forward foo.bar.com to the a host running nginx-proxy, the request will be routed to a container with the VIRTUAL_HOST env var set. 26 | 27 | ### Multiple Ports 28 | 29 | 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. 30 | 31 | [1]: https://github.com/jwilder/docker-gen 32 | [2]: http://jasonwilder.com/blog/2014/03/25/automated-nginx-reverse-proxy-for-docker/ 33 | 34 | ### Multiple Hosts 35 | 36 | If you need to support multiple virtual hosts for a container, you can separate each entry with commas. For example, `foo.bar.com,baz.bar.com,bar.com` and each host will be setup the same. 37 | 38 | ### Wildcard Hosts 39 | 40 | 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). 41 | 42 | ### SSL Backends 43 | 44 | If you would like to connect to your backend using HTTPS instead of HTTP, set `VIRTUAL_PROTO=https` on the backend container. 45 | 46 | ### Default Host 47 | 48 | To set the default host for nginx use the env var `DEFAULT_HOST=foo.bar.com` for example 49 | 50 | $ docker run -d -p 80:80 -e DEFAULT_HOST=foo.bar.com -v /var/run/docker.sock:/tmp/docker.sock:ro dmp1ce/nginx-proxy-letsencrypt 51 | 52 | 53 | ### Separate Containers 54 | 55 | nginx-proxy can also be run as two separate containers using the [jwilder/docker-gen](https://index.docker.io/u/jwilder/docker-gen/) 56 | image and the official [nginx](https://registry.hub.docker.com/_/nginx/) image. 57 | 58 | You may want to do this to prevent having the docker socket bound to a publicly exposed container service. 59 | 60 | To run nginx proxy as a separate container you'll need to have [nginx.tmpl](https://github.com/dmp1ce/nginx-proxy-letsencrypt/blob/master/nginx.tmpl) on your host system. 61 | 62 | First start nginx with a volume: 63 | 64 | 65 | $ docker run -d -p 80:80 --name nginx -v /tmp/nginx:/etc/nginx/conf.d -t nginx 66 | 67 | Then start the docker-gen container with the shared volume and template: 68 | 69 | ``` 70 | $ docker run --volumes-from nginx \ 71 | -v /var/run/docker.sock:/tmp/docker.sock:ro \ 72 | -v $(pwd):/etc/docker-gen/templates \ 73 | -t jwilder/docker-gen -notify-sighup nginx -watch -only-exposed /etc/docker-gen/templates/nginx.tmpl /etc/nginx/conf.d/default.conf 74 | ``` 75 | 76 | Finally, start your containers with `VIRTUAL_HOST` environment variables. 77 | 78 | $ docker run -e VIRTUAL_HOST=foo.bar.com ... 79 | 80 | ### SSL Support 81 | 82 | SSL is supported using single host, wildcard and SNI certificates using naming conventions for 83 | certificates or optionally specifying a cert name (for SNI) as an environment variable. 84 | 85 | To enable SSL: 86 | 87 | $ docker run -d -p 80:80 -p 443:443 -v /path/to/certs:/etc/nginx/certs -v /var/run/docker.sock:/tmp/docker.sock:ro dmp1ce/nginx-proxy-letsencrypt 88 | 89 | The contents of `/path/to/certs` should contain the certificates and private keys for any virtual 90 | hosts in use. The certificate and keys should be named after the virtual host with a `.crt` and 91 | `.key` extension. For example, a container with `VIRTUAL_HOST=foo.bar.com` should have a 92 | `foo.bar.com.crt` and `foo.bar.com.key` file in the certs directory. 93 | 94 | #### Diffie-Hellman Groups 95 | 96 | If you have Diffie-Hellman groups enabled, the files should be named after the virtual host with a 97 | `dhparam` suffix and `.pem` extension. For example, a container with `VIRTUAL_HOST=foo.bar.com` 98 | should have a `foo.bar.com.dhparam.pem` file in the certs directory. 99 | 100 | #### Wildcard Certificates 101 | 102 | Wildcard certificates and keys should be named after the domain name with a `.crt` and `.key` extension. 103 | For example `VIRTUAL_HOST=foo.bar.com` would use cert name `bar.com.crt` and `bar.com.key`. 104 | 105 | #### SNI 106 | 107 | If your certificate(s) supports multiple domain names, you can start a container with `CERT_NAME=` 108 | to identify the certificate to be used. For example, a certificate for `*.foo.com` and `*.bar.com` 109 | could be named `shared.crt` and `shared.key`. A container running with `VIRTUAL_HOST=foo.bar.com` 110 | and `CERT_NAME=shared` will then use this shared cert. 111 | 112 | #### How SSL Support Works 113 | 114 | The SSL cipher configuration is based on [mozilla nginx intermediate profile](https://wiki.mozilla.org/Security/Server_Side_TLS#Nginx) which 115 | should provide compatibility with clients back to Firefox 1, Chrome 1, IE 7, Opera 5, Safari 1, 116 | Windows XP IE8, Android 2.3, Java 7. The configuration also enables HSTS, and SSL 117 | session caches. 118 | 119 | The behavior for the proxy when port 80 and 443 are exposed is as follows: 120 | 121 | * If a container has a usable cert, port 80 will redirect to 443 for that container so that HTTPS 122 | is always preferred when available. 123 | * If the container does not have a usable cert, a 503 will be returned. 124 | 125 | Note that in the latter case, a browser may get an connection error as no certificate is available 126 | to establish a connection. A self-signed or generic cert named `default.crt` and `default.key` 127 | will allow a client browser to make a SSL connection (likely w/ a warning) and subsequently receive 128 | a 503. 129 | 130 | #### Let's Encrypt 131 | 132 | Use the Let's Encrypt service to automatically create a valid certificate for a virtual host. 133 | 134 | Set the following environment variables to enable Let's Encrypt support for a container being proxied. 135 | 136 | - `LETSENCRYPT_HOST` 137 | - `LETSENCRYPT_EMAIL` 138 | 139 | The `LETSENCRYPT_HOST` variable most likely needs to be the same as the `VIRTUAL_HOST` variable and must be publicly reachable domains. Specify multiple hosts with a comma delimiter. 140 | 141 | For example 142 | 143 | ``` 144 | $ docker run -d -p 80:80 \ 145 | -e VIRTUAL_HOST="foo.bar.com,bar.com" \ 146 | -e LETSENCRYPT_HOST="foo.bar.com,bar.com" \ 147 | -e LETSENCRYPT_EMAIL="foo@bar.com" ... 148 | ``` 149 | 150 | ##### Optional container environment variables 151 | 152 | Optional nginx-proxy-letsencrypt container environment variables for custom configuration. 153 | 154 | - `ACME_CA_URI` - Directory URI for the CA ACME API endpoint (default: ``https://acme-v01.api.letsencrypt.org/directory``) 155 | 156 | For example 157 | 158 | ``` 159 | $ docker run -d -p 80:80 -p 443:443 \ 160 | -e ACME_CA_URI="https://acme-staging.api.letsencrypt.org/directory" \ 161 | -v /path/to/certs:/etc/nginx/certs \ 162 | -v /var/run/docker.sock:/tmp/docker.sock:ro \ 163 | dmp1ce/nginx-proxy-letsencrypt 164 | ``` 165 | 166 | ### Basic Authentication Support 167 | 168 | In order to be able to secure your virtual host, you have to create a file named as its equivalent VIRTUAL_HOST variable on directory 169 | /etc/nginx/htpasswd/$VIRTUAL_HOST 170 | 171 | ``` 172 | $ docker run -d -p 80:80 -p 443:443 \ 173 | -v /path/to/htpasswd:/etc/nginx/htpasswd \ 174 | -v /path/to/certs:/etc/nginx/certs \ 175 | -v /var/run/docker.sock:/tmp/docker.sock:ro \ 176 | dmp1ce/nginx-proxy-letsencrypt 177 | ``` 178 | 179 | You'll need apache2-utils on the machine where you plan to create the htpasswd file. Follow these [instructions](http://httpd.apache.org/docs/2.2/programs/htpasswd.html) 180 | 181 | ### Custom Nginx Configuration 182 | 183 | If you need to configure Nginx beyond what is possible using environment variables, you can provide custom configuration files on either a proxy-wide or per-`VIRTUAL_HOST` basis. 184 | 185 | #### Replacing default proxy settings 186 | 187 | If you want to replace the default proxy settings for the nginx container, add a configuration file at `/etc/nginx/proxy.conf`. A file with the default settings would 188 | look like this: 189 | 190 | ```Nginx 191 | # HTTP 1.1 support 192 | proxy_http_version 1.1; 193 | proxy_buffering off; 194 | proxy_set_header Host $http_host; 195 | proxy_set_header Upgrade $http_upgrade; 196 | proxy_set_header Connection $proxy_connection; 197 | proxy_set_header X-Real-IP $remote_addr; 198 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 199 | proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; 200 | ``` 201 | 202 | ***NOTE***: If you provide this file it will replace the defaults; you may want to check the .tmpl file to make sure you have all of the needed options. 203 | 204 | #### Proxy-wide 205 | 206 | To add settings on a proxy-wide basis, add your configuration file under `/etc/nginx/conf.d` using a name ending in `.conf`. 207 | 208 | This can be done in a derived image by creating the file in a `RUN` command or by `COPY`ing the file into `conf.d`: 209 | 210 | ```Dockerfile 211 | FROM dmp1ce/nginx-proxy-letsencrypt 212 | RUN { \ 213 | echo 'server_tokens off;'; \ 214 | echo 'client_max_body_size 100m;'; \ 215 | } > /etc/nginx/conf.d/my_proxy.conf 216 | ``` 217 | 218 | Or it can be done by mounting in your custom configuration in your `docker run` command: 219 | 220 | $ docker run -d -p 80:80 -p 443:443 -v /path/to/my_proxy.conf:/etc/nginx/conf.d/my_proxy.conf:ro -v /var/run/docker.sock:/tmp/docker.sock:ro dmp1ce/nginx-proxy-letsencrypt 221 | 222 | #### Per-VIRTUAL_HOST 223 | 224 | To add settings on a per-`VIRTUAL_HOST` basis, add your configuration file under `/etc/nginx/vhost.d`. Unlike in the proxy-wide case, which allows multiple config files with any name ending in `.conf`, the per-`VIRTUAL_HOST` file must be named exactly after the `VIRTUAL_HOST`. 225 | 226 | In order to allow virtual hosts to be dynamically configured as backends are added and removed, it makes the most sense to mount an external directory as `/etc/nginx/vhost.d` as opposed to using derived images or mounting individual configuration files. 227 | 228 | For example, if you have a virtual host named `app.example.com`, you could provide a custom configuration for that host as follows: 229 | 230 | $ 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 dmp1ce/nginx-proxy-letsencrypt 231 | $ { echo 'server_tokens off;'; echo 'client_max_body_size 100m;'; } > /path/to/vhost.d/app.example.com 232 | 233 | If you are using multiple hostnames for a single container (e.g. `VIRTUAL_HOST=example.com,www.example.com`), the virtual host configuration file must exist for each hostname. If you would like to use the same configuration for multiple virtual host names, you can use a symlink: 234 | 235 | $ { echo 'server_tokens off;'; echo 'client_max_body_size 100m;'; } > /path/to/vhost.d/www.example.com 236 | $ ln -s /path/to/vhost.d/www.example.com /path/to/vhost.d/example.com 237 | 238 | #### Per-VIRTUAL_HOST default configuration 239 | 240 | If you want most of your virtual hosts to use a default single configuration and then override on a few specific ones, add those settings to the `/etc/nginx/vhost.d/default` file. This file 241 | will be used on any virtual host which does not have a `/etc/nginx/vhost.d/{VIRTUAL_HOST}` file associated with it. 242 | 243 | #### Per-VIRTUAL_HOST location configuration 244 | 245 | To add settings to the "location" block on a per-`VIRTUAL_HOST` basis, add your configuration file under `/etc/nginx/vhost.d` 246 | just like the previous section except with the suffix `_location`. 247 | 248 | For example, if you have a virtual host named `app.example.com` and you have configured a proxy_cache `my-cache` in another custom file, you could tell it to use a proxy cache as follows: 249 | 250 | $ 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 dmp1ce/nginx-proxy-letsencrypt 251 | $ { 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 252 | 253 | If you are using multiple hostnames for a single container (e.g. `VIRTUAL_HOST=example.com,www.example.com`), the virtual host configuration file must exist for each hostname. If you would like to use the same configuration for multiple virtual host names, you can use a symlink: 254 | 255 | $ { 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 256 | $ ln -s /path/to/vhost.d/www.example.com /path/to/vhost.d/example.com 257 | 258 | #### Per-VIRTUAL_HOST location default configuration 259 | 260 | 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 261 | will be used on any virtual host which does not have a `/etc/nginx/vhost.d/{VIRTUAL_HOST}` file associated with it. 262 | 263 | ### Contributing 264 | 265 | Before submitting pull requests or issues, please check github to make sure an existing issue or pull request is not already open. 266 | 267 | #### Running Tests Locally 268 | 269 | To run tests, you'll need to install [bats 0.4.0](https://github.com/sstephenson/bats). 270 | 271 | make test 272 | 273 | -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | machine: 2 | pre: 3 | # install docker 1.9.1 with some CircleCI suggestions: 4 | # https://discuss.circleci.com/t/how-use-a-different-docker-version/298/8 5 | - sudo curl -L -o /usr/bin/docker 'https://s3-external-1.amazonaws.com/circle-downloads/docker-1.9.1-circleci' 6 | - sudo chmod 0755 /usr/bin/docker 7 | - sudo sed -i -e 's/ --userland-proxy=false//' /etc/default/docker 8 | services: 9 | - docker 10 | 11 | dependencies: 12 | override: 13 | - sudo add-apt-repository ppa:duggan/bats --yes 14 | - sudo apt-get update -qq 15 | - sudo apt-get install -qq bats 16 | - make update-dependencies 17 | 18 | test: 19 | override: 20 | - make test 21 | -------------------------------------------------------------------------------- /install_simp_le.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | apt-get update 6 | 7 | # Install python packages needed by simp_le 8 | apt-get install -y -q --no-install-recommends python python-requests 9 | 10 | # Install python packages needed to build simp_le 11 | apt-get install -y -q --no-install-recommends git gcc libssl-dev libffi-dev python-dev python-pip 12 | 13 | # Get Let's Encrypt simp_le client source 14 | git -C /opt clone https://github.com/kuba/simp_le.git 15 | 16 | cd /opt/simp_le 17 | # Upgrade setuptools 18 | pip install -U setuptools 19 | # Install simp_le in /usr/local/bin 20 | python ./setup.py install 21 | 22 | # Make house cleaning 23 | rm -rf /opt/simp_le 24 | 25 | apt-get autoremove -y git gcc libssl-dev libffi-dev python-dev python-pip 26 | 27 | apt-get clean all 28 | rm -r /var/lib/apt/lists/* 29 | -------------------------------------------------------------------------------- /letsencrypt_service: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" 4 | 5 | seconds_to_wait=3600 6 | acme_ca_uri="${ACME_CA_URI:-https://acme-v01.api.letsencrypt.org/directory}" 7 | 8 | update_certs() { 9 | [[ ! -f "$DIR"/letsencrypt_service_data ]] && return 10 | 11 | # Load relevant container settings 12 | source "$DIR"/letsencrypt_service_data 13 | 14 | reload_nginx='false' 15 | for cid in "${LETSENCRYPT_CONTAINERS[@]}"; do 16 | # Derive host and email variable names 17 | host_varname="LETSENCRYPT_${cid}_HOST" 18 | # Array variable indirection hack: http://stackoverflow.com/a/25880676/350221 19 | hosts_array=$host_varname[@] 20 | email_varname="LETSENCRYPT_${cid}_EMAIL" 21 | 22 | params_d_str="" 23 | hosts_array_expanded=("${!hosts_array}") 24 | # First domain will be our base domain 25 | base_domain="${hosts_array_expanded[0]}" 26 | 27 | # Create directorty for the first domain 28 | mkdir -p /etc/nginx/certs/$base_domain 29 | cd /etc/nginx/certs/$base_domain 30 | 31 | for domain in "${!hosts_array}"; do 32 | # Add all the domains to certificate 33 | params_d_str+=" -d $domain" 34 | done 35 | echo "Creating/renewal $base_domain certificates... (${hosts_array_expanded[*]})" 36 | /usr/local/bin/simp_le \ 37 | -f account_key.json -f key.pem -f fullchain.pem \ 38 | $params_d_str \ 39 | --email "${!email_varname}" \ 40 | --server=$acme_ca_uri \ 41 | --default_root /usr/share/nginx/html/ 42 | 43 | simp_le_return=$? 44 | 45 | if [[ $simp_le_return -eq 0 ]]; then 46 | for domain in "${!hosts_array}"; do 47 | # Symlink all alternative names to base domain certificate 48 | ln -sf ./$base_domain/fullchain.pem /etc/nginx/certs/$domain".crt" 49 | ln -sf ./$base_domain/key.pem /etc/nginx/certs/$domain".key" 50 | done 51 | reload_nginx='true' 52 | fi 53 | done 54 | unset LETSENCRYPT_CONTAINERS 55 | if [[ "$reload_nginx" == 'true' ]]; then 56 | /usr/local/bin/docker-gen -only-exposed /app/nginx.tmpl /etc/nginx/conf.d/default.conf 57 | nginx -s reload 58 | fi 59 | } 60 | 61 | pid= 62 | trap '[[ $pid ]] && kill $pid; exec $0' EXIT 63 | trap 'trap - EXIT' INT TERM 64 | 65 | echo 'Waiting 10s before updating certs...' 66 | sleep 10s 67 | 68 | update_certs 69 | 70 | # Wait some amount of time 71 | echo "Sleep for ${seconds_to_wait}s" 72 | sleep $seconds_to_wait & pid=$! 73 | wait 74 | pid= 75 | -------------------------------------------------------------------------------- /letsencrypt_service_data.tmpl: -------------------------------------------------------------------------------- 1 | LETSENCRYPT_CONTAINERS=({{ range $host, $containers := groupBy $ "Env.LETSENCRYPT_HOST" }}{{ range $container := $containers }} '{{ $container.ID }}' {{ end }}{{ end }}) 2 | 3 | {{ range $hosts, $containers := groupBy $ "Env.LETSENCRYPT_HOST" }} 4 | 5 | {{ range $container := $containers }} 6 | LETSENCRYPT_{{ $container.ID }}_HOST=( {{ range $host := split $hosts "," }}'{{ $host }}' {{ end }}) 7 | LETSENCRYPT_{{ $container.ID }}_EMAIL="{{ $container.Env.LETSENCRYPT_EMAIL }}" 8 | {{ end }} 9 | 10 | {{ end }} 11 | -------------------------------------------------------------------------------- /nginx.tmpl: -------------------------------------------------------------------------------- 1 | {{ define "upstream" }} 2 | {{ if .Address }} 3 | {{/* If we got the containers from swarm and this container's port is published to host, use host IP:PORT */}} 4 | {{ if and .Container.Node.ID .Address.HostPort }} 5 | # {{ .Container.Node.Name }}/{{ .Container.Name }} 6 | server {{ .Container.Node.Address.IP }}:{{ .Address.HostPort }}; 7 | {{/* If there is no swarm node or the port is not published on host, use container's IP:PORT */}} 8 | {{ else }} 9 | # {{ .Container.Name }} 10 | server {{ .Address.IP }}:{{ .Address.Port }}; 11 | {{ end }} 12 | {{ else }} 13 | # {{ .Container.Name }} 14 | server {{ .Container.IP }} down; 15 | {{ end }} 16 | {{ end }} 17 | 18 | # If we receive X-Forwarded-Proto, pass it through; otherwise, pass along the 19 | # scheme used to connect to this server 20 | map $http_x_forwarded_proto $proxy_x_forwarded_proto { 21 | default $http_x_forwarded_proto; 22 | '' $scheme; 23 | } 24 | 25 | # If we receive Upgrade, set Connection to "upgrade"; otherwise, delete any 26 | # Connection header that may have been passed to this server 27 | map $http_upgrade $proxy_connection { 28 | default upgrade; 29 | '' close; 30 | } 31 | 32 | gzip_types text/plain text/css application/javascript application/json application/x-javascript text/xml application/xml application/xml+rss text/javascript; 33 | 34 | log_format vhost '$host $remote_addr - $remote_user [$time_local] ' 35 | '"$request" $status $body_bytes_sent ' 36 | '"$http_referer" "$http_user_agent"'; 37 | 38 | access_log off; 39 | 40 | {{ if (exists "/etc/nginx/proxy.conf") }} 41 | include /etc/nginx/proxy.conf; 42 | {{ else }} 43 | # HTTP 1.1 support 44 | proxy_http_version 1.1; 45 | proxy_buffering off; 46 | proxy_set_header Host $http_host; 47 | proxy_set_header Upgrade $http_upgrade; 48 | proxy_set_header Connection $proxy_connection; 49 | proxy_set_header X-Real-IP $remote_addr; 50 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 51 | proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; 52 | {{ end }} 53 | 54 | server { 55 | server_name _; # This is just an invalid value which will never trigger on a real hostname. 56 | listen 80; 57 | access_log /var/log/nginx/access.log vhost; 58 | return 503; 59 | } 60 | 61 | {{ if (and (exists "/etc/nginx/certs/default.crt") (exists "/etc/nginx/certs/default.key")) }} 62 | server { 63 | server_name _; # This is just an invalid value which will never trigger on a real hostname. 64 | listen 443 ssl http2; 65 | access_log /var/log/nginx/access.log vhost; 66 | return 503; 67 | 68 | ssl_certificate /etc/nginx/certs/default.crt; 69 | ssl_certificate_key /etc/nginx/certs/default.key; 70 | } 71 | {{ end }} 72 | 73 | {{ range $host, $containers := groupByMulti $ "Env.VIRTUAL_HOST" "," }} 74 | 75 | upstream {{ $host }} { 76 | {{ range $container := $containers }} 77 | {{ $addrLen := len $container.Addresses }} 78 | {{/* If only 1 port exposed, use that */}} 79 | {{ if eq $addrLen 1 }} 80 | {{ $address := index $container.Addresses 0 }} 81 | {{ template "upstream" (dict "Container" $container "Address" $address) }} 82 | {{/* If more than one port exposed, use the one matching VIRTUAL_PORT env var, falling back to standard web port 80 */}} 83 | {{ else }} 84 | {{ $port := coalesce $container.Env.VIRTUAL_PORT "80" }} 85 | {{ $address := where $container.Addresses "Port" $port | first }} 86 | {{ template "upstream" (dict "Container" $container "Address" $address) }} 87 | {{ end }} 88 | {{ end }} 89 | } 90 | 91 | {{ $default_host := or ($.Env.DEFAULT_HOST) "" }} 92 | {{ $default_server := index (dict $host "" $default_host "default_server") $host }} 93 | 94 | {{/* Get the VIRTUAL_PROTO defined by containers w/ the same vhost, falling back to "http" */}} 95 | {{ $proto := or (first (groupByKeys $containers "Env.VIRTUAL_PROTO")) "http" }} 96 | 97 | {{/* Get the first cert name defined by containers w/ the same vhost */}} 98 | {{ $certName := (first (groupByKeys $containers "Env.CERT_NAME")) }} 99 | 100 | {{/* Get the best matching cert by name for the vhost. */}} 101 | {{ $vhostCert := (closest (dir "/etc/nginx/certs") (printf "%s.crt" $host))}} 102 | 103 | {{/* vhostCert is actually a filename so remove any suffixes since they are added later */}} 104 | {{ $vhostCert := replace $vhostCert ".crt" "" -1 }} 105 | {{ $vhostCert := replace $vhostCert ".key" "" -1 }} 106 | 107 | {{/* Use the cert specifid on the container or fallback to the best vhost match */}} 108 | {{ $cert := (coalesce $certName $vhostCert) }} 109 | 110 | {{ if (and (ne $cert "") (exists (printf "/etc/nginx/certs/%s.crt" $cert)) (exists (printf "/etc/nginx/certs/%s.key" $cert))) }} 111 | 112 | server { 113 | server_name {{ $host }}; 114 | listen 80 {{ $default_server }}; 115 | access_log /var/log/nginx/access.log vhost; 116 | return 301 https://$host$request_uri; 117 | } 118 | 119 | server { 120 | server_name {{ $host }}; 121 | listen 443 ssl http2 {{ $default_server }}; 122 | access_log /var/log/nginx/access.log vhost; 123 | 124 | ssl_protocols TLSv1 TLSv1.1 TLSv1.2; 125 | ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-DSS-AES128-GCM-SHA256:kEDH+AESGCM:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA:ECDHE-ECDSA-AES256-SHA:DHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA:DHE-DSS-AES128-SHA256:DHE-RSA-AES256-SHA256:DHE-DSS-AES256-SHA:DHE-RSA-AES256-SHA:AES128-GCM-SHA256:AES256-GCM-SHA384:AES128-SHA256:AES256-SHA256:AES128-SHA:AES256-SHA:AES:CAMELLIA:DES-CBC3-SHA:!aNULL:!eNULL:!EXPORT:!DES:!RC4:!MD5:!PSK:!aECDH:!EDH-DSS-DES-CBC3-SHA:!EDH-RSA-DES-CBC3-SHA:!KRB5-DES-CBC3-SHA; 126 | 127 | ssl_prefer_server_ciphers on; 128 | ssl_session_timeout 5m; 129 | ssl_session_cache shared:SSL:50m; 130 | 131 | ssl_certificate /etc/nginx/certs/{{ (printf "%s.crt" $cert) }}; 132 | ssl_certificate_key /etc/nginx/certs/{{ (printf "%s.key" $cert) }}; 133 | 134 | {{ if (exists (printf "/etc/nginx/certs/%s.dhparam.pem" $cert)) }} 135 | ssl_dhparam {{ printf "/etc/nginx/certs/%s.dhparam.pem" $cert }}; 136 | {{ end }} 137 | 138 | add_header Strict-Transport-Security "max-age=31536000"; 139 | 140 | {{ if (exists (printf "/etc/nginx/vhost.d/%s" $host)) }} 141 | include {{ printf "/etc/nginx/vhost.d/%s" $host }}; 142 | {{ else if (exists "/etc/nginx/vhost.d/default") }} 143 | include /etc/nginx/vhost.d/default; 144 | {{ end }} 145 | 146 | location /.well-known/ { 147 | root /usr/share/nginx/html; 148 | try_files $uri @proxy_pass; 149 | } 150 | 151 | # Redirect hack. See: http://stackoverflow.com/a/20694873/350221 152 | location / { 153 | error_page 418 = @proxy_pass; return 418; 154 | } 155 | 156 | location @proxy_pass { 157 | proxy_pass {{ trim $proto }}://{{ trim $host }}; 158 | {{ if (exists (printf "/etc/nginx/htpasswd/%s" $host)) }} 159 | auth_basic "Restricted {{ $host }}"; 160 | auth_basic_user_file {{ (printf "/etc/nginx/htpasswd/%s" $host) }}; 161 | {{ end }} 162 | {{ if (exists (printf "/etc/nginx/vhost.d/%s_location" $host)) }} 163 | include {{ printf "/etc/nginx/vhost.d/%s_location" $host}}; 164 | {{ else if (exists "/etc/nginx/vhost.d/default_location") }} 165 | include /etc/nginx/vhost.d/default_location; 166 | {{ end }} 167 | } 168 | } 169 | {{ else }} 170 | 171 | server { 172 | server_name {{ $host }}; 173 | listen 80 {{ $default_server }}; 174 | access_log /var/log/nginx/access.log vhost; 175 | 176 | {{ if (exists (printf "/etc/nginx/vhost.d/%s" $host)) }} 177 | include {{ printf "/etc/nginx/vhost.d/%s" $host }}; 178 | {{ else if (exists "/etc/nginx/vhost.d/default") }} 179 | include /etc/nginx/vhost.d/default; 180 | {{ end }} 181 | 182 | location /.well-known/ { 183 | root /usr/share/nginx/html; 184 | try_files $uri @proxy_pass; 185 | } 186 | 187 | # Redirect hack. See: http://stackoverflow.com/a/20694873/350221 188 | location / { 189 | error_page 418 = @proxy_pass; return 418; 190 | } 191 | 192 | location @proxy_pass { 193 | proxy_pass {{ trim $proto }}://{{ trim $host }}; 194 | {{ if (exists (printf "/etc/nginx/htpasswd/%s" $host)) }} 195 | auth_basic "Restricted {{ $host }}"; 196 | auth_basic_user_file {{ (printf "/etc/nginx/htpasswd/%s" $host) }}; 197 | {{ end }} 198 | {{ if (exists (printf "/etc/nginx/vhost.d/%s_location" $host)) }} 199 | include {{ printf "/etc/nginx/vhost.d/%s_location" $host}}; 200 | {{ else if (exists "/etc/nginx/vhost.d/default_location") }} 201 | include /etc/nginx/vhost.d/default_location; 202 | {{ end }} 203 | } 204 | } 205 | 206 | {{ if (and (exists "/etc/nginx/certs/default.crt") (exists "/etc/nginx/certs/default.key")) }} 207 | server { 208 | server_name {{ $host }}; 209 | listen 443 ssl http2 {{ $default_server }}; 210 | access_log /var/log/nginx/access.log vhost; 211 | return 503; 212 | 213 | ssl_certificate /etc/nginx/certs/default.crt; 214 | ssl_certificate_key /etc/nginx/certs/default.key; 215 | } 216 | {{ end }} 217 | 218 | {{ end }} 219 | {{ end }} 220 | -------------------------------------------------------------------------------- /test/README.md: -------------------------------------------------------------------------------- 1 | Test suite 2 | ========== 3 | 4 | This test suite is implemented on top of the [Bats](https://github.com/sstephenson/bats/blob/master/README.md) test framework. 5 | 6 | It is intended to verify the correct behavior of the Docker image `dmp1ce/nginx-proxy-letsencrypt:bats`. 7 | 8 | Running the test suite 9 | ---------------------- 10 | 11 | Make sure you have Bats installed, then run: 12 | 13 | docker build -t jwilder/nginx-proxy-letsencrypt:bats . 14 | bats test/ 15 | -------------------------------------------------------------------------------- /test/cleanup_test_containers.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Remove "bats-*" containers 4 | function teardown { 5 | docker rm -f $(docker ps -aq -f name=bats-*) 6 | } 7 | 8 | teardown 9 | -------------------------------------------------------------------------------- /test/default-host.bats: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bats 2 | load test_helpers 3 | 4 | function setup { 5 | # make sure to stop any web container before each test so we don't 6 | # have any unexpected contaiener running with VIRTUAL_HOST or VIRUTAL_PORT set 7 | CIDS=( $(docker ps -q --filter "label=bats-type=web") ) 8 | if [ ${#CIDS[@]} -gt 0 ]; then 9 | docker stop ${CIDS[@]} >&2 10 | fi 11 | } 12 | 13 | 14 | @test "[$TEST_FILE] DEFAULT_HOST=web1.bats" { 15 | SUT_CONTAINER=bats-nginx-proxy-${TEST_FILE}-1 16 | 17 | # GIVEN a webserver with VIRTUAL_HOST set to web.bats 18 | prepare_web_container bats-web 80 -e VIRTUAL_HOST=web.bats 19 | 20 | # WHEN nginx-proxy runs with DEFAULT_HOST set to web.bats 21 | run nginxproxy $SUT_CONTAINER -v /var/run/docker.sock:/tmp/docker.sock:ro -e DEFAULT_HOST=web.bats 22 | assert_success 23 | docker_wait_for_log $SUT_CONTAINER 3 "Watching docker events" 24 | 25 | # THEN querying the proxy without Host header → 200 26 | run curl_container $SUT_CONTAINER / --head 27 | assert_output -l 0 $'HTTP/1.1 200 OK\r' 28 | 29 | # THEN querying the proxy with any other Host header → 200 30 | run curl_container $SUT_CONTAINER / --head --header "Host: something.I.just.made.up" 31 | assert_output -l 0 $'HTTP/1.1 200 OK\r' 32 | } 33 | -------------------------------------------------------------------------------- /test/docker.bats: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bats 2 | load test_helpers 3 | 4 | 5 | @test "[$TEST_FILE] start 2 web containers" { 6 | prepare_web_container bats-web1 81 -e VIRTUAL_HOST=web1.bats 7 | prepare_web_container bats-web2 82 -e VIRTUAL_HOST=web2.bats 8 | } 9 | 10 | 11 | @test "[$TEST_FILE] -v /var/run/docker.sock:/tmp/docker.sock:ro" { 12 | SUT_CONTAINER=bats-nginx-proxy-${TEST_FILE}-1 13 | 14 | # WHEN nginx-proxy runs on our docker host using the default unix socket 15 | run nginxproxy $SUT_CONTAINER -v /var/run/docker.sock:/tmp/docker.sock:ro 16 | assert_success 17 | docker_wait_for_log $SUT_CONTAINER 3 "Watching docker events" 18 | 19 | # THEN 20 | assert_nginxproxy_behaves $SUT_CONTAINER 21 | } 22 | 23 | 24 | @test "[$TEST_FILE] -v /var/run/docker.sock:/f00.sock:ro -e DOCKER_HOST=unix:///f00.sock" { 25 | SUT_CONTAINER=bats-nginx-proxy-${TEST_FILE}-2 26 | 27 | # WHEN nginx-proxy runs on our docker host using a custom unix socket 28 | run nginxproxy $SUT_CONTAINER -v /var/run/docker.sock:/f00.sock:ro -e DOCKER_HOST=unix:///f00.sock 29 | assert_success 30 | docker_wait_for_log $SUT_CONTAINER 3 "Watching docker events" 31 | 32 | # THEN 33 | assert_nginxproxy_behaves $SUT_CONTAINER 34 | } 35 | 36 | 37 | @test "[$TEST_FILE] -e DOCKER_HOST=tcp://..." { 38 | SUT_CONTAINER=bats-nginx-proxy-${TEST_FILE}-3 39 | # GIVEN a container exposing our docker host over TCP 40 | run docker_tcp bats-docker-tcp 41 | assert_success 42 | sleep 1s 43 | 44 | # WHEN nginx-proxy runs on our docker host using tcp to connect to our docker host 45 | run nginxproxy $SUT_CONTAINER -e DOCKER_HOST="tcp://bats-docker-tcp:2375" --link bats-docker-tcp:bats-docker-tcp 46 | assert_success 47 | docker_wait_for_log $SUT_CONTAINER 3 "Watching docker events" 48 | 49 | # THEN 50 | assert_nginxproxy_behaves $SUT_CONTAINER 51 | } 52 | 53 | 54 | @test "[$TEST_FILE] separated containers (nginx + docker-gen + nginx.tmpl)" { 55 | docker_clean bats-nginx 56 | docker_clean bats-docker-gen 57 | 58 | # GIVEN a simple nginx container 59 | run docker run -d \ 60 | --name bats-nginx \ 61 | -v /etc/nginx/conf.d/ \ 62 | -v /etc/nginx/certs/ \ 63 | nginx:latest 64 | assert_success 65 | run retry 5 1s docker run --rm appropriate/curl --silent --fail --head http://$(docker_ip bats-nginx)/ 66 | assert_output -l 0 $'HTTP/1.1 200 OK\r' 67 | 68 | # WHEN docker-gen runs on our docker host 69 | run docker run -d \ 70 | --name bats-docker-gen \ 71 | -v /var/run/docker.sock:/tmp/docker.sock:ro \ 72 | -v $BATS_TEST_DIRNAME/../nginx.tmpl:/etc/docker-gen/templates/nginx.tmpl:ro \ 73 | --volumes-from bats-nginx \ 74 | jwilder/docker-gen:latest \ 75 | -notify-sighup bats-nginx \ 76 | -watch \ 77 | -only-exposed \ 78 | /etc/docker-gen/templates/nginx.tmpl \ 79 | /etc/nginx/conf.d/default.conf 80 | assert_success 81 | docker_wait_for_log bats-docker-gen 6 "Watching docker events" 82 | 83 | # Give some time to the docker-gen container to notify bats-nginx so it 84 | # reloads its config 85 | sleep 2s 86 | 87 | run docker_running_state bats-nginx 88 | assert_output "true" || { 89 | docker logs bats-docker-gen 90 | false 91 | } >&2 92 | 93 | # THEN 94 | assert_nginxproxy_behaves bats-nginx 95 | } 96 | 97 | 98 | # $1 nginx-proxy container 99 | function assert_nginxproxy_behaves { 100 | local -r container=$1 101 | 102 | # Querying the proxy without Host header → 503 103 | run curl_container $container / --head 104 | assert_output -l 0 $'HTTP/1.1 503 Service Temporarily Unavailable\r' 105 | 106 | # Querying the proxy with Host header → 200 107 | run curl_container $container /data --header "Host: web1.bats" 108 | assert_output -l 0 "answer from port 81" 109 | 110 | run curl_container $container /data --header "Host: web2.bats" 111 | assert_output -l 0 "answer from port 82" 112 | 113 | # Querying the proxy with unknown Host header → 503 114 | run curl_container $container /data --header "Host: webFOO.bats" --head 115 | assert_output -l 0 $'HTTP/1.1 503 Service Temporarily Unavailable\r' 116 | } 117 | -------------------------------------------------------------------------------- /test/lib/README.md: -------------------------------------------------------------------------------- 1 | bats lib 2 | ======== 3 | 4 | found on https://github.com/sstephenson/bats/pull/110 5 | 6 | When that pull request will be merged, the `test/lib/bats` won't be necessary anymore. -------------------------------------------------------------------------------- /test/lib/bats/batslib.bash: -------------------------------------------------------------------------------- 1 | # 2 | # batslib.bash 3 | # ------------ 4 | # 5 | # The Standard Library is a collection of test helpers intended to 6 | # simplify testing. It contains the following types of test helpers. 7 | # 8 | # - Assertions are functions that perform a test and output relevant 9 | # information on failure to help debugging. They return 1 on failure 10 | # and 0 otherwise. 11 | # 12 | # All output is formatted for readability using the functions of 13 | # `output.bash' and sent to the standard error. 14 | # 15 | 16 | source "${BATS_LIB}/batslib/output.bash" 17 | 18 | 19 | ######################################################################## 20 | # ASSERTIONS 21 | ######################################################################## 22 | 23 | # Fail and display a message. When no parameters are specified, the 24 | # message is read from the standard input. Other functions use this to 25 | # report failure. 26 | # 27 | # Globals: 28 | # none 29 | # Arguments: 30 | # $@ - [=STDIN] message 31 | # Returns: 32 | # 1 - always 33 | # Inputs: 34 | # STDIN - [=$@] message 35 | # Outputs: 36 | # STDERR - message 37 | fail() { 38 | (( $# == 0 )) && batslib_err || batslib_err "$@" 39 | return 1 40 | } 41 | 42 | # Fail and display details if the expression evaluates to false. Details 43 | # include the expression, `$status' and `$output'. 44 | # 45 | # NOTE: The expression must be a simple command. Compound commands, such 46 | # as `[[', can be used only when executed with `bash -c'. 47 | # 48 | # Globals: 49 | # status 50 | # output 51 | # Arguments: 52 | # $1 - expression 53 | # Returns: 54 | # 0 - expression evaluates to TRUE 55 | # 1 - otherwise 56 | # Outputs: 57 | # STDERR - details, on failure 58 | assert() { 59 | if ! "$@"; then 60 | { local -ar single=( 61 | 'expression' "$*" 62 | 'status' "$status" 63 | ) 64 | local -ar may_be_multi=( 65 | 'output' "$output" 66 | ) 67 | local -ir width="$( batslib_get_max_single_line_key_width \ 68 | "${single[@]}" "${may_be_multi[@]}" )" 69 | batslib_print_kv_single "$width" "${single[@]}" 70 | batslib_print_kv_single_or_multi "$width" "${may_be_multi[@]}" 71 | } | batslib_decorate 'assertion failed' \ 72 | | fail 73 | fi 74 | } 75 | 76 | # Fail and display details if the expected and actual values do not 77 | # equal. Details include both values. 78 | # 79 | # Globals: 80 | # none 81 | # Arguments: 82 | # $1 - actual value 83 | # $2 - expected value 84 | # Returns: 85 | # 0 - values equal 86 | # 1 - otherwise 87 | # Outputs: 88 | # STDERR - details, on failure 89 | assert_equal() { 90 | if [[ $1 != "$2" ]]; then 91 | batslib_print_kv_single_or_multi 8 \ 92 | 'expected' "$2" \ 93 | 'actual' "$1" \ 94 | | batslib_decorate 'values do not equal' \ 95 | | fail 96 | fi 97 | } 98 | 99 | # Fail and display details if `$status' is not 0. Details include 100 | # `$status' and `$output'. 101 | # 102 | # Globals: 103 | # status 104 | # output 105 | # Arguments: 106 | # none 107 | # Returns: 108 | # 0 - `$status' is 0 109 | # 1 - otherwise 110 | # Outputs: 111 | # STDERR - details, on failure 112 | assert_success() { 113 | if (( status != 0 )); then 114 | { local -ir width=6 115 | batslib_print_kv_single "$width" 'status' "$status" 116 | batslib_print_kv_single_or_multi "$width" 'output' "$output" 117 | } | batslib_decorate 'command failed' \ 118 | | fail 119 | fi 120 | } 121 | 122 | # Fail and display details if `$status' is 0. Details include `$output'. 123 | # 124 | # Optionally, when the expected status is specified, fail when it does 125 | # not equal `$status'. In this case, details include the expected and 126 | # actual status, and `$output'. 127 | # 128 | # Globals: 129 | # status 130 | # output 131 | # Arguments: 132 | # $1 - [opt] expected status 133 | # Returns: 134 | # 0 - `$status' is not 0, or 135 | # `$status' equals the expected status 136 | # 1 - otherwise 137 | # Outputs: 138 | # STDERR - details, on failure 139 | assert_failure() { 140 | (( $# > 0 )) && local -r expected="$1" 141 | if (( status == 0 )); then 142 | batslib_print_kv_single_or_multi 6 'output' "$output" \ 143 | | batslib_decorate 'command succeeded, but it was expected to fail' \ 144 | | fail 145 | elif (( $# > 0 )) && (( status != expected )); then 146 | { local -ir width=8 147 | batslib_print_kv_single "$width" \ 148 | 'expected' "$expected" \ 149 | 'actual' "$status" 150 | batslib_print_kv_single_or_multi "$width" \ 151 | 'output' "$output" 152 | } | batslib_decorate 'command failed as expected, but status differs' \ 153 | | fail 154 | fi 155 | } 156 | 157 | # Fail and display details if the expected does not match the actual 158 | # output or a fragment of it. 159 | # 160 | # By default, the entire output is matched. The assertion fails if the 161 | # expected output does not equal `$output'. Details include both values. 162 | # 163 | # When `-l ' is used, only the -th line is matched. The 164 | # assertion fails if the expected line does not equal 165 | # `${lines[}'. Details include the compared lines and . 166 | # 167 | # When `-l' is used without the argument, the output is searched 168 | # for the expected line. The expected line is matched against each line 169 | # in `${lines[@]}'. If no match is found the assertion fails. Details 170 | # include the expected line and `$output'. 171 | # 172 | # By default, literal matching is performed. Options `-p' and `-r' 173 | # enable partial (i.e. substring) and extended regular expression 174 | # matching, respectively. Specifying an invalid extended regular 175 | # expression with `-r' displays an error. 176 | # 177 | # Options `-p' and `-r' are mutually exclusive. When used 178 | # simultaneously, an error is displayed. 179 | # 180 | # Globals: 181 | # output 182 | # lines 183 | # Options: 184 | # -l - match against the -th element of `${lines[@]}' 185 | # -l - search `${lines[@]}' for the expected line 186 | # -p - partial matching 187 | # -r - extended regular expression matching 188 | # Arguments: 189 | # $1 - expected output 190 | # Returns: 191 | # 0 - expected matches the actual output 192 | # 1 - otherwise 193 | # Outputs: 194 | # STDERR - details, on failure 195 | # error message, on error 196 | assert_output() { 197 | local -i is_match_line=0 198 | local -i is_match_contained=0 199 | local -i is_mode_partial=0 200 | local -i is_mode_regex=0 201 | 202 | # Handle options. 203 | while (( $# > 0 )); do 204 | case "$1" in 205 | -l) 206 | if (( $# > 2 )) && [[ $2 =~ ^([0-9]|[1-9][0-9]+)$ ]]; then 207 | is_match_line=1 208 | local -ri idx="$2" 209 | shift 210 | else 211 | is_match_contained=1; 212 | fi 213 | shift 214 | ;; 215 | -p) is_mode_partial=1; shift ;; 216 | -r) is_mode_regex=1; shift ;; 217 | --) break ;; 218 | *) break ;; 219 | esac 220 | done 221 | 222 | if (( is_match_line )) && (( is_match_contained )); then 223 | echo "\`-l' and \`-l ' are mutually exclusive" \ 224 | | batslib_decorate 'ERROR: assert_output' \ 225 | | fail 226 | return $? 227 | fi 228 | 229 | if (( is_mode_partial )) && (( is_mode_regex )); then 230 | echo "\`-p' and \`-r' are mutually exclusive" \ 231 | | batslib_decorate 'ERROR: assert_output' \ 232 | | fail 233 | return $? 234 | fi 235 | 236 | # Arguments. 237 | local -r expected="$1" 238 | 239 | if (( is_mode_regex == 1 )) && [[ '' =~ $expected ]] || (( $? == 2 )); then 240 | echo "Invalid extended regular expression: \`$expected'" \ 241 | | batslib_decorate 'ERROR: assert_output' \ 242 | | fail 243 | return $? 244 | fi 245 | 246 | # Matching. 247 | if (( is_match_contained )); then 248 | # Line contained in output. 249 | if (( is_mode_regex )); then 250 | local -i idx 251 | for (( idx = 0; idx < ${#lines[@]}; ++idx )); do 252 | [[ ${lines[$idx]} =~ $expected ]] && return 0 253 | done 254 | { local -ar single=( 255 | 'regex' "$expected" 256 | ) 257 | local -ar may_be_multi=( 258 | 'output' "$output" 259 | ) 260 | local -ir width="$( batslib_get_max_single_line_key_width \ 261 | "${single[@]}" "${may_be_multi[@]}" )" 262 | batslib_print_kv_single "$width" "${single[@]}" 263 | batslib_print_kv_single_or_multi "$width" "${may_be_multi[@]}" 264 | } | batslib_decorate 'no output line matches regular expression' \ 265 | | fail 266 | elif (( is_mode_partial )); then 267 | local -i idx 268 | for (( idx = 0; idx < ${#lines[@]}; ++idx )); do 269 | [[ ${lines[$idx]} == *"$expected"* ]] && return 0 270 | done 271 | { local -ar single=( 272 | 'substring' "$expected" 273 | ) 274 | local -ar may_be_multi=( 275 | 'output' "$output" 276 | ) 277 | local -ir width="$( batslib_get_max_single_line_key_width \ 278 | "${single[@]}" "${may_be_multi[@]}" )" 279 | batslib_print_kv_single "$width" "${single[@]}" 280 | batslib_print_kv_single_or_multi "$width" "${may_be_multi[@]}" 281 | } | batslib_decorate 'no output line contains substring' \ 282 | | fail 283 | else 284 | local -i idx 285 | for (( idx = 0; idx < ${#lines[@]}; ++idx )); do 286 | [[ ${lines[$idx]} == "$expected" ]] && return 0 287 | done 288 | { local -ar single=( 289 | 'line' "$expected" 290 | ) 291 | local -ar may_be_multi=( 292 | 'output' "$output" 293 | ) 294 | local -ir width="$( batslib_get_max_single_line_key_width \ 295 | "${single[@]}" "${may_be_multi[@]}" )" 296 | batslib_print_kv_single "$width" "${single[@]}" 297 | batslib_print_kv_single_or_multi "$width" "${may_be_multi[@]}" 298 | } | batslib_decorate 'output does not contain line' \ 299 | | fail 300 | fi 301 | elif (( is_match_line )); then 302 | # Specific line. 303 | if (( is_mode_regex )); then 304 | if ! [[ ${lines[$idx]} =~ $expected ]]; then 305 | batslib_print_kv_single 5 \ 306 | 'index' "$idx" \ 307 | 'regex' "$expected" \ 308 | 'line' "${lines[$idx]}" \ 309 | | batslib_decorate 'regular expression does not match line' \ 310 | | fail 311 | fi 312 | elif (( is_mode_partial )); then 313 | if [[ ${lines[$idx]} != *"$expected"* ]]; then 314 | batslib_print_kv_single 9 \ 315 | 'index' "$idx" \ 316 | 'substring' "$expected" \ 317 | 'line' "${lines[$idx]}" \ 318 | | batslib_decorate 'line does not contain substring' \ 319 | | fail 320 | fi 321 | else 322 | if [[ ${lines[$idx]} != "$expected" ]]; then 323 | batslib_print_kv_single 8 \ 324 | 'index' "$idx" \ 325 | 'expected' "$expected" \ 326 | 'actual' "${lines[$idx]}" \ 327 | | batslib_decorate 'line differs' \ 328 | | fail 329 | fi 330 | fi 331 | else 332 | # Entire output. 333 | if (( is_mode_regex )); then 334 | if ! [[ $output =~ $expected ]]; then 335 | batslib_print_kv_single_or_multi 6 \ 336 | 'regex' "$expected" \ 337 | 'output' "$output" \ 338 | | batslib_decorate 'regular expression does not match output' \ 339 | | fail 340 | fi 341 | elif (( is_mode_partial )); then 342 | if [[ $output != *"$expected"* ]]; then 343 | batslib_print_kv_single_or_multi 9 \ 344 | 'substring' "$expected" \ 345 | 'output' "$output" \ 346 | | batslib_decorate 'output does not contain substring' \ 347 | | fail 348 | fi 349 | else 350 | if [[ $output != "$expected" ]]; then 351 | batslib_print_kv_single_or_multi 8 \ 352 | 'expected' "$expected" \ 353 | 'actual' "$output" \ 354 | | batslib_decorate 'output differs' \ 355 | | fail 356 | fi 357 | fi 358 | fi 359 | } 360 | 361 | # Fail and display details if the unexpected matches the actual output 362 | # or a fragment of it. 363 | # 364 | # By default, the entire output is matched. The assertion fails if the 365 | # unexpected output equals `$output'. Details include `$output'. 366 | # 367 | # When `-l ' is used, only the -th line is matched. The 368 | # assertion fails if the unexpected line equals `${lines[}'. 369 | # Details include the compared line and . 370 | # 371 | # When `-l' is used without the argument, the output is searched 372 | # for the unexpected line. The unexpected line is matched against each 373 | # line in `${lines[]}'. If a match is found the assertion fails. 374 | # Details include the unexpected line, the index where it was found and 375 | # `$output' (with the unexpected line highlighted in it if `$output` is 376 | # longer than one line). 377 | # 378 | # By default, literal matching is performed. Options `-p' and `-r' 379 | # enable partial (i.e. substring) and extended regular expression 380 | # matching, respectively. On failure, the substring or the regular 381 | # expression is added to the details (if not already displayed). 382 | # Specifying an invalid extended regular expression with `-r' displays 383 | # an error. 384 | # 385 | # Options `-p' and `-r' are mutually exclusive. When used 386 | # simultaneously, an error is displayed. 387 | # 388 | # Globals: 389 | # output 390 | # lines 391 | # Options: 392 | # -l - match against the -th element of `${lines[@]}' 393 | # -l - search `${lines[@]}' for the unexpected line 394 | # -p - partial matching 395 | # -r - extended regular expression matching 396 | # Arguments: 397 | # $1 - unexpected output 398 | # Returns: 399 | # 0 - unexpected matches the actual output 400 | # 1 - otherwise 401 | # Outputs: 402 | # STDERR - details, on failure 403 | # error message, on error 404 | refute_output() { 405 | local -i is_match_line=0 406 | local -i is_match_contained=0 407 | local -i is_mode_partial=0 408 | local -i is_mode_regex=0 409 | 410 | # Handle options. 411 | while (( $# > 0 )); do 412 | case "$1" in 413 | -l) 414 | if (( $# > 2 )) && [[ $2 =~ ^([0-9]|[1-9][0-9]+)$ ]]; then 415 | is_match_line=1 416 | local -ri idx="$2" 417 | shift 418 | else 419 | is_match_contained=1; 420 | fi 421 | shift 422 | ;; 423 | -L) is_match_contained=1; shift ;; 424 | -p) is_mode_partial=1; shift ;; 425 | -r) is_mode_regex=1; shift ;; 426 | --) break ;; 427 | *) break ;; 428 | esac 429 | done 430 | 431 | if (( is_match_line )) && (( is_match_contained )); then 432 | echo "\`-l' and \`-l ' are mutually exclusive" \ 433 | | batslib_decorate 'ERROR: refute_output' \ 434 | | fail 435 | return $? 436 | fi 437 | 438 | if (( is_mode_partial )) && (( is_mode_regex )); then 439 | echo "\`-p' and \`-r' are mutually exclusive" \ 440 | | batslib_decorate 'ERROR: refute_output' \ 441 | | fail 442 | return $? 443 | fi 444 | 445 | # Arguments. 446 | local -r unexpected="$1" 447 | 448 | if (( is_mode_regex == 1 )) && [[ '' =~ $unexpected ]] || (( $? == 2 )); then 449 | echo "Invalid extended regular expression: \`$unexpected'" \ 450 | | batslib_decorate 'ERROR: refute_output' \ 451 | | fail 452 | return $? 453 | fi 454 | 455 | # Matching. 456 | if (( is_match_contained )); then 457 | # Line contained in output. 458 | if (( is_mode_regex )); then 459 | local -i idx 460 | for (( idx = 0; idx < ${#lines[@]}; ++idx )); do 461 | if [[ ${lines[$idx]} =~ $unexpected ]]; then 462 | { local -ar single=( 463 | 'regex' "$unexpected" 464 | 'index' "$idx" 465 | ) 466 | local -a may_be_multi=( 467 | 'output' "$output" 468 | ) 469 | local -ir width="$( batslib_get_max_single_line_key_width \ 470 | "${single[@]}" "${may_be_multi[@]}" )" 471 | batslib_print_kv_single "$width" "${single[@]}" 472 | if batslib_is_single_line "${may_be_multi[1]}"; then 473 | batslib_print_kv_single "$width" "${may_be_multi[@]}" 474 | else 475 | may_be_multi[1]="$( printf '%s' "${may_be_multi[1]}" \ 476 | | batslib_prefix \ 477 | | batslib_mark '>' "$idx" )" 478 | batslib_print_kv_multi "${may_be_multi[@]}" 479 | fi 480 | } | batslib_decorate 'no line should match the regular expression' \ 481 | | fail 482 | return $? 483 | fi 484 | done 485 | elif (( is_mode_partial )); then 486 | local -i idx 487 | for (( idx = 0; idx < ${#lines[@]}; ++idx )); do 488 | if [[ ${lines[$idx]} == *"$unexpected"* ]]; then 489 | { local -ar single=( 490 | 'substring' "$unexpected" 491 | 'index' "$idx" 492 | ) 493 | local -a may_be_multi=( 494 | 'output' "$output" 495 | ) 496 | local -ir width="$( batslib_get_max_single_line_key_width \ 497 | "${single[@]}" "${may_be_multi[@]}" )" 498 | batslib_print_kv_single "$width" "${single[@]}" 499 | if batslib_is_single_line "${may_be_multi[1]}"; then 500 | batslib_print_kv_single "$width" "${may_be_multi[@]}" 501 | else 502 | may_be_multi[1]="$( printf '%s' "${may_be_multi[1]}" \ 503 | | batslib_prefix \ 504 | | batslib_mark '>' "$idx" )" 505 | batslib_print_kv_multi "${may_be_multi[@]}" 506 | fi 507 | } | batslib_decorate 'no line should contain substring' \ 508 | | fail 509 | return $? 510 | fi 511 | done 512 | else 513 | local -i idx 514 | for (( idx = 0; idx < ${#lines[@]}; ++idx )); do 515 | if [[ ${lines[$idx]} == "$unexpected" ]]; then 516 | { local -ar single=( 517 | 'line' "$unexpected" 518 | 'index' "$idx" 519 | ) 520 | local -a may_be_multi=( 521 | 'output' "$output" 522 | ) 523 | local -ir width="$( batslib_get_max_single_line_key_width \ 524 | "${single[@]}" "${may_be_multi[@]}" )" 525 | batslib_print_kv_single "$width" "${single[@]}" 526 | if batslib_is_single_line "${may_be_multi[1]}"; then 527 | batslib_print_kv_single "$width" "${may_be_multi[@]}" 528 | else 529 | may_be_multi[1]="$( printf '%s' "${may_be_multi[1]}" \ 530 | | batslib_prefix \ 531 | | batslib_mark '>' "$idx" )" 532 | batslib_print_kv_multi "${may_be_multi[@]}" 533 | fi 534 | } | batslib_decorate 'line should not be in output' \ 535 | | fail 536 | return $? 537 | fi 538 | done 539 | fi 540 | elif (( is_match_line )); then 541 | # Specific line. 542 | if (( is_mode_regex )); then 543 | if [[ ${lines[$idx]} =~ $unexpected ]] || (( $? == 0 )); then 544 | batslib_print_kv_single 5 \ 545 | 'index' "$idx" \ 546 | 'regex' "$unexpected" \ 547 | 'line' "${lines[$idx]}" \ 548 | | batslib_decorate 'regular expression should not match line' \ 549 | | fail 550 | fi 551 | elif (( is_mode_partial )); then 552 | if [[ ${lines[$idx]} == *"$unexpected"* ]]; then 553 | batslib_print_kv_single 9 \ 554 | 'index' "$idx" \ 555 | 'substring' "$unexpected" \ 556 | 'line' "${lines[$idx]}" \ 557 | | batslib_decorate 'line should not contain substring' \ 558 | | fail 559 | fi 560 | else 561 | if [[ ${lines[$idx]} == "$unexpected" ]]; then 562 | batslib_print_kv_single 5 \ 563 | 'index' "$idx" \ 564 | 'line' "${lines[$idx]}" \ 565 | | batslib_decorate 'line should differ' \ 566 | | fail 567 | fi 568 | fi 569 | else 570 | # Entire output. 571 | if (( is_mode_regex )); then 572 | if [[ $output =~ $unexpected ]] || (( $? == 0 )); then 573 | batslib_print_kv_single_or_multi 6 \ 574 | 'regex' "$unexpected" \ 575 | 'output' "$output" \ 576 | | batslib_decorate 'regular expression should not match output' \ 577 | | fail 578 | fi 579 | elif (( is_mode_partial )); then 580 | if [[ $output == *"$unexpected"* ]]; then 581 | batslib_print_kv_single_or_multi 9 \ 582 | 'substring' "$unexpected" \ 583 | 'output' "$output" \ 584 | | batslib_decorate 'output should not contain substring' \ 585 | | fail 586 | fi 587 | else 588 | if [[ $output == "$unexpected" ]]; then 589 | batslib_print_kv_single_or_multi 6 \ 590 | 'output' "$output" \ 591 | | batslib_decorate 'output equals, but it was expected to differ' \ 592 | | fail 593 | fi 594 | fi 595 | fi 596 | } -------------------------------------------------------------------------------- /test/lib/bats/batslib/output.bash: -------------------------------------------------------------------------------- 1 | # 2 | # output.bash 3 | # ----------- 4 | # 5 | # Private functions implementing output formatting. Used by public 6 | # helper functions. 7 | # 8 | 9 | # Print a message to the standard error. When no parameters are 10 | # specified, the message is read from the standard input. 11 | # 12 | # Globals: 13 | # none 14 | # Arguments: 15 | # $@ - [=STDIN] message 16 | # Returns: 17 | # none 18 | # Inputs: 19 | # STDIN - [=$@] message 20 | # Outputs: 21 | # STDERR - message 22 | batslib_err() { 23 | { if (( $# > 0 )); then 24 | echo "$@" 25 | else 26 | cat - 27 | fi 28 | } >&2 29 | } 30 | 31 | # Count the number of lines in the given string. 32 | # 33 | # TODO(ztombol): Fix tests and remove this note after #93 is resolved! 34 | # NOTE: Due to a bug in Bats, `batslib_count_lines "$output"' does not 35 | # give the same result as `${#lines[@]}' when the output contains 36 | # empty lines. 37 | # See PR #93 (https://github.com/sstephenson/bats/pull/93). 38 | # 39 | # Globals: 40 | # none 41 | # Arguments: 42 | # $1 - string 43 | # Returns: 44 | # none 45 | # Outputs: 46 | # STDOUT - number of lines 47 | batslib_count_lines() { 48 | local -i n_lines=0 49 | local line 50 | while IFS='' read -r line || [[ -n $line ]]; do 51 | (( ++n_lines )) 52 | done < <(printf '%s' "$1") 53 | echo "$n_lines" 54 | } 55 | 56 | # Determine whether all strings are single-line. 57 | # 58 | # Globals: 59 | # none 60 | # Arguments: 61 | # $@ - strings 62 | # Returns: 63 | # 0 - all strings are single-line 64 | # 1 - otherwise 65 | batslib_is_single_line() { 66 | for string in "$@"; do 67 | (( $(batslib_count_lines "$string") > 1 )) && return 1 68 | done 69 | return 0 70 | } 71 | 72 | # Determine the length of the longest key that has a single-line value. 73 | # 74 | # This function is useful in determining the correct width of the key 75 | # column in two-column format when some keys may have multi-line values 76 | # and thus should be excluded. 77 | # 78 | # Globals: 79 | # none 80 | # Arguments: 81 | # $odd - key 82 | # $even - value of the previous key 83 | # Returns: 84 | # none 85 | # Outputs: 86 | # STDOUT - length of longest key 87 | batslib_get_max_single_line_key_width() { 88 | local -i max_len=-1 89 | while (( $# != 0 )); do 90 | local -i key_len="${#1}" 91 | batslib_is_single_line "$2" && (( key_len > max_len )) && max_len="$key_len" 92 | shift 2 93 | done 94 | echo "$max_len" 95 | } 96 | 97 | # Print key-value pairs in two-column format. 98 | # 99 | # Keys are displayed in the first column, and their corresponding values 100 | # in the second. To evenly line up values, the key column is fixed-width 101 | # and its width is specified with the first parameter (possibly computed 102 | # using `batslib_get_max_single_line_key_width'). 103 | # 104 | # Globals: 105 | # none 106 | # Arguments: 107 | # $1 - width of key column 108 | # $even - key 109 | # $odd - value of the previous key 110 | # Returns: 111 | # none 112 | # Outputs: 113 | # STDOUT - formatted key-value pairs 114 | batslib_print_kv_single() { 115 | local -ir col_width="$1"; shift 116 | while (( $# != 0 )); do 117 | printf '%-*s : %s\n' "$col_width" "$1" "$2" 118 | shift 2 119 | done 120 | } 121 | 122 | # Print key-value pairs in multi-line format. 123 | # 124 | # The key is displayed first with the number of lines of its 125 | # corresponding value in parenthesis. Next, starting on the next line, 126 | # the value is displayed. For better readability, it is recommended to 127 | # indent values using `batslib_prefix'. 128 | # 129 | # Globals: 130 | # none 131 | # Arguments: 132 | # $odd - key 133 | # $even - value of the previous key 134 | # Returns: 135 | # none 136 | # Outputs: 137 | # STDOUT - formatted key-value pairs 138 | batslib_print_kv_multi() { 139 | while (( $# != 0 )); do 140 | printf '%s (%d lines):\n' "$1" "$( batslib_count_lines "$2" )" 141 | printf '%s\n' "$2" 142 | shift 2 143 | done 144 | } 145 | 146 | # Print all key-value pairs in either two-column or multi-line format 147 | # depending on whether all values are single-line. 148 | # 149 | # If all values are single-line, print all pairs in two-column format 150 | # with the specified key column width (identical to using 151 | # `batslib_print_kv_single'). 152 | # 153 | # Otherwise, print all pairs in multi-line format after indenting values 154 | # with two spaces for readability (identical to using `batslib_prefix' 155 | # and `batslib_print_kv_multi') 156 | # 157 | # Globals: 158 | # none 159 | # Arguments: 160 | # $1 - width of key column (for two-column format) 161 | # $even - key 162 | # $odd - value of the previous key 163 | # Returns: 164 | # none 165 | # Outputs: 166 | # STDOUT - formatted key-value pairs 167 | batslib_print_kv_single_or_multi() { 168 | local -ir width="$1"; shift 169 | local -a pairs=( "$@" ) 170 | 171 | local -a values=() 172 | local -i i 173 | for (( i=1; i < ${#pairs[@]}; i+=2 )); do 174 | values+=( "${pairs[$i]}" ) 175 | done 176 | 177 | if batslib_is_single_line "${values[@]}"; then 178 | batslib_print_kv_single "$width" "${pairs[@]}" 179 | else 180 | local -i i 181 | for (( i=1; i < ${#pairs[@]}; i+=2 )); do 182 | pairs[$i]="$( batslib_prefix < <(printf '%s' "${pairs[$i]}") )" 183 | done 184 | batslib_print_kv_multi "${pairs[@]}" 185 | fi 186 | } 187 | 188 | # Prefix each line read from the standard input with the given string. 189 | # 190 | # Globals: 191 | # none 192 | # Arguments: 193 | # $1 - [= ] prefix string 194 | # Returns: 195 | # none 196 | # Inputs: 197 | # STDIN - lines 198 | # Outputs: 199 | # STDOUT - prefixed lines 200 | batslib_prefix() { 201 | local -r prefix="${1:- }" 202 | local line 203 | while IFS='' read -r line || [[ -n $line ]]; do 204 | printf '%s%s\n' "$prefix" "$line" 205 | done 206 | } 207 | 208 | # Mark select lines of the text read from the standard input by 209 | # overwriting their beginning with the given string. 210 | # 211 | # Usually the input is indented by a few spaces using `batslib_prefix' 212 | # first. 213 | # 214 | # Globals: 215 | # none 216 | # Arguments: 217 | # $1 - marking string 218 | # $@ - indices (zero-based) of lines to mark 219 | # Returns: 220 | # none 221 | # Inputs: 222 | # STDIN - lines 223 | # Outputs: 224 | # STDOUT - lines after marking 225 | batslib_mark() { 226 | local -r symbol="$1"; shift 227 | # Sort line numbers. 228 | set -- $( sort -nu <<< "$( printf '%d\n' "$@" )" ) 229 | 230 | local line 231 | local -i idx=0 232 | while IFS='' read -r line || [[ -n $line ]]; do 233 | if (( ${1:--1} == idx )); then 234 | printf '%s\n' "${symbol}${line:${#symbol}}" 235 | shift 236 | else 237 | printf '%s\n' "$line" 238 | fi 239 | (( ++idx )) 240 | done 241 | } 242 | 243 | # Enclose the input text in header and footer lines. 244 | # 245 | # The header contains the given string as title. The output is preceded 246 | # and followed by an additional newline to make it stand out more. 247 | # 248 | # Globals: 249 | # none 250 | # Arguments: 251 | # $1 - title 252 | # Returns: 253 | # none 254 | # Inputs: 255 | # STDIN - text 256 | # Outputs: 257 | # STDOUT - decorated text 258 | batslib_decorate() { 259 | echo 260 | echo "-- $1 --" 261 | cat - 262 | echo '--' 263 | echo 264 | } -------------------------------------------------------------------------------- /test/lib/docker_helpers.bash: -------------------------------------------------------------------------------- 1 | ## functions to help deal with docker 2 | 3 | # Removes container $1 4 | function docker_clean { 5 | docker kill $1 &>/dev/null ||: 6 | sleep .25s 7 | docker rm -vf $1 &>/dev/null ||: 8 | sleep .25s 9 | } 10 | 11 | # get the ip of docker container $1 12 | function docker_ip { 13 | docker inspect --format '{{ .NetworkSettings.IPAddress }}' $1 14 | } 15 | 16 | # get the running state of container $1 17 | # → true/false 18 | # fails if the container does not exist 19 | function docker_running_state { 20 | docker inspect -f {{.State.Running}} $1 21 | } 22 | 23 | # get the docker container $1 PID 24 | function docker_pid { 25 | docker inspect --format {{.State.Pid}} $1 26 | } 27 | 28 | # asserts logs from container $1 contains $2 29 | function docker_assert_log { 30 | local -r container=$1 31 | shift 32 | run docker logs $container 33 | assert_output -p "$*" 34 | } 35 | 36 | # wait for a container to produce a given text in its log 37 | # $1 container 38 | # $2 timeout in second 39 | # $* text to wait for 40 | function docker_wait_for_log { 41 | local -r container=$1 42 | local -ir timeout_sec=$2 43 | shift 2 44 | retry $(( $timeout_sec * 2 )) .5s docker_assert_log $container "$*" 45 | } 46 | 47 | # Create a docker container named $1 which exposes the docker host unix 48 | # socket over tcp on port 2375. 49 | # 50 | # $1 container name 51 | function docker_tcp { 52 | local container_name="$1" 53 | docker_clean $container_name 54 | docker run -d \ 55 | --name $container_name \ 56 | --expose 2375 \ 57 | -v /var/run/docker.sock:/var/run/docker.sock \ 58 | rancher/socat-docker 59 | docker run --rm --link "$container_name:docker" docker:1.9.1 version 60 | } 61 | 62 | # Remove "bats-*" containers after each test 63 | #function teardown { 64 | #docker rm -f $(docker ps -aq -f name=bats-*) 65 | #} 66 | -------------------------------------------------------------------------------- /test/lib/helpers.bash: -------------------------------------------------------------------------------- 1 | ## add the retry function to bats 2 | 3 | # Retry a command $1 times until it succeeds. Wait $2 seconds between retries. 4 | function retry { 5 | local attempts=$1 6 | shift 7 | local delay=$1 8 | shift 9 | local i 10 | 11 | for ((i=0; i < attempts; i++)); do 12 | run "$@" 13 | if [ "$status" -eq 0 ]; then 14 | echo "$output" 15 | return 0 16 | fi 17 | sleep $delay 18 | done 19 | 20 | echo "Command \"$@\" failed $attempts times. Status: $status. Output: $output" >&2 21 | false 22 | } 23 | -------------------------------------------------------------------------------- /test/multiple-hosts.bats: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bats 2 | load test_helpers 3 | SUT_CONTAINER=bats-nginx-proxy-${TEST_FILE} 4 | 5 | function setup { 6 | # make sure to stop any web container before each test so we don't 7 | # have any unexpected contaiener running with VIRTUAL_HOST or VIRUTAL_PORT set 8 | CIDS=( $(docker ps -q --filter "label=bats-type=web") ) 9 | if [ ${#CIDS[@]} -gt 0 ]; then 10 | docker stop ${CIDS[@]} >&2 11 | fi 12 | } 13 | 14 | 15 | @test "[$TEST_FILE] start a nginx-proxy container" { 16 | run nginxproxy $SUT_CONTAINER -v /var/run/docker.sock:/tmp/docker.sock:ro 17 | assert_success 18 | docker_wait_for_log $SUT_CONTAINER 3 "Watching docker events" 19 | } 20 | 21 | @test "[$TEST_FILE] nginx-proxy forwards requests for 2 hosts" { 22 | # WHEN a container runs a web server with VIRTUAL_HOST set for multiple hosts 23 | prepare_web_container bats-multiple-hosts-1 80 -e VIRTUAL_HOST=multiple-hosts-1-A.bats,multiple-hosts-1-B.bats 24 | 25 | # THEN querying the proxy without Host header → 503 26 | run curl_container $SUT_CONTAINER / --head 27 | assert_output -l 0 $'HTTP/1.1 503 Service Temporarily Unavailable\r' 28 | 29 | # THEN querying the proxy with unknown Host header → 503 30 | run curl_container $SUT_CONTAINER /data --header "Host: webFOO.bats" --head 31 | assert_output -l 0 $'HTTP/1.1 503 Service Temporarily Unavailable\r' 32 | 33 | # THEN 34 | run curl_container $SUT_CONTAINER /data --header 'Host: multiple-hosts-1-A.bats' 35 | assert_output -l 0 "answer from port 80" 36 | 37 | # THEN 38 | run curl_container $SUT_CONTAINER /data --header 'Host: multiple-hosts-1-B.bats' 39 | assert_output -l 0 "answer from port 80" 40 | } 41 | -------------------------------------------------------------------------------- /test/multiple-ports.bats: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bats 2 | load test_helpers 3 | SUT_CONTAINER=bats-nginx-proxy-${TEST_FILE} 4 | 5 | function setup { 6 | # make sure to stop any web container before each test so we don't 7 | # have any unexpected contaiener running with VIRTUAL_HOST or VIRUTAL_PORT set 8 | CIDS=( $(docker ps -q --filter "label=bats-type=web") ) 9 | if [ ${#CIDS[@]} -gt 0 ]; then 10 | docker stop ${CIDS[@]} >&2 11 | fi 12 | } 13 | 14 | 15 | @test "[$TEST_FILE] start a nginx-proxy container" { 16 | # GIVEN nginx-proxy 17 | run nginxproxy $SUT_CONTAINER -v /var/run/docker.sock:/tmp/docker.sock:ro 18 | assert_success 19 | docker_wait_for_log $SUT_CONTAINER 3 "Watching docker events" 20 | } 21 | 22 | 23 | @test "[$TEST_FILE] nginx-proxy defaults to the service running on port 80" { 24 | # WHEN 25 | prepare_web_container bats-web-${TEST_FILE}-1 "80 90" -e VIRTUAL_HOST=web.bats 26 | 27 | # THEN 28 | assert_response_is_from_port 80 29 | } 30 | 31 | 32 | @test "[$TEST_FILE] VIRTUAL_PORT=90 while port 80 is also exposed" { 33 | # GIVEN 34 | prepare_web_container bats-web-${TEST_FILE}-2 "80 90" -e VIRTUAL_HOST=web.bats -e VIRTUAL_PORT=90 35 | 36 | # THEN 37 | assert_response_is_from_port 90 38 | } 39 | 40 | 41 | @test "[$TEST_FILE] single exposed port != 80" { 42 | # GIVEN 43 | prepare_web_container bats-web-${TEST_FILE}-3 1234 -e VIRTUAL_HOST=web.bats 44 | 45 | # THEN 46 | assert_response_is_from_port 1234 47 | } 48 | 49 | 50 | # assert querying nginx-proxy provides a response from the expected port of the web container 51 | # $1 port we are expecting an response from 52 | function assert_response_is_from_port { 53 | local -r port=$1 54 | run curl_container $SUT_CONTAINER /data --header "Host: web.bats" 55 | assert_output -l 0 "answer from port $port" 56 | } 57 | 58 | -------------------------------------------------------------------------------- /test/test_helpers.bash: -------------------------------------------------------------------------------- 1 | # Test if requirements are met 2 | ( 3 | type docker &>/dev/null || ( echo "docker is not available"; exit 1 ) 4 | )>&2 5 | 6 | 7 | # set a few global variables 8 | SUT_IMAGE=dmp1ce/nginx-proxy-letsencrypt:bats 9 | TEST_FILE=$(basename $BATS_TEST_FILENAME .bats) 10 | 11 | 12 | # load the Bats stdlib (see https://github.com/sstephenson/bats/pull/110) 13 | DIR=$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd ) 14 | export BATS_LIB="${DIR}/lib/bats" 15 | load "${BATS_LIB}/batslib.bash" 16 | 17 | 18 | # load additional bats helpers 19 | load ${DIR}/lib/helpers.bash 20 | load ${DIR}/lib/docker_helpers.bash 21 | 22 | 23 | # Define functions specific to our test suite 24 | 25 | # run the SUT docker container 26 | # and makes sure it remains started 27 | # and displays the nginx-proxy start logs 28 | # 29 | # $1 container name 30 | # $@ other options for the `docker run` command 31 | function nginxproxy { 32 | local -r container_name=$1 33 | shift 34 | docker_clean $container_name \ 35 | && docker run -d \ 36 | --name $container_name \ 37 | "$@" \ 38 | $SUT_IMAGE \ 39 | && wait_for_nginxproxy_container_to_start $container_name \ 40 | && docker logs $container_name 41 | } 42 | 43 | 44 | # wait until the nginx-proxy container is ready to operate 45 | # 46 | # $1 container name 47 | function wait_for_nginxproxy_container_to_start { 48 | local -r container_name=$1 49 | sleep .5s # give time to eventually fail to initialize 50 | 51 | function is_running { 52 | run docker_running_state $container_name 53 | assert_output "true" 54 | } 55 | retry 3 1 is_running 56 | } 57 | 58 | 59 | # Send a HTTP request to container $1 for path $2 and 60 | # Additional curl options can be passed as $@ 61 | # 62 | # $1 container name 63 | # $2 HTTP path to query 64 | # $@ additional options to pass to the curl command 65 | function curl_container { 66 | local -r container=$1 67 | local -r path=$2 68 | shift 2 69 | docker run --rm appropriate/curl --silent \ 70 | --connect-timeout 5 \ 71 | --max-time 20 \ 72 | "$@" \ 73 | http://$(docker_ip $container)${path} 74 | } 75 | 76 | 77 | # start a container running (one or multiple) webservers listening on given ports 78 | # 79 | # $1 container name 80 | # $2 container port(s). If multiple ports, provide them as a string: "80 90" with a space as a separator 81 | # $@ `docker run` additional options 82 | function prepare_web_container { 83 | local -r container_name=$1 84 | local -r ports=$2 85 | shift 2 86 | local -r options="$@" 87 | 88 | local expose_option="" 89 | IFS=$' \t\n' # See https://github.com/sstephenson/bats/issues/89 90 | for port in $ports; do 91 | expose_option="${expose_option}--expose=$port " 92 | done 93 | 94 | ( # used for debugging purpose. Will be display if test fails 95 | echo "container_name: $container_name" 96 | echo "ports: $ports" 97 | echo "options: $options" 98 | echo "expose_option: $expose_option" 99 | )>&2 100 | 101 | docker_clean $container_name 102 | 103 | # GIVEN a container exposing 1 webserver on ports 1234 104 | run docker run -d \ 105 | --label bats-type="web" \ 106 | --name $container_name \ 107 | $expose_option \ 108 | -w /var/www/ \ 109 | $options \ 110 | -e PYTHON_PORTS="$ports" \ 111 | python:3 bash -c " 112 | trap '[ \${#PIDS[@]} -gt 0 ] && kill -TERM \${PIDS[@]}' TERM 113 | declare -a PIDS 114 | for port in \$PYTHON_PORTS; do 115 | echo starting a web server listening on port \$port; 116 | mkdir /var/www/\$port 117 | cd /var/www/\$port 118 | echo \"answer from port \$port\" > data 119 | python -m http.server \$port & 120 | PIDS+=(\$!) 121 | done 122 | wait \${PIDS[@]} 123 | trap - TERM 124 | wait \${PIDS[@]} 125 | " 126 | assert_success 127 | 128 | # THEN querying directly port works 129 | IFS=$' \t\n' # See https://github.com/sstephenson/bats/issues/89 130 | for port in $ports; do 131 | run retry 5 1s docker run --rm appropriate/curl --silent --fail http://$(docker_ip $container_name):$port/data 132 | assert_output -l 0 "answer from port $port" 133 | done 134 | } 135 | -------------------------------------------------------------------------------- /test/wildcard-hosts.bats: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bats 2 | load test_helpers 3 | SUT_CONTAINER=bats-nginx-proxy-${TEST_FILE} 4 | 5 | function setup { 6 | # make sure to stop any web container before each test so we don't 7 | # have any unexpected contaiener running with VIRTUAL_HOST or VIRUTAL_PORT set 8 | CIDS=( $(docker ps -q --filter "label=bats-type=web") ) 9 | if [ ${#CIDS[@]} -gt 0 ]; then 10 | docker stop ${CIDS[@]} >&2 11 | fi 12 | } 13 | 14 | 15 | @test "[$TEST_FILE] start a nginx-proxy container" { 16 | # GIVEN 17 | run nginxproxy $SUT_CONTAINER -v /var/run/docker.sock:/tmp/docker.sock:ro 18 | assert_success 19 | docker_wait_for_log $SUT_CONTAINER 3 "Watching docker events" 20 | } 21 | 22 | 23 | @test "[$TEST_FILE] VIRTUAL_HOST=*.wildcard.bats" { 24 | # WHEN 25 | prepare_web_container bats-wildcard-hosts-1 80 -e VIRTUAL_HOST=*.wildcard.bats 26 | 27 | # THEN 28 | assert_200 f00.wildcard.bats 29 | assert_200 bar.wildcard.bats 30 | assert_503 unexpected.host.bats 31 | } 32 | 33 | @test "[$TEST_FILE] VIRTUAL_HOST=wildcard.bats.*" { 34 | # WHEN 35 | prepare_web_container bats-wildcard-hosts-2 80 -e VIRTUAL_HOST=wildcard.bats.* 36 | 37 | # THEN 38 | assert_200 wildcard.bats.f00 39 | assert_200 wildcard.bats.bar 40 | assert_503 unexpected.host.bats 41 | } 42 | 43 | @test "[$TEST_FILE] VIRTUAL_HOST=~^foo\.bar\..*\.bats" { 44 | # WHEN 45 | prepare_web_container bats-wildcard-hosts-2 80 -e VIRTUAL_HOST=~^foo\.bar\..*\.bats 46 | 47 | # THEN 48 | assert_200 foo.bar.whatever.bats 49 | assert_200 foo.bar.why.not.bats 50 | assert_503 unexpected.host.bats 51 | 52 | } 53 | 54 | 55 | # assert that querying nginx-proxy with the given Host header produces a `HTTP 200` response 56 | # $1 Host HTTP header to use when querying nginx-proxy 57 | function assert_200 { 58 | local -r host=$1 59 | 60 | run curl_container $SUT_CONTAINER / --head --header "Host: $host" 61 | assert_output -l 0 $'HTTP/1.1 200 OK\r' 62 | } 63 | 64 | # assert that querying nginx-proxy with the given Host header produces a `HTTP 503` response 65 | # $1 Host HTTP header to use when querying nginx-proxy 66 | function assert_503 { 67 | local -r host=$1 68 | 69 | run curl_container $SUT_CONTAINER / --head --header "Host: $host" 70 | assert_output -l 0 $'HTTP/1.1 503 Service Temporarily Unavailable\r' 71 | } 72 | -------------------------------------------------------------------------------- /update_certs: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | pkill -f -SIGUSR1 /app/letsencrypt_service 4 | -------------------------------------------------------------------------------- /update_nginx: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | nginx -s reload 4 | 5 | docker-gen \ 6 | -only-exposed \ 7 | -notify '/app/update_certs' \ 8 | /app/letsencrypt_service_data.tmpl /app/letsencrypt_service_data 9 | --------------------------------------------------------------------------------