From a898f0bb2a7e73c5c42bd28e66a08a5d7bbfc234 Mon Sep 17 00:00:00 2001 From: Paulo Date: Mon, 14 Sep 2026 11:38:59 +0200 Subject: [PATCH] Publish Docker SSH on a configured host address Docker publishes each sandbox's sshd on DOCKER_SSH_HOST and host responses return that address as external_ssh_host. The default stays 127.0.0.1. A Linux daemon host sets its tailnet address so remote callers can connect. The setting must be an IP address other than the unspecified one, so a misconfiguration fails at startup instead of producing an active host nobody can dial. The deployment guide explains the setting and the API bind. The security guide states that a published sandbox shares the daemon host's network identity. Co-Authored-By: Claude Fable 5.1 --- docs/deploy.md | 32 ++++++++++++--------- docs/networking.md | 3 +- docs/security.md | 4 +++ src/hosts/tests/test_tailscale_optional.py | 2 +- src/providers/docker/api.py | 6 ++-- src/providers/docker/provider.py | 15 +++++++--- src/providers/docker/settings.py | 15 +++++++++- src/providers/docker/tests/test_api.py | 11 ++++--- src/providers/docker/tests/test_provider.py | 15 ++++++++-- 9 files changed, 72 insertions(+), 31 deletions(-) diff --git a/docs/deploy.md b/docs/deploy.md index 620276a..5b4fccd 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -89,9 +89,9 @@ On macOS, use `--group-add 0` instead: Docker Desktop mounts the socket into the container as `root:root` mode `0660`, so only group 0 grants access — the host socket's own gid is irrelevant. -Host networking is required because Docker sandboxes publish SSH on the -host's `127.0.0.1`. The loopback Uvicorn binding makes the API reachable -only from that host. Do not combine this mode with the generic +Host networking lets the API reach the published sandbox ports on the +daemon host. The loopback Uvicorn binding keeps the API reachable only +from that host. Do not combine this mode with the generic `-p 8780:8780` invocation above. On macOS, if sandbox SSH is unreachable, enable host networking in Docker Desktop's settings. @@ -100,13 +100,19 @@ The sandbox image (`DOCKER_DEFAULT_IMAGE`, default To customize it, build [images/local/](../images/local/) and point `DOCKER_DEFAULT_IMAGE` at your tag. -Containers publish sshd on a random `127.0.0.1` port and are reachable -only from the host that runs drukbox; the per-host key is the auth -boundary. Tailscale is not supported — a local container has no path -onto the tailnet, so docker hosts stay local under a tailnet-mode -service: no join, no `internal_ssh_host`, the published port is the only -path. One drukbox can serve tailnet VMs and local containers side by -side. +Docker publishes each sandbox's sshd on a random port at `DOCKER_SSH_HOST`, +and host responses return that address as `external_ssh_host`. The default +is `127.0.0.1`, so only the daemon host can connect. For remote callers, +set it to an address of the daemon host that the callers and the API can +reach: `DOCKER_SSH_HOST=100.64.0.10`. Bind the API where those callers +reach it with `UVICORN_HOST`. Docker Desktop publishes only on loopback, +so remote callers need a Linux daemon. Open the published ports in the +network policy of the daemon host. A remote caller reaches every sandbox +port the daemon host exposes, and each sandbox's SSH key is the only +boundary. + +Containers do not join a tailnet and have no `internal_ssh_host`. One +drukbox can serve tailnet VMs and Docker containers together. This provider is for local development and demos, not production: it talks to the host's Docker daemon, and granting drukbox access to that @@ -123,8 +129,7 @@ The `docker-sbx` provider runs each sandbox as a [Docker Sandboxes](https://docs.docker.com/ai/sandboxes/) microVM. Each microVM has its own kernel, its own filesystem, and its own Docker daemon. The sandboxd network policy controls the egress. This provider -is local to the drukbox machine, the same as the `docker` provider. It -does not support Tailscale. +runs on the drukbox machine. It does not support Tailscale. Prepare the host fully before drukbox starts. drukbox only connects to the host: @@ -562,13 +567,14 @@ Docker provider: | Variable | Default | Purpose | | --- | --- | --- | | `DOCKER_DEFAULT_IMAGE` | `ghcr.io/czpython/drukbox/sandbox:latest` | Sandbox image with sshd, git, and gh; auto-pulled. Build `images/local/Dockerfile` to customize. | +| `DOCKER_SSH_HOST` | `127.0.0.1` | Daemon host address where Docker publishes sshd and callers dial it. | | `DOCKER_SSH_USERNAME` | `root` | In-container user callers SSH as. | | `DOCKER_BOOTSTRAP_SSH_TIMEOUT_SECONDS` | `30.0` | ssh-keyscan retry budget for a fresh container. | The published image includes the Docker CLI. Mount the local daemon socket with its supplemental group on Linux, or use `DOCKER_HOST` for a remote or rootless daemon. Drukbox mints a per-VM ed25519 key and publishes sshd on a -random `127.0.0.1` port. See +random port at `DOCKER_SSH_HOST`. See [Local sandboxes with Docker](#local-sandboxes-with-docker) for the container command and the trust caveat. diff --git a/docs/networking.md b/docs/networking.md index 5104a22..5d7a8fc 100644 --- a/docs/networking.md +++ b/docs/networking.md @@ -7,7 +7,8 @@ shaped the way it is. For turning these modes on, read ## Two modes `TAILSCALE_ENABLED` selects between two networking models. A provider -whose hosts cannot join a tailnet (docker — local containers, +whose hosts cannot join a tailnet (docker — containers published on the +daemon host, docker-sbx — local microVMs) always takes the external path, whatever the mode. The API response carries both addresses; which is populated depends on the mode and the diff --git a/docs/security.md b/docs/security.md index 1302e76..cc1b5b6 100644 --- a/docs/security.md +++ b/docs/security.md @@ -68,6 +68,10 @@ covered in [Networking](networking.md). The security-relevant summary: - **Hetzner has no firewall.** A fresh server exposes port 22 to the internet; the per-VM key is the only boundary. There is no ingress configuration to manage. +- **Docker shares the daemon host's identity.** A sandbox published on a + non-loopback `DOCKER_SSH_HOST` is reachable by everything that + reaches that address. On a tailnet it has no device of its own, so ACL + tags cannot scope it. Each sandbox's key is the only boundary. - **First-keyscan MITM window.** With Tailscale off, the `known_hosts` material is scanned over the public network and carries the usual trust-on-first-use window. Enable Tailscale to run the scan over the diff --git a/src/hosts/tests/test_tailscale_optional.py b/src/hosts/tests/test_tailscale_optional.py index b4a1524..b3fda4d 100644 --- a/src/hosts/tests/test_tailscale_optional.py +++ b/src/hosts/tests/test_tailscale_optional.py @@ -93,7 +93,7 @@ async def test_docker_host_stays_local_on_a_tailnet_mode_service( ) -> None: # TAILSCALE_ENABLED=true serves remote VMs over the tailnet, but a local # container has no path onto it — a docker host skips the join entirely - # and keeps its published 127.0.0.1 port as the only path. + # and keeps its published port as the only path. tailscale = AsyncMock() create_vm = AsyncMock( diff --git a/src/providers/docker/api.py b/src/providers/docker/api.py index 774ac04..1817753 100644 --- a/src/providers/docker/api.py +++ b/src/providers/docker/api.py @@ -46,17 +46,15 @@ async def run_container( image: str, env: dict[str, str], labels: dict[str, str], + ssh_host: str, ) -> str: - # Publish the in-container sshd on a random loopback host port: the - # sandbox is reachable from the host that runs drukbox, never from the - # network. The per-VM key remains the auth boundary. config = { "Image": image, "Env": [f"{key}={value}" for key, value in env.items()], "Labels": labels, "ExposedPorts": {"22/tcp": {}}, "HostConfig": { - "PortBindings": {"22/tcp": [{"HostIp": "127.0.0.1", "HostPort": ""}]}, + "PortBindings": {"22/tcp": [{"HostIp": ssh_host, "HostPort": ""}]}, }, } try: diff --git a/src/providers/docker/provider.py b/src/providers/docker/provider.py index c5e7087..3ae7903 100644 --- a/src/providers/docker/provider.py +++ b/src/providers/docker/provider.py @@ -22,10 +22,11 @@ class DockerProvider(VMProvider, TemplateCapability): + # Containers run a real sshd, published on DOCKER_SSH_HOST, so callers + # get scp, sftp, and agent forwarding natively. The SSH gateway is for + # providers without an sshd of their own. name: ClassVar[str] = "docker" diagnose_hint: ClassVar[str] = "check_docker_daemon_is_running" - # A local container has no path onto the tailnet; its hosts keep the - # published 127.0.0.1 sshd port even on a tailnet-mode service. supports_tailnet: ClassVar[bool] = False def __init__( @@ -97,7 +98,13 @@ async def create_vm( labels = {"managed-by": self._service_label, "drukbox-host-name": name} try: - await self.api.run_container(name=name, image=image, env=container_env, labels=labels) + await self.api.run_container( + name=name, + image=image, + env=container_env, + labels=labels, + ssh_host=str(self.settings.ssh_host), + ) except DockerProviderError as exc: raise ProviderTransportError(str(exc)) from exc @@ -116,7 +123,7 @@ async def create_vm( provider_id=name, name=name, ssh_port=ssh_port, - ssh_host="127.0.0.1", + ssh_host=str(self.settings.ssh_host), ssh_username=self.settings.ssh_username, private_key=private_key, ) diff --git a/src/providers/docker/settings.py b/src/providers/docker/settings.py index 0081646..35d6ce2 100644 --- a/src/providers/docker/settings.py +++ b/src/providers/docker/settings.py @@ -1,4 +1,6 @@ -from pydantic import Field +from ipaddress import IPv4Address, IPv6Address + +from pydantic import Field, field_validator from pydantic_settings import BaseSettings, SettingsConfigDict @@ -20,7 +22,18 @@ class DockerSettings(BaseSettings): default="root", description="In-container user callers SSH as. The sandbox image runs sshd for root.", ) + ssh_host: IPv4Address | IPv6Address = Field( + default=IPv4Address("127.0.0.1"), + description="Daemon host address where Docker publishes sshd and callers dial it.", + ) bootstrap_ssh_timeout_seconds: float = Field( default=30.0, description="ssh-keyscan retry budget for a freshly-started sandbox container.", ) + + @field_validator("ssh_host") + @classmethod + def reject_unspecified(cls, address: IPv4Address | IPv6Address) -> IPv4Address | IPv6Address: + if address.is_unspecified: + raise ValueError("DOCKER_SSH_HOST must be an address callers can dial, not 0.0.0.0") + return address diff --git a/src/providers/docker/tests/test_api.py b/src/providers/docker/tests/test_api.py index d07aa07..6e50f9d 100644 --- a/src/providers/docker/tests/test_api.py +++ b/src/providers/docker/tests/test_api.py @@ -43,7 +43,7 @@ def _api(fake: SimpleNamespace) -> DockerAPI: return DockerAPI(docker=fake) # type: ignore[arg-type] -async def test_run_container_publishes_on_loopback_and_passes_env_in_the_body() -> None: +async def test_run_container_publishes_on_the_ssh_host_and_passes_env() -> None: fake = _fake_docker() container_id = await _api(fake).run_container( @@ -51,6 +51,7 @@ async def test_run_container_publishes_on_loopback_and_passes_env_in_the_body() image="sandbox:latest", env={"KEY": "value", "MULTI": "line one\nline two"}, labels={"managed-by": "drukbox"}, + ssh_host="100.64.0.10", ) assert container_id == "abc123" @@ -60,7 +61,7 @@ async def test_run_container_publishes_on_loopback_and_passes_env_in_the_body() assert config["Env"] == ["KEY=value", "MULTI=line one\nline two"] assert config["Labels"] == {"managed-by": "drukbox"} assert config["HostConfig"]["PortBindings"] == { - "22/tcp": [{"HostIp": "127.0.0.1", "HostPort": ""}] + "22/tcp": [{"HostIp": "100.64.0.10", "HostPort": ""}] } @@ -69,10 +70,12 @@ async def test_run_container_translates_engine_errors() -> None: fake.containers.run.side_effect = DockerError(409, "name already in use") with pytest.raises(DockerTransportError, match="name already in use"): - await _api(fake).run_container(name="sb-test", image="sandbox:latest", env={}, labels={}) + await _api(fake).run_container( + name="sb-test", image="sandbox:latest", env={}, labels={}, ssh_host="127.0.0.1" + ) -async def test_published_ssh_port_reads_the_loopback_binding() -> None: +async def test_published_ssh_port_reads_the_binding() -> None: fake = _fake_docker() assert await _api(fake).published_ssh_port("sb-test") == 49160 diff --git a/src/providers/docker/tests/test_provider.py b/src/providers/docker/tests/test_provider.py index 150006b..4bfa159 100644 --- a/src/providers/docker/tests/test_provider.py +++ b/src/providers/docker/tests/test_provider.py @@ -2,6 +2,7 @@ from unittest.mock import AsyncMock, MagicMock import pytest +from pydantic import ValidationError from providers.docker.exceptions import ( DockerImageNotFoundError, @@ -32,10 +33,11 @@ def _api_mock() -> MagicMock: return api +@pytest.mark.parametrize("ssh_host", ["127.0.0.1", "100.64.0.10"]) @pytest.mark.asyncio -async def test_create_vm_runs_container_and_returns_loopback_coords(): +async def test_create_vm_publishes_and_advertises_the_ssh_host(ssh_host: str): api = _api_mock() - provider = DockerProvider(api, _settings()) + provider = DockerProvider(api, _settings(ssh_host=ssh_host)) result = await provider.create_vm(name="sb-test", image="drukbox/sandbox:latest", env={}) @@ -45,14 +47,21 @@ async def test_create_vm_runs_container_and_returns_loopback_coords(): assert run_kwargs["labels"] == {"managed-by": "drukbox", "drukbox-host-name": "sb-test"} # The public key is injected so the container's entrypoint can seed authorized_keys. assert run_kwargs["env"]["DRUKBOX_AUTHORIZED_KEY"].startswith("ssh-ed25519 ") + assert run_kwargs["ssh_host"] == ssh_host - assert result.ssh_host == "127.0.0.1" + assert result.ssh_host == ssh_host assert result.ssh_port == 49160 assert result.ssh_username == "root" assert result.private_key assert "-----BEGIN OPENSSH PRIVATE KEY-----" in result.private_key +@pytest.mark.parametrize("ssh_host", ["0.0.0.0", "::", "", "sandbox.example"]) +def test_settings_reject_an_ssh_host_callers_cannot_dial(ssh_host: str): + with pytest.raises(ValidationError): + _settings(ssh_host=ssh_host) + + @pytest.mark.asyncio async def test_create_vm_passes_caller_env_and_names_it_for_the_entrypoint(): api = _api_mock()