From b530704b0ebbac26273fcfc63d6b942b40d73bc6 Mon Sep 17 00:00:00 2001 From: Wei Wu <161988856+Wilbert957@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:51:02 +0800 Subject: [PATCH 1/5] =?UTF-8?q?feat(proxy):=20publicPorts=20=E2=80=94=20pe?= =?UTF-8?q?r-port=20public=20preview=20for=20sandboxes=20(#57)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(proxy): publicPorts — per-port public preview for sandboxes Create requests may now carry "publicPorts": [8080, ...]; only listed ports are publicly reachable, the rest fall back to Daytona's private-sandbox auth. Enforcement lives in the 0g-daytona fork (v0.189.0-0g.1 images); this side: - validate at the boundary (range/reserved/cap; sealed must include 8080) - round-trip check: if the Daytona backend drops the field (stock image), fail the create with 502 and stop the orphan instead of silently handing out an unrestricted sandbox - enrich create responses with ready-to-use preview_urls - cmd/user --ports flag; compose api/proxy images parameterized (DAYTONA_API_IMAGE/DAYTONA_PROXY_IMAGE, official images as default) Co-Authored-By: Claude Fable 5 * feat(compose): parameterize broker image and runner runtime - BROKER_IMAGE: registry-agnostic broker image ref (same pattern as SANDBOX_IMAGE from #54) - RUNNER_RUNTIME: sysbox-runc needs host kernel >=5.12 (shiftfs/idmapped mounts; on 5.10 sysbox cannot write resolv.conf). Default flips to runc — hosts that can run sysbox must now opt in with RUNNER_RUNTIME=sysbox-runc to keep the host-boundary isolation from #51. Co-Authored-By: Claude Fable 5 * feat(compose): self-host all third-party images under REGISTRY_PREFIX Upstream Daytona went closed source (github main emptied; last open tag v0.190.0), so Docker Hub availability of daytonaio/* images is not guaranteed — and everything else was one Hub outage away from blocking deploys. All third-party images are now mirrored into our own registry (crane copy, upstream digests preserved — @sha256 pins unchanged) and referenced via a single REGISTRY_PREFIX: - daytona api/proxy: 0g-daytona fork v0.189.0-0g.1 (publicPorts) — replaces the per-image DAYTONA_API_IMAGE/DAYTONA_PROXY_IMAGE vars - daytona runner/ssh-gateway: v0.189.0 mirrors - redis/postgres/dex/minio/registry: digest-pinned mirrors; the previously floating postgres:16-alpine (grant-admin-perms) is now digest-pinned too - daytonaio/sandbox:0.5.0-slim mirrored as insurance (DEFAULT_SNAPSHOT ref unchanged for now — renaming it would re-key the registered snapshot) Default prefix: GCP Artifact Registry. Aliyun-VPC hosts set REGISTRY_PREFIX to the eliza mirror (push pending a fresh cr_temp_user token). Co-Authored-By: Claude Fable 5 * docs+web: surface publicPorts in user frontend, CLI and API references - web/user.html: "Public ports" input in the create modal (auto-adds 8080 for sealed-only providers), request pass-through, and a ports badge on sandbox rows - docs/CLI.md: --ports flag + response example with preview_urls - docs/API_REFERENCE(.zh).md: publicPorts field, semantics, 502 behavior Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- CLAUDE.md | 10 +++ cmd/user/main.go | 13 +++ docker/broker/docker-compose.yml | 4 +- docker/sandbox/.env.example | 7 ++ docker/sandbox/docker-compose.yml | 31 +++++--- docs/API_REFERENCE.md | 15 +++- docs/API_REFERENCE.zh.md | 13 ++- docs/CLI.md | 16 ++++ internal/proxy/handler.go | 31 ++++++++ internal/proxy/ports.go | 128 ++++++++++++++++++++++++++++++ internal/proxy/ports_test.go | 104 ++++++++++++++++++++++++ web/user.html | 20 ++++- 12 files changed, 371 insertions(+), 21 deletions(-) create mode 100644 internal/proxy/ports.go create mode 100644 internal/proxy/ports_test.go diff --git a/CLAUDE.md b/CLAUDE.md index e2c5af8..b5a4be0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -160,6 +160,16 @@ remain protected by Daytona regardless of this flag. **Proxy URL format:** `http://-./` +**`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-."}`. + The `PROXY_DOMAIN` env var controls the URL format. Examples: - nip.io (no real domain): `PROXY_DOMAIN=.nip.io:4000` → `http://8080-..nip.io:4000/result` diff --git a/cmd/user/main.go b/cmd/user/main.go index 7193454..7a4396d 100644 --- a/cmd/user/main.go +++ b/cmd/user/main.go @@ -51,6 +51,7 @@ import ( "math/big" "net/http" "os" + "strconv" "strings" "time" "unicode/utf8" @@ -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) @@ -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 { diff --git a/docker/broker/docker-compose.yml b/docker/broker/docker-compose.yml index 6621b95..684cd8f 100644 --- a/docker/broker/docker-compose.yml +++ b/docker/broker/docker-compose.yml @@ -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" @@ -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: > diff --git a/docker/sandbox/.env.example b/docker/sandbox/.env.example index 3117ffc..c99f033 100644 --- a/docker/sandbox/.env.example +++ b/docker/sandbox/.env.example @@ -56,6 +56,13 @@ BACKEND_APP_NAME= # tapp appId — must match the value used in # MOCK_TEE=true # MOCK_APP_PRIVATE_KEY=0x +# 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 diff --git a/docker/sandbox/docker-compose.yml b/docker/sandbox/docker-compose.yml index 8baf936..a60dec8 100644 --- a/docker/sandbox/docker-compose.yml +++ b/docker/sandbox/docker-compose.yml @@ -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: > @@ -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 @@ -199,7 +204,7 @@ 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 @@ -207,7 +212,9 @@ services: # 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 @@ -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: @@ -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: @@ -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 @@ -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} @@ -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 @@ -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: @@ -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} @@ -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' diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index b9717c0..d3ce803 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -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. @@ -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-."}` +- 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) diff --git a/docs/API_REFERENCE.zh.md b/docs/API_REFERENCE.zh.md index c7cf2f3..3ef43a8 100644 --- a/docs/API_REFERENCE.zh.md +++ b/docs/API_REFERENCE.zh.md @@ -263,8 +263,9 @@ service 注册(例如调用 `deregisterService` 之后),返回 `[]`。 **Body:** ```json { - "image": "ubuntu:22.04", - "sealed": false + "image": "ubuntu:22.04", + "sealed": false, + "publicPorts": [8080, 3000] } ``` 所有字段可选。 @@ -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-."}` +- 名单外端口返回 **307 跳转**到 provider 的 OIDC 端点(对公众等于被拦) **Sealed 沙箱**(`"sealed": true`): - 通过内部 registry 把镜像解析为 content digest(无法解析则硬失败) diff --git a/docs/CLI.md b/docs/CLI.md index 36b78f9..dd866ac 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -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** @@ -555,6 +556,21 @@ USER_KEY=0x go run ./cmd/user/ create --api http://:8080 # Sealed sandbox (SSH/toolbox blocked; TEE attestation injected) USER_KEY=0x go run ./cmd/user/ create --api http://:8080 --sealed + +# Only port 8080 publicly reachable; 9090 etc. fall back to preview auth +USER_KEY=0x go run ./cmd/user/ create --api http://: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-…." } +} ``` --- diff --git a/internal/proxy/handler.go b/internal/proxy/handler.go index b901f87..c34a483 100644 --- a/internal/proxy/handler.go +++ b/internal/proxy/handler.go @@ -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", @@ -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 diff --git a/internal/proxy/ports.go b/internal/proxy/ports.go new file mode 100644 index 0000000..652bd99 --- /dev/null +++ b/internal/proxy/ports.go @@ -0,0 +1,128 @@ +package proxy + +import ( + "encoding/json" + "fmt" + "math" + "os" + "strconv" +) + +// reservedPreviewPorts are Daytona system ports that always require auth at +// the preview proxy; publicPorts may never include them. +var reservedPreviewPorts = map[int]bool{22222: true, 2280: true, 33333: true} + +const maxPublicPorts = 16 + +// agentPort is the agent-fronting proxy port sealed containers serve on; a +// sealed sandbox restricted with publicPorts must keep it reachable. +const agentPort = 8080 + +// ValidatePublicPorts checks the optional publicPorts field of a create +// request. Full normalization (dedupe/sort) happens in the patched Daytona +// API; this boundary check exists to return clear 400s and to enforce the +// sealed rule, which Daytona knows nothing about. Returns nil when the field +// is absent. +func ValidatePublicPorts(body []byte, sealed bool) error { + ports, present, err := parsePublicPorts(body) + if err != nil { + return err + } + if !present { + return nil + } + if len(ports) == 0 { + return fmt.Errorf("publicPorts must not be empty; omit it for a fully public sandbox") + } + if len(ports) > maxPublicPorts { + return fmt.Errorf("publicPorts allows at most %d entries", maxPublicPorts) + } + hasAgentPort := false + for _, p := range ports { + if p < 1 || p > 65535 { + return fmt.Errorf("publicPorts entry %d out of range 1-65535", p) + } + if reservedPreviewPorts[p] { + return fmt.Errorf("publicPorts entry %d is a reserved system port", p) + } + if p == agentPort { + hasAgentPort = true + } + } + if sealed && !hasAgentPort { + return fmt.Errorf("sealed sandboxes with publicPorts must include port %d (the agent-fronting proxy)", agentPort) + } + return nil +} + +// parsePublicPorts extracts publicPorts from a JSON body. present is false +// when the field is absent or null. Rejects non-array values and non-integer +// elements. +func parsePublicPorts(body []byte) (ports []int, present bool, err error) { + if len(body) == 0 { + return nil, false, nil + } + var m map[string]any + if err := json.Unmarshal(body, &m); err != nil { + return nil, false, fmt.Errorf("invalid request body") + } + raw, ok := m["publicPorts"] + if !ok || raw == nil { + return nil, false, nil + } + arr, ok := raw.([]any) + if !ok { + return nil, false, fmt.Errorf("publicPorts must be an array of integers") + } + for _, v := range arr { + f, ok := v.(float64) + if !ok || f != math.Trunc(f) { + return nil, false, fmt.Errorf("publicPorts must be an array of integers") + } + ports = append(ports, int(f)) + } + return ports, true, nil +} + +// decoratePublicPorts post-processes a 2xx create response for a request +// that asked for publicPorts. supported is false when the response carries +// no publicPorts field — the tell that the Daytona backend silently dropped +// the restriction (stock image, whitelist validation), in which case the +// caller must fail the create rather than hand out an unrestricted sandbox. +// When supported, a preview_urls map is attached so callers get ready-to-use +// URLs for each opened port (skipped when PROXY_DOMAIN is unset). +func decoratePublicPorts(respBytes []byte) (out []byte, supported bool, err error) { + var m map[string]any + if err := json.Unmarshal(respBytes, &m); err != nil { + return nil, false, err + } + rawPorts, ok := m["publicPorts"].([]any) + if !ok || len(rawPorts) == 0 { + return respBytes, false, nil + } + + domain := os.Getenv("PROXY_DOMAIN") + id, _ := m["id"].(string) + if domain != "" && id != "" { + scheme := os.Getenv("PROXY_PROTOCOL") + if scheme == "" { + scheme = "http" + } + urls := make(map[string]string, len(rawPorts)) + for _, v := range rawPorts { + f, ok := v.(float64) + if !ok { + continue + } + port := strconv.Itoa(int(f)) + urls[port] = fmt.Sprintf("%s://%s-%s.%s", scheme, port, id, domain) + } + m["preview_urls"] = urls + } + + out, err = json.Marshal(m) + if err != nil { + return nil, false, err + } + return out, true, nil +} diff --git a/internal/proxy/ports_test.go b/internal/proxy/ports_test.go new file mode 100644 index 0000000..d83211e --- /dev/null +++ b/internal/proxy/ports_test.go @@ -0,0 +1,104 @@ +package proxy + +import ( + "encoding/json" + "os" + "strings" + "testing" +) + +func TestValidatePublicPorts(t *testing.T) { + cases := []struct { + name string + body string + sealed bool + wantErr string // empty = no error + }{ + {"absent", `{"snapshot":"x"}`, false, ""}, + {"null", `{"publicPorts":null}`, false, ""}, + {"valid", `{"publicPorts":[8080,3000]}`, false, ""}, + {"empty array", `{"publicPorts":[]}`, false, "must not be empty"}, + {"not an array", `{"publicPorts":"8080"}`, false, "array of integers"}, + {"float element", `{"publicPorts":[8080.5]}`, false, "array of integers"}, + {"string element", `{"publicPorts":["8080"]}`, false, "array of integers"}, + {"out of range", `{"publicPorts":[70000]}`, false, "out of range"}, + {"zero", `{"publicPorts":[0]}`, false, "out of range"}, + {"reserved terminal", `{"publicPorts":[22222]}`, false, "reserved"}, + {"reserved toolbox", `{"publicPorts":[2280]}`, false, "reserved"}, + {"reserved recording", `{"publicPorts":[33333]}`, false, "reserved"}, + {"too many", `{"publicPorts":[1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17]}`, false, "at most"}, + {"sealed with 8080", `{"publicPorts":[8080]}`, true, ""}, + {"sealed without 8080", `{"publicPorts":[3000]}`, true, "must include port 8080"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + err := ValidatePublicPorts([]byte(c.body), c.sealed) + if c.wantErr == "" { + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + return + } + if err == nil || !strings.Contains(err.Error(), c.wantErr) { + t.Fatalf("error = %v, want containing %q", err, c.wantErr) + } + }) + } +} + +func TestDecoratePublicPorts_Supported(t *testing.T) { + os.Setenv("PROXY_DOMAIN", "1.2.3.4.nip.io:4000") + defer os.Unsetenv("PROXY_DOMAIN") + + resp := `{"id":"abc-123","publicPorts":[8080,3000]}` + out, supported, err := decoratePublicPorts([]byte(resp)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !supported { + t.Fatal("response with publicPorts must report supported") + } + var m map[string]any + if err := json.Unmarshal(out, &m); err != nil { + t.Fatalf("output not JSON: %v", err) + } + urls, ok := m["preview_urls"].(map[string]any) + if !ok { + t.Fatalf("preview_urls missing: %s", out) + } + if urls["8080"] != "http://8080-abc-123.1.2.3.4.nip.io:4000" { + t.Errorf("preview_urls[8080] = %v", urls["8080"]) + } + if urls["3000"] != "http://3000-abc-123.1.2.3.4.nip.io:4000" { + t.Errorf("preview_urls[3000] = %v", urls["3000"]) + } +} + +func TestDecoratePublicPorts_UnsupportedBackend(t *testing.T) { + // Stock Daytona strips the field: response carries no publicPorts. + resp := `{"id":"abc-123","state":"started"}` + out, supported, err := decoratePublicPorts([]byte(resp)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if supported { + t.Fatal("response without publicPorts must report unsupported") + } + if string(out) != resp { + t.Error("body must be unchanged when unsupported") + } +} + +func TestDecoratePublicPorts_NoProxyDomain(t *testing.T) { + os.Unsetenv("PROXY_DOMAIN") + resp := `{"id":"abc-123","publicPorts":[8080]}` + out, supported, err := decoratePublicPorts([]byte(resp)) + if err != nil || !supported { + t.Fatalf("supported=%v err=%v", supported, err) + } + var m map[string]any + _ = json.Unmarshal(out, &m) + if _, has := m["preview_urls"]; has { + t.Error("preview_urls must be skipped without PROXY_DOMAIN") + } +} diff --git a/web/user.html b/web/user.html index 4a8b6f0..e58160e 100644 --- a/web/user.html +++ b/web/user.html @@ -660,6 +660,11 @@

Just tell Claude
what you want.

Select a pre-built environment.
+
+ + +
Comma-separated. Only these ports are publicly reachable; all others require auth. Leave blank to keep every port public.
+
Status
-
Provider Addr
+
Provider (TEE signer)
+
App Owner (admin)
Service URL
AppId (on-chain)
@@ -253,7 +254,7 @@
Address
Chain ID
-
Owner
+
Contract Admin
@@ -443,8 +444,9 @@ "function services(address) view returns (string url, string appId, uint256 pricePerCPUPerMin, uint256 pricePerMemGBPerMin, uint256 createFee)", "function serviceExists(address) view returns (bool)", "function providerEarnings(address) view returns (uint256)", - "function addOrUpdateService(string url, string appId, uint256 pricePerCPUPerMin, uint256 createFee, uint256 pricePerMemGBPerMin)", - "function withdrawEarnings()", + "function addOrUpdateService(address signer, string url, string appId, uint256 pricePerCPUPerMin, uint256 createFee, uint256 pricePerMemGBPerMin)", + "function withdrawEarnings(address signer)", + "function removeService(address signer)", "function owner() view returns (address)", "function tappRegistry() view returns (address)", ]; @@ -460,6 +462,7 @@ const cEl = document.getElementById('infoContract'); cEl.textContent = shortAddr(info.contract_address||'') || '—'; cEl.title = info.contract_address || ''; document.getElementById('infoChain').textContent = info.chain_id || '—'; const pEl = document.getElementById('infoProvider'); pEl.textContent = shortAddr(info.provider_address||'') || '—'; pEl.title = info.provider_address || ''; + const oEl = document.getElementById('infoAppOwner'); oEl.textContent = shortAddr(info.owner_address||'') || '—'; oEl.title = (info.owner_address || '') + ' — the appId\'s TappRegistry owner: log in with this wallet (or an ADMIN_ADDRESSES wallet) to operate this dashboard'; // Pre-fill AppId from /api/info.signer.app_id (set after first registration). if (info.signer && info.signer.app_id) { document.getElementById('svcAppIdInput').value = info.signer.app_id; @@ -582,15 +585,23 @@ } // ── Provider Data ───────────────────────────────────────────────────────────── +// v2 identity model: the provider address is this node's TEE signer, taken +// from /api/info — NEVER the connected wallet. The wallet is the appId owner +// (or an extra admin): it signs admin API requests and owner-only txs +// (register / withdraw), but is not a ledger identity. +function nodeProviderAddr() { + return info && info.provider_address ? info.provider_address : null; +} async function loadProviderData() { - if (!contract || !walletAddr) return; + const prov = nodeProviderAddr(); + if (!contract || !walletAddr || !prov) return; try { - const exists = await contract.serviceExists(walletAddr); + const exists = await contract.serviceExists(prov); document.getElementById('svcRegistered').innerHTML = exists ? '✓ Registered' : '✗ Not Registered'; if (exists) { - const svc = await contract.services(walletAddr); + const svc = await contract.services(prov); document.getElementById('svcUrl').textContent = svc.url || '—'; const teeEl = document.getElementById('svcTEE'); teeEl.textContent = svc.appId || '—'; teeEl.title = svc.appId || ''; @@ -599,7 +610,7 @@ document.getElementById('svcFee').textContent = fmtNeuron(svc.createFee.toString()); } const [earn, owner] = await Promise.all([ - contract.providerEarnings(walletAddr), + contract.providerEarnings(prov), contract.owner().catch(() => null), ]); document.getElementById('providerEarnings').textContent = fmtNeuron(earn.toString()); @@ -623,14 +634,14 @@ const fee = document.getElementById('svcFeeInput').value; if (!url || !appId) { setErr('serviceErr', 'URL and AppId are required'); return; } setErr('serviceErr', ''); - await sendTx('Service update', () => contract.addOrUpdateService(url, appId, BigInt(cpuPrice), BigInt(fee), BigInt(memPrice)), { + await sendTx('Service update', () => contract.addOrUpdateService(nodeProviderAddr(), url, appId, BigInt(cpuPrice), BigInt(fee), BigInt(memPrice)), { onSuccess: async () => { closeModal('serviceModal'); await loadProviderData(); }, errEl: 'serviceErr', }); } async function withdrawEarnings() { - await sendTx('Withdraw earnings', () => contract.withdrawEarnings(), { onSuccess: () => loadProviderData() }); + await sendTx('Withdraw earnings', () => contract.withdrawEarnings(nodeProviderAddr()), { onSuccess: () => loadProviderData() }); } // ── Archive All ─────────────────────────────────────────────────────────────── From 588c19fe7dd9c54c227d943a4321420bd5b08805 Mon Sep 17 00:00:00 2001 From: Wei Wu <161988856+Wilbert957@users.noreply.github.com> Date: Mon, 20 Jul 2026 23:20:56 +0800 Subject: [PATCH 4/5] docs(skill): new-domain checklist + domain drift from the testnet rename (#61) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - user skill: provider domain examples updated to provider-private-sandbox.0g.ai (broker URL unchanged) - provider skill: new 5-item domain checklist (apex→8082, wildcard→4000, wildcard cert, tcp 2222, grey-cloud) with the acceptance probe, plus two troubleshooting rows (broker offline via orange-cloud WAF; wildcard hitting the nginx default page) Co-authored-by: Claude Fable 5 --- .../0g-private-sandbox-provider/SKILL.md | 25 +++++++++++++++++++ .claude/skills/0g-private-sandbox/SKILL.md | 4 +-- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/.claude/skills/0g-private-sandbox-provider/SKILL.md b/.claude/skills/0g-private-sandbox-provider/SKILL.md index 7ab2f68..46b162f 100644 --- a/.claude/skills/0g-private-sandbox-provider/SKILL.md +++ b/.claude/skills/0g-private-sandbox-provider/SKILL.md @@ -254,6 +254,29 @@ State: `pending` → `active` in ~30s. Standard base snapshots to offer users: --- +## New Provider Domain Checklist + +Setting up a domain for a provider is a MANUAL infra ritual (DNS/LB/certs live +outside the deploy tooling). Five items — miss any and a whole feature dies +silently (verified the hard way, 2026-07-20): + +1. Apex `domain` → `:8082` (billing API + dashboard) +2. **Wildcard `*.domain` → `:4000`** (daytona proxy; preserve Host + header, WebSocket upgrade) — powers port previews (`8000-.domain`) +3. Wildcard TLS certificate covering `*.domain` +4. TCP 2222 forwarding (SSH gateway); then set `SSH_GATEWAY_HOST=` +5. **All records DNS-only (grey cloud)** — Cloudflare proxying (orange) blocks + server-to-server calls (the broker health check gets WAF 403 → "offline") + +Acceptance probe after wiring: +```bash +curl -s https://8000-test./ | head -c 100 +# "dex/auth" redirect = wildcard routing OK; "Welcome to OpenResty!" = rule missing +``` +Domain/env changes alter volumesHash → users must re-acknowledge; batch them. + +--- + ## Resource Quotas (oversell control) Two independent layers (both verified live): @@ -361,3 +384,5 @@ owner and the node's TappRegistry state (active/stake). | Snapshot stays `pending` | Daytona can't pull the image | registry:6000 reachable? tag must not be `:latest` | | Everyone can create unlimited sandboxes | Org quota not enforced | See Resource Quotas — `enforceQuotas` + region_quota row | | SSH shows IP instead of domain | `SSH_GATEWAY_HOST` set to IP | Set it to the domain (confirm the LB forwards :2222 first) | +| Broker frontend shows provider "offline" | Broker's server-side health check blocked: Cloudflare orange-cloud WAF 403, or container DNS can't resolve a fresh domain | Grey-cloud the DNS record (or WAF-whitelist the broker IP); fresh domains: wait out propagation | +| Port preview URL shows an OpenResty/nginx default page | Wildcard `*.domain` traffic not forwarded to the daytona proxy | Add the wildcard→4000 rule (see New Provider Domain Checklist) | diff --git a/.claude/skills/0g-private-sandbox/SKILL.md b/.claude/skills/0g-private-sandbox/SKILL.md index dde5267..c3c4477 100644 --- a/.claude/skills/0g-private-sandbox/SKILL.md +++ b/.claude/skills/0g-private-sandbox/SKILL.md @@ -146,7 +146,7 @@ The command scans the chain and prints available providers with their URL, prici ``` [1] 0xf982279B872B9a99d64C547a0faC2Dfdfc2AEE5D - URL: https://provider-private-sandbox-testnet.0g.ai + URL: https://provider-private-sandbox.0g.ai Create fee: 0.0600 0G CPU price: 0.001000 0G/CPU/min Mem price: 0.000500 0G/GB/min @@ -282,7 +282,7 @@ If the provider has `PROXY_DOMAIN` configured, user-defined service ports are re The protocol matches the provider's setup (the hosted testnet uses **https**). For example, if a process listens on port 8000 inside the sandbox: ``` -https://8000-.provider-private-sandbox-testnet.0g.ai/ +https://8000-.provider-private-sandbox.0g.ai/ ``` Prefer the `preview_urls` field from the create response — it is already the correct protocol + domain. Requires the provider to have wildcard DNS + TLS for From a5e2e8a98e92ee1b7f71557157a48e3719266b90 Mon Sep 17 00:00:00 2001 From: Wei Wu <161988856+Wilbert957@users.noreply.github.com> Date: Mon, 20 Jul 2026 23:24:29 +0800 Subject: [PATCH 5/5] chore(plugin): bump plugin version to 2.0.0 (#62) * chore(plugin): bump marketplace plugin version to 2.0.0 for the v2 release Co-Authored-By: Claude Fable 5 * chore(plugin): bump plugin.json to 2.0.0 as well Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- .claude-plugin/marketplace.json | 2 +- .claude-plugin/plugin.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index d401dba..82e19f1 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -11,7 +11,7 @@ { "name": "0g-private-sandbox", "description": "Create and manage 0G Private Sandboxes from Claude Code", - "version": "1.0.0", + "version": "2.0.0", "author": { "name": "0G Foundation" }, diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 08e5a02..1bd0b84 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "0g-private-sandbox", - "version": "1.1.0", + "version": "2.0.0", "description": "0G Private Sandbox — create and manage private TEE sandboxes. Includes user skill (create, manage, vibe-code) and provider skill (deploy, register, operate).", "author": { "name": "0G Foundation" }, "repository": "https://github.com/0gfoundation/0g-sandbox",