diff --git a/.github/workflows/cloudflare.yml b/.github/workflows/cloudflare.yml new file mode 100644 index 0000000..01e5ba7 --- /dev/null +++ b/.github/workflows/cloudflare.yml @@ -0,0 +1,23 @@ +name: Validate Cloudflare Sandbox + +on: + pull_request: + paths: + - deploy/cloudflare/** + - .github/workflows/cloudflare.yml + +permissions: + contents: read + +jobs: + worker: + runs-on: ubuntu-latest + defaults: + run: + working-directory: deploy/cloudflare + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: '24' + - run: npx --yes wrangler@4.147.0 deploy --dry-run diff --git a/.gitignore b/.gitignore index 48bc890..aadc54b 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,4 @@ db.sqlite3 api-tests/node_modules/ api-tests/playwright-report/ api-tests/test-results/ +deploy/cloudflare/.wrangler/ diff --git a/AGENTS.md b/AGENTS.md index d3a8593..f36ee05 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,7 +13,7 @@ It owns: - Template records, async template builds, and their provider images - Inline provisioning in `POST /hosts` - Provider VM creation and deletion (exe.dev, AWS, Hetzner, Exoscale, Boat, - Daytona, local Docker, Docker Sandboxes) + Cloudflare, Daytona, 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`) diff --git a/deploy/cloudflare/Dockerfile b/deploy/cloudflare/Dockerfile new file mode 100644 index 0000000..a81ff32 --- /dev/null +++ b/deploy/cloudflare/Dockerfile @@ -0,0 +1,8 @@ +FROM tailscale/tailscale:v1.102.5 AS tailscale +FROM ubuntu:24.04 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends bash ca-certificates sudo jq git gh openssh-client iptables iproute2 \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=tailscale /usr/local/bin/tailscale /usr/local/bin/tailscaled /usr/local/bin/ diff --git a/deploy/cloudflare/worker.js b/deploy/cloudflare/worker.js new file mode 100644 index 0000000..7dea296 --- /dev/null +++ b/deploy/cloudflare/worker.js @@ -0,0 +1,113 @@ +import { DurableObject } from "cloudflare:workers"; + +const INSTANCES = ["lite", "standard-1", "standard-2", "standard-3", "standard-4"]; +const INACTIVITY_MS = 5 * 60 * 1000; +const ALARM_MS = 60 * 1000; +const BOOTSTRAP_MS = 120 * 1000; + +export class Sandbox extends DurableObject { + constructor(ctx, env) { + super(ctx, env); + if (ctx.container.running) { + void ctx.blockConcurrencyWhile(() => ctx.container.setInactivityTimeout(INACTIVITY_MS)); + } + } + + async fetch(request) { + const container = this.ctx.container; + if (request.method === "DELETE") { + await this.ctx.storage.put("status", "deleted"); + await container.destroy(); + await this.ctx.storage.deleteAlarm(); + return Response.json({ status: "deleted" }); + } + if (request.method !== "PUT") { + return new Response(null, { status: 405 }); + } + + const body = await request.json(); + if (!Object.hasOwn(container.images, body.image) || !INSTANCES.includes(body.instance)) { + return Response.json({ error: "Invalid sandbox configuration" }, { status: 400 }); + } + + const claimed = await this.ctx.blockConcurrencyWhile(async () => { + if (await this.ctx.storage.get("status")) return false; + await this.ctx.storage.put("status", "creating"); + return true; + }); + if (!claimed) return Response.json({ error: "Sandbox name already exists" }, { status: 409 }); + + try { + container.start({ + image: container.images[body.image], + instance: body.instance, + entrypoint: ["sleep", "infinity"], + enableInternet: true, + labels: { "managed-by": body.label }, + }); + await container.setInactivityTimeout(INACTIVITY_MS); + await this.ctx.storage.setAlarm(Date.now() + ALARM_MS); + const abort = new AbortController(); + let timer; + const deadline = new Promise((_, reject) => { + timer = setTimeout(() => { + reject(new Error("Bootstrap timed out")); + abort.abort(); + }, BOOTSTRAP_MS); + }); + try { + const exitCode = await Promise.race([ + container.exec(["bash", "-e", "-c", body.script], { + signal: abort.signal, stdout: "ignore", stderr: "ignore", + }).then(process => process.exitCode), + deadline, + ]); + if (exitCode !== 0) throw new Error("Bootstrap failed"); + } finally { + clearTimeout(timer); + } + if (await this.ctx.storage.get("status") !== "creating") { + await container.destroy(); + return Response.json({ error: "Sandbox was deleted" }, { status: 409 }); + } + await this.ctx.storage.put("status", "active"); + return Response.json({ status: "active" }, { status: 201 }); + } catch { + if (await this.ctx.storage.get("status") === "creating") { + await this.ctx.storage.put("status", "error"); + } + await container.destroy(); + await this.ctx.storage.deleteAlarm(); + return Response.json({ error: "Sandbox provisioning failed" }, { status: 502 }); + } + } + + async alarm() { + const status = await this.ctx.storage.get("status"); + if (this.ctx.container.running && (status === "active" || status === "creating")) { + await this.ctx.container.setInactivityTimeout(INACTIVITY_MS); + await this.ctx.storage.setAlarm(Date.now() + ALARM_MS); + } + } +} + +export default { + async fetch(request, env) { + const encoder = new TextEncoder(); + const supplied = encoder.encode(request.headers.get("Authorization") ?? ""); + const expected = encoder.encode(`Bearer ${env.DRUKBOX_TOKEN}`); + if ( + !env.DRUKBOX_TOKEN || supplied.byteLength !== expected.byteLength || + !crypto.subtle.timingSafeEqual(supplied, expected) + ) { + return new Response(null, { status: 401 }); + } + const url = new URL(request.url); + if (url.pathname === "/health" && request.method === "GET") { + return Response.json({ status: "ok" }); + } + const match = /^\/sandboxes\/([a-z0-9-]{1,100})$/.exec(url.pathname); + if (match) return env.SANDBOXES.getByName(match[1]).fetch(request); + return new Response(null, { status: 404 }); + }, +}; diff --git a/deploy/cloudflare/wrangler.jsonc b/deploy/cloudflare/wrangler.jsonc new file mode 100644 index 0000000..310b2e0 --- /dev/null +++ b/deploy/cloudflare/wrangler.jsonc @@ -0,0 +1,14 @@ +{ + "name": "drukbox-sandboxes", + "main": "worker.js", + "compatibility_date": "2026-10-04", + // Request bodies carry Tailscale auth keys and host env. + "observability": { "enabled": false }, + "containers": [{ + "class_name": "Sandbox", + "scheduling_policy": "durable_object", + "images": { "base": { "dockerfile": "Dockerfile" } } + }], + "durable_objects": { "bindings": [{ "name": "SANDBOXES", "class_name": "Sandbox" }] }, + "exports": { "Sandbox": { "type": "durable-object", "storage": "sqlite" } } +} diff --git a/docs/cloudflare.md b/docs/cloudflare.md new file mode 100644 index 0000000..7fb0ba1 --- /dev/null +++ b/docs/cloudflare.md @@ -0,0 +1,49 @@ +# Cloudflare sandboxes + +`POST /hosts` with `{"provider": "cloudflare"}` starts a Cloudflare container, +joins it to Tailscale, and returns `internal_ssh_host`. The container has no +public SSH address, so this provider requires `TAILSCALE_ENABLED=true`. + +Cloudflare starts containers only from a Durable Object in a Worker. Drukbox +calls the Worker in `deploy/cloudflare`, which you deploy to your account. + +## Deploy + +Enable Cloudflare Containers for the account. The deploy machine needs +Node.js and Docker. In `deploy/cloudflare`: + +```bash +npx wrangler@4.147.0 login +npx wrangler@4.147.0 secret put DRUKBOX_TOKEN +npx wrangler@4.147.0 deploy +``` + +Give drukbox the Worker URL, the same token, and the Tailscale settings from +[Networking](networking.md). The tailnet SSH policy must let the API and +callers reach the sandbox tag as `root`. + +```dotenv +CLOUDFLARE_WORKER_URL=https://drukbox-sandboxes.YOUR-SUBDOMAIN.workers.dev +CLOUDFLARE_WORKER_TOKEN=YOUR-DEDICATED-TOKEN +TAILSCALE_ENABLED=true +``` + +The token can also come from `/run/secrets/CLOUDFLARE_WORKER_TOKEN`. + +| Variable | Default | Purpose | +| --- | --- | --- | +| `CLOUDFLARE_DEFAULT_IMAGE` | `base` | Image alias from `wrangler.jsonc` | +| `CLOUDFLARE_INSTANCE_TYPE` | `standard-1` | Default size: `lite` or `standard-1` to `standard-4` | +| `CLOUDFLARE_API_TIMEOUT` | `150` | Worker request timeout, in seconds | +| `CLOUDFLARE_BOOTSTRAP_SSH_TIMEOUT_SECONDS` | `120` | SSH readiness timeout, in seconds | + +## Limits + +- `image` is an alias from `containers[].images` in `wrangler.jsonc`. An added + image needs the tools in `deploy/cloudflare/Dockerfile`. Template builds and + `disk_gb` are not supported. +- Without `/dev/net/tun`, Tailscale runs in userspace, so the container + reaches `SECRETS_PROXY_URL` over its normal outbound network, not over the + tailnet. +- A container that stops loses its files and does not restart. A container + runs, and costs money, until drukbox deletes it, so keep the janitor running. diff --git a/docs/deploy.md b/docs/deploy.md index f5e48b1..842521b 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -101,6 +101,7 @@ account token returns `503`. See [API](api.md#service-accounts). | `aws` | EC2 instances | Remote | | `hetzner` | Hetzner Cloud VMs | Remote | | `exoscale` | Exoscale VMs | Remote | +| `cloudflare` | Containers ([Cloudflare setup](cloudflare.md)) | Remote | | `docker` | Containers ([Local sandboxes with Docker](#local-sandboxes-with-docker)) | Local, no external account | | `docker-sbx` | microVMs ([Local microVMs with Docker Sandboxes](#local-microvms-with-docker-sandboxes)) | Local | | `boat` | Sandboxes ([Boat configuration](boat.md)) | Cloud | diff --git a/src/providers/__init__.py b/src/providers/__init__.py index 0233969..d66dbe9 100644 --- a/src/providers/__init__.py +++ b/src/providers/__init__.py @@ -1,5 +1,6 @@ import providers.aws import providers.boat +import providers.cloudflare import providers.daytona import providers.docker import providers.docker_sbx diff --git a/src/providers/cloudflare/__init__.py b/src/providers/cloudflare/__init__.py new file mode 100644 index 0000000..934ca89 --- /dev/null +++ b/src/providers/cloudflare/__init__.py @@ -0,0 +1,4 @@ +from providers.cloudflare.provider import CloudflareProvider +from providers.registry import register_vm_provider + +register_vm_provider(CloudflareProvider) diff --git a/src/providers/cloudflare/api.py b/src/providers/cloudflare/api.py new file mode 100644 index 0000000..f7ba5e0 --- /dev/null +++ b/src/providers/cloudflare/api.py @@ -0,0 +1,65 @@ +import httpx + +from providers.exceptions import ( + ProviderAuthError, + ProviderCommandError, + ProviderTransportError, +) + +from .settings import CloudflareSettings + + +class CloudflareAPI: + def __init__(self, settings: CloudflareSettings) -> None: + self.client = httpx.AsyncClient( + base_url=settings.worker_url.rstrip("/") + "/", + headers={"Authorization": f"Bearer {settings.worker_token}"}, + timeout=httpx.Timeout(settings.api_timeout, connect=5), + ) + + async def create_sandbox( + self, name: str, *, image: str, instance: str, script: str, label: str + ) -> None: + await self.request( + "PUT", + f"sandboxes/{name}", + expected="active", + json={ + "image": image, + "instance": instance, + "script": script, + "label": label, + }, + ) + + async def delete_sandbox(self, name: str) -> None: + await self.request("DELETE", f"sandboxes/{name}", expected="deleted") + + async def diagnose(self) -> str: + await self.request("GET", "health", expected="ok") + return "Cloudflare Worker authentication succeeded" + + async def aclose(self) -> None: + await self.client.aclose() + + async def request( + self, method: str, path: str, *, expected: str, json: dict[str, str] | None = None + ) -> None: + try: + response = await self.client.request(method, path, json=json) + except httpx.RequestError as exc: + raise ProviderTransportError("Cloudflare Worker transport failed") from exc + if response.status_code in {401, 403}: + raise ProviderAuthError("Cloudflare Worker authentication failed") + if response.status_code >= 500 or response.status_code == 429: + raise ProviderTransportError(f"Cloudflare Worker returned HTTP {response.status_code}") + if response.status_code >= 400: + raise ProviderCommandError( + f"Cloudflare Worker rejected request: HTTP {response.status_code}" + ) + try: + status = response.json()["status"] + except (ValueError, KeyError, TypeError) as exc: + raise ProviderTransportError("Invalid Cloudflare Worker response") from exc + if status != expected: + raise ProviderTransportError("Unexpected Cloudflare Worker response") diff --git a/src/providers/cloudflare/provider.py b/src/providers/cloudflare/provider.py new file mode 100644 index 0000000..5e6edf1 --- /dev/null +++ b/src/providers/cloudflare/provider.py @@ -0,0 +1,70 @@ +from typing import ClassVar, Self + +from core.settings import get_settings +from providers import environment +from providers.base import VMCreateResult, VMProvider +from providers.exceptions import ProviderAuthError, ProviderCommandError + +from .api import CloudflareAPI +from .settings import CloudflareSettings + + +class CloudflareProvider(VMProvider): + name: ClassVar[str] = "cloudflare" + diagnose_hint: ClassVar[str] = "check_cloudflare_worker_and_tailscale_settings" + supports_instance_type = True + + def __init__( + self, api: CloudflareAPI, settings: CloudflareSettings, *, service_label: str + ) -> None: + self.api = api + self.settings = settings + self.service_label = service_label + + @classmethod + def from_settings(cls) -> Self: + settings = CloudflareSettings() # pyright: ignore[reportCallIssue] + return cls(CloudflareAPI(settings), settings, service_label=get_settings().service_label) + + @property + def default_image(self) -> str: + return self.settings.default_image + + @property + def bootstrap_ssh_timeout_seconds(self) -> float: + return self.settings.bootstrap_ssh_timeout_seconds + + async def create_vm( + self, + *, + name: str, + image: str, + env: dict[str, str] | None = None, + setup_script: str | None = None, + instance_type: str | None = None, + disk_gb: int | None = None, + ) -> VMCreateResult: + if not setup_script: + raise ProviderCommandError("Cloudflare requires TAILSCALE_ENABLED=true") + try: + await self.api.create_sandbox( + name, + image=image, + instance=instance_type or self.settings.instance_type, + script=environment.get_cloud_init(setup_script, env), + label=self.service_label, + ) + except ProviderAuthError as exc: + raise ProviderCommandError(str(exc)) from exc + return VMCreateResult(provider_id=name, name=name, ssh_username="root") + + async def delete_vm(self, name: str) -> None: + await self.api.delete_sandbox(name) + + async def diagnose(self) -> str: + if not get_settings().tailscale_enabled: + raise ProviderCommandError("Cloudflare requires TAILSCALE_ENABLED=true") + return await self.api.diagnose() + + async def aclose(self) -> None: + await self.api.aclose() diff --git a/src/providers/cloudflare/settings.py b/src/providers/cloudflare/settings.py new file mode 100644 index 0000000..92c5189 --- /dev/null +++ b/src/providers/cloudflare/settings.py @@ -0,0 +1,21 @@ +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict + +from core.settings import get_secrets_dir + + +class CloudflareSettings(BaseSettings): + model_config = SettingsConfigDict( + env_file=".env", + env_prefix="CLOUDFLARE_", + extra="ignore", + hide_input_in_errors=True, + secrets_dir=get_secrets_dir(), + ) + + worker_url: str + worker_token: str + default_image: str = "base" + instance_type: str = "standard-1" + api_timeout: float = Field(default=150, gt=0) + bootstrap_ssh_timeout_seconds: float = Field(default=120, gt=0) diff --git a/src/providers/cloudflare/tests/__init__.py b/src/providers/cloudflare/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/providers/cloudflare/tests/test_provider.py b/src/providers/cloudflare/tests/test_provider.py new file mode 100644 index 0000000..bc38267 --- /dev/null +++ b/src/providers/cloudflare/tests/test_provider.py @@ -0,0 +1,101 @@ +import json +from collections.abc import AsyncIterator + +import httpx +import pytest +import respx + +from providers.cloudflare.api import CloudflareAPI +from providers.cloudflare.provider import CloudflareProvider +from providers.cloudflare.settings import CloudflareSettings +from providers.exceptions import ProviderCommandError, ProviderTransportError +from providers.registry import get_provider_names + + +@pytest.fixture +async def provider() -> AsyncIterator[CloudflareProvider]: + settings = CloudflareSettings(worker_url="https://worker.test", worker_token="test-token") + provider = CloudflareProvider(CloudflareAPI(settings), settings, service_label="drukbox") + yield provider + await provider.aclose() + + +@respx.mock +async def test_create_uses_named_image_and_returns_tailnet_only_host(provider: CloudflareProvider): + route = respx.put("https://worker.test/sandboxes/sb-test").respond( + 201, json={"status": "active"} + ) + result = await provider.create_vm( + name="sb-test", + image="base", + env={"GREETING": "hello world"}, + setup_script="tailscale up", + instance_type="lite", + ) + request = route.calls[0].request + body = json.loads(request.content) + assert request.headers["Authorization"] == "Bearer test-token" + assert body["image"] == "base" + assert body["instance"] == "lite" + assert body["label"] == "drukbox" + assert "export GREETING=" in body["script"] + assert "tailscale up" in body["script"] + assert result.name == "sb-test" + assert result.ssh_username == "root" + assert not result.ssh_host + assert not result.private_key + + +@respx.mock +async def test_requires_tailscale_before_any_request(provider: CloudflareProvider): + with pytest.raises(ProviderCommandError, match="TAILSCALE_ENABLED"): + await provider.create_vm(name="sb-test", image="base") + assert not respx.calls + + +@respx.mock +@pytest.mark.parametrize("status", [429, 500]) +async def test_worker_failure_is_a_transport_error_without_its_body( + provider: CloudflareProvider, status: int +): + respx.put("https://worker.test/sandboxes/sb-test").respond(status, text="sensitive details") + with pytest.raises(ProviderTransportError) as error: + await provider.create_vm(name="sb-test", image="base", setup_script="true") + assert "sensitive" not in str(error.value) + + +@respx.mock +@pytest.mark.parametrize("status", [400, 401]) +async def test_rejected_request_is_a_command_error(provider: CloudflareProvider, status: int): + respx.put("https://worker.test/sandboxes/sb-test").respond(status) + with pytest.raises(ProviderCommandError): + await provider.create_vm(name="sb-test", image="base", setup_script="true") + + +@respx.mock +async def test_lost_response_is_a_transport_error(provider: CloudflareProvider): + respx.put("https://worker.test/sandboxes/sb-test").mock(side_effect=httpx.ReadTimeout("lost")) + with pytest.raises(ProviderTransportError): + await provider.create_vm(name="sb-test", image="base", setup_script="true") + + +@respx.mock +async def test_wrong_worker_response_fails(provider: CloudflareProvider): + respx.put("https://worker.test/sandboxes/sb-test").respond(200, text="login page") + with pytest.raises(ProviderTransportError, match="Invalid"): + await provider.create_vm(name="sb-test", image="base", setup_script="true") + + +@respx.mock +async def test_delete_and_health(provider: CloudflareProvider): + deletion = respx.delete("https://worker.test/sandboxes/sb-test").respond( + 200, json={"status": "deleted"} + ) + respx.get("https://worker.test/health").respond(200, json={"status": "ok"}) + await provider.delete_vm("sb-test") + assert deletion.called + assert "authentication succeeded" in await provider.diagnose() + + +def test_provider_is_registered(): + assert "cloudflare" in get_provider_names()