From fd0000f3a0e3e3ec1c5845152952ebc1253d4ca1 Mon Sep 17 00:00:00 2001 From: Muritala David Ilerioluwa Date: Thu, 30 Apr 2026 17:49:39 +0100 Subject: [PATCH] feat: add TLS and optional mTLS support for Docker socket proxy --- .gitignore | 3 + Dockerfile | 6 +- README.md | 49 +++++++++++-- docker-entrypoint.sh | 62 ++++++++++++---- tests/conftest.py | 18 +++-- tests/test_service.py | 159 ++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 274 insertions(+), 23 deletions(-) diff --git a/.gitignore b/.gitignore index 55348a6..c63f576 100644 --- a/.gitignore +++ b/.gitignore @@ -240,6 +240,9 @@ pythonenv* .dmypy.json dmypy.json +# Local TLS certificate material +tests/certs/ + # Pyre type checker .pyre/ diff --git a/Dockerfile b/Dockerfile index 25aca9f..5927fe3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ FROM haproxy:3.2.4-alpine -EXPOSE 2375 +EXPOSE 2375 2376 ENV ALLOW_RESTARTS=0 \ ALLOW_STOP=0 \ ALLOW_START=0 \ @@ -26,6 +26,10 @@ ENV ALLOW_RESTARTS=0 \ SERVICES=0 \ SESSION=0 \ SOCKET_PATH=/var/run/docker.sock \ + TLS=0 \ + TLS_CERT_PATH=/run/secrets/server.pem \ + TLS_CLIENT_CA_CERT_PATH=/run/secrets/client-ca.pem \ + TLS_VERIFY_CLIENT=0 \ SWARM=0 \ SYSTEM=0 \ TASKS=0 \ diff --git a/README.md b/README.md index 8c1aceb..7c86fe0 100644 --- a/README.md +++ b/README.md @@ -28,10 +28,8 @@ never happen. - Never expose this container's port to a public network. Only to a Docker networks where only reside the proxy itself and the service that uses it. - Revoke access to any API section that you consider your service should not need. -- This image does not include TLS support, just plain HTTP proxy to the host Docker - Unix socket (which is not TLS protected even if you configured your host for TLS - protection). This is by design because you are supposed to restrict access to it - through Docker's built-in firewall. +- By default, this image runs in plain HTTP mode. Enable TLS when traffic can cross + untrusted networks. - [Read the docs](#supported-api-versions) for the API version you are using, and **know what you are doing**. @@ -79,9 +77,36 @@ never happen. Request forbidden by administrative rules. -The same will happen to any containers that use this proxy's `2375` port to access the +The same will happen to any containers that use this proxy's port to access the Docker socket API. +## Enable TLS + +TLS is disabled by default. To enable it, provide a server PEM file (certificate plus +private key), mount it into the container, and set `TLS=1`. + +```sh +docker container run \ + -d --privileged \ + --name dockerproxy \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -v "$(pwd)/certs:/certs:ro" \ + -e TLS=1 \ + -e TLS_CERT_PATH=/certs/server.pem \ + -p 127.0.0.1:2376:2376 \ + tecnativa/docker-socket-proxy +``` + +Then configure your Docker client for TLS (for example with `DOCKER_TLS_VERIFY=1` and +`DOCKER_CERT_PATH` containing `ca.pem`, `cert.pem`, and `key.pem`). + +### Optional mTLS + +To require client certificates (mTLS), also set: + +- `TLS_VERIFY_CLIENT=1` +- `TLS_CLIENT_CA_CERT_PATH=/path/to/ca.pem` + ## Grant or revoke access to certain API sections You grant and revoke access to certain features of the Docker API through environment @@ -155,6 +180,20 @@ For example, [balenaOS](https://www.balena.io/os/) exposes its socket at `/var/run/balena-engine.sock`. To accommodate this, merely set the `SOCKET_PATH` environment variable to `/var/run/balena-engine.sock`. +## Bind and TLS environment variables + +- `BIND_CONFIG`: Full HAProxy `bind` value override. When set, it takes precedence + over all bind-related environment variables below. +- `BIND_PORT`: Port used by the auto-generated bind configuration. +- `DISABLE_IPV6`: When true, bind in IPv4-only mode. +- `TLS`: Set to `1` to enable TLS on the frontend listener. +- `TLS_CERT_PATH`: Path to the server PEM file used when `TLS=1`. +- `TLS_VERIFY_CLIENT`: Set to `1` to require client certificates (mTLS). +- `TLS_CLIENT_CA_CERT_PATH`: Path to the CA file used to validate client certs when + `TLS_VERIFY_CLIENT=1`. + +If `BIND_PORT` is not set, it defaults to `2375` in plain mode and `2376` in TLS mode. + ## Development All the dependencies you need to develop this project (apart from Docker itself) are diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 2303cee..b75fed2 100755 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -4,19 +4,57 @@ set -e # Raise default nofile limit for HAProxy v3 ulimit -n 10000 2>/dev/null || true -if [ -z "$BIND_CONFIG" ]; then - # Normalize the input for DISABLE_IPV6 to lowercase - DISABLE_IPV6_LOWER=$(echo "$DISABLE_IPV6" | tr '[:upper:]' '[:lower:]') - - # Check for different representations of 'true' and set BIND_CONFIG - case "$DISABLE_IPV6_LOWER" in - 1|true|yes) - BIND_CONFIG=":2375" - ;; - *) - BIND_CONFIG="[::]:2375 v4v6" - ;; +is_true() { + case "$(echo "$1" | tr '[:upper:]' '[:lower:]')" in + 1|true|yes) + return 0 + ;; + *) + return 1 + ;; esac +} + +if [ -z "$BIND_PORT" ]; then + if is_true "$TLS"; then + BIND_PORT=2376 + else + BIND_PORT=2375 + fi +fi + +if [ -z "$BIND_CONFIG" ]; then + if is_true "$DISABLE_IPV6"; then + BIND_ADDRESS=":$BIND_PORT" + else + BIND_ADDRESS="[::]:$BIND_PORT v4v6" + fi + + if is_true "$TLS"; then + if [ -z "$TLS_CERT_PATH" ]; then + echo >&2 "TLS is enabled but TLS_CERT_PATH is not set." + exit 1 + fi + if [ ! -f "$TLS_CERT_PATH" ]; then + echo >&2 "TLS certificate file not found: $TLS_CERT_PATH" + exit 1 + fi + BIND_CONFIG="$BIND_ADDRESS ssl crt $TLS_CERT_PATH" + + if is_true "$TLS_VERIFY_CLIENT"; then + if [ -z "$TLS_CLIENT_CA_CERT_PATH" ]; then + echo >&2 "Client certificate verification is enabled but TLS_CLIENT_CA_CERT_PATH is not set." + exit 1 + fi + if [ ! -f "$TLS_CLIENT_CA_CERT_PATH" ]; then + echo >&2 "Client CA file not found: $TLS_CLIENT_CA_CERT_PATH" + exit 1 + fi + BIND_CONFIG="$BIND_CONFIG verify required ca-file $TLS_CLIENT_CA_CERT_PATH" + fi + else + BIND_CONFIG="$BIND_ADDRESS" + fi fi # Process the HAProxy configuration template using sed diff --git a/tests/conftest.py b/tests/conftest.py index 4324a54..585c3a8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -54,9 +54,12 @@ def proxy_factory(image): """ @contextmanager - def _proxy(**env_vars): + def _proxy(publish_port=2375, mounts=None, docker_env=None, **env_vars): container_id = None env_list = [f"--env={key}={value}" for key, value in env_vars.items()] + volume_args = ["--volume=/var/run/docker.sock:/var/run/docker.sock"] + if mounts: + volume_args.extend([f"--volume={mount}" for mount in mounts]) _logger.info(f"Starting {image} container with: {env_list}") try: container_id = docker( @@ -64,8 +67,8 @@ def _proxy(**env_vars): "run", "--detach", "--privileged", - "--publish=2375", - "--volume=/var/run/docker.sock:/var/run/docker.sock", + f"--publish={publish_port}", + *volume_args, *env_list, image, ).strip() @@ -73,10 +76,15 @@ def _proxy(**env_vars): container_data = json.loads( docker("container", "inspect", container_id.strip()) ) - socket_port = container_data[0]["NetworkSettings"]["Ports"]["2375/tcp"][0][ + socket_port = container_data[0]["NetworkSettings"]["Ports"][ + f"{publish_port}/tcp" + ][0][ "HostPort" ] - with local.env(DOCKER_HOST=f"tcp://localhost:{socket_port}"): + env = {"DOCKER_HOST": f"tcp://localhost:{socket_port}"} + if docker_env: + env.update(docker_env) + with local.env(**env): yield container_id finally: if container_id: diff --git a/tests/test_service.py b/tests/test_service.py index 4724d3f..3726092 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -1,4 +1,5 @@ import logging +import subprocess import pytest from plumbum import ProcessExecutionError @@ -7,6 +8,116 @@ logger = logging.getLogger() +def _run_openssl(cert_dir, *args): + subprocess.run( + ["openssl", *args], + cwd=cert_dir, + check=True, + capture_output=True, + text=True, + ) + + +@pytest.fixture(scope="session") +def tls_certs(tmp_path_factory): + cert_dir = tmp_path_factory.mktemp("tls-certs") + _run_openssl(cert_dir, "genrsa", "-out", "ca-key.pem", "2048") + _run_openssl( + cert_dir, + "req", + "-x509", + "-new", + "-key", + "ca-key.pem", + "-sha256", + "-days", + "3650", + "-subj", + "/CN=docker-socket-proxy-test-ca", + "-out", + "ca.pem", + ) + + _run_openssl(cert_dir, "genrsa", "-out", "server-key.pem", "2048") + _run_openssl( + cert_dir, + "req", + "-new", + "-key", + "server-key.pem", + "-subj", + "/CN=localhost", + "-out", + "server.csr", + ) + (cert_dir / "server-ext.cnf").write_text( + "subjectAltName=DNS:localhost,IP:127.0.0.1\nextendedKeyUsage=serverAuth\n", + encoding="utf-8", + ) + _run_openssl( + cert_dir, + "x509", + "-req", + "-in", + "server.csr", + "-CA", + "ca.pem", + "-CAkey", + "ca-key.pem", + "-CAcreateserial", + "-out", + "server-cert.pem", + "-days", + "3650", + "-sha256", + "-extfile", + "server-ext.cnf", + ) + (cert_dir / "server.pem").write_text( + (cert_dir / "server-cert.pem").read_text(encoding="utf-8") + + (cert_dir / "server-key.pem").read_text(encoding="utf-8"), + encoding="utf-8", + ) + + _run_openssl(cert_dir, "genrsa", "-out", "key.pem", "2048") + _run_openssl( + cert_dir, + "req", + "-new", + "-key", + "key.pem", + "-subj", + "/CN=docker-socket-proxy-test-client", + "-out", + "client.csr", + ) + (cert_dir / "client-ext.cnf").write_text( + "extendedKeyUsage=clientAuth\n", + encoding="utf-8", + ) + _run_openssl( + cert_dir, + "x509", + "-req", + "-in", + "client.csr", + "-CA", + "ca.pem", + "-CAkey", + "ca-key.pem", + "-CAcreateserial", + "-out", + "cert.pem", + "-days", + "3650", + "-sha256", + "-extfile", + "client-ext.cnf", + ) + + return cert_dir + + def _check_permissions(allowed_calls, forbidden_calls): for args in allowed_calls: docker(*args) @@ -85,3 +196,51 @@ def test_exec_permissions(proxy_factory): ] forbidden_calls = [] _check_permissions(allowed_calls, forbidden_calls) + + +def test_tls_permissions(proxy_factory, tls_certs): + certs_mount = f"{tls_certs}:/certs:ro" + tls_docker_env = { + "DOCKER_CERT_PATH": str(tls_certs), + "DOCKER_TLS_VERIFY": "1", + } + + with proxy_factory( + publish_port=2376, + mounts=[certs_mount], + docker_env=tls_docker_env, + TLS=1, + TLS_CERT_PATH="/certs/server.pem", + ): + allowed_calls = [ + ("version",), + ] + forbidden_calls = [ + ("network", "ls"), + ] + _check_permissions(allowed_calls, forbidden_calls) + + +def test_mtls_permissions(proxy_factory, tls_certs): + certs_mount = f"{tls_certs}:/certs:ro" + tls_docker_env = { + "DOCKER_CERT_PATH": str(tls_certs), + "DOCKER_TLS_VERIFY": "1", + } + + with proxy_factory( + publish_port=2376, + mounts=[certs_mount], + docker_env=tls_docker_env, + TLS=1, + TLS_CERT_PATH="/certs/server.pem", + TLS_VERIFY_CLIENT=1, + TLS_CLIENT_CA_CERT_PATH="/certs/ca.pem", + ): + allowed_calls = [ + ("version",), + ] + forbidden_calls = [ + ("network", "ls"), + ] + _check_permissions(allowed_calls, forbidden_calls)