diff --git a/README.md b/README.md index 6e88021..5068985 100644 --- a/README.md +++ b/README.md @@ -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::`), 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::"` (compose) or `-p 127.0.0.1::` (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 diff --git a/enlace_docker/_docker.py b/enlace_docker/_docker.py index 85edef0..87e735d 100644 --- a/enlace_docker/_docker.py +++ b/enlace_docker/_docker.py @@ -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 ------------------------------------------------------ @@ -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. @@ -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, @@ -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) diff --git a/enlace_docker/lifecycle.py b/enlace_docker/lifecycle.py index 40568cf..770c2f4 100644 --- a/enlace_docker/lifecycle.py +++ b/enlace_docker/lifecycle.py @@ -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. @@ -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-`` 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. """ @@ -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}"] @@ -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() @@ -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) @@ -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 " @@ -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() diff --git a/enlace_docker/strategies.py b/enlace_docker/strategies.py index 222410c..b3a04a2 100644 --- a/enlace_docker/strategies.py +++ b/enlace_docker/strategies.py @@ -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) @@ -62,7 +62,8 @@ class DockerStrategy(BackendStrategy): - Image tag is ``enlace/:dev`` (local, never pushed). - Container is named ``enlace-``. - ``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" @@ -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 ) @@ -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 diff --git a/enlace_docker/tests/conftest.py b/enlace_docker/tests/conftest.py index 93eac85..d6857ee 100644 --- a/enlace_docker/tests/conftest.py +++ b/enlace_docker/tests/conftest.py @@ -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, @@ -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 --------------------------------------------------------- @@ -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 diff --git a/enlace_docker/tests/test_lifecycle_compose.py b/enlace_docker/tests/test_lifecycle_compose.py index c6c779f..6835b38 100644 --- a/enlace_docker/tests/test_lifecycle_compose.py +++ b/enlace_docker/tests/test_lifecycle_compose.py @@ -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 diff --git a/enlace_docker/tests/test_lifecycle_docker.py b/enlace_docker/tests/test_lifecycle_docker.py index 9283c10..ba33705 100644 --- a/enlace_docker/tests/test_lifecycle_docker.py +++ b/enlace_docker/tests/test_lifecycle_docker.py @@ -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 diff --git a/enlace_docker/tests/test_strategies.py b/enlace_docker/tests/test_strategies.py index 24e94bd..3b8838d 100644 --- a/enlace_docker/tests/test_strategies.py +++ b/enlace_docker/tests/test_strategies.py @@ -6,6 +6,7 @@ ``model_config = ConfigDict(extra="allow")``. """ +import pytest from enlace.base import AppConfig, PlatformConfig from enlace_docker.lifecycle import ( @@ -137,3 +138,43 @@ def test_docker_attached_make_asgi_carries_container_name(): assert proxy.container == "some-running" assert proxy.container_port == 8080 assert proxy.route_prefix == "/api/myapp" + + +# -- exposure checks (loopback-only publishing) -------------------------------- + + +@pytest.mark.asyncio +async def test_attached_container_published_everywhere_is_flagged(fake_docker, capsys): + from enlace_docker.strategies import _AttachedProxy + + fake_docker.published_port_state[("my-running", 8080)] = 8080 + fake_docker.published_hosts = ["0.0.0.0"] + proxy = _AttachedProxy( + container="my-running", container_port=8080, route_prefix="/api/x" + ) + assert await proxy._get_proxy() is not None + out = capsys.readouterr().out + assert "not loopback" in out + assert "-p 127.0.0.1:8080:8080" in out + + +@pytest.mark.asyncio +async def test_attached_container_on_loopback_is_quiet(fake_docker, capsys): + from enlace_docker.strategies import _AttachedProxy + + fake_docker.published_port_state[("my-running", 8080)] = 8080 + proxy = _AttachedProxy( + container="my-running", container_port=8080, route_prefix="/api/x" + ) + assert await proxy._get_proxy() is not None + assert "not loopback" not in capsys.readouterr().out + + +def test_proxies_connect_to_the_publish_interface(): + """One constant decides where ports are published AND where the gateway + connects, so the two cannot drift apart.""" + from enlace_docker import _docker + from enlace_docker.lifecycle import _publish_spec + + assert _publish_spec(8080, 80).startswith(_docker.LOOPBACK + ":") + assert _docker.is_loopback_host(_docker.LOOPBACK)