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
23 changes: 23 additions & 0 deletions .github/workflows/cloudflare.yml
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,4 @@ db.sqlite3
api-tests/node_modules/
api-tests/playwright-report/
api-tests/test-results/
deploy/cloudflare/.wrangler/
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand Down
8 changes: 8 additions & 0 deletions deploy/cloudflare/Dockerfile
Original file line number Diff line number Diff line change
@@ -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/
113 changes: 113 additions & 0 deletions deploy/cloudflare/worker.js
Original file line number Diff line number Diff line change
@@ -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 });
},
};
14 changes: 14 additions & 0 deletions deploy/cloudflare/wrangler.jsonc
Original file line number Diff line number Diff line change
@@ -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" } }
}
49 changes: 49 additions & 0 deletions docs/cloudflare.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions docs/deploy.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
1 change: 1 addition & 0 deletions src/providers/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import providers.aws
import providers.boat
import providers.cloudflare
import providers.daytona
import providers.docker
import providers.docker_sbx
Expand Down
4 changes: 4 additions & 0 deletions src/providers/cloudflare/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from providers.cloudflare.provider import CloudflareProvider
from providers.registry import register_vm_provider

register_vm_provider(CloudflareProvider)
65 changes: 65 additions & 0 deletions src/providers/cloudflare/api.py
Original file line number Diff line number Diff line change
@@ -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")
70 changes: 70 additions & 0 deletions src/providers/cloudflare/provider.py
Original file line number Diff line number Diff line change
@@ -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()
Loading
Loading