Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,9 @@ pythonenv*
.dmypy.json
dmypy.json

# Local TLS certificate material
tests/certs/

# Pyre type checker
.pyre/

Expand Down
6 changes: 5 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -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 \
Expand All @@ -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 \
Expand Down
49 changes: 44 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**.

Expand Down Expand Up @@ -79,9 +77,36 @@ never happen.
Request forbidden by administrative rules.
</body></html>

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
Expand Down Expand Up @@ -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
Expand Down
62 changes: 50 additions & 12 deletions docker-entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 13 additions & 5 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,29 +54,37 @@ 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(
"container",
"run",
"--detach",
"--privileged",
"--publish=2375",
"--volume=/var/run/docker.sock:/var/run/docker.sock",
f"--publish={publish_port}",
*volume_args,
*env_list,
image,
).strip()
time.sleep(0.5)
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:
Expand Down
159 changes: 159 additions & 0 deletions tests/test_service.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import logging
import subprocess

import pytest
from plumbum import ProcessExecutionError
Expand All @@ -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)
Expand Down Expand Up @@ -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)