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
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ It owns:
local Docker, Docker Sandboxes)
- Tailscale auth key creation, device discovery, and cleanup
- SSH host key scanning and `known_hosts` material
- An SSH gateway for hosts of gateway providers (`python -m gateway.server`)
- Account-bound exe.dev HTTP proxy resources

Periodic maintenance runs as cron jobs: `python -m hosts.janitor` reaps
Expand Down Expand Up @@ -44,6 +45,7 @@ src/
api/ # FastAPI app and global handlers
core/ # Settings, database, exception base
hosts/ # Host API, models, schemas, service, janitor, pool, auth
gateway/ # SSH gateway for gateway-provider hosts
http_proxies/ # HTTP proxy API, schemas, service, deps
providers/ # VM provider ABC, capabilities, registry, adapters
networking/ # Network provider framework and Tailscale adapter
Expand Down
23 changes: 23 additions & 0 deletions alembic/versions/0003_host_public_key.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
"""per-host public key for the SSH gateway

Revision ID: 0003_host_public_key
Revises: 0002_host_sizing
"""

from collections.abc import Sequence

from alembic import op
import sqlalchemy as sa

revision: str = "0003_host_public_key"
down_revision: str | None = "0002_host_sizing"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None


def upgrade() -> None:
op.add_column("hosts", sa.Column("public_key", sa.Text(), nullable=False, server_default=""))


def downgrade() -> None:
op.drop_column("hosts", "public_key")
55 changes: 49 additions & 6 deletions docs/deploy.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,13 +155,12 @@ docker run --rm --network host \
The daemon reads workspace paths on its own filesystem. Thus the
workspace mount must have the same path on the host and in the
container. The janitor and pool containers need the same mounts and
variables. Host networking is necessary: the daemon publishes sandbox
SSH ports on the host loopback interface only, and drukbox returns
`127.0.0.1` addresses, the same as the `docker` provider.
variables.

Only this machine can connect to the sandboxes. The key for each host
is the auth boundary. Sandboxes have no `SERVICE_LABEL` tag, because
`sbx create` has no label option.
Callers reach the sandboxes through
[the SSH gateway](#the-ssh-gateway); the provider requires it. The key
for each host is the auth boundary. Sandboxes have no `SERVICE_LABEL`
tag, because `sbx create` has no label option.

The template image (`DOCKER_SBX_DEFAULT_IMAGE`, default
`ghcr.io/czpython/drukbox/sbx-sandbox:latest`) must start sshd without
Expand All @@ -187,6 +186,50 @@ cache, and more than 30 seconds at the first pull. Thus a warm pool
`DOCKER_SBX_CPUS` and `DOCKER_SBX_MEMORY` sizes. Without them,
the daemon gives one sandbox all host CPUs and half of the host memory.

## The SSH gateway

The gateway gives remote callers SSH access to the hosts of gateway
providers such as `docker-sbx`, whose sandboxes have no dialable sshd
of their own. Callers connect with normal SSH:

```bash
ssh -p 2222 -i <private-key> <host-name>@<gateway-address>
```

The gateway authenticates the key against the host's stored public key,
and the username must name the same host. It then opens a session
through the provider — `sbx exec` for `docker-sbx`. The daemon stops an
idle sandbox; a connection through the gateway wakes it (approximately
6 seconds) and keeps it awake while connected. The first data can
therefore come after a short delay.

The gateway serves an interactive shell and command execution. It
refuses SFTP, scp, and port forwarding.

The gateway is a requirement for gateway providers: `POST /hosts` for
`docker-sbx` fails without `GATEWAY_SSH_HOST`. Set it to the address
callers use. The response then carries the gateway coordinates:
`external_ssh_host` is the gateway, `external_ssh_port` is the gateway
port, and `ssh_username` is the host name. `known_hosts` carries the
gateway's host key. Hosts of the other providers are not affected.

Run the gateway on the machine that runs sandboxd, as the same user, and
run the migrations first:

```bash
uv run python -m gateway.server
```

A systemd unit follows the same pattern as the API service. The gateway
reads the same `drukbox.env` and connects to the same database.

| Variable | Default | Purpose |
| --- | --- | --- |
| `GATEWAY_SSH_HOST` | — (required) | Address of the gateway. Gateway-provider hosts advertise it; creation fails without it. |
| `GATEWAY_SSH_PORT` | `2222` | Port the gateway listens on and advertises. |
| `GATEWAY_BIND_HOST` | `0.0.0.0` | Interface the gateway server binds. |
| `GATEWAY_HOST_KEY_PATH` | `~/.drukbox/gateway_host_key` | Private host key. The server makes one at start when the file does not exist. |

## Choose a networking mode

`TAILSCALE_ENABLED=false` (default): callers reach sandboxes over the
Expand Down
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ packages = [
"src/api",
"src/core",
"src/diagnostics",
"src/gateway",
"src/hosts",
"src/http_proxies",
"src/networking",
Expand Down Expand Up @@ -50,6 +51,7 @@ classifiers = [
dependencies = [
"aiosqlite>=0.20",
"alembic>=1.14",
"asyncssh>=2.14",
"cryptography>=43",
"fastapi>=0.115",
"greenlet>=3.0",
Expand Down
Empty file added src/gateway/__init__.py
Empty file.
173 changes: 173 additions & 0 deletions src/gateway/server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import asyncio
import contextlib
import logging

import asyncssh
from sqlalchemy import select

from core.database import async_session_factory
from gateway.settings import GatewaySettings
from hosts.models import Host, HostStatus
from providers.base import SandboxProcess, TerminalSize
from providers.exceptions import ProviderError
from providers.registry import get_vm_provider

logger = logging.getLogger(__name__)

_RECEIVE_CHUNK_BYTES = 32768


class GatewayConnection(asyncssh.SSHServer):
"""One caller connection. The key is the identity; the username must name
the same host, so one leaked key cannot probe other host names."""

def __init__(self) -> None:
self.host: Host | None = None

def begin_auth(self, username: str) -> bool:
return True

def public_key_auth_supported(self) -> bool:
return True

async def validate_public_key(self, username: str, key: asyncssh.SSHKey) -> bool:
async with async_session_factory() as session:
result = await session.execute(
select(Host).where(Host.name == username, Host.status == HostStatus.ACTIVE.value)
)
host = result.scalar_one_or_none()
if host and host.public_key:
try:
stored = asyncssh.import_public_key(host.public_key)
except asyncssh.KeyImportError:
logger.warning("gateway: host %s has an unreadable public key", host.name)
return False
if stored.public_data == key.public_data:
self.host = host
return True
logger.info("gateway: rejected key for username=%r", username)
return False


async def _bridge(process: asyncssh.SSHServerProcess) -> None:
connection = process.channel.get_connection()
server = connection.get_owner()
assert isinstance(server, GatewayConnection) and server.host is not None
host = server.host

terminal: TerminalSize | None = None
if process.get_terminal_type():
columns, rows, _, _ = process.get_terminal_size()
terminal = TerminalSize(columns=columns, rows=rows)

process_class = get_vm_provider(host.provider).gateway_process_class
if not process_class:
logger.warning("gateway: host %s has a direct-dial provider", host.name)
process.stderr.write(b"cannot open a session for this host\n")
process.exit(255)
return
try:
sandbox_process = await process_class.open(
host.name, command=process.command, terminal=terminal
)
except ProviderError as error:
logger.warning("gateway: open failed for host=%s: %s", host.name, error)
process.stderr.write(b"cannot open a session for this host\n")
process.exit(255)
return

logger.info(
"gateway: session open host=%s command=%r terminal=%s",
host.name,
process.command,
terminal,
)
# The input pump can outlive the session (a caller who sends no EOF),
# thus it gets a cancel, not a wait.
input_pump = asyncio.create_task(_pump_channel_to_process(process, sandbox_process))
stderr_pump = asyncio.create_task(_pump_stderr_to_channel(process, sandbox_process))
try:
while data := await sandbox_process.receive(_RECEIVE_CHUNK_BYTES):
process.stdout.write(data)
await process.stdout.drain()
await stderr_pump
process.exit(await sandbox_process.wait())
finally:
input_pump.cancel()
stderr_pump.cancel()
with contextlib.suppress(asyncio.CancelledError):
await input_pump
with contextlib.suppress(asyncio.CancelledError):
await stderr_pump
await sandbox_process.aclose()
logger.info("gateway: session closed host=%s", host.name)


async def _pump_stderr_to_channel(
process: asyncssh.SSHServerProcess,
sandbox_process: SandboxProcess,
) -> None:
while data := await sandbox_process.receive_stderr(_RECEIVE_CHUNK_BYTES):
process.stderr.write(data)
await process.stderr.drain()


async def _pump_channel_to_process(
process: asyncssh.SSHServerProcess,
sandbox_process: SandboxProcess,
) -> None:
while True:
try:
data = await process.stdin.read(_RECEIVE_CHUNK_BYTES)
except asyncssh.TerminalSizeChanged as change:
sandbox_process.resize(TerminalSize(columns=change.width, rows=change.height))
continue
except asyncssh.BreakReceived:
continue
if not data:
sandbox_process.send_eof()
return
sandbox_process.send(data)


def _load_host_key(settings: GatewaySettings) -> asyncssh.SSHKey:
path = settings.host_key_path
if path.exists():
return asyncssh.read_private_key(path)
path.parent.mkdir(parents=True, exist_ok=True)
key = asyncssh.generate_private_key("ssh-ed25519")
path.touch(mode=0o600)
path.write_bytes(key.export_private_key("openssh"))
logger.info("gateway: made a new host key at %s", path)
return key


async def start(settings: GatewaySettings) -> asyncssh.SSHAcceptor:
server = await asyncssh.listen(
host=settings.bind_host,
port=settings.ssh_port,
server_host_keys=[_load_host_key(settings)],
server_factory=GatewayConnection,
process_factory=_bridge,
encoding=None,
allow_scp=False,
sftp_factory=None,
agent_forwarding=False,
x11_forwarding=False,
)
logger.info("gateway: listening on %s:%d", settings.bind_host, settings.ssh_port)
return server


async def serve() -> None:
server = await start(GatewaySettings())
await server.wait_closed()


if __name__ == "__main__":
# Service entry point: `python -m gateway.server`.
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s %(message)s",
)
asyncio.run(serve())
38 changes: 38 additions & 0 deletions src/gateway/settings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
from pathlib import Path

from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict


class GatewaySettings(BaseSettings):
"""SSH gateway configuration."""

model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
env_prefix="GATEWAY_",
extra="ignore",
)

ssh_host: str = Field(
default="",
description=(
"Address of the gateway. Gateway-provider hosts advertise it; "
"the service refuses to provision them without it."
),
)
ssh_port: int = Field(
default=2222,
description="Port the gateway listens on and advertises.",
)
bind_host: str = Field(
default="0.0.0.0",
description="Interface the gateway server binds.",
)
host_key_path: Path = Field(
default_factory=lambda: Path.home() / ".drukbox" / "gateway_host_key",
description=(
"Private host key of the gateway server. The server makes one "
"at start when the file does not exist."
),
)
Empty file added src/gateway/tests/__init__.py
Empty file.
Loading