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
32 changes: 19 additions & 13 deletions docs/deploy.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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.

Expand Down
3 changes: 2 additions & 1 deletion docs/networking.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/hosts/tests/test_tailscale_optional.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
6 changes: 2 additions & 4 deletions src/providers/docker/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
15 changes: 11 additions & 4 deletions src/providers/docker/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__(
Expand Down Expand Up @@ -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

Expand All @@ -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,
)
Expand Down
15 changes: 14 additions & 1 deletion src/providers/docker/settings.py
Original file line number Diff line number Diff line change
@@ -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


Expand All @@ -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
11 changes: 7 additions & 4 deletions src/providers/docker/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,14 +43,15 @@ 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(
name="sb-test",
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"
Expand All @@ -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": ""}]
}


Expand All @@ -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
Expand Down
15 changes: 12 additions & 3 deletions src/providers/docker/tests/test_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from unittest.mock import AsyncMock, MagicMock

import pytest
from pydantic import ValidationError

from providers.docker.exceptions import (
DockerImageNotFoundError,
Expand Down Expand Up @@ -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={})

Expand All @@ -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()
Expand Down