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
10 changes: 10 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,16 @@ remain protected by Daytona regardless of this flag.

**Proxy URL format:** `http://<port>-<sandboxId>.<PROXY_DOMAIN>/<path>`

**`publicPorts` (per-port public preview)** — a create request may include
`"publicPorts": [8080, 3000]`: only listed ports are publicly reachable; all other
ports fall back to Daytona's private-sandbox auth (owner preview tokens still work).
Omit for the default all-ports-public behavior. Requires the 0g-daytona fork images
(compose defaults to them via `REGISTRY_PREFIX`); against stock Daytona images
the billing proxy rejects such creates with 502 instead of silently ignoring the
restriction. Rules: max 16 ports, system ports (22222/2280/33333) rejected, immutable
after create, sealed sandboxes must include 8080. Successful creates return
`preview_urls: {"8080": "http://8080-<id>.<PROXY_DOMAIN>"}`.

The `PROXY_DOMAIN` env var controls the URL format. Examples:
- nip.io (no real domain): `PROXY_DOMAIN=<your-ip>.nip.io:4000`
→ `http://8080-<sandboxId>.<your-ip>.nip.io:4000/result`
Expand Down
13 changes: 13 additions & 0 deletions cmd/user/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import (
"math/big"
"net/http"
"os"
"strconv"
"strings"
"time"
"unicode/utf8"
Expand Down Expand Up @@ -488,6 +489,7 @@ func runCreate(args []string) {
disk := fs.Int("disk", 0, "Disk in GB (optional, overrides class)")
sealed := fs.Bool("sealed", false, "Create a sealed sandbox (blocks SSH and toolbox access)")
sealID := fs.String("seal-id", "", "Optional caller-chosen seal_id (64 hex chars); random if unset")
ports := fs.String("ports", "", "Comma-separated ports to expose publicly (e.g. 8080,3000); others require auth. Empty = all ports public")
var envArgs multiString
fs.Var(&envArgs, "env", "Env var KEY=VAL injected into container; repeatable")
_ = fs.Parse(args)
Expand Down Expand Up @@ -523,6 +525,17 @@ func runCreate(args []string) {
if *sealID != "" {
body["seal_id"] = *sealID
}
if *ports != "" {
var portList []int
for _, p := range strings.Split(*ports, ",") {
n, err := strconv.Atoi(strings.TrimSpace(p))
if err != nil {
fatalf("--ports must be comma-separated integers, got %q", p)
}
portList = append(portList, n)
}
body["publicPorts"] = portList
}
if len(envArgs) > 0 {
env := map[string]string{}
for _, kv := range envArgs {
Expand Down
4 changes: 2 additions & 2 deletions docker/broker/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ services:

# ── Broker ────────────────────────────────────────────────────────────────────
broker:
image: eliza-registry-vpc.ap-southeast-1.cr.aliyuncs.com/eliza/0g-sandbox:broker
image: ${BROKER_IMAGE:-eliza-registry-vpc.ap-southeast-1.cr.aliyuncs.com/eliza/0g-sandbox:broker}
pull_policy: always
ports:
- "${BROKER_PORT:-8082}:8081"
Expand Down Expand Up @@ -41,7 +41,7 @@ services:

# ── Broker Redis ───────────────────────────────────────────────────────────────
broker-redis:
image: redis@sha256:8b81dd37ff027bec4e516d41acfbe9fe2460070dc6d4a4570a2ac5b9d59df065
image: ${REGISTRY_PREFIX:-us-central1-docker.pkg.dev/g-devops/zg-sandbox}/redis@sha256:8b81dd37ff027bec4e516d41acfbe9fe2460070dc6d4a4570a2ac5b9d59df065
volumes:
- broker_redis_data:/data
command: >
Expand Down
7 changes: 7 additions & 0 deletions docker/sandbox/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,13 @@ BACKEND_APP_NAME= # tapp appId — must match the value used in
# MOCK_TEE=true
# MOCK_APP_PRIVATE_KEY=0x<hex-private-key>

# Registry prefix for ALL third-party images (daytona fork api/proxy, runner,
# ssh-gateway, redis, postgres, dex, minio, registry). Upstream Daytona went
# closed source, so every image is self-hosted; mirrors preserve upstream
# digests. Default: GCP Artifact Registry. Aliyun-VPC hosts use the eliza
# mirror instead:
# REGISTRY_PREFIX=eliza-registry-vpc.ap-southeast-1.cr.aliyuncs.com/eliza

# ── Daytona internals ─────────────────────────────────────────────────────────
# These have safe defaults for local dev. Change in production.
DAYTONA_ENCRYPTION_KEY=supersecretkey
Expand Down
31 changes: 19 additions & 12 deletions docker/sandbox/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ services:
restart: unless-stopped

sandbox-redis:
image: redis@sha256:8b81dd37ff027bec4e516d41acfbe9fe2460070dc6d4a4570a2ac5b9d59df065
image: ${REGISTRY_PREFIX:-us-central1-docker.pkg.dev/g-devops/zg-sandbox}/redis@sha256:8b81dd37ff027bec4e516d41acfbe9fe2460070dc6d4a4570a2ac5b9d59df065
volumes:
- ${SANDBOX_REDIS_DATA:-sandbox_redis_data}:/data
command: >
Expand All @@ -78,7 +78,12 @@ services:
# Port 3000 is intentionally NOT published. All external API access must go
# through the sandbox proxy on port 8080.
api:
image: daytonaio/daytona-api:v0.189.0-amd64
# 0g-daytona fork image (v0.189.0 + publicPorts). All third-party images
# are self-hosted under REGISTRY_PREFIX — upstream Daytona went closed
# source, so Docker Hub availability is not guaranteed. Mirrors preserve
# upstream digests (crane copy). Set REGISTRY_PREFIX per environment
# (GCP AR default; aliyun hosts use the eliza VPC registry mirror).
image: ${REGISTRY_PREFIX:-us-central1-docker.pkg.dev/g-devops/zg-sandbox}/daytona-api:v0.189.0-0g.1
environment:
- OTEL_ENABLED=false
# Snapshot used when a create request doesn't specify one. Per-env: a
Expand Down Expand Up @@ -199,15 +204,17 @@ services:
privileged: true

runner:
image: daytonaio/daytona-runner:v0.189.0-amd64
image: ${REGISTRY_PREFIX:-us-central1-docker.pkg.dev/g-devops/zg-sandbox}/daytona-runner:v0.189.0-amd64
# Sysbox at the outer boundary: host dockerd starts the runner via
# sysbox-runc so the runner (and its DinD + user sandboxes) live in a
# user namespace. Container uid 0 maps to a non-root uid on host, so
# even a privileged user sandbox escaping to the runner is not host
# root. Preserves the DinD + internal-registry topology unchanged;
# only the outer runtime shifts. Requires sysbox-runc registered on
# the host dockerd (see 0g-tapp gcp-cvm build-gcp-tapp.sh ENABLE_SYSBOX).
runtime: sysbox-runc
# Set RUNNER_RUNTIME=sysbox-runc to enable (needs host kernel >=5.12 for
# shiftfs/idmapped-mounts; on 5.10 sysbox can't write resolv.conf). Default runc.
runtime: ${RUNNER_RUNTIME:-runc}
environment:
- ENVIRONMENT=production
- API_PORT=3003
Expand Down Expand Up @@ -240,7 +247,7 @@ services:
restart: always

proxy:
image: daytonaio/daytona-proxy:v0.189.0-amd64
image: ${REGISTRY_PREFIX:-us-central1-docker.pkg.dev/g-devops/zg-sandbox}/daytona-proxy:v0.189.0-0g.1
ports:
- "4000:4000"
environment:
Expand All @@ -262,7 +269,7 @@ services:
restart: always

ssh-gateway:
image: daytonaio/daytona-ssh-gateway:v0.189.0-amd64
image: ${REGISTRY_PREFIX:-us-central1-docker.pkg.dev/g-devops/zg-sandbox}/daytona-ssh-gateway:v0.189.0-amd64
ports:
- "2222:2222"
environment:
Expand All @@ -276,7 +283,7 @@ services:
restart: always

dex:
image: dexidp/dex@sha256:1b4a6eee8550240b0faedad04d984ca939513650e1d9bd423502c67355e3822f
image: ${REGISTRY_PREFIX:-us-central1-docker.pkg.dev/g-devops/zg-sandbox}/dex@sha256:1b4a6eee8550240b0faedad04d984ca939513650e1d9bd423502c67355e3822f
volumes:
- ./config/dex/config.yaml:/etc/dex/config.yaml
- ${DEX_DB_DATA:-dex_db}:/var/dex
Expand All @@ -291,7 +298,7 @@ services:
restart: unless-stopped

db:
image: postgres@sha256:1090bc3a8ccfb0b55f78a494d76f8d603434f7e4553543d6e807bc7bd6bbd17f
image: ${REGISTRY_PREFIX:-us-central1-docker.pkg.dev/g-devops/zg-sandbox}/postgres@sha256:1090bc3a8ccfb0b55f78a494d76f8d603434f7e4553543d6e807bc7bd6bbd17f
environment:
- POSTGRES_PASSWORD=${DAYTONA_DB_PASSWORD}
- POSTGRES_USER=${DAYTONA_DB_USER}
Expand All @@ -313,7 +320,7 @@ services:
# v0.189 permission enum once the api has finished migrations. Idempotent:
# the WHERE clause skips the row after the first successful update.
grant-admin-perms:
image: postgres:16-alpine
image: ${REGISTRY_PREFIX:-us-central1-docker.pkg.dev/g-devops/zg-sandbox}/postgres:16-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777
depends_on:
db:
condition: service_healthy
Expand Down Expand Up @@ -341,7 +348,7 @@ services:
restart: on-failure:5

daytona-redis:
image: redis@sha256:8b81dd37ff027bec4e516d41acfbe9fe2460070dc6d4a4570a2ac5b9d59df065
image: ${REGISTRY_PREFIX:-us-central1-docker.pkg.dev/g-devops/zg-sandbox}/redis@sha256:8b81dd37ff027bec4e516d41acfbe9fe2460070dc6d4a4570a2ac5b9d59df065
volumes:
- ${DAYTONA_REDIS_DATA:-daytona_redis_data}:/data
healthcheck:
Expand All @@ -354,7 +361,7 @@ services:
restart: unless-stopped

minio:
image: minio/minio@sha256:14cea493d9a34af32f524e538b8346cf79f3321eff8e708c1e2960462bd8936e
image: ${REGISTRY_PREFIX:-us-central1-docker.pkg.dev/g-devops/zg-sandbox}/minio@sha256:14cea493d9a34af32f524e538b8346cf79f3321eff8e708c1e2960462bd8936e
environment:
- MINIO_ROOT_USER=${MINIO_ROOT_USER}
- MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD}
Expand All @@ -371,7 +378,7 @@ services:
restart: unless-stopped

registry:
image: registry@sha256:bcece5dd3d4b6189e13e7ac71b2ccbc2aae649365f0c589852d687efeba6b290
image: ${REGISTRY_PREFIX:-us-central1-docker.pkg.dev/g-devops/zg-sandbox}/registry@sha256:bcece5dd3d4b6189e13e7ac71b2ccbc2aae649365f0c589852d687efeba6b290
environment:
REGISTRY_HTTP_ADDR: registry:6000
REGISTRY_STORAGE_DELETE_ENABLED: 'true'
Expand Down
15 changes: 13 additions & 2 deletions docs/API_REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -267,8 +267,9 @@ All monetary amounts are in **neuron** (1 0G = 10¹⁸ neuron).
**Body:**
```json
{
"image": "ubuntu:22.04",
"sealed": false
"image": "ubuntu:22.04",
"sealed": false,
"publicPorts": [8080, 3000]
}
```
All fields are optional.
Expand All @@ -277,6 +278,16 @@ All fields are optional.
|-------|------|-------------|
| `image` | string | Docker image or snapshot name to use |
| `sealed` | bool | If `true`, creates a sealed sandbox (see below) |
| `publicPorts` | int[] | Public port allowlist: only these ports are publicly reachable via the preview proxy; all others fall back to preview auth (owner tokens still work). Omit = all ports public. Max 16 entries, range 1-65535, system ports 22222/2280/33333 rejected, immutable after create. Sealed sandboxes must include 8080 |

**Per-port public preview** (`"publicPorts": [...]`):
- Requires the provider to run the 0g-daytona fork images; against stock
Daytona the request fails with **502** (`publicPorts is not supported by
this provider's Daytona backend`) and no sandbox is left running
- The `200` response echoes `publicPorts` and adds
`preview_urls: {"8080": "http://8080-<id>.<PROXY_DOMAIN>"}`
- Non-listed ports answer with a **307 redirect** to the provider's OIDC
endpoint (effectively blocked for public callers)

**Sealed sandboxes** (`"sealed": true`):
- Resolves the image to its content digest via the internal registry (hard failure if unresolvable)
Expand Down
13 changes: 11 additions & 2 deletions docs/API_REFERENCE.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -263,8 +263,9 @@ service 注册(例如调用 `deregisterService` 之后),返回 `[]`。
**Body:**
```json
{
"image": "ubuntu:22.04",
"sealed": false
"image": "ubuntu:22.04",
"sealed": false,
"publicPorts": [8080, 3000]
}
```
所有字段可选。
Expand All @@ -273,6 +274,14 @@ service 注册(例如调用 `deregisterService` 之后),返回 `[]`。
|------|------|------|
| `image` | string | 要用的 Docker 镜像或 snapshot 名 |
| `sealed` | bool | `true` 则创建 sealed 沙箱(见下) |
| `publicPorts` | int[] | 公开端口白名单:只有名单内端口可经预览代理公开访问,其余端口回落到预览认证(owner 的 token 仍可用)。不传 = 全端口公开。最多 16 个,范围 1-65535,系统端口 22222/2280/33333 拒绝,创建后不可改。sealed 沙箱必须包含 8080 |

**端口级公开预览**(`"publicPorts": [...]`):
- 要求 provider 运行 0g-daytona fork 镜像;对官方 Daytona 该请求返回 **502**
(`publicPorts is not supported by this provider's Daytona backend`),且不会留下运行中的沙箱
- `200` 响应会回显 `publicPorts` 并附上
`preview_urls: {"8080": "http://8080-<id>.<PROXY_DOMAIN>"}`
- 名单外端口返回 **307 跳转**到 provider 的 OIDC 端点(对公众等于被拦)

**Sealed 沙箱**(`"sealed": true`):
- 通过内部 registry 把镜像解析为 content digest(无法解析则硬失败)
Expand Down
16 changes: 16 additions & 0 deletions docs/CLI.md
Original file line number Diff line number Diff line change
Expand Up @@ -546,6 +546,7 @@ go run ./cmd/user/ create \
| `--memory` | — | Memory in GB (overrides `--class`) |
| `--disk` | — | Disk in GB (overrides `--class`) |
| `--sealed` | `false` | Create a sealed sandbox: injects TEE attestation, blocks SSH and toolbox access |
| `--ports` | — | Comma-separated public port allowlist (e.g. `8080,3000`). Only these ports are publicly reachable; all others require preview auth. Empty = all ports public. Max 16; system ports 22222/2280/33333 rejected; immutable after create; sealed sandboxes must include 8080 |

**Example**

Expand All @@ -555,6 +556,21 @@ USER_KEY=0x<hex> go run ./cmd/user/ create --api http://<provider>:8080

# Sealed sandbox (SSH/toolbox blocked; TEE attestation injected)
USER_KEY=0x<hex> go run ./cmd/user/ create --api http://<provider>:8080 --sealed

# Only port 8080 publicly reachable; 9090 etc. fall back to preview auth
USER_KEY=0x<hex> go run ./cmd/user/ create --api http://<provider>:8080 --ports 8080
```

With `--ports`, the create response echoes `publicPorts` (confirmation the
provider's Daytona supports it — providers on stock Daytona reject such
creates with 502) and includes ready-to-use URLs:

```json
{
"id": "54a4c0ee-…",
"publicPorts": [8080],
"preview_urls": { "8080": "http://8080-54a4c0ee-….<PROXY_DOMAIN>" }
}
```

---
Expand Down
31 changes: 31 additions & 0 deletions internal/proxy/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,10 @@ func (h *Handler) handleCreate(c *gin.Context) {
// Sealed containers: resolve image hash and inject TEE attestation + keypair
// before forwarding to Daytona.
sealed := extractSealed(body)
if err := ValidatePublicPorts(body, sealed); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if !sealed && h.SealedOnly {
c.JSON(http.StatusBadRequest, gin.H{
"error": "this provider only accepts sealed sandboxes; set \"sealed\": true in the create request",
Expand Down Expand Up @@ -366,6 +370,33 @@ func (h *Handler) handleCreate(c *gin.Context) {
respBytes = stripped
}
}

// publicPorts round-trip check: a stock Daytona API silently strips the
// field (whitelist validation), which would hand the user an unrestricted
// sandbox while they believe ports are locked down. Fail the create loudly
// instead, and stop the orphan so it doesn't run unbilled.
if _, requestedPorts, _ := parsePublicPorts(body); requestedPorts && result.StatusCode >= 200 && result.StatusCode < 300 {
decorated, supported, derr := decoratePublicPorts(respBytes)
if derr == nil && !supported {
id := extractID(respBytes)
if id != "" {
go func() {
ctx := context.WithoutCancel(c.Request.Context())
if serr := h.dtona.StopSandbox(ctx, id); serr != nil {
h.log.Error("stop sandbox after unsupported publicPorts", zap.String("id", id), zap.Error(serr))
}
}()
}
h.log.Error("publicPorts requested but Daytona backend dropped the field — fork images not deployed?", zap.String("id", id))
c.JSON(http.StatusBadGateway, gin.H{
"error": "publicPorts is not supported by this provider's Daytona backend; the sandbox was not started",
})
return
}
if derr == nil {
respBytes = decorated
}
}
for k, vs := range result.Header {
if strings.EqualFold(k, "Content-Length") {
continue // recomputed below from actual body length
Expand Down
Loading