Skip to content
Merged
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
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,17 @@ Requires the `docker` CLI on `PATH`. Compose support requires `docker compose`

## Usage

### Network exposure

Containers enlace starts (`docker`, `image` modes) publish their port on the
host's **loopback interface only** (`127.0.0.1:<port>:<port>`), so they are
reachable solely through the enlace gateway, where auth is applied. For
`compose` and `docker_attached`, the port mapping is yours: enlace logs a
warning when the routed port is published on a non-loopback address — publish
it as `"127.0.0.1::<port>"` (compose) or `-p 127.0.0.1:<port>:<port>` (docker
run). Note that on Docker Engine < 28, hosts on the same L2 network could still
reach loopback-published ports (moby/moby#45610).

### Dockerfile-per-app

```toml
Expand Down
89 changes: 81 additions & 8 deletions enlace_docker/_docker.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@
from dataclasses import dataclass
from typing import Optional, Sequence

#: The only interface enlace publishes container ports on, and the address the
#: gateway (proxy + health probes) connects to. Loopback keeps a container
#: reachable solely through the gateway, which is where auth is applied.
LOOPBACK = "127.0.0.1"

# -- Naming conventions ------------------------------------------------------


Expand Down Expand Up @@ -177,6 +182,23 @@ async def container_exit_code(name: str) -> Optional[int]:
return None


async def container_published_host_ips(name: str, container_port: int) -> list[str]:
"""Host interfaces the container's ``container_port`` is published on.

``docker inspect`` reports an empty ``HostIp`` for "all interfaces"; that
is returned as ``0.0.0.0``.
"""
fmt = (
'{{range (index .NetworkSettings.Ports "%d/tcp")}}{{.HostIp}},{{end}}'
% container_port
)
raw = await inspect_format(name, fmt)
if not raw:
return []
parts = raw.split(",")[:-1] # every entry ends with the separator
return [ip.strip() or "0.0.0.0" for ip in parts]


async def container_published_port(name: str, container_port: int) -> Optional[int]:
"""Host port the container's ``container_port`` is published on.

Expand Down Expand Up @@ -205,6 +227,63 @@ async def compose_published_port(

Compose outputs ``0.0.0.0:54321`` on stdout. We return the port int.
"""
addresses = await compose_published_addresses(
project, compose_file, service, service_port
)
return addresses[0][1] if addresses else None


def parse_published_addresses(output: str) -> list[tuple[str, int]]:
"""Parse ``docker compose port`` output into ``[(host, port), ...]``.

One line per binding (Compose prints v4 and v6 separately).

>>> parse_published_addresses("0.0.0.0:54321")
[('0.0.0.0', 54321)]
>>> parse_published_addresses("127.0.0.1:1\\n[::]:1")
[('127.0.0.1', 1), ('::', 1)]
>>> parse_published_addresses(":::54321")
[('::', 54321)]
>>> parse_published_addresses("nonsense")
[]
"""
out = []
for line in output.strip().splitlines():
host, sep, port = line.strip().rpartition(":")
if not sep:
continue
try:
out.append((host.strip("[]").rstrip(":") or "::", int(port)))
except ValueError:
continue
return out


def is_loopback_host(host: str) -> bool:
"""True if a published address is reachable only from this machine.

>>> is_loopback_host("127.0.0.1"), is_loopback_host("::1")
(True, True)
>>> is_loopback_host("0.0.0.0"), is_loopback_host("::")
(False, False)
"""
import ipaddress

if host == "localhost":
return True
try:
return ipaddress.ip_address(host).is_loopback
except ValueError:
return False


async def compose_published_addresses(
project: str,
compose_file: str,
service: str,
service_port: int,
) -> list[tuple[str, int]]:
"""Resolve ``docker compose port`` to every ``(host_address, host_port)``."""
result = await run_docker_compose(
"-f",
compose_file,
Expand All @@ -216,11 +295,5 @@ async def compose_published_port(
check=False,
)
if not result.ok:
return None
line = result.stdout.strip()
if ":" not in line:
return None
try:
return int(line.rsplit(":", 1)[1])
except ValueError:
return None
return []
return parse_published_addresses(result.stdout)
36 changes: 31 additions & 5 deletions enlace_docker/lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,15 @@
# -- Restart accounting (shared mixin) ---------------------------------------


def _publish_spec(host_port: int, container_port: int) -> str:
"""``docker run -p`` value publishing *container_port* on loopback only.

>>> _publish_spec(8080, 80)
'127.0.0.1:8080:80'
"""
return f"{_docker.LOOPBACK}:{host_port}:{container_port}"


@dataclass
class _RestartAccounting:
"""Restart-policy plumbing shared by every docker-backed lifecycle.
Expand Down Expand Up @@ -81,7 +90,8 @@ class — the only difference is whether ``build_step`` runs
``docker build`` or ``docker pull`` (or nothing) before each start.

The container is named ``enlace-<app>`` and the in-container ``port`` is
published to the same port on the host. Health is observed via
published to the same port on the host's **loopback** interface only, so
it is reachable solely through the gateway. Health is observed via
``docker inspect`` ``State.Health.Status`` when a ``HEALTHCHECK`` is
declared; otherwise we fall back to a plain TCP probe on the host port.
"""
Expand Down Expand Up @@ -136,7 +146,10 @@ async def start(self) -> None:
"--name",
self._container_name,
"-p",
f"{self.host_port}:{self.container_port}",
# Loopback only: the gateway proxies to it locally, and publishing
# on every interface would let clients reach the app around the
# gateway's auth (Docker's iptables rules also bypass ufw).
_publish_spec(self.host_port, self.container_port),
]
for k, v in self.env.items():
run_argv += ["-e", f"{k}={v}"]
Expand Down Expand Up @@ -271,7 +284,7 @@ async def _remove_existing_container(self) -> None:
async def _tcp_ready(self) -> bool:
try:
_, writer = await asyncio.wait_for(
asyncio.open_connection("127.0.0.1", self.host_port),
asyncio.open_connection(_docker.LOOPBACK, self.host_port),
timeout=1.0,
)
writer.close()
Expand Down Expand Up @@ -307,6 +320,7 @@ class ComposeStackLifecycle(_RestartAccounting):
state: str = "stopped"
_log_proc: Optional[asyncio.subprocess.Process] = field(default=None, repr=False)
_project: str = field(default="", repr=False)
_warned_exposure: bool = field(default=False, repr=False)

def __post_init__(self):
self._project = _docker.compose_project_for(self.name)
Expand All @@ -332,12 +346,24 @@ async def start(self) -> None:
await _docker.run_docker_compose(*argv, env=child_env)

# Resolve the host port for routing.
self.host_port = await _docker.compose_published_port(
addresses = await _docker.compose_published_addresses(
self._project,
str(self.compose_file),
self.service,
self.service_port,
)
self.host_port = addresses[0][1] if addresses else None
exposed = [h for h, _ in addresses if not _docker.is_loopback_host(h)]
if exposed and not self._warned_exposure:
self._warned_exposure = True
self.log(
f"WARNING: service '{self.service}' port {self.service_port} is "
f"published on {', '.join(exposed)} (not loopback), so it is "
"reachable directly, around the gateway's auth. Publish it on "
f"loopback in {self.compose_file.name}, e.g. "
f'"{_docker.LOOPBACK}::{self.service_port}". Other services in '
"the file are not checked -- publish those on loopback too."
)
if self.host_port is None:
self.log(
f"WARNING: service '{self.service}' has no published port for "
Expand Down Expand Up @@ -433,7 +459,7 @@ def log(self, msg: str) -> None:
async def _tcp_ready(self, port: int) -> bool:
try:
_, writer = await asyncio.wait_for(
asyncio.open_connection("127.0.0.1", port),
asyncio.open_connection(_docker.LOOPBACK, port),
timeout=1.0,
)
writer.close()
Expand Down
25 changes: 21 additions & 4 deletions enlace_docker/strategies.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ def _proxy(app):
"""Build the standard reverse-proxy app for ``app`` at its route prefix."""
from enlace.proxy import make_proxy_app

upstream = f"http://127.0.0.1:{app.port}"
upstream = f"http://{_docker.LOOPBACK}:{app.port}"
return make_proxy_app(upstream=upstream, strip_prefix=app.route_prefix)


Expand All @@ -62,7 +62,8 @@ class DockerStrategy(BackendStrategy):
- Image tag is ``enlace/<app>:dev`` (local, never pushed).
- Container is named ``enlace-<app>``.
- ``port`` is the in-container port; we publish it to the same port on
the host (matching how the Dockerfile's ``EXPOSE`` directive reads).
the host's loopback interface (matching how the Dockerfile's ``EXPOSE``
directive reads), so only the gateway can reach it.
"""

name = "docker"
Expand Down Expand Up @@ -270,7 +271,7 @@ def _resolve_proxy(self):
lifecycle = self._app_config.__dict__.get("_compose_lifecycle")
if lifecycle is None or lifecycle.host_port is None:
return None
upstream = f"http://127.0.0.1:{lifecycle.host_port}"
upstream = f"http://{_docker.LOOPBACK}:{lifecycle.host_port}"
return make_proxy_app(
upstream=upstream, strip_prefix=self._app_config.route_prefix
)
Expand Down Expand Up @@ -357,10 +358,26 @@ async def _get_proxy(self) -> Optional[object]:
)
if host_port is None:
return None
exposed = [
ip
for ip in await _docker.container_published_host_ips(
self.container, self.container_port
)
if not _docker.is_loopback_host(ip)
]
if exposed:
print(
f"[enlace_docker] WARNING: container {self.container!r} publishes "
f"port {self.container_port} on {', '.join(exposed)} (not "
"loopback), so it is reachable directly, around the gateway's "
f"auth. Start it with -p {_docker.LOOPBACK}:{host_port}:"
f"{self.container_port}.",
flush=True,
)
from enlace.proxy import make_proxy_app

self._proxy = make_proxy_app(
upstream=f"http://127.0.0.1:{host_port}",
upstream=f"http://{_docker.LOOPBACK}:{host_port}",
strip_prefix=self.route_prefix,
)
return self._proxy
Expand Down
20 changes: 20 additions & 0 deletions enlace_docker/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ class FakeDockerCLI:
published_port_state: dict[tuple[str, int], Optional[int]] = field(
default_factory=dict
)
# Interfaces a port is reported published on (``docker compose port`` /
# ``docker inspect`` HostIp).
published_hosts: list[str] = field(default_factory=lambda: ["127.0.0.1"])

def queue(
self,
Expand Down Expand Up @@ -118,6 +121,17 @@ async def compose_published_port(
) -> Optional[int]:
return self.published_port_state.get((service, service_port))

async def compose_published_addresses(
self, project: str, compose_file: str, service: str, service_port: int
) -> list[tuple[str, int]]:
port = self.published_port_state.get((service, service_port))
return [] if port is None else [(h, port) for h in self.published_hosts]

async def container_published_host_ips(
self, name: str, container_port: int
) -> list[str]:
return list(self.published_hosts)


# -- pytest fixtures ---------------------------------------------------------

Expand All @@ -141,6 +155,12 @@ def fake_docker(monkeypatch):
_docker, "container_published_port", fake.container_published_port
)
monkeypatch.setattr(_docker, "compose_published_port", fake.compose_published_port)
monkeypatch.setattr(
_docker, "compose_published_addresses", fake.compose_published_addresses
)
monkeypatch.setattr(
_docker, "container_published_host_ips", fake.container_published_host_ips
)
return fake


Expand Down
27 changes: 27 additions & 0 deletions enlace_docker/tests/test_lifecycle_compose.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,3 +83,30 @@ async def test_stop_runs_compose_down(fake_docker):

assert any("down" in a for k, a in fake_docker.calls if k == "compose")
assert lifecycle.state == "exited"


@pytest.mark.asyncio
async def test_start_warns_when_published_on_all_interfaces(fake_docker, capsys):
"""A compose service published on 0.0.0.0 is reachable around the gateway."""
fake_docker.published_port_state[("web", 8080)] = 54321
fake_docker.published_hosts = ["127.0.0.1", "::"] # mixed v4/v6 binding
lifecycle = _lifecycle()
fake_docker.queue_ok(lambda k, a: k == "compose" and "up" in a)
await lifecycle.start()
out = capsys.readouterr().out
assert "not loopback" in out
assert '"127.0.0.1::8080"' in out
assert lifecycle.host_port == 54321
# Warned once, not on every restart.
fake_docker.queue_ok(lambda k, a: k == "compose" and "up" in a)
await lifecycle.start()
assert "not loopback" not in capsys.readouterr().out


@pytest.mark.asyncio
async def test_start_is_quiet_when_published_on_loopback(fake_docker, capsys):
fake_docker.published_port_state[("web", 8080)] = 54321
lifecycle = _lifecycle()
fake_docker.queue_ok(lambda k, a: k == "compose" and "up" in a)
await lifecycle.start()
assert "not loopback" not in capsys.readouterr().out
4 changes: 3 additions & 1 deletion enlace_docker/tests/test_lifecycle_docker.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,9 @@ def capture_run(k, a):
await lifecycle.start()

argv = captured["argv"]
assert "8080:8080" in argv # -p host:container
# -p publishes on loopback only: the gateway proxies locally, and an
# all-interfaces publish would let clients reach the app around its auth.
assert argv[argv.index("-p") + 1] == "127.0.0.1:8080:8080"
# env propagation
assert "LOG_LEVEL=debug" in argv
# enlace contract: managed apps see ENLACE_MANAGED=1
Expand Down
Loading
Loading