diff --git a/Dockerfile b/Dockerfile index 66f4bee..2f37032 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ # mesudip/python-nginx:alpine is merge of official python and nginx images. FROM mesudip/python-nginx:alpine -HEALTHCHECK --interval=10s --timeout=2s --start-period=10s --retries=3 CMD pgrep nginx >> /dev/null || exit 1 +HEALTHCHECK --interval=10s --timeout=2s --start-period=10s --retries=3 CMD pgrep nginx && pgrep python3 >> /dev/null || exit 1 VOLUME ["/etc/nginx/dhparam", "/tmp/acme-challenges/","/etc/nginx/conf.d","/etc/nginx/ssl"] CMD ["sh","-e" ,"/docker-entrypoint.sh"] COPY ./requirements.txt /requirements.txt @@ -14,6 +14,8 @@ RUN apk --no-cache add openssl && \ ARG LETSENCRYPT_API="https://acme-v02.api.letsencrypt.org/directory" ENV LETSENCRYPT_API=${LETSENCRYPT_API} \ CHALLENGE_DIR=/tmp/acme-challenges/ \ - DHPARAM_SIZE=2048 + DHPARAM_SIZE=2048 \ + CLIENT_MAX_BODY_SIZE=1m \ + DEFAULT_HOST=true WORKDIR /app COPY . /app/ \ No newline at end of file diff --git a/README.md b/README.md index f870541..fc73be4 100644 --- a/README.md +++ b/README.md @@ -2,35 +2,70 @@ Nginx-Proxy =================================================== Docker container for automatically creating nginx configuration based on active containers in docker host. -## Basic setup of nginx-proxy +- Easy server configuration with environment variables +- Map multiple containers to different locations on same server +- Automatic Let's Encrypt ssl certificate registration +- Basic Authorization + +## Quick Setup +### Setup nginx-proxy ``` +docker pull mesudip/nginx-proxy docker network create frontend; # create a network for nginx proxy docker run --network frontend \ - --name nginx \ + --name nginx-proxy \ -v /var/run/docker.sock:/var/run/docker.sock:ro \ -v /etc/ssl:/etc/ssl \ + -v /etc/ssl/dhparam:/etc/nginx/dhparam \ -p 80:80 \ -p 443:443 \ - mesudip/nginx-proxy + -d --restart always mesudip/nginx-proxy ``` -### Volumes -Following directries can be made into volumes to persist configurations -- `/etc/nginx/conf.d` nginx configuration directory. You can add your own server configurations here -- `/etc/nginx/dhparam` the directory for storing DH parameter for ssl connections -- `/etc/ssl` directory for storing ssl certificates, ssl private key and letsencrypt account key. - -## Configuring the container in detail +### Setup your container The only thing that matters is that the container shares at least one common network to the nginx container and `VIRTUAL_HOST` -environment variable is set. If you have multiple exposed ports in the container, don't forget to -mention the container port too. +environment variable is set. + +Examples: +- **WordPress** ``` docker run --network frontend \ - --name test-host \ - -e VIRTUAL_HOST="example.com" \ - nginx:alpine + --name wordpress-server \ + -e VIRTUAL_HOST="wordpress.example.com" \ + wordpress +``` + - **Docker Registry** + ``` +docker run --network frontend \ + --name docker-registry \ + -e VIRTUAL_HOST='registry.example.com/v2 -> /v2; client_max_body_size 2g' \ + -e PROXY_BASIC_AUTH="registry.example.com -> user1:password,user2:password2,user3:password3" + registry:2 ``` -### Using the environment `VIRTUAL_HOST` +Details of Using nginx-proxy +====================== + - [Configure `nginx-proxy`](#configure-nginx-proxy) + - [Configure enrironment VIRTUAL_HOST in your containers](#configure-environment-virtual_host-in-your-containers) + - [WebSockets](#support-for-websocket) + - [Multiple hosts in same container](#multiple-virtual-hosts-on-same-container) + - [Redirection](#redirection) + - [Https and SSL](#ssl-support) + - [Basic Authorization](#basic-authorization) + - [Default Server](#default-server) + +## Configure `nginx-proxy` +Following directries can be made into volumes to persist configurations +- `/etc/nginx/conf.d` nginx configuration directory. You can add your own server configurations here +- `/etc/nginx/dhparam` the directory for storing DH parameter for ssl connections +- `/etc/ssl` directory for storing ssl certificates, ssl private key and Let's Encrypt account key. +- `/var/log/nginx` directory nginx logs +- `/tmp/acme-challenges` directory for storing challenge content when registering Let's Encrypt certificate + +Some of the default behaviour of `nginx-proxy` can be changed with environment variables. +- `DHPARAM_SIZE` Default - `2048` : Set size of dhparam usd for ssl certificates +- `CLIENT_MAX_BODY_SIZE` Default - `1m` : Set default max body size for all the servers. + +## Configure environment `VIRTUAL_HOST` in your containers When you want a container's to be hosted on a domain set `VIRTUAL_HOST` environment variable to desired `server_name` entry. For virtual host to work it requires - nginx-proxy container to be on the same network as that of the container. @@ -49,7 +84,22 @@ example.com/api | http://example.com/api |/ | exposed example.com/api -> :8080/api | http://example.com/api | /api | 8080 https://example.com/api/v1:5001 -> :8080/api | https://example.com/api/v1:5001 | /api | 8080 wss://example.com/websocket | wss://example.com/websocket | / | exposed port - + +With `VIRTUAL_HOST` you can inject nginx directives into location each configuration must be separed with a `;` +. You can see the possible directives in nginx documentation. + +**Example :** `VIRTUAL_HOST=somesite.example.com -> :8080 ;proxy_read_timeout 900;client_max_body_size 2g;` will generate configuration as follows +```nginx.conf +server{ + server_name somesite.example.com; + listen 80; + location /{ + proxy_read_timeout 900; + client_max_body_size 2g; + proxy_pass http://127.2.3.4; // your container ip here + } +} +``` ### Support for websocket Exposing websocket requires the websocket endpoint to be explicitly configured via virtual host. The websocket endpoint can be `ws://` or `wss://`. If you want to use both websocket and non-websocket endpoints you will have to use multiple hosts @@ -67,10 +117,17 @@ To have multiple virtual hosts out of single container, you can use `VIRTUAL_HOS ethereum/client-go \ --rpc --rpcaddr "0.0.0.0" --ws --wsaddr 0.0.0.0 ``` +## Redirection + Let's say you want to serve a website on `example.uk`. You might want users visiting `www.example.uk`,`example.com`,`www.example.com` + to redirect to `example.uk`. You can simply use `PROXY_FULL_REDIRECT` environment variable. + ``` + -e 'VIRTUAL_HOST=https://example.uk -> :7000' \ + -e 'PROXY_FULL_REDIRECT=example.com,www.example.com,www.example.uk->example.uk' + ``` ## SSL Support Issuing of SSL certificate is done using acme-nginx library for Let's Encrypt. If a precheck determines that -the domain we are trying to issue certificate is not owned by current machine, a self signed certificate is +the domain we are trying to issue certificate is not owned by current machine, a self-signed certificate is generated instead. ### Using SSL for exposing endpoint @@ -85,7 +142,7 @@ If you already have a ssl certificate that you want to use, copy it under the `/ Wildcard certificates can be used. For example to use `*.example.com` wildcard, you should create files `/etc/ssl/certs/*.example.com.crt` and `/etc/ssl/private/*.example.com.key` in the container's filesystem. -**Note that `*.blah` or `*` is not a valid wildcard.** +**Note that `*.com` or `*` is not a valid wildcard.** Wild card must have at least 2 dots. `/etc/ssl/certs/*.example.com.crt` certificate will : - be used for `host1.example.com` @@ -93,7 +150,6 @@ Wildcard certificates can be used. For example to use `*.example.com` wildcard, - not be used for `xyz.host1.example.com` - not be used for `example.com` - ***DHPARAM_SIZE :*** Default size of DH key used for https connection is `2048`bits. The key size can be changed by changing `DHPARAM_SIZE` environment variable @@ -102,20 +158,27 @@ You can manually obtain Let's encrypt certificate using the nginx-proxy containe Note that you must set ip in DNS entry to point the correct server. To issue a certificate for a domain you can simply use this command. -- `docker exec nginx getssl www.example.com` +- `docker exec nginx-proxy getssl www.example.com` Obtained certificate is saved on `/etc/ssl/certs/www.example.com` and private is saved on `/etc/ssl/private/www.example.com` To issue certificates for multiple domain you can simply add more parameters to the above command - - `docker exec nginx getssl www.example.com example.com ww.example.com` + - `docker exec nginx-proxy getssl www.example.com example.com ww.example.com` All the domains are registered on the same certificate and the filename is set from the first parameter passed to the command. so `/etc/ssl/certs/www.example.com` and `/etc/ssl/private/www.example.com` are generated -Use `docker exec nginx getssl --help` for getting help with the command +Use `docker exec nginx-proxy getssl --help` for getting help with the command + +## Basic Authorization +Basic Auth can be enabled on the container with environment variable `PROXY_BASIC_AUTH`. +- `PROXY_BASIC_AUTH=user1:password1,user2:password2,user3:password3` adds basic auth feature to your configured `VIRTUAL_HOST` server root. +- `PROXY_BASIC_AUTH=example.com/api/v1/admin -> admin1:password1,admin2:password2` adds basic auth only to the location starting from `api/v1/admin` + +## Default Server +When request comes for a server name that is not registered in `nginx-proxy`, It responds with 503 by default. +If you want the requested to be passed to a container instead, when setting up the container you can add `PROXY_DEFAULT_SERVER=true` environment along with `VIRTUAL_HOST`. -## Compatibility with jwilder/nginx-proxy -This nginx-proxy supports `VIRTUAL_HOST` `LETSENCRYPT_HOST` AND `VIRTUAL_PORT` like in jwilder/nginx-proxy. -But comma separated `VIRTUAL_HOST` is not supported. It's still missing a lot of other feature of jwilder/nginx-proxy -hopefully they will be available in future versions. +This much is sufficient for http connections, but for https connections, you might want to setup +[wildcard certificates](#using-your-own-ssl-certificate) so that your users dont get invalid ssl certificate errors. diff --git a/acme_nginx/AWSRoute53.py b/acme_nginx/AWSRoute53.py deleted file mode 100644 index e8a39a2..0000000 --- a/acme_nginx/AWSRoute53.py +++ /dev/null @@ -1,91 +0,0 @@ -import boto3 - - -class AWSRoute53(object): - def __init__(self): - self.session = boto3.Session() - self.client = self.session.client('route53') - - def determine_domain(self, domain): - """ - Determine registered domain in API and return it's hosted zone id - Params: - domain, string, domain name that is be part of account's hosted zones - Returns: - zone_id, string, hosted zone id of matching domain - """ - if not domain.endswith('.'): - domain = domain + '.' - # use paginator to iterate over all hosted zones - paginator = self.client.get_paginator('list_hosted_zones') - # https://github.com/boto/botocore/issues/1535 result_key_iters is undocumented - for page in paginator.paginate().result_key_iters(): - for result in page: - if result['Name'] in domain: - return result['Id'] - - def create_record(self, name, data, domain): - """ - Create TXT DNS record - Params: - name, string, record name - data, string, record data - domain, string, dns domain - Return: - record_id, int, created record id - """ - zone_id = self.determine_domain(domain) - if not zone_id: - raise Exception('Hosted zone for domain {0} not found'.format(domain)) - response = self.client.change_resource_record_sets( - HostedZoneId=zone_id, - ChangeBatch={ - 'Changes': [ - { - 'Action': 'UPSERT', - 'ResourceRecordSet': { - 'Name': name, - 'Type': 'TXT', - 'TTL': 60, - 'ResourceRecords': [ - { - 'Value': '"{0}"'.format(data) - } - ] - } - } - ] - } - ) - waiter = self.client.get_waiter('resource_record_sets_changed') - waiter.wait(Id=response['ChangeInfo']['Id']) - return {'name': name, 'data': data} - - def delete_record(self, record, domain): - """ - Delete TXT DNS record - Params: - record, dict, record dict with name, data keys - domain, string, dns domain - """ - zone_id = self.determine_domain(domain) - self.client.change_resource_record_sets( - HostedZoneId=zone_id, - ChangeBatch={ - 'Changes': [ - { - 'Action': 'DELETE', - 'ResourceRecordSet': { - 'Name': record['name'], - 'Type': 'TXT', - 'TTL': 60, - 'ResourceRecords': [ - { - 'Value': '"{0}"'.format(record['data']) - } - ] - } - } - ] - } - ) diff --git a/acme_nginx/Acme.py b/acme_nginx/Acme.py index 0353126..70a40e8 100644 --- a/acme_nginx/Acme.py +++ b/acme_nginx/Acme.py @@ -220,6 +220,8 @@ def _send_signed_request(self, url, payload=None, directory=None): except UnicodeDecodeError: pass return resp.getcode(), resp_data, resp.headers + except (KeyboardInterrupt, SystemExit) as e: + raise e except Exception as e: return getattr(e, "code", None), \ getattr(e, "read", e.__str__)(), \ diff --git a/acme_nginx/AcmeV1.py b/acme_nginx/AcmeV1.py index 0c2a50b..12c000a 100644 --- a/acme_nginx/AcmeV1.py +++ b/acme_nginx/AcmeV1.py @@ -21,6 +21,8 @@ def register_account(self): try: self.log.info('trying to create account key {0}'.format(self.account_key)) account_key = self.create_key(self.account_key) + except (KeyboardInterrupt, SystemExit) as e: + raise e except Exception as e: self.log.error('creating key {0} {1}'.format(type(e).__name__, e)) sys.exit(1) @@ -44,6 +46,8 @@ def get_certificate(self): self.register_account() try: self.create_key(self.domain_key) + except (KeyboardInterrupt, SystemExit) as e: + raise e except Exception as e: self.log.error('creating key {0} {1}'.format(type(e).__name__, e)) sys.exit(1) @@ -66,6 +70,8 @@ def get_certificate(self): self.log.info('adding nginx virtual host and completing challenge') try: self._write_challenge(token, thumbprint) + except (KeyboardInterrupt, SystemExit) as e: + raise e except Exception as e: self.log.error('error adding virtual host {0} {1}'.format(type(e).__name__, e)) sys.exit(1) @@ -97,6 +103,8 @@ def get_certificate(self): chain_str = urlopen(self.chain).read() if chain_str: chain_str = chain_str.decode('utf8') + except (KeyboardInterrupt, SystemExit) as e: + raise e except Exception as e: self.log.error('error getting chain: {0} {1}'.format(type(e).__name__, e)) sys.exit(1) @@ -111,6 +119,8 @@ def get_certificate(self): ))) ) fd.write(chain_str) + except (KeyboardInterrupt, SystemExit) as e: + raise e except Exception as e: self.log.error('error writing cert: {0} {1}'.format(type(e).__name__, e)) sys.exit(1) diff --git a/acme_nginx/AcmeV2.py b/acme_nginx/AcmeV2.py index 0e46b47..9804fa7 100644 --- a/acme_nginx/AcmeV2.py +++ b/acme_nginx/AcmeV2.py @@ -2,13 +2,10 @@ import json import re import sys -try: - from urllib.request import urlopen, Request # Python 3 -except ImportError: - from urllib2 import urlopen, Request # Python 2 +from urllib.request import urlopen, Request # Python 3 + from .Acme import Acme from .DigitalOcean import DigitalOcean -from .AWSRoute53 import AWSRoute53 class AcmeV2(Acme): @@ -22,6 +19,8 @@ def register_account(self): try: self.log.info('trying to create account key {0}'.format(self.account_key)) self.create_key(self.account_key) + except (KeyboardInterrupt, SystemExit) as e: + raise e except Exception as e: self.log.error('creating key {0} {1}'.format(type(e).__name__, e)) sys.exit(1) @@ -50,6 +49,8 @@ def register_account(self): try: self.log.info('trying to create domain key') self.create_key(self.domain_key) + except (KeyboardInterrupt, SystemExit) as e: + raise e except Exception as e: self.log.error('creating key {0} {1}'.format(type(e).__name__, e)) sys.exit(1) @@ -73,6 +74,8 @@ def _sign_certificate(self, order, directory): try: with open(self.cert_path, 'w') as fd: fd.write(certificate_pem) + except (KeyboardInterrupt, SystemExit) as e: + raise e except Exception as e: self.log.error('error writing cert: {0} {1}'.format(type(e).__name__, e)) if not self.skip_nginx_reload: @@ -110,6 +113,8 @@ def solve_http_challenge(self, directory): self.log.info('adding nginx virtual host and completing challenge') try: self._write_challenge(token, thumbprint) + except (KeyboardInterrupt, SystemExit) as e: + raise e except Exception as e: self.log.error('error adding virtual host {0} {1}'.format(type(e).__name__, e)) sys.exit(1) @@ -160,6 +165,8 @@ def solve_dns_challenge(self, directory, client): domain=domain, name='_acme-challenge.{0}.'.format(domain.lstrip('*.').rstrip('.')), data=txt_record) + except (KeyboardInterrupt, SystemExit) as e: + raise e except Exception as e: self.log.error('error creating dns record') self.log.error(e) @@ -182,6 +189,8 @@ def solve_dns_challenge(self, directory, client): if not self.debug: self.log.info('delete dns record') client.delete_record(domain=domain, record=record) + except (KeyboardInterrupt, SystemExit) as e: + raise e except Exception as e: self.log.error('error deleting dns record') self.log.error(e) @@ -192,8 +201,6 @@ def get_certificate(self): if self.dns_provider: if self.dns_provider == 'digitalocean': dns_client = DigitalOcean() - elif self.dns_provider == 'route53': - dns_client = AWSRoute53() self.solve_dns_challenge(directory, dns_client) else: self.solve_http_challenge(directory) diff --git a/acme_nginx/Readme.md b/acme_nginx/Readme.md new file mode 100644 index 0000000..d4252e4 --- /dev/null +++ b/acme_nginx/Readme.md @@ -0,0 +1,4 @@ +Acme Interaction for Let's Encrypt certificate +============================================== +Contents copied from [kshcherban/acme-nginx](https://github.com/kshcherban/acme-nginx) and modified to work in alpine-linux and added failure recovery instead of exit(1) + \ No newline at end of file diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index b71c233..c3cda5e 100755 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -1,8 +1,7 @@ #!/usr/bin/env sh mkdir -p /etc/nginx/dhparam -if ! openssl dhparam -in /etc/nginx/dhparam/dhparam.pem > /dev/null 2>&1 -then - echo "Generating new DH Parameters for SSL as it's missing" - openssl dhparam -out /etc/nginx/dhparam/dhparam.pem ${DHPARAM_SIZE:-2048} + +if ! openssl dhparam -in /etc/nginx/dhparam/dhparam.pem >/dev/null 2>&1; then + openssl dhparam -out /etc/nginx/dhparam/dhparam.pem ${DHPARAM_SIZE:-2048} fi -python3 -u /app/main.py \ No newline at end of file +exec python3 -u main.py diff --git a/easy_debug.sh b/easy_debug.sh new file mode 100755 index 0000000..11c82e8 --- /dev/null +++ b/easy_debug.sh @@ -0,0 +1,26 @@ +#!/bin/sh +#When working on the project locally, you want to debug things running inside docker container +# like a normal python script. This helps achieve that by creating proper directory mapping in the container +# and then starting the container with pydevd enabled. +# +if ! docker network inspect frontend >>/dev/null; then + docker network create frontend >>/dev/null +fi +IMAGE_NAME="mesudip/nginx-proxy:local-debug" +echo "Started Docker build. This will take a while if you have changed requirements.txt" +docker build -t "$IMAGE_NAME" --build-arg WORK_DIR="$(pwd)" . >>/dev/null +docker rm --force mesudip-nginx-local-debug >/dev/null +docker run -d \ + -v /var/run/docker.sock:/var/run/docker.sock:ro \ + -v /etc/ssl:/etc/ssl \ + -v "$(pwd):$(pwd)" \ + -v /etc/ssl/dhparam:/etc/nginx/dhparam \ + -v /tmp/mesdip-nginx-conf:/etc/nginx/conf.d \ + -e PYTHON_DEBUG_ENABLE=true -e PYTHON_DEBUG_PORT=5678 \ + -p 80:80 -p 443:443 \ + --entrypoint /bin/sh \ + --name mesudip-nginx-local-debug \ + "$IMAGE_NAME" -c "cd $(pwd) && ./docker-entrypoint.sh" +docker network connect frontend mesudip-nginx-local-debug +echo "Container started :) " +docker logs -f mesudip-nginx-local-debug diff --git a/main.py b/main.py index 39e350f..03b1c5c 100644 --- a/main.py +++ b/main.py @@ -1,12 +1,31 @@ import os import re +import signal import subprocess import sys +import traceback import docker -from nginx_proxy import WebServer as containers +from nginx_proxy.WebServer import WebServer +server = None + + +# Handle exit signal to respond to stop command. +def receiveSignal(signalNumber, frame): + global server + if signalNumber == 15: + print("\nShutdown Requested") + if server is not None: + server.cleanup() + server = None + sys.exit(0) + + +signal.signal(signal.SIGTERM, receiveSignal) + +# Enable pydevd for debugging locally. debug_config = {} if "PYTHON_DEBUG_PORT" in os.environ: if os.environ["PYTHON_DEBUG_PORT"].strip(): @@ -19,11 +38,12 @@ debug_config["host"] = re.findall("([0-9]+\.[0-9]+\.[0-9]+\.[0-9]+)+", subprocess.run(["ip", "route"], stdout=subprocess.PIPE).stdout.decode().split( - "\n")[0])[0] + "\n")[0])[0] if len(debug_config): import pydevd + print("Starting nginx-proxy in debug mode. Trying to connect to debug server ", str(debug_config)) pydevd.settrace(stdoutToServer=True, stderrToServer=True, **debug_config) # fix for https://trello.com/c/dMG5lcTZ @@ -31,9 +51,10 @@ client = docker.from_env() client.version() except Exception as e: - print("There was error connecting with the docker server \nHave you correctly mounted /var/run/docker.sock?\n"+str(e.args),file=sys.stderr) + print( + "There was error connecting with the docker server \nHave you correctly mounted /var/run/docker.sock?\n" + str( + e.args), file=sys.stderr) sys.exit(1) -hosts = containers.WebServer(client) def eventLoop(): @@ -46,8 +67,12 @@ def eventLoop(): process_network_event(event["Action"], event) elif eventType == "container": process_container_event(event["Action"], event) + except (KeyboardInterrupt, SystemExit) as e: + raise e except Exception as e: - print("Unexpected error :" + e.__class__.__name__ + str(e)) + print("Unexpected error :" + e.__class__.__name__ + ' -> ' + str(e), file=sys.stderr) + traceback.print_exc(limit=10) + def process_service_event(action, event): if action == "create": @@ -57,10 +82,10 @@ def process_service_event(action, event): def process_container_event(action, event): if action == "start": # print("container started", event["id"]) - hosts.update_container(event["id"]) + server.update_container(event["id"]) elif action == "die": # print("container died", event["id"]) - hosts.remove_container(event["id"]) + server.remove_container(event["id"]) def process_network_event(action, event): @@ -70,18 +95,21 @@ def process_network_event(action, event): elif "container" in event["Actor"]["Attributes"]: if action == "disconnect": # print("network disconnect") - hosts.disconnect(network=event["Actor"]["ID"], container=event["Actor"]["Attributes"]["container"], - scope=event["scope"]) + server.disconnect(network=event["Actor"]["ID"], container=event["Actor"]["Attributes"]["container"], + scope=event["scope"]) elif action == "connect": # print("network connect") - hosts.connect(network=event["Actor"]["ID"], container=event["Actor"]["Attributes"]["container"], - scope=event["scope"]) + server.connect(network=event["Actor"]["ID"], container=event["Actor"]["Attributes"]["container"], + scope=event["scope"]) elif action == "destroy": # print("network destryed") pass try: + server = WebServer(client) eventLoop() except (KeyboardInterrupt, SystemExit): - hosts.cleanup(); + print("-------------------------------\nPerforming Graceful ShutDown !!") + if server is not None: + server.cleanup() diff --git a/nginx/Config.py b/nginx/Config.py index 30c7c16..6fc395b 100644 --- a/nginx/Config.py +++ b/nginx/Config.py @@ -1,13 +1,117 @@ -class Block: +import re + + +class ConfigNode(): def __init__(self): - self.parameters=[] - self.contents=[] + self._parent_block = None + self.offset_char = " " + + def is_block(self): + return False + + def is_direction(self): + return False + + +class Block(ConfigNode): + def __init__(self, name, parameters, contents): + super().__init__() + self.name = name + self.parameters = parameters + self.contents = contents + + def __del__(self): + if self._parent_block: + self._parent_block.delete(self) + + def append(self, data): + data._parent_block = self + self.contents.append(data) + + def is_block(self): + return True + + def is_direction(self): + return False + + def delete(self, content): + self.contents.remove(content) + def __str__(self, offset=-1, sep=" ", gen_block_name=False): -class Direction: - def __init__(self,name,value): + data = "".join([c.__str__(offset=offset + 1, sep=sep, gen_block_name=True) for c in self.contents]) + if gen_block_name: + return '%(offset)s%(name)s %(param)s {\n%(data)s%(offset)s}\n' % { + 'offset': sep * offset, + 'name': self.name, + 'data': data, + 'param': " ".join(self.parameters) if type(self.parameters) is not str else self.parameters} + else: + return data + def __len__(self): + return len(self.contents) + def __getitem__(self, item): + if type(item) is str: + for x in self.contents: + if x.name == item: + yield x + if type(item) is tuple: + for x in self.contents: + if x.name==item[0]: + type(x is Block) + + + def __setitem__(self, key, value): + pass + def __delitem__(self, key): + pass + def __iter__(self): + pass + def __contains__(self, item): + pass + + def __repr__(self): + return self.name + "{}" + + +class Direction(ConfigNode): + def __init__(self, name, value): + super().__init__() self.name = name - if type(value) in (tuple,list): - self.values=value + if type(value) in (tuple, list): + self.values = [x for x in value] else: - self.values=[value] + self.values = [value] + + def __del__(self): + if self._parent_block: + self._parent_block.delete(self) + + def __hash__(self): + return hash(self.name, self.values) + + def is_block(self): + return False + + def is_direction(self): + return True + + def __str__(self, offset=0, __values=None, sep=" ", gen_block_name=False): + + return sep * offset + self.name + " " + " ".join(self.values) + ";\n" + # if not __values: + # block = self.values + # else: + # block = __values + # if isinstance(block, tuple): + # if len(block) == 1 and type(block[0]) == str: # single param + # return sep * offset + '%s;\n' % (block[0]) + # elif isinstance(block[1], str): + # return sep * offset + '%s %s;\n' % (block[0], block[1]) + # else: # multiline + # return sep * offset + '%s %s;\n' % (block[0], + # self.__str__(block[1], offset + len( + # block[0]) + 1).rstrip()) + + def __repr__(self): + return "<" + self.name + ">" diff --git a/nginx/ConfigParser.py b/nginx/ConfigParser.py index adf8206..ab50102 100644 --- a/nginx/ConfigParser.py +++ b/nginx/ConfigParser.py @@ -21,10 +21,11 @@ ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ====================================================================================================== ''' +from nginx.Config import * class ConfigParser: def __init__(self, offset_char=' '): - self.i = 0 #char iterator for parsing + self.i = 0 # char iterator for parsing self.length = 0 self.config = '' self.data = [] @@ -42,152 +43,11 @@ def __delitem__(self, index): def __call__(self): return self.gen_config() - def get_value(self, data): - if isinstance(data, tuple): - return data[1] - elif isinstance(data, dict): - return data['value'] - else: - return data - - def get_name(self, data): - if isinstance(data, tuple): - return data[0] - elif isinstance(data, dict): - return data['name'] - else: - return data - - def set(self, item_arr, value=None, param=None, name=None): - if isinstance(item_arr, str): - elem = item_arr - parent = self.data - elif isinstance(item_arr, list) and len(item_arr) == 1: - elem = item_arr[0] - parent = self.data - else: - elem = item_arr.pop() - parent = self.get_value(self.get(item_arr)) - - if parent is None: - raise KeyError('No such block.') - - if isinstance(elem, str) and isinstance(value, str): - #modifying text parameter - for i, param in enumerate(parent): - if isinstance(param, tuple): - if param[0] == elem: - if value is not None and name is not None: - parent[i] = (name, value) - return - elif value is not None: - parent[i] = (param[0], value) - return - elif name is not None: - parent[i] = (name, param[1]) - return - raise TypeError('Not expected value type') - - elif isinstance(elem, tuple): - #modifying block - if len(elem) == 1: - elem = (elem[0], '') - for i, block in enumerate(parent): - if isinstance(block, dict): - if elem == (block['name'], block['param']): - if value is not None and isinstance(value, list): - parent[i]['value'] = value - return - if param is not None and isinstance(param, str): - parent[i]['param'] = param - return - if name is not None and isinstance(name, str): - parent[i]['name'] = name - return - raise TypeError('Not expected value type') - raise KeyError('No such parameter.') - - def get(self, item_arr, data=[]): - if data == []: - data = self.data - if type(item_arr) in [str, tuple]: - item = item_arr - elif isinstance(item_arr, list): - if len(item_arr) == 1: - item = item_arr[0] - else: - element = item_arr.pop(0) - if isinstance(element, tuple):#cannot be a string - if len(element) == 1: - element = (element[0], '') - for i, data_elem in enumerate(data): - if isinstance(data_elem, dict): - if (data_elem['name'], data_elem['param']) == element: - return self.get(item_arr, self.get_value(data[i])) - - if not 'item' in locals(): - raise KeyError('Error while getting parameter.') - if isinstance(item, str): - for i, elem in enumerate(data): - if isinstance(elem, tuple): - if elem[0] == item: - return data[i] - elif isinstance(item, tuple): - if len(item) == 1: - item = (item[0], '') - for i, elem in enumerate(data): - if isinstance(elem, dict): - if (elem['name'], elem['param']) == item: - return data[i] - return None - - def append(self, item, root=[], position=None): - if root == []: - root = self.data - elif root is None: - raise AttributeError('Root element is None') - if position: - root.insert(position, item) - else: - root.append(item) - - def remove(self, item_arr, data=[]): - if data == []: - data = self.data - if type(item_arr) in [str, tuple]: - item = item_arr - elif isinstance(item_arr, list): - if len(item_arr) == 1: - item = item_arr[0] - else: - elem = item_arr.pop(0) - if type(elem) in [tuple,str]: - self.remove(item_arr, self.get_value(self.get(elem, data))) - return - - if isinstance(item, str): - for i,elem in enumerate(data): - if isinstance(elem, tuple): - if elem[0] == item: - del data[i] - return - elif isinstance(item, tuple): - if len(item) == 1: - item = (item[0], '') - for i,elem in enumerate(data): - if isinstance(elem, dict): - if (elem['name'], elem['param']) == item: - del data[i] - return - else: - raise AttributeError("Unknown item type '%s' in item_arr" % item.__class__.__name__) - raise KeyError('Unable to remove') - def load(self, config): self.config = config self.length = len(config) - 1 self.i = 0 - self.data = self.parse_block() + self.data = self.parse_block("","") def loadf(self, filename): with open(filename, 'r') as f: @@ -199,13 +59,14 @@ def savef(self, filename): conf = self.gen_config() f.write(conf) - def parse_block(self): + def parse_block(self,name,parameters): data = [] param_name = None param_value = None buf = '' + block=Block(name,parameters,[]) while self.i < self.length: - if self.config[self.i] == '\n': #multiline value + if self.config[self.i] == '\n': # multiline value if buf and param_name: if param_value is None: param_value = [] @@ -223,43 +84,45 @@ def parse_block(self): else: param_value = buf.strip() if param_name: - data.append((param_name, param_value)) + block.append(Direction(param_name,param_value)) else: - data.append((param_value,)) + block.append(Direction(param_value,[])) param_name = None param_value = None buf = '' elif self.config[self.i] == '{': self.i += 1 - block = self.parse_block() - data.append({'name':param_name, 'param':buf.strip(), 'value':block}) + _block = self.parse_block(param_name,"") + _block.parameters=buf.strip() + block.append(_block) param_name = None param_value = None buf = '' elif self.config[self.i] == '}': self.i += 1 - return data - elif self.config[self.i] == '#': #skip comments + return block + elif self.config[self.i] == '#': # skip comments while self.i < self.length and self.config[self.i] != '\n': self.i += 1 else: buf += self.config[self.i] self.i += 1 - return data + return block def gen_block(self, blocks, offset): - subrez = '' # ready to return string + subrez = '' # ready to return string block_name = None block_param = '' for i, block in enumerate(blocks): if isinstance(block, tuple): - if len(block) == 1 and type(block[0]) == str: #single param + if len(block) == 1 and type(block[0]) == str: # single param subrez += self.off_char * offset + '%s;\n' % (block[0]) elif isinstance(block[1], str): subrez += self.off_char * offset + '%s %s;\n' % (block[0], block[1]) - else: #multiline + else: # multiline subrez += self.off_char * offset + '%s %s;\n' % (block[0], - self.gen_block(block[1], offset + len(block[0]) + 1).rstrip()) + self.gen_block(block[1], offset + len( + block[0]) + 1).rstrip()) elif isinstance(block, dict): block_value = self.gen_block(block['value'], offset + 4) @@ -270,10 +133,12 @@ def gen_block(self, blocks, offset): if subrez != '': subrez += '\n' subrez += '%(offset)s%(name)s %(param)s{\n%(data)s%(offset)s}\n' % { - 'offset':self.off_char * offset, 'name':block['name'], 'data':block_value, - 'param':param} + 'offset': self.off_char * offset, + 'name': block['name'], + 'data': block_value, + 'param': param} - elif isinstance(block, str): #multiline params + elif isinstance(block, str): # multiline params if i == 0: subrez += '%s\n' % block else: @@ -281,8 +146,8 @@ def gen_block(self, blocks, offset): if block_name: return '%(offset)s%(name)s %(param)s{\n%(data)s%(offset)s}\n' % { - 'offset':self.off_char * offset, 'name':block_name, 'data':subrez, - 'param':block_param} + 'offset': self.off_char * offset, 'name': block_name, 'data': subrez, + 'param': block_param} else: return subrez diff --git a/nginx/Nginx.py b/nginx/Nginx.py index 5e59ebe..c1433e5 100644 --- a/nginx/Nginx.py +++ b/nginx/Nginx.py @@ -2,14 +2,16 @@ import os import pathlib import random -import re import string import subprocess import sys from os import path +from typing import Union, Tuple import requests +from nginx import Url + class Nginx: command_config_test = ["nginx", "-t"] @@ -69,7 +71,7 @@ def pop_config(self): file.write(self.config_stack.pop()) return self.reload() - def forced_update(self, config_str): + def force_start(self, config_str): """ Simply reload the nginx with the configuration, don't check whether or not configuration is changed or not. If change causes nginx to fail, revert to last working config. @@ -95,44 +97,54 @@ def update_config(self, config_str) -> bool: if config_str == self.last_working_config: print("Configuration not changed, skipping nginx reload") return False - diff = str.join("\n", difflib.unified_diff(self.last_working_config.splitlines(), - config_str.splitlines(), - fromfile='Old Config', - tofile='New Config', - lineterm='\n')) + with open(self.config_file_path, "w") as file: file.write(config_str) - if not self.reload(): + result, data = self.reload(return_error=True) + if not result: + diff = str.join("\n", difflib.unified_diff(self.last_working_config.splitlines(), + config_str.splitlines(), + fromfile='Old Config', + tofile='New Config', + lineterm='\n')) print(diff, file=sys.stderr) - print("ERROR: Above change made nginx to fail. Thus it's rolled back", file=sys.stderr) - + if data is not None: + print(data, file=sys.stderr) + print("ERROR: New change made nginx to fail. Thus it's rolled back", file=sys.stderr) with open(self.config_file_path, "w") as file: file.write(self.last_working_config) return False else: - print(diff) + print("Nginx Reloaded Successfully") self.last_working_config = config_str return True - def reload(self) -> bool: + def reload(self, return_error=False) -> Union[bool, Tuple[bool, Union[str, None]]]: """ Reload nginx so that new configurations are applied. :return: true if nginx reload was successful false otherwise """ - if self.config_test(): - reload_result = subprocess.run(Nginx.command_reload, stdout=subprocess.PIPE, - stderr=subprocess.PIPE) - if reload_result.returncode is not 0: + reload_result = subprocess.run(Nginx.command_reload, stdout=subprocess.PIPE, + stderr=subprocess.PIPE) + if reload_result.returncode is not 0: + if return_error: + return False, reload_result.stderr.decode("utf-8") + else: print("Nginx reload failed with exit code ", file=sys.stderr) print(reload_result.stderr.decode("utf-8"), file=sys.stderr) - return False - return True - return False + result = False + else: + result = True + + if return_error: + return result, None + else: + return result def verify_domain(self, _domain: list or str): domain = [_domain] if type(_domain) is str else _domain ## when not included, one invalid domain in a list of 100 will make all domains to be unverified due to nginx failing to start. - domain = [x for x in domain if Nginx.is_valid_hostname(x)] + domain = [x for x in domain if Url.is_valid_hostname(x)] success = [] while True: r1 = "".join([random.choice(string.ascii_letters + string.digits) for _ in range(32)]) @@ -150,13 +162,17 @@ def verify_domain(self, _domain: list or str): if response.content.decode("utf-8") == r2: success.append(d) continue - print("[ERROR] " + url + "\n" + "Status Code :" + str(response.status_code), file=sys.stderr) - if len(response.content) > 0: - print(response.content.decode("utf-8"), file=sys.stderr) + print("[Error] [" + d + "] Not owned by this machine:" + "Status Code[" + str( + response.status_code) + "] -> " + url, file=sys.stderr) continue except requests.exceptions.RequestException as e: - print("[ERROR] Domain is not owned by this machine :" + d, file=sys.stderr) - print("Reason: " + str(e)) + error=str(e) + if error.find("Name does not resolve") > -1: + print("[Error] [" + d + "] Domain Name could not be resolved", file=sys.stderr) + elif error.find("Connection refused") >-1: + print("[Error] [" + d + "] Connection Refused! The port is filtered or not open.", file=sys.stderr) + else: + print("[ERROR] Domain is not owned by this machine : Reason: " + str(e)) continue os.remove(file) break @@ -164,24 +180,3 @@ def verify_domain(self, _domain: list or str): return len(success) > 0 else: return success - - @staticmethod - def is_valid_hostname(hostname: str): - """ - https://stackoverflow.com/a/33214423/2804342 - :return: True if for valid hostname False otherwise - """ - if hostname[-1] == ".": - # strip exactly one dot from the right, if present - hostname = hostname[:-1] - if len(hostname) > 253: - return False - - labels = hostname.split(".") - - # the TLD must be not all-numeric - if re.match(r"[0-9]+$", labels[-1]): - return False - - allowed = re.compile(r"(?!-)[a-z0-9-]{1,63}(? 'Url': + # Tried parsing urls with urllib.parse.urlparse but it doesn't work quiet + # well when scheme( eg: "https://") is missing eg "example.com" + # it says that example.com is path not the hostname. + if default_scheme is None: + default_scheme = [] + split_scheme = entry_string.strip().split("://", 1) + scheme, host_part = split_scheme if len(split_scheme) is 2 else (default_scheme, split_scheme[0]) + host_entries = host_part.split("/", 1) + hostport, location = (host_entries[0], "/" + host_entries[1]) if len(host_entries) is 2 else ( + host_entries[0], default_location) + hostport_entries = hostport.split(":", 1) + host, port = hostport_entries if len(hostport_entries) is 2 else (hostport_entries[0], default_port) + return Url(scheme, host if host else None, port, location) + + @staticmethod + def is_valid_hostname(hostname: str) -> bool: + """ + https://stackoverflow.com/a/33214423/2804342 + :return: True if for valid hostname False otherwise + """ + if hostname[-1] == ".": + # strip exactly one dot from the right, if present + hostname = hostname[:-1] + if len(hostname) > 253: + return False + + labels = hostname.split(".") + + # the TLD must be not all-numeric + if re.match(r"[0-9]+$", labels[-1]): + return False + + allowed = re.compile(r"(?!-)[a-z0-9-]{1,63}(?") - external, internal = host_list if len(host_list) is 2 else (host_list[0], "") - external, internal = (split_url(external), split_url(internal)) - c = Container(None, - scheme=internal["scheme"] if internal["scheme"] else "http", - address=None, - port=internal["port"] if internal["port"] else None, - path=internal["location"] if internal["location"] else "/") - h = Host.Host( - external["host"] if external["host"] else None, - # having https port on 80 will be detected later and used for redirection. - external["port"] if external["port"] else "80", - scheme=external["scheme"] if external["scheme"] else "http" - ) - - return (h, - external["location"] if external["location"] else "/", - c) - - - @staticmethod - def host_generator(container, service_id: str = None, known_networks: set = {}): - """ - :param container: - :param service_id: - :param known_networks: - :return: (Host,str,Container) - """ - c = Container(container.id) - network_settings = container.attrs["NetworkSettings"] + def get_env_map(container: DockerContainer): # first we get the list of tuples each containing data in form (key, value) env_list = [x.split("=", 1) for x in container.attrs['Config']['Env']] # convert the environment list into map - env_map = {x[0]: x[1].strip() for x in env_list if len(x) is 2 and x[1].strip()} - - # List all the environment variables with VIRTUAL_HOST and list them. - virtual_hosts = [x[1] for x in env_map.items() if x[0].startswith("VIRTUAL_HOST")] - if len(virtual_hosts) is 0: - raise NoHostConiguration() - - # Instead of directly processing container details, check whether or not it's accessible through known networks. - known_networks = set(known_networks) - unknown = True - for name, detail in network_settings["Networks"].items(): - c.add_network(detail["NetworkID"]) - # fix for https://trello.com/c/js37t4ld - if detail["Aliases"] is not None: - if detail["NetworkID"] in known_networks and unknown: - alias = detail["Aliases"][len(detail["Aliases"]) - 1] - ip_address = detail["IPAddress"] - network = name - if ip_address: - break - else: - raise UnreachableNetwork() - - override_ssl = False - override_port = None - if len(virtual_hosts) is 1: - if "LETSENCRYPT_HOST" in env_map: - override_ssl = True - if "VIRTUAL_PORT" in env_map: - override_port=env_map["VIRTUAL_PORT"] + return {x[0]: x[1].strip() for x in env_list if len(x) is 2} - for host_config in virtual_hosts: - host, location, container_data = Container._parse_host_entry(host_config) - container_data.address = ip_address - container_data.id = container.id - if override_port: - container_data.port = override_port - elif container_data.port is None: - if len(network_settings["Ports"]) is 1: - container_data.port = list(network_settings["Ports"].keys())[0].split("/")[0] - else: - container_data.port = "80" - if override_ssl: - if "ws" in host.scheme: - host.scheme = {"wss", "https"} - else: - host.scheme = {"https", } - yield (host, location, container_data) class UnconfiguredContainer(Exception): pass diff --git a/nginx_proxy/Host.py b/nginx_proxy/Host.py index 5d78122..a795962 100644 --- a/nginx_proxy/Host.py +++ b/nginx_proxy/Host.py @@ -1,3 +1,6 @@ +from typing import Set, Dict, Union, Any + +from nginx import Url from nginx_proxy import Container from nginx_proxy.Location import Location @@ -9,33 +12,65 @@ class Host: """ - def __init__(self, hostname, port, scheme="http"): - self.port = port - self.hostname = hostname - self.locations: dict[str:Location] = {} # the map of locations.and the container that serve the locations - self.container_set: set = set() - self.scheme = scheme + @staticmethod + def fromurl(url: Url): + return Host(url.hostname, url.port, url.scheme) + + def __init__(self, hostname: str, port: int, scheme=None): + if scheme is None: + scheme = {'http', } + self.port: int = port + self.hostname: str = hostname + self.locations: Dict[str, Location] = {} # the map of locations.and the container that serve the locations + self.container_set: Set[str] = set() + self.scheme: set = scheme + self.secured: bool = 'https' in scheme or 'wss' in scheme + self.full_redirect: Union[Url, None] = None + self.extras: Dict[str, Any] = {} - def set_external_parameters(self, host, port): + def set_external_parameters(self, host, port) -> None: self.hostname = host self.port = port - def add_container(self, location: str, container: Container, websocket=False, http=True): + def update_extras(self, extras: Dict[str, Any]) -> None: + for x in extras: + self.update_extras_content(x, extras[x]) + + def update_extras_content(self, key: str, value: Any) -> None: + if key in self.extras: + data = self.extras[key] + if type(data) in (dict, set): + self.extras[key].update(value) + elif type(data) is list: + self.extras[key].extend(value) + else: + self.extras[key] = value + else: + self.extras[key] = value + + def add_container(self, location: str, container: Container, websocket=False, http=True) -> None: if location not in self.locations: self.locations[location] = Location(location, is_websocket_backend=websocket, is_http_backend=http) elif websocket: - self.locations[location].websocket = self.locations[location].websocket or websocket + self.locations[location].websocket = websocket self.locations[location].http = self.locations[location].http or http self.locations[location].add(container) self.container_set.add(container.id) - def remove_container(self, container_id): + def update_with_host(self, host: 'Host') -> None: + for location in host.locations.values(): + for container in location.containers: + self.add_container(location.name, container, location.websocket, location.http) + self.container_set.add(container.id) + self.locations[location.name].update_extras(location.extras) + + def remove_container(self, container_id) -> None: removed = False deletions = [] if container_id in self.container_set: for path, location in self.locations.items(): removed = location.remove(container_id) or removed - if location.isEmpty(): + if location.isempty(): deletions.append(path) for path in deletions: del self.locations[path] @@ -43,14 +78,14 @@ def remove_container(self, container_id): self.container_set.remove(container_id) return removed - def isEmpty(self): + def isempty(self) -> bool: return len(self.container_set) == 0 - def isManaged(self): + def ismanaged(self) -> bool: return False - def is_redirect(self): - return False + def isredirect(self) -> bool: + return self.full_redirect is not None def __repr__(self): return str({ @@ -58,3 +93,10 @@ def __repr__(self): "locations": self.locations, "server_name": self.hostname, "port": self.port}) + + def __str__(self): + hostname= "%s:%s" % ( + self.hostname if self.hostname else '?', + str(self.port) if self.port is not None else '?') + + diff --git a/nginx_proxy/Location.py b/nginx_proxy/Location.py index 74d268c..52f9810 100644 --- a/nginx_proxy/Location.py +++ b/nginx_proxy/Location.py @@ -1,3 +1,5 @@ +from typing import Dict, Any + from . import Container @@ -11,11 +13,25 @@ def __init__(self, name, is_websocket_backend=False, is_http_backend=True): self.websocket = is_websocket_backend self.name = name self.containers = set() + self.extras: Dict[str, Any] = {} + + def update_extras(self, extras: Dict[str, Any]): + for x in extras: + if x in self.extras: + data = self.extras[x] + if type(data) in (dict, set): + self.extras[x].update(extras[x]) + elif type(data) in list: + self.extras[x].extend(extras[x]) + else: + self.extras[x] = extras[x] + else: + self.extras[x] = extras[x] def add(self, container: Container): self.containers.add(container) - def isEmpty(self): + def isempty(self): return len(self.containers) == 0 def remove(self, container: Container): diff --git a/nginx_proxy/ProxyConfigData.py b/nginx_proxy/ProxyConfigData.py new file mode 100644 index 0000000..8ee180e --- /dev/null +++ b/nginx_proxy/ProxyConfigData.py @@ -0,0 +1,106 @@ +from typing import Dict, Set, Generator, Tuple, Union + +from nginx_proxy.Host import Host + + +class ProxyConfigData: + """ + All the configuration data that are obtained from the current state of container. + nginx configuration or any other reverse proxy configuration can be generated using the data available here. + """ + + def __init__(self): + # map the hostname -> port -> hostCofiguration + self.config_map: Dict[str, Dict[int, Host]] = {} + self.containers: Set[str] = set() + self._len = 0 + + def getHost(self, hostname: str, port: int = 80) -> Union[None, Host]: + if hostname in self.config_map: + if port in self.config_map[hostname]: + return self.config_map[hostname][port] + return None + + def add_host(self, host: Host) -> None: + if host.hostname in self.config_map: + port_map = self.config_map[host.hostname] + if host.port in port_map: + existing_host: Host = port_map[host.port] + existing_host.secured = host.secured or existing_host.secured + existing_host.update_extras(host.extras) + for location in host.locations.values(): + for container in location.containers: + existing_host.add_container(location.name, container, location.websocket, location.http) + self.containers.add(container.id) + existing_host.locations[location.name].update_extras(location.extras) + return + else: + self._len = self._len + 1 + port_map[host.port] = host + + else: + self._len = self._len + 1 + self.config_map[host.hostname] = {host.port: host} + + for location in host.locations.values(): + for container in location.containers: + self.containers.add(container.id) + + def remove_container(self, container_id: str) -> Tuple[bool, Set[Tuple[str, int]]]: + removed_domains = set() + result = False + if container_id in self.containers: + self.containers.remove(container_id) + for host in self.host_list(): + if host.remove_container(container_id): + result = True + if host.isempty(): + host.extras={} + removed_domains.add((host.hostname, host.port)) + return result, removed_domains + + def has_container(self, container_id): + return container_id in self.containers + + def host_list(self) -> Generator[Host, None, None]: + for port_map in self.config_map.values(): + for host in port_map.values(): + yield host + + def __len__(self): + return self._len + + def print(self): + + for host in self.host_list(): + if host.port != 80: + url = "- " + ("https" if host.secured else "http") + "://" + host.hostname + ":" + str(host.port) + else: + url = "- " + ("https" if host.secured else "http") + "://" + host.hostname + if host.isredirect(): + print(url) + print(" redirect : ", host.full_redirect) + else: + if len(host.extras): + print(url) + self.printextra(" ", host.extras) + for location in host.locations.values(): + print(url + location.name) + print(" Type: ", "Websocket" if location.websocket else "Http") + if len(location.extras): + self.printextra(" ", location.extras) + + @staticmethod + def printextra(gap, extra): + print(gap + "Extras:") + for x in extra: + if x is 'security' or type(x) in (set, list): + print(gap + " " + x + ":") + for s in extra[x]: + print(gap + " " + s) + elif type(x) is dict: + print(gap + " " + x + ":") + for s in extra[x]: + print(gap + " " + s + ":" + extra[x][s]) + else: + print(gap + " " + x + " : " + str(extra[x])) diff --git a/nginx_proxy/SSL.py b/nginx_proxy/SSL.py index 741e030..2ea50ca 100644 --- a/nginx_proxy/SSL.py +++ b/nginx_proxy/SSL.py @@ -12,6 +12,7 @@ class SSL: + def __init__(self, ssl_path, nginx: Nginx): self.ssl_path = ssl_path self.nginx = nginx @@ -32,9 +33,18 @@ def __init__(self, ssl_path, nginx: Nginx): except FileExistsError as e: pass - def get_cert_file(self, domain): + def cert_file(self, domain): return os.path.join(self.ssl_path, "certs", domain + ".crt") + def private_file(self, domain): + return os.path.join(self.ssl_path, "private", domain + ".key") + + def selfsigned_cert_file(self, domain): + return os.path.join(self.ssl_path, "certs", domain + ".selfsigned.cert") + + def selfsigned_private_file(self, domain): + return os.path.join(self.ssl_path, "private", domain + "selfsgned.key") + def self_sign(self, domain): CERT_FILE = domain + ".selfsigned.crt" KEY_FILE = domain + ".selfsigned.key" @@ -64,7 +74,7 @@ def self_sign(self, domain): def expiry_time(self, domain) -> datetime: if self.cert_exists(domain): - with open(self.get_cert_file(domain)) as file: + with open(self.cert_file(domain)) as file: x509 = OpenSSL.crypto.load_certificate(OpenSSL.crypto.FILETYPE_PEM, file.read()) return datetime.datetime.strptime(x509.get_notAfter().decode(), "%Y%m%d%H%M%SZ") return datetime.datetime.now() @@ -99,10 +109,10 @@ def reuse(self, domain1, domain2): def register_certificate(self, domain, no_self_check=False, ignore_existing=False): if type(domain) is str: domain = [domain] + domain = [d for d in domain if + '.' in d] # when the domain doesn't have '.' it shouldn't be requested for letsencrypt certificate verified_domain = domain if no_self_check else self.nginx.verify_domain(domain) domain = verified_domain if ignore_existing else [x for x in verified_domain if not self.cert_exists(x)] - domain = [d for d in domain if - '.' in d] # when the domain doesn't have '.' it shouldn't be requested for letsencrypt certificate. if len(domain): logging.basicConfig(format='%(asctime)s - %(levelname)s - %(message)s', level=logging.DEBUG) acme = AcmeV2( @@ -145,7 +155,8 @@ def register_certificate_or_selfsign(self, domain, no_self_check=False, ignore_e def register_certificate_self_sign(self, domain): if type(domain) is str: - domain = [domain] - domain = [x for x in domain if not self.cert_exists_self_signed(x)] - for d in domain: - self.self_sign(d) + self.self_sign(domain) + else: + for d in domain: + if not self.cert_exists_self_signed(d): + self.self_sign(d) diff --git a/nginx_proxy/WebServer.py b/nginx_proxy/WebServer.py index 8dad038..d456232 100644 --- a/nginx_proxy/WebServer.py +++ b/nginx_proxy/WebServer.py @@ -1,96 +1,77 @@ import copy -import datetime import os import re import sys -import threading +import time +from typing import List import requests from docker import DockerClient +from docker.models.containers import Container as DockerContainer from jinja2 import Template +import nginx_proxy.post_processors as post_processors +import nginx_proxy.pre_processors as pre_processors from nginx.Nginx import Nginx from nginx_proxy import Container +from nginx_proxy import ProxyConfigData from nginx_proxy.Host import Host -from nginx_proxy.SSL import SSL class WebServer(): def __init__(self, client: DockerClient, *args): + import socket + def wait_nginx(): + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + result = sock.connect_ex(('127.0.0.1', 80)) + while result != 0: + print("Waiting for nginx process to be ready") + time.sleep(1) + result = sock.connect_ex(('127.0.0.1', 80)) + sock.close() + print("Nginx is alive") + + self.config = self.loadconfig() self.shouldExit = False self.client = client - challenge_dir = os.environ.get("CHALLENGE_DIR") conf_file = "/etc/nginx/conf.d/default.conf" - self.nginx = Nginx(conf_file, challenge_dir=challenge_dir) if challenge_dir else Nginx(conf_file) - self.ssl = SSL("/etc/ssl", self.nginx) - self.containers = set() + self.nginx = Nginx(conf_file, self.config['challenge_dir']) + self.config_data = ProxyConfigData() self.services = set() self.networks = {} self.conf_file_name = "/etc/nginx/conf.d/default.conf" self.host_file = "/etc/hosts" - self.hosts = {} - self.ssl_certificates = {} - self.self_signed_certificates = set() - self.next_ssl_expiry = None - file = open("vhosts_template/default.conf.template") + file = open("vhosts_template/default.conf.jinja2") self.template = Template(file.read()) file.close() self.learn_yourself() - self.rescan_all_container() - self.rescan_time = None - self.lock = threading.Condition() - self.expiry_changed = threading.Event() - self.certificate_expiry_thread = threading.Thread(target=self.check_certificate_expiry) + self.ssl_processor = post_processors.SslCertificateProcessor(self.nginx, self, start_ssl_thread=False) + self.basic_auth_processor = post_processors.BasicAuthProcessor() + self.redirect_processor = post_processors.RedirectProcessor() if self.nginx.config_test(): - if not self.nginx.start(): + if len(self.nginx.last_working_config) < 50: + print("Writing default config before reloading server.") + if not self.nginx.force_start(self.template.render(config=self.config)): + print("Nginx failed when reloaded with default config", file=sys.stderr) + print("Exiting .....", file=sys.stderr) + exit(1) + elif not self.nginx.start(): print("ERROR: Config test succeded but nginx failed to start", file=sys.stderr) print("Exiting .....", file=sys.stderr) - self.reload() + exit(1) else: - print("ERROR: Existing nginx configuration has error, trying to override with new configuration", + print("ERROR: Existing nginx configuration has error, trying to override with default configuration", file=sys.stderr) - if not self.reload(forced=True): - print("ERROR: Existing nginx configuration has error", file=sys.stderr) - print("ERROR: New generated configuration also has error", file=sys.stderr) - print("Please check the configuration of your containers and restart this container", file=sys.stderr) - print("EXITING .....", file=sys.stderr) + if not self.nginx.force_start(self.template.render(config=self.config)): + print("Nginx failed when reloaded with default config", file=sys.stderr) + print("Exiting .....", file=sys.stderr) exit(1) - self.certificate_expiry_thread.start() + wait_nginx() - def check_certificate_expiry(self): - self.lock.acquire() - while True: - if self.shouldExit: - return - if self.next_ssl_expiry is None: - print("[SSL Refresh Thread] Looks like there no ssl certificates, Sleeping until there's one") - self.lock.wait() - else: - now = datetime.datetime.now() - remaining_days = (self.next_ssl_expiry - now).days - remaining_days = 30 if remaining_days > 30 else remaining_days - - if remaining_days > 2: - print( - "[SSL Refresh Thread] All the certificates are up to date sleeping for" + str( - remaining_days) + "days.") - self.lock.wait((remaining_days - 2) * 3600 * 24) - else: - print("[SSL Refresh Thread] Looks like we need to refresh certificates that are about to expire") - for x in self.ssl_certificates: - print("Remaining days :", x, ":", (self.ssl_certificates[x] - now).days) - x = [x for x in self.ssl_certificates if (self.ssl_certificates[x] - now).days < 6] - acme_ssl_certificates = set(self.ssl.register_certificate_or_selfsign(x, ignore_existing=True)) - for host in x: - if host not in acme_ssl_certificates: - del self.ssl_certificates[host] - if not self.ssl.cert_exists_wildcard(host): - self.self_signed_certificates.add(host) - else: - self.ssl_certificates[host] = self.ssl.expiry_time(domain=host) - self.next_ssl_expiry = min(self.ssl_certificates.values()) - self.reload(forced=True) + self.rescan_all_container() + self.reload() + self.ssl_processor.certificate_expiry_thread.start() def learn_yourself(self): """ @@ -100,7 +81,7 @@ def learn_yourself(self): """ try: file = open("/proc/self/cgroup") - self.id = [l for l in file.read().split("\n") if l.find("cpu") != -1][0].split("/")[-1] + self.id = [l.strip() for l in file.readlines() if l.find("cpu") != -1][0].split("/")[-1] if len(self.id) > 64: slice = [x for x in re.split('[^a-fA-F0-9]', self.id) if len(x) is 64] if len(slice) is 1: @@ -112,6 +93,8 @@ def learn_yourself(self): self.networks = [a for a in self.container.attrs["NetworkSettings"]["Networks"].keys()] self.networks = {self.client.networks.get(a).id: a for a in self.networks} file.close() + except (KeyboardInterrupt, SystemExit) as e: + raise e except Exception as e: print("[ERROR]Couldn't determine container ID of this container:", e.args, "\n Is it running in docker environment?", @@ -120,144 +103,67 @@ def learn_yourself(self): network = self.client.networks.get("frontend") self.networks[network.id] = "frontend" - def _register_container(self, container): + def _register_container(self, container: DockerContainer): """ Find the details about container and register it and return True. If it's not configured with desired settings or is not accessible, return False @:returns True if the container is added to virtual hosts, false otherwise. """ - found = False - try: - for host, location, container in Container.Container.host_generator(container, - known_networks=self.networks.keys()): - websocket = "ws" in host.scheme or "wss" in host.scheme - secured = 'https' in host.scheme or 'wss' in host.scheme - http = 'http' in host.scheme or 'https' in host.scheme - # it might return string if there's a error in processing - if type(host) is not str: - if (host.hostname, host.port) in self.hosts: - existing_host: Host = self.hosts[(host.hostname, host.port)] - existing_host.add_container(location, container, websocket=websocket, http=http) - ## if any of the containers in for the virtualHost require https, the all others will be redirected to https. - if secured: - existing_host.secured = True - host = existing_host - else: - host.secured = secured - host.add_container(location, container, websocket=websocket, http=http) - self.hosts[(host.hostname, host.port)] = host - - if host.secured: - - if host.hostname not in self.ssl_certificates: - host.ssl_expiry = self.ssl.expiry_time(host.hostname) - if (host.ssl_expiry - datetime.datetime.now()).days > 2: - self.ssl_certificates[host.hostname] = host.ssl_expiry - else: - host.ssl_expiry = self.ssl_certificates[host.hostname] - - found = True - self.containers.add(container.id) - - except Container.NoHostConiguration: - print("Skip Container:", "No VIRTUAL_HOST configuration", "Id:" + container.id, - "Name:" + container.attrs["Name"].replace("/", ""), sep="\t") - except Container.UnreachableNetwork: - print("Skip Container:", "UNREACHABLE Network ", "Id:" + container.id, - "Name:" + container.attrs["Name"].replace("/", ""), sep="\t") - return found + environments = Container.Container.get_env_map(container) + known_networks = set(self.networks.keys()) + hosts = pre_processors.process_virtual_hosts(container, environments, known_networks) + if len(hosts): + pre_processors.process_default_server(container, environments, hosts) + pre_processors.process_basic_auth(container, environments, hosts.config_map) + pre_processors.process_redirection(container, environments, hosts.config_map) + hosts.print() + for h in hosts.host_list(): + self.config_data.add_host(h) + return len(hosts) > 0 # removes container from the maintained list. # this is called when a caontainer dies or leaves a known network - def remove_container(self, container): - if type(container) is Container: - container = container.id - - if container in self.containers: - removed = False - deletions = [] - for host in self.hosts.values(): - if host.remove_container(container): - removed = True - if host.isEmpty(): - if host.scheme == "https": - if host.hostname in self.self_signed_certificates: - self.self_signed_certificates.remove(host.hostname) - else: - del self.ssl_certificates[host.hostname] - deletions.append((host.hostname, host.port)) - if removed: - for d in deletions: - del self.hosts[d] - self.containers.remove(container) - return self.reload() + def remove_container(self, container_id: str): + deleted, deleted_domain = self.config_data.remove_container(container_id) + if deleted: + self.reload() def reload(self, forced=False) -> bool: - self.lock.acquire() """ Creates a new configuration based on current state and signals nginx to reload. This is called whenever there's change in container or network state. :return: """ - host_list = [copy.deepcopy(host) for host in self.hosts.values()] - - next_reload = None - now = datetime.datetime.now() - ssl_requests = set() - for host in host_list: - host.locations = list(host.locations.values()) + self.redirect_processor.process_redirection(self.config_data) + hosts: List[Host] = [] + has_default = False + for host_data in self.config_data.host_list(): + host = copy.deepcopy(host_data) host.upstreams = {} - for i, location in enumerate(host.locations): + host.is_down = host_data.isempty() + if 'default_server' in host.extras: + if has_default: + del host.extras['default_server'] + else: + has_default = True + for i, location in enumerate(host.locations.values()): location.container = list(location.containers)[0] if len(location.containers) > 1: - location.upstream = host.hostname + "-" + str(host.port) + "-" + str(i + 1) + location.upstream = host_data.hostname + "-" + str(host.port) + "-" + str(i + 1) host.upstreams[location.upstream] = location.containers else: location.upstream = False host.upstreams = [{"id": x, "containers": y} for x, y in host.upstreams.items()] - if host.secured: - if int(host.port) in (80, 443): - host.ssl_redirect = True - host.port = 443 - host.ssl_host = True - wildcard = self.ssl.wildcard_domain_name(host.hostname) - if wildcard is not None: - if self.ssl.cert_exists(wildcard): - host.ssl_file = wildcard - continue - if host.hostname in self.ssl_certificates: - host.ssl_file = host.hostname - elif host.hostname in self.self_signed_certificates: - host.ssl_file = host.hostname + ".selfsigned" - else: - ssl_requests.add(host.hostname) + hosts.append(host) - if len(ssl_requests): - registered = self.ssl.register_certificate_or_selfsign(list(ssl_requests)) - for host in host_list: - if host.hostname in ssl_requests: - if host.hostname not in registered: - self.ssl.register_certificate_self_sign(host.hostname) - host.ssl_file = host.hostname + ".selfsigned" - self.self_signed_certificates.add(host.hostname) - else: - host.ssl_file = host.hostname - self.ssl_certificates[host.hostname] = self.ssl.expiry_time(host.hostname) - host.ssl_expiry = self.ssl_certificates[host.hostname] - if self.next_ssl_expiry: - if self.next_ssl_expiry > host.ssl_expiry: - self.next_ssl_expiry = host.ssl_expiry - self.lock.notify() - else: - self.lock.notify() - self.next_ssl_expiry = host.ssl_expiry - - output = self.template.render(virtual_servers=host_list, challenge_dir=self.nginx.challenge_dir) + self.basic_auth_processor.process_basic_auth(hosts) + self.ssl_processor.process_ssl_certificates(hosts) + self.config['default_server'] = not has_default + output = self.template.render(virtual_servers=hosts, config=self.config) if forced: - response = self.nginx.forced_update(output) + response = self.nginx.force_start(output) else: response = self.nginx.update_config(output) - self.lock.release() return response def disconnect(self, network, container, scope): @@ -276,11 +182,10 @@ def connect(self, network, container, scope): if network not in self.networks: self.networks[network] = self.client.networks.get(network).name self.rescan_and_reload() - elif container not in self.containers and network in self.networks: - if self.update_container(container): - self.reload() + elif network in self.networks: + self.update_container(container) - def update_container(self, container): + def update_container(self, container_id): ''' Rescan the container to detect changes. And update nginx configuration if necessary. This is usually called in one of the following conditions: @@ -291,8 +196,10 @@ def update_container(self, container): :return: true if container state change affected the nginx configuration else false ''' try: - if self._register_container(self.client.containers.get(container)): - return True + if not self.config_data.has_container(container_id): + if self._register_container(self.client.containers.get(container_id)): + self.reload() + return True except requests.exceptions.HTTPError as e: pass return False @@ -316,7 +223,11 @@ def rescan_and_reload(self): return self.reload() def cleanup(self): - self.lock.acquire() - self.shouldExit = True - self.lock.notify() - self.lock.release() + self.ssl_processor.shutdown() + + def loadconfig(self): + return { + 'client_max_body_size': os.getenv("CLIENT_MAX_BODY_SIZE", "1m"), + 'challenge_dir': os.getenv("CHALLENGE_DIR", "/tmp/acme-challenges"), + 'default_server': os.getenv("DEFAULT_HOST", "true") == "true" + } diff --git a/nginx_proxy/__init__.py b/nginx_proxy/__init__.py index e69de29..b6d8ee3 100644 --- a/nginx_proxy/__init__.py +++ b/nginx_proxy/__init__.py @@ -0,0 +1,2 @@ +from .Host import Host +from .ProxyConfigData import ProxyConfigData diff --git a/nginx_proxy/post_processors/__init__.py b/nginx_proxy/post_processors/__init__.py new file mode 100644 index 0000000..a358ecc --- /dev/null +++ b/nginx_proxy/post_processors/__init__.py @@ -0,0 +1,3 @@ +from .basic_auth_processor import BasicAuthProcessor +from .redirect_processor import RedirectProcessor +from .ssl_certificate_processor import SslCertificateProcessor diff --git a/nginx_proxy/post_processors/basic_auth_processor.py b/nginx_proxy/post_processors/basic_auth_processor.py new file mode 100644 index 0000000..9b9d229 --- /dev/null +++ b/nginx_proxy/post_processors/basic_auth_processor.py @@ -0,0 +1,47 @@ +import crypt +import os +from pathlib import Path +from random import random +from typing import List, Dict + +from nginx_proxy.Host import Host + + +class BasicAuthProcessor(): + def __init__(self, basic_auth_dir: str = "/etc/nginx/basic_auth"): + self.cache: Dict[str:hash] = {} + self.basic_auth_dir = basic_auth_dir + if not os.path.exists(basic_auth_dir): + Path(basic_auth_dir).mkdir(parents=True) + # self.certificate_expiry_thread = threading.Thread(target=self.check_certificate_expiry) + # self.certificate_expiry_thread.start() + + @staticmethod + def salt(): + """Returns a string of 2 randome letters""" + letters = 'abcdefghijklmnopqrstuvwxyz' \ + 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' \ + '0123456789/.' + return random.choice(letters) + random.choice(letters) + + def generate_htpasswd_file(self, folder, file, securities): + data = [user + ':' + crypt.crypt(securities[user]) for user in securities] + folder = os.path.join(self.basic_auth_dir, folder) + if not os.path.exists(folder): + os.mkdir(folder) + + file = os.path.join(folder, file) + with open(file, "w") as openfile: + openfile.write('\n'.join(data)) + return file + + def process_basic_auth(self, hosts: List[Host]): + for host in hosts: + if 'security' in host.extras: + host.extras['security_file'] = self.generate_htpasswd_file(host.hostname, '_', host.extras['security']) + + for location in host.locations.values(): + if 'security' in location.extras: + location.extras['security_file'] = self.generate_htpasswd_file(host.hostname, + location.name.replace('/', '_'), + location.extras['security']) diff --git a/nginx_proxy/post_processors/redirect_processor.py b/nginx_proxy/post_processors/redirect_processor.py new file mode 100644 index 0000000..902d1b0 --- /dev/null +++ b/nginx_proxy/post_processors/redirect_processor.py @@ -0,0 +1,26 @@ +from nginx_proxy import ProxyConfigData + + +class RedirectProcessor: + def __init__(self, ): + pass + + def process_redirection(self, config: ProxyConfigData): + redirected_hosts = {} + for host in config.host_list(): + if host.isredirect(): + redirected_hosts[host.hostname] = host.full_redirect + target = config.getHost(host.full_redirect.hostname) + if target is not None: + if target.hostname == host.hostname: + host.full_redirect = None + if target.hostname in redirected_hosts: + continue + target.update_with_host(host) + host.container_set = set() + host.locations = {} + host.secured = host.secured or target.secured + target.secured = host.secured + target.update_extras(extras=host.extras) + host.extras = {} + host.is_redirect = host.isredirect() diff --git a/nginx_proxy/post_processors/ssl_certificate_processor.py b/nginx_proxy/post_processors/ssl_certificate_processor.py new file mode 100644 index 0000000..0330a5b --- /dev/null +++ b/nginx_proxy/post_processors/ssl_certificate_processor.py @@ -0,0 +1,102 @@ +import threading +from datetime import date, datetime +from typing import List, Dict, Set, Union + +from nginx.Nginx import Nginx +from nginx_proxy import WebServer +from nginx_proxy.Host import Host +from nginx_proxy.SSL import SSL + + +class SslCertificateProcessor(): + def __init__(self, nginx: Nginx, server: WebServer, start_ssl_thread=False): + self.cache: Dict[str:date] = {} + self.self_signed: Set[str] = set() + self.shutdown_requested: bool = False + self.lock: threading.Condition = threading.Condition() + self.nginx: Nginx = nginx + self.ssl: SSL = SSL("/etc/ssl", nginx) + self.server: WebServer = server + self.next_ssl_expiry: Union[datetime, None] = None + self.certificate_expiry_thread: threading.Thread = threading.Thread(target=self.update_ssl_certificates) + if start_ssl_thread: + self.certificate_expiry_thread.start() + + def update_ssl_certificates(self): + self.lock.acquire() + while not self.shutdown_requested: + if self.next_ssl_expiry is None: + print("[SSL Refresh Thread] Looks like there no ssl certificates, Sleeping until there's one") + self.lock.wait() + else: + now = datetime.now() + remaining_days = (self.next_ssl_expiry - now).days + + if remaining_days > 2: + print("[SSL Refresh Thread] SSL certificate status:") + + max_size = max([len(x) for x in self.cache]) + for host in self.cache: + print(' {host: <{width}} - {remain}'.format(host=host, width=max_size + 2, + remain=self.cache[host] - now)) + sleep_time = (32 if remaining_days > 30 else remaining_days) - 2 + print( + "[SSL Refresh Thread] All the certificates are up to date sleeping for " + str( + sleep_time) + " days.") + self.lock.wait(sleep_time * 3600 * 24 - 10) + else: + print( + "[SSL Refresh Thread] Looks like we need to refresh certificates that are about to expire") + for x in self.cache: + print("Remaining days :", x, ":", (self.cache[x] - now).days) + x = [x for x in self.cache if (self.cache[x] - now).days < 6] + self.server.reload() + + def process_ssl_certificates(self, hosts: List[Host]): + ssl_requests: Set[Host] = set() + self.lock.acquire() + for host in hosts: + if host.secured: + if int(host.port) in (80, 443): + host.ssl_redirect = True + host.port = 443 + if host.hostname in self.cache: + host.ssl_file = host.hostname + else: + wildcard = self.ssl.wildcard_domain_name(host.hostname) + if wildcard is not None: + if self.ssl.cert_exists(wildcard): + host.ssl_file = wildcard + continue + # find the ssl certificate if it exists + time = self.ssl.expiry_time(host.hostname) + if (time - datetime.now()).days > 2: + self.cache[host.hostname] = time + host.ssl_file = host.hostname + else: + ssl_requests.add(host) + + if len(ssl_requests): + registered = self.ssl.register_certificate_or_selfsign([h.hostname for h in ssl_requests], + ignore_existing=False) + for host in ssl_requests: + if host.hostname not in registered: + host.ssl_file = host.hostname + ".selfsigned" + self.self_signed.add(host.hostname) + else: + host.ssl_file = host.hostname + self.cache[host.hostname] = self.ssl.expiry_time(host.hostname) + host.ssl_expiry = self.cache[host.hostname] + if len(self.cache): + expiry = min(self.cache.values()) + if expiry != self.next_ssl_expiry: + self.next_ssl_expiry = expiry + self.lock.notify() + self.lock.release() + + def shutdown(self): + self.lock.acquire() + self.shutdown_requested = True + self.lock.notify() + self.lock.release() + pass diff --git a/nginx_proxy/pre_processors/__init__.py b/nginx_proxy/pre_processors/__init__.py new file mode 100644 index 0000000..f06cfb7 --- /dev/null +++ b/nginx_proxy/pre_processors/__init__.py @@ -0,0 +1,4 @@ +from .basic_auth_processor import process_basic_auth as process_basic_auth +from .default_server_processor import process_default_server as process_default_server +from .redirect_processor import process_redirection +from .virtual_host_processor import process_virtual_hosts as process_virtual_hosts diff --git a/nginx_proxy/pre_processors/basic_auth_processor.py b/nginx_proxy/pre_processors/basic_auth_processor.py new file mode 100644 index 0000000..834fff4 --- /dev/null +++ b/nginx_proxy/pre_processors/basic_auth_processor.py @@ -0,0 +1,63 @@ +# this one will get the list of environment variables and will process them. +from typing import Dict, Tuple, List + +from nginx import Url +from nginx_proxy.Host import Host + +htaccess_folder = "/etc/nginx/generated/htaccess" +from docker.models.containers import Container + +def process_basic_auth(container: Container, environments: map, vhost_map: Dict[str, Dict[int, Host]]): + def get_auth_map(credentials: str) -> Dict[str, str]: + auth_map = {} + for credential in credentials.split(','): + username_password = credential.split(':') + if len(username_password) == 2: + u = username_password[0].strip() + p = username_password[1].strip() + if len(u) > 2 and len(p) > 2: + auth_map[u] = p + return auth_map + + def update_security(): + if basic_auth_host.location == '/': + host.update_extras_content('security', keys) + else: + for location in host.locations.values(): + if location.name.startswith(basic_auth_host.location): + if 'security' in location.extras: + location.extras['security'].update(keys) + else: + location.extras['security'] = keys + + auth_env = [e[1] for e in environments.items() if e[0].startswith("PROXY_BASIC_AUTH")] + if len(auth_env): + auth_list: List[Tuple[Url, Dict[str, str]]] = [] + for auth_entry in auth_env: + host_list = auth_entry.split("->") + if len(host_list) is 2: + url = host_list[0] + keys = get_auth_map(host_list[1]) + auth_list.append((Url.parse(url, default_location='/',default_port=80), keys)) + elif len(host_list) is 1: + keys = get_auth_map(auth_entry) + if len(keys): + auth_list.append((Url.root, keys)) + + for basic_auth_host, keys in auth_list: + # if there is no hostname in auth, then there must be + if basic_auth_host.hostname is None: + if len(vhost_map) == 1: + port_map = list(vhost_map.values())[0] + if len(port_map) == 1: + host = list(port_map.values())[0] + update_security() + elif basic_auth_host.hostname in vhost_map: + port_map = vhost_map[basic_auth_host.hostname] + if basic_auth_host.port in port_map: + host = port_map[basic_auth_host.port] + update_security() + else: + print("Basic Auth for "+basic_auth_host.hostname+":"+str(basic_auth_host.port)+" in container with "+str(list(vhost_map.keys()))) + else: + print("Unknown hostname : "+basic_auth_host.hostname+"+ in PROXY_BASIC_AUTH in container: " + container.name) diff --git a/nginx_proxy/pre_processors/default_server_processor.py b/nginx_proxy/pre_processors/default_server_processor.py new file mode 100644 index 0000000..abc722e --- /dev/null +++ b/nginx_proxy/pre_processors/default_server_processor.py @@ -0,0 +1,29 @@ +# this one will get the list of environment variables and will process them. +from typing import Dict + +from nginx import Url +from nginx_proxy import ProxyConfigData +from nginx_proxy.Host import Host + +htaccess_folder = "/etc/nginx/generated/htaccess" +from docker.models.containers import Container + + +def process_default_server(container: Container, environments: Dict[str, str], vhosts: ProxyConfigData): + if 'PROXY_DEFAULT_SERVER' in environments: + server: str = environments['PROXY_DEFAULT_SERVER'] + url = Url.parse(server, default_port=80) + if url.hostname not in ("true","false","yes") and Url.is_valid_hostname(url.hostname): + host = vhosts.getHost(hostname=url.hostname) + if host is None: + host = Host.fromurl(url) + vhosts.add_host(host) + else: + if len(vhosts) is 1: + for host in vhosts.host_list(): + pass + else: + print("DEFAULT_SERVER configured for ", container.name, "but has multiple hosts") + return + + host.update_extras_content("default_server", "default_server") diff --git a/nginx_proxy/pre_processors/redirect_processor.py b/nginx_proxy/pre_processors/redirect_processor.py new file mode 100644 index 0000000..28ba8b2 --- /dev/null +++ b/nginx_proxy/pre_processors/redirect_processor.py @@ -0,0 +1,52 @@ +# this one will get the list of environment variables and will process them. +# this one can call itself before each configuration and modify the dhparam parameter. +import re +from typing import Dict + +from nginx import Url +from nginx_proxy import Container, Host + + +def process_redirection(container: Container, environments: map, vhost_map: Dict[str, Dict[int, Host]]): + redirect_env = [e[1] for e in environments.items() if e[0].startswith("PROXY_FULL_REDIRECT")] + hosts = [] + for port_map in vhost_map.values(): + for vhosts in port_map.values(): + hosts.append(vhosts) + single_host = len(hosts) == 1 + if len(redirect_env): + for redirect_entry in redirect_env: + redirect_entry = re.sub(r"\s+", "", redirect_entry, flags=re.UNICODE) + + split = redirect_entry.split("->") + if len(split) is 2: + _sources, target = split + sources = [Url.parse(source) for source in _sources.split(',')] + target = Url.parse(target, default_port=80) + if single_host: + if target.hostname is None: + target = single_host + elif target.hostname is None: + print("Unknown target to redirect with PROXY_FULL_REDIRECT" + redirect_entry) + continue + for source in sources: + if source.hostname is not None: + port = 80 if source.port is None else source.port + if source.hostname not in vhost_map: + host = Host(source.hostname, port) + host.full_redirect = target + vhost_map[source.hostname] = {port: host} + else: + if port in vhost_map[source.hostname]: + existing_host = vhost_map[source.hostname][port] + existing_host.full_redirect = target + else: + host = Host(source.hostname, port) + host.full_redirect = target + vhost_map[source.hostname][port] = host + if target.hostname not in vhost_map: + vhost_map[target.hostname] = {target.port: Host(target.hostname, target.port)} + elif target.port not in vhost_map[target.hostname]: + vhost_map[target.hostname][target.port] = Host(target.hostname, target.port) + else: + print("Invalid entry of PROXY_FULL_REDIRECT :" + redirect_entry) diff --git a/nginx_proxy/pre_processors/vhost_template_processor.py b/nginx_proxy/pre_processors/vhost_template_processor.py new file mode 100644 index 0000000..7cdfb63 --- /dev/null +++ b/nginx_proxy/pre_processors/vhost_template_processor.py @@ -0,0 +1,2 @@ +# this one will get all the sites and need to determine if it has a template that can be loaded. +# TODO diff --git a/nginx_proxy/pre_processors/virtual_host_processor.py b/nginx_proxy/pre_processors/virtual_host_processor.py new file mode 100644 index 0000000..52ff923 --- /dev/null +++ b/nginx_proxy/pre_processors/virtual_host_processor.py @@ -0,0 +1,131 @@ +from docker.models.containers import Container as DockerContainer + +from nginx_proxy import Host, ProxyConfigData +from nginx_proxy.Container import Container, NoHostConiguration, UnreachableNetwork +from nginx_proxy.utils import split_url + + +def process_virtual_hosts(container: DockerContainer, environments: map, known_networks: set) -> ProxyConfigData: + """ + + :param container: + :param environments: parsed container environment + :param known_networks: networks known to the nginx-proxy container + :return: + """ + hosts = ProxyConfigData() + try: + for host, location, proxied_container, extras in host_generator(container, known_networks=known_networks): + websocket = "ws" in host.scheme or "wss" in host.scheme + secured = 'https' in host.scheme or 'wss' in host.scheme + http = 'http' in host.scheme or 'https' in host.scheme + # it might return string if there's a error in processing + if type(host) is not str: + host.add_container(location, proxied_container, websocket=websocket, http=http) + if len(extras): + host.locations[location].update_extras({'injected': extras}) + hosts.add_host(host) + print("Valid configuration ", "Id:" + container.id[:12], + " " + container.attrs["Name"].replace("/", ""), sep="\t") + return hosts + except NoHostConiguration: + print("No VIRTUAL_HOST ", "Id:" + container.id[:12], + " " + container.attrs["Name"].replace("/", ""), sep="\t") + except UnreachableNetwork: + print("Unreachable Network ", "Id:" + container.id[:12], + " " + container.attrs["Name"].replace("/", ""), sep="\t") + return hosts + + +def _parse_host_entry(entry_string: str): + """ + + :param entry_string: + :return: (dict,dict) + """ + configs = entry_string.split(";", 1) + extras = set() + if len(configs) > 1: + entry_string = configs[0] + for x in configs[1].split(';'): + x = x.strip() + if x: + extras.add(x) + host_list = entry_string.strip().split("->") + external, internal = host_list if len(host_list) is 2 else (host_list[0], "") + external, internal = (split_url(external), split_url(internal)) + c = Container(None, + scheme=internal["scheme"] if internal["scheme"] else "http", + address=None, + port=internal["port"] if internal["port"] else None, + path=internal["location"] if internal["location"] else "/") + h = Host( + external["host"] if external["host"] else None, + # having https port on 80 will be detected later and used for redirection. + int(external["port"]) if external["port"] else 80, + scheme=external["scheme"] if external["scheme"] else {"http"} + ) + return (h, + external["location"] if external["location"] else "/", + c, extras) + + +def host_generator(container: DockerContainer, service_id: str = None, known_networks: set = {}): + """ + :param container: + :param service_id: + :param known_networks: + :return: (Host,str,Container,set) + """ + c = Container(container.id) + network_settings = container.attrs["NetworkSettings"] + env_map = Container.get_env_map(container) + + # List all the environment variables with VIRTUAL_HOST and list them. + virtual_hosts = [x[1] for x in env_map.items() if x[0].startswith("VIRTUAL_HOST")] + if len(virtual_hosts) is 0: + raise NoHostConiguration() + + # Instead of directly processing container details, check whether or not it's accessible through known networks. + known_networks = set(known_networks) + unknown = True + for name, detail in network_settings["Networks"].items(): + c.add_network(detail["NetworkID"]) + # fix for https://trello.com/c/js37t4ld + if detail["Aliases"] is not None: + if detail["NetworkID"] in known_networks and unknown: + alias = detail["Aliases"][len(detail["Aliases"]) - 1] + ip_address = detail["IPAddress"] + network = name + if ip_address: + break + else: + raise UnreachableNetwork() + + override_ssl = False + override_port = None + if len(virtual_hosts) is 1: + if "LETSENCRYPT_HOST" in env_map: + override_ssl = True + if "VIRTUAL_PORT" in env_map: + override_port = env_map["VIRTUAL_PORT"] + + for host_config in virtual_hosts: + host, location, container_data, extras = _parse_host_entry(host_config) + container_data.address = ip_address + container_data.id = container.id + if override_port: + container_data.port = override_port + elif container_data.port is None: + if len(network_settings["Ports"]) is 1: + container_data.port = int(list(network_settings["Ports"].keys())[0].split("/")[0]) + else: + container_data.port = 80 + if override_ssl: + if "ws" in host.scheme: + host.scheme = {"wss", "https"} + host.secured = True + else: + host.scheme = {"https", } + host.secured = 'https' in host.scheme or host.port == 443 + yield (host, location, container_data, extras) diff --git a/nginx_proxy/utils/__init__.py b/nginx_proxy/utils/__init__.py new file mode 100644 index 0000000..6e31328 --- /dev/null +++ b/nginx_proxy/utils/__init__.py @@ -0,0 +1,24 @@ +from typing import Union, Dict + + +def split_url(entry_string: str, default_scheme=None, default_port=None, default_location=None) -> Dict[ + str, Union[str, int]]: + # Tried parsing urls with urllib.parse.urlparse but it doesn't work quiet + # well when scheme( eg: "https://") is missing eg "example.com" + # it says that example.com is path not the hostname. + if default_scheme is None: + default_scheme = [] + split_scheme = entry_string.strip().split("://", 1) + scheme, host_part = split_scheme if len(split_scheme) is 2 else (default_scheme, split_scheme[0]) + host_entries = host_part.split("/", 1) + hostport, location = (host_entries[0], "/" + host_entries[1]) if len(host_entries) is 2 else ( + host_entries[0], default_location) + hostport_entries = hostport.split(":", 1) + host, port = hostport_entries if len(hostport_entries) is 2 else (hostport_entries[0], default_port) + + return { + "scheme": set([x for x in scheme.split("+") if x]) if scheme else default_scheme, + "host": host if host else None, + "port": port, + "location": location + } diff --git a/nginx_proxy/vhosts.py b/nginx_proxy/vhosts.py new file mode 100644 index 0000000..208e73a --- /dev/null +++ b/nginx_proxy/vhosts.py @@ -0,0 +1,11 @@ +from nginx.ConfigParser import ConfigParser as Parser + + +def get_vhost_as_template(vhost_file: str): + parser = Parser() + parser.loadf(vhost_file) + b=3 + print(str(parser.data)) + parser.loadf(vhost_file) + + diff --git a/requirements.txt b/requirements.txt index 8796a8e..351e659 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ -docker==4.1.0 -Jinja2==2.10.3 -acme-nginx==0.2.1 -pydevd==1.9.0 +docker==4.2.0 +Jinja2==2.11.1 +pyOpenSSL==19.1.0 +pycrypto==2.6.1 +pydevd==1.9.0 \ No newline at end of file diff --git a/vhosts_template/default.conf.jinja2 b/vhosts_template/default.conf.jinja2 new file mode 100644 index 0000000..020ebc6 --- /dev/null +++ b/vhosts_template/default.conf.jinja2 @@ -0,0 +1,96 @@ +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} + +proxy_cache off; +proxy_request_buffering off; + +ssl_ciphers 'ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES256-SHA384:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA:ECDHE-RSA-AES256-SHA:DHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA:DHE-RSA-AES256-SHA256:DHE-RSA-AES256-SHA:AES128-GCM-SHA256:AES256-GCM-SHA384:AES128-SHA256:AES256-SHA256:AES128-SHA:AES256-SHA:!DSS'; +ssl_protocols TLSv1 TLSv1.1 TLSv1.2 TLSv1.3; +ssl_prefer_server_ciphers on; +ssl_session_timeout 5m; +ssl_session_cache shared:SSL:50m; +ssl_session_tickets off; +ssl_stapling on; +ssl_stapling_verify on; +add_header Strict-Transport-Security "max-age=31536000" always; +access_log /var/log/nginx/access.log; +client_max_body_size {{ config.client_max_body_size }}; + +{% for server in virtual_servers%}{% for upstream in server.upstreams %} + upstream {{ upstream.id }} { {% for container in upstream.containers %} + server {{ container.address }}:{{ container.port }};{% endfor %} + }{% endfor %} +{% if server.secured %} +server{ + server_name {{ server.hostname }}; + listen {{ server.port }} ssl http2 {{ server.extras.default_server }} ; + ssl_certificate /etc/ssl/certs/{{ server.ssl_file }}.crt; + ssl_certificate_key /etc/ssl/private/{{ server.ssl_file }}.key;{% if server.is_redirect %} + return 301 https://{{ server.full_redirect.hostname }}$request_uri;{% elif server.is_down %} + return 503;{% else %} {% if server.extras.security %} + auth_basic "Basic Auth Enabled"; + auth_basic_user_file {{ server.extras.security_file }};{% endif %} {% for location in server.locations.values() %} + location {{ location.name }} { {% for injection in location.extras.injected %} + {{ injection }};{% endfor %} {% if location.extras.security %} + auth_basic_user_file {{ location.extras.security_file }};{% endif %} {% if location.upstream %} + proxy_pass {{ location.container.scheme }}://{{ location.upstream }}{{location.container.path}};{% else %} + proxy_pass {{location.container.scheme }}://{{ location.container.address }}:{{ location.container.port }}{{ location.container.path }};{% endif %}{% if location.name != '/' %} + proxy_redirect $scheme://$http_host{{ location.container.path if location.container.path else '/' }} $scheme://$http_host{{location.name}};{% endif %} {% if location.websocket and location.http %} + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade;{% elif location.websocket %} + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "Upgrade"; + proxy_read_timeout 1h; + proxy_send_timeout 1h;{% endif %} + }{% endfor %} {% endif %} + +}{% else %} +server{ + listen {{ server.port }} {{ server.extras.default_server }}; + server_name {{ server.hostname }};{% if server.is_redirect %} + return 301 {{ "https" if server.secured else "http" }}://{{ server.full_redirect.hostname }}$request_uri;{% else %}{% for location in server.locations.values() %} + location {{ location.name }} { {% if location.upstream %}{% for injection in location.extras.injected %} + {{ injection }};{% endfor %} + proxy_pass {{ location.container.scheme }}://{{ location.upstream }}{{location.container.path}};{% else %} + proxy_pass {{location.container.scheme }}://{{ location.container.address }}:{{ location.container.port }}{{ location.container.path }};{% endif %}{% if location.name != '/' %} + proxy_redirect $scheme://$http_host{{ location.container.path if location.container.path else '/' }} $scheme://$http_host{{location.name}};{% endif %} {% if location.websocket and location.http %} + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade;{% elif location.websocket %} + proxy_http_version 1.1; + proxy_read_timeout 1h; + proxy_send_timeout 1h; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "Upgrade";{% endif %} + }{% endfor %} {%endif %} + location /.well-known/acme-challenge/ { + alias {{ config.challenge_dir }}; + try_files $uri =404; + } + }{% endif %}{% if server.ssl_redirect %} +server { + listen 80 {{ server.extras.default_server }}; + server_name {{ server.hostname }}; + location /.well-known/acme-challenge/ { + alias {{ config.challenge_dir }}; + try_files $uri =404; + } + location /{ {% if server.is_redirect %} + return 301 https://{{ server.full_redirect.hostname }}$request_uri;{% else %} + return 301 https://$host$request_uri;{% endif %} + } +}{% endif %}{% endfor %} +{% if config.default_server %} +server{ + listen 80 default_server; + server_name _ ; + location /.well-known/acme-challenge/ { + alias {{ config.challenge_dir }}; + try_files $uri =404; + } + location / { + return 503; + } +}{% endif %} \ No newline at end of file diff --git a/vhosts_template/default.conf.template b/vhosts_template/default.conf.template deleted file mode 100644 index 3a3d9ba..0000000 --- a/vhosts_template/default.conf.template +++ /dev/null @@ -1,83 +0,0 @@ -map $http_upgrade $connection_upgrade { - default upgrade; - '' close; -} - -{% for server in virtual_servers%}{% for upstream in server.upstreams %} - upstream {{ upstream.id }} { {% for container in upstream.containers %} - server {{ container.address }}:{{ container.port }}; {% endfor %} - }{% endfor %} -{% if server.secured %} -server{ - server_name {{ server.hostname }}; - listen {{ server.port }} ssl http2 ; - access_log /var/log/nginx/access.log; - ssl_protocols TLSv1 TLSv1.1 TLSv1.2 TLSv1.3; - ssl_ciphers 'ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES256-SHA384:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA:ECDHE-RSA-AES256-SHA:DHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA:DHE-RSA-AES256-SHA256:DHE-RSA-AES256-SHA:AES128-GCM-SHA256:AES256-GCM-SHA384:AES128-SHA256:AES256-SHA256:AES128-SHA:AES256-SHA:!DSS'; - ssl_prefer_server_ciphers on; - ssl_session_timeout 5m; - ssl_session_cache shared:SSL:50m; - ssl_session_tickets off; - ssl_certificate /etc/ssl/certs/{{ server.ssl_file }}.crt; - ssl_certificate_key /etc/ssl/private/{{ server.ssl_file }}.key; - ssl_stapling on; - ssl_stapling_verify on; - add_header Strict-Transport-Security "max-age=31536000" always; - {% for location in server.locations %} - location {{ location.name }} { {% if location.upstream %} - proxy_pass {{ location.container.scheme }}://{{ location.upstream }}{{location.container.path}};{% else %} - proxy_pass {{location.container.scheme }}://{{ location.container.address }}:{{ location.container.port }}{{ location.container.path }};{% endif %}{% if location.name != '/' %} - proxy_redirect $scheme://$http_host{% if location.container.path!="/" %}{{ location.container.path }}{% endif%} $scheme://$http_host{{location.name}};{% endif %} {% if location.websocket and location.http %} - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection $connection_upgrade;{% elif location.websocket %} - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "Upgrade"; - proxy_read_timeout 7d; - proxy_send_timeout 7d;{% endif %} - }{% endfor %} - -}{% else %} -server{ - listen {{ server.port }}; - server_name {{ server.hostname }};{% for location in server.locations %} - location {{ location.name }} { {% if location.upstream %} - proxy_pass {{ location.container.scheme }}://{{ location.upstream }}{{location.container.path}};{% else %} - proxy_pass {{location.container.scheme }}://{{ location.container.address }}:{{ location.container.port }}{{ location.container.path }};{% endif %}{% if location.name != '/' %} - proxy_redirect $scheme://$http_host{% if location.container.path!="/" %}{{ location.container.path }}{% endif%} $scheme://$http_host{{location.name}};{% endif %} {% if location.websocket and location.http %} - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection $connection_upgrade;{% elif location.websocket %} - proxy_http_version 1.1; - proxy_read_timeout 7d; - proxy_send_timeout 7d; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "Upgrade";{% endif %} - }{% endfor %} - location /.well-known/acme-challenge/ { - alias {{ challenge_dir }}; - try_files $uri =404; - } - }{% endif %}{% if server.ssl_redirect %} -server { - listen 80; - server_name {{ server.hostname }}; - location /.well-known/acme-challenge/ { - alias {{ challenge_dir }}; - try_files $uri =404; - } - location /{ - return 301 https://$host$request_uri; - } -}{% endif %}{% endfor %} - -server{ - listen 80 default_server; - server_name _ ; - location /.well-known/acme-challenge/ { - alias {{ challenge_dir }}; - try_files $uri =404; - } - location / { - return 503; - } -}