diff --git a/Dockerfile b/Dockerfile index f0412c867848ef2320b644ec53981b5a14786b33..ef933fd5ca6106ddbe8ed5bc11bedc52cbfdfa81 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,9 @@ FROM bitnami/minideb:stretch COPY build.sh /tmp/build.sh -#COPY gunicorn.conf /etc/gunicorn.conf +COPY utils /tmp/utils RUN /tmp/build.sh && rm /tmp/build.sh +# Example entrypoint for child images: #ENTRYPOINT ["/usr/sbin/gunicorn", "--worker-class=gevent", "--workers=2", "-c", "/etc/gunicorn.conf", "app:app"] diff --git a/README.md b/README.md index b8f0c1455f19b35ac4ce88abd70208398737772e..ae50ed9dc556007bf43e1d3c815d2c45016014f9 100644 --- a/README.md +++ b/README.md @@ -6,3 +6,11 @@ The configuration for the app should be copied by sub-images to /etc/gunicorn.co (even though it is a Python file). The name of the application module to run should be passed as part of the ENTRY\_POINT set by the child image. +### TLS Authentication + +This image installs a bit of Python code, required to use TLS +authentication for your application in a standardized fashion (since +it requires a custom GUnicorn worker). + +To read how to do so, check the [utils/README.md](utils/README.md) +file. diff --git a/build.sh b/build.sh index f85d1985024f3cc5d8491c4a35d8d012cf62f045..ed5a9c987d867c8c296dbae73e37ee4f7b52df80 100755 --- a/build.sh +++ b/build.sh @@ -25,6 +25,9 @@ set -x install_packages ${PACKAGES} \ || die "could not install packages" +# Set up the ai3_gunicorn_utils package. +pip install /tmp/utils + # Remove packages used for installation. #apt-get remove -y --purge ${BUILD_PACKAGES} apt-get autoremove -y diff --git a/utils/README.md b/utils/README.md new file mode 100644 index 0000000000000000000000000000000000000000..d0c2502210c767cfbd31d1acefd4d892930675a6 --- /dev/null +++ b/utils/README.md @@ -0,0 +1,75 @@ +ai3_gunicorn_utils +=== + +WSGI middleware and utilities for gunicorn that enable TLS-based +authorization to secure service-to-service communication, in line with +the features provided +by +[ai3/go-common](https://git.autistici.org/ai3/go-common/blob/master/serverutil/tls.go). + +To enable the feature for an application using +the +[ai3/docker-gunicorn-base](https://git.autistici.org/ai3/docker-gunicorn-base) image, +follow these steps: + +### Setup GUnicorn + +In the *gunicorn.conf* file for your application, set up our custom +gunicorn worker (ai3_gunicorn_utils.worker.TLSAuthWorker), and +enable SSL with client certificates: + +```yaml +worker_class: "ai3_gunicorn_utils.worker.TLSAuthWorker" +keyfile: "/etc/credentials/x509/myservice/server/private_key.pem" +certfile: "/etc/credentials/x509/myservice/server/cert.pem" +ca_certs: "/etc/credentials/x509/myservice/ca.pem" +ssl_version: 5 +cert_reqs: 2 +``` + +Note that we need to use numeric values for constants in Python's ssl +module: `ssl.PROTOCOL_TLSv1_2` is 5 above, and `ssl.CERT_REQUIRED` is +2. + +The custom worker is necessary to pass the client certificate +information to the WSGI middleware. + +### Setup your application + +Configure your WSGI application file to (optionally) initialize TLS +authentication if the TLS_AUTH_CONFIG environment variable is defined: + +```python +from ai3_gunicorn_utils.tls_auth import tls_auth_wrap +from myapplication import create_wsgi_app + +application = tls_auth_wrap(create_wsgi_app()) +``` + +or, if you are using Flask for instance: + +```python +from ai3_gunicorn_utils.tls_auth import init_flask_app +from myapplication import create_app + +application = create_app() +init_flask_app(application) +``` + +### Configure TLS authentication + +The file pointed to by TLS_AUTH_CONFIG must be a Python file, defining +one top-level variable `ACL`, which contains a list of access-control +patterns. For each requests, at least one of these patterns must match +the request parameters. ACL patterns are made of two regular +expressions, one that should match the HTTP request path, another that +should match the client certificate's subject CN. They are expressed +as Python dictionaries with *path* and *cn* attributes, for instance: + +```python +ACL = [ + {"path": "^/api/", "cn": "^other-service$"}, + {"path": "^/metrics", "cn": ".*"}, +] +``` + diff --git a/utils/ai3_gunicorn_utils/__init__.py b/utils/ai3_gunicorn_utils/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..4722b66974062dbcd06a75f733de80e7b97a994f --- /dev/null +++ b/utils/ai3_gunicorn_utils/__init__.py @@ -0,0 +1,4 @@ + +# The special header that we set in our custom gunicorn worker, that +# holds the client certificate's subject CN. +SSL_CLIENT_CN_HEADER = 'X-SSL-Client-CN' diff --git a/utils/ai3_gunicorn_utils/tls_auth.py b/utils/ai3_gunicorn_utils/tls_auth.py new file mode 100644 index 0000000000000000000000000000000000000000..a452e11ce5e5c7a5cc3306035766fd7ba48d9ead --- /dev/null +++ b/utils/ai3_gunicorn_utils/tls_auth.py @@ -0,0 +1,61 @@ +import re + +from ai3_gunicorn_utils import SSL_CLIENT_CN_HEADER +WSGI_SSL_CLIENT_CN_HEADER = 'HTTP_' + SSL_CLIENT_CN_HEADER.upper() + + +def _abort(start_response, code, code_str): + hdrs = [('Content-Type', 'text/plain')] + start_response(code, code_str) + return [code_str] + + +def _compile_config(config): + # Turn config dictionaries into path/cn compiled regexp tuples. + def _compile_entry(e): + return (re.compile(e['path']), re.compile(e['cn'])) + return filter(_compile_entry, config) + + +class TLSAuthMiddleware(object): + + def __init__(self, next_app, config): + self._next_app = next_app + self._config = _compile_config(config) + + def __call__(self, environ, start_response): + path = environ['SCRIPT_NAME'] + environ['PATH_INFO'] + client_cn = environ.get(WSGI_SSL_CLIENT_CN_HEADER) + if not client_cn: + return _abort(start_response, 401, 'Unauthorized') + for path_rx, cn_rx in self._config: + if path_rx.match(path) and cn_rx.match(client_cn): + return self._next_app(environ, start_response) + return _abort(start_response, 403, 'Forbidden') + + +def tls_auth_wrap(next_app): + """Wrap a WSGI application with TLSAuthMiddleware. + + Configuration is loaded from the file specified by the environment + variable TLS_AUTH_CONFIG. If the variable is not defined or empty, + TLS authentication is disabled. + + The configuration file should be a Python file defining a single + ACL variable, a list of {path, cn} dictionaries containing ACL + definitions. + + """ + config_file = os.getenv('TLS_AUTH_CONFIG') + if not config_file: + return next_app + + config_ctx = {'ACL': []} + execfile(config_file, config_ctx) + config = config_ctx['ACL'] + return TLSAuthMiddleware(next_app, config) + + +def init_flask_app(flask_app): + """Add TLSAuthMiddleware to a Flask application.""" + app.wsgi_app = tls_auth_wrap(app.wsgi_app) diff --git a/utils/ai3_gunicorn_utils/worker.py b/utils/ai3_gunicorn_utils/worker.py new file mode 100644 index 0000000000000000000000000000000000000000..af1457b6a262a48c6b9b069a6e1486a1ea85c80c --- /dev/null +++ b/utils/ai3_gunicorn_utils/worker.py @@ -0,0 +1,20 @@ +from ai3_gunicorn_utils import SSL_CLIENT_CN_HEADER +from gunicorn.workers.ggevent import GeventWorker as worker_base + + +class TLSAuthWorker(worker_base): + """Custom gunicorn worker that sets SSL_CLIENT_CN_HEADER. + + Based on the gevent worker. + + """ + def handle_request(self, listener, req, client, addr): + subject = dict(client.getpeercert().get('subject')[0]) + + # Remove any SSL_CLIENT_CN_HEADER that might have been already + # present in the incoming request, and set our own value. + headers = filter(lambda x: x[0] != SSL_CLIENT_CN_HEADER, req.headers) + headers.append((SSL_CLIENT_CN_HEADER, subject.get('commonName'))) + req.headers = headers + + super(CustomWorker, self).handle_request(listener, req, client, addr) diff --git a/utils/setup.py b/utils/setup.py new file mode 100644 index 0000000000000000000000000000000000000000..73a58e7f1802d3e5aafa2e02034ce2e10a5f1b9b --- /dev/null +++ b/utils/setup.py @@ -0,0 +1,11 @@ +from setuptools import setup, find_packages + +setup( + name='ai3-gunicorn-utils', + version='0.1', + description='Misc utilities to support running gunicorn in the ai3 system.', + author='A/I', + author_email='info@autistici.org', + install_requires=[], + packages=find_packages(), +)