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.
+