diff --git a/.changeset/configurable-control-plane-port.md b/.changeset/configurable-control-plane-port.md new file mode 100644 index 00000000..dc267371 --- /dev/null +++ b/.changeset/configurable-control-plane-port.md @@ -0,0 +1,10 @@ +--- +"@cloudflare/sandbox": patch +"@repo/sandbox-container": patch +--- + +Make control plane port configurable via SANDBOX_CONTROL_PLANE_PORT environment variable + +The SDK now supports configuring the control plane port (default: 3000) via the SANDBOX_CONTROL_PLANE_PORT environment variable. This allows users to avoid port conflicts when port 3000 is already in use by their application. + +Additionally, improved error detection and logging for port conflicts. When the control plane port is already in use, the container will now provide clear error messages indicating the conflict and suggesting solutions. diff --git a/CLAUDE.md b/CLAUDE.md index aecfcd35..67f098b8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -307,6 +307,10 @@ different users share the same sandbox instance. - Expose internal services via preview URLs - Token-based authentication for exposed ports - Automatic cleanup on sandbox sleep +- **Control plane port**: The SDK uses a control plane port (default: 3000) for internal communication between the Sandbox DO and the container + - Configurable via `SANDBOX_CONTROL_PLANE_PORT` environment variable in both the Worker and Dockerfile + - If you need to use port 3000 for your application, set `SANDBOX_CONTROL_PLANE_PORT` to a different port + - Example: `ENV SANDBOX_CONTROL_PLANE_PORT=3001` in your Dockerfile - **Production requirement**: Preview URLs require custom domain with wildcard DNS (*.yourdomain.com) - `.workers.dev` domains do NOT support the subdomain patterns needed for preview URLs - See Cloudflare docs for "Deploy to Production" guide when ready to expose services diff --git a/packages/sandbox-container/src/config.ts b/packages/sandbox-container/src/config.ts index 73a02457..1d4679e7 100644 --- a/packages/sandbox-container/src/config.ts +++ b/packages/sandbox-container/src/config.ts @@ -81,6 +81,25 @@ const STREAM_CHUNK_DELAY_MS = 100; */ const DEFAULT_CWD = '/workspace'; +/** + * Control plane port for the container's Bun HTTP server. + * This port is used for internal communication between the Sandbox DO and the container. + * + * Default: 3000 + * Environment variable: SANDBOX_CONTROL_PLANE_PORT + */ +const CONTROL_PLANE_PORT = (() => { + const port = process.env.SANDBOX_CONTROL_PLANE_PORT + ? parseInt(process.env.SANDBOX_CONTROL_PLANE_PORT, 10) + : 3000; + + if (Number.isNaN(port) || port < 1 || port > 65535) { + throw new Error(`Invalid SANDBOX_CONTROL_PLANE_PORT: ${process.env.SANDBOX_CONTROL_PLANE_PORT}. Port must be between 1 and 65535.`); + } + + return port; +})(); + export const CONFIG = { SANDBOX_LOG_LEVEL, SANDBOX_LOG_FORMAT, @@ -90,4 +109,5 @@ export const CONFIG = { MAX_OUTPUT_SIZE_BYTES, STREAM_CHUNK_DELAY_MS, DEFAULT_CWD, + CONTROL_PLANE_PORT, } as const; diff --git a/packages/sandbox-container/src/index.ts b/packages/sandbox-container/src/index.ts index 8b1bc9a9..0f637e35 100644 --- a/packages/sandbox-container/src/index.ts +++ b/packages/sandbox-container/src/index.ts @@ -1,5 +1,6 @@ import { createLogger } from '@repo/shared'; import { serve } from "bun"; +import { CONFIG } from './config'; import { Container } from './core/container'; import { Router } from './core/router'; import { setupRoutes } from './routes/setup'; @@ -7,6 +8,13 @@ import { setupRoutes } from './routes/setup'; // Create module-level logger for server lifecycle events const logger = createLogger({ component: 'container' }); +const CONTROL_PLANE_PORT = CONFIG.CONTROL_PLANE_PORT; + +logger.info('Control plane port configuration', { + port: CONTROL_PLANE_PORT, + source: process.env.SANDBOX_CONTROL_PLANE_PORT ? 'environment' : 'default' +}); + async function createApplication(): Promise<{ fetch: (req: Request) => Promise }> { // Initialize dependency injection container const container = new Container(); @@ -29,24 +37,44 @@ async function createApplication(): Promise<{ fetch: (req: Request) => Promise; +try { + server = serve({ + idleTimeout: 255, + fetch: app.fetch, + hostname: "0.0.0.0", + port: CONTROL_PLANE_PORT, + // Enhanced WebSocket placeholder for future streaming features + websocket: { + async message() { + // WebSocket functionality can be added here in the future + } + }, + }); -logger.info('Container server started', { - port: server.port, - hostname: '0.0.0.0' -}); + logger.info('Container server started successfully', { + port: server.port, + hostname: '0.0.0.0' + }); +} catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + logger.error('Failed to start container server', err, { + port: CONTROL_PLANE_PORT, + possibleCause: 'Port may already be in use by another process. Check if another service is using this port, or set SANDBOX_CONTROL_PLANE_PORT to a different port.' + }); + + if (err.message.includes('EADDRINUSE')) { + const conflictError = new Error(`Port ${CONTROL_PLANE_PORT} is already in use. The Sandbox SDK requires this port for its control plane. Either: +1. Stop the process using port ${CONTROL_PLANE_PORT}, or +2. Set SANDBOX_CONTROL_PLANE_PORT environment variable to a different port in your Dockerfile`); + logger.error('Port conflict detected', conflictError, { + port: CONTROL_PLANE_PORT + }); + } + + throw err; +} // Graceful shutdown handling process.on('SIGTERM', async () => { diff --git a/packages/sandbox-container/src/security/security-service.ts b/packages/sandbox-container/src/security/security-service.ts index 84c6261b..4d281465 100644 --- a/packages/sandbox-container/src/security/security-service.ts +++ b/packages/sandbox-container/src/security/security-service.ts @@ -5,18 +5,19 @@ // Philosophy: // - Container isolation handles system-level security // - Users have full control over their sandbox (it's the value proposition!) -// - Only protect port 3000 (SDK control plane) from interference +// - Only protect control plane port (SDK control plane) from interference // - Format validation only (null bytes, length limits) // - No content restrictions (no path blocking, no command blocking, no URL allowlists) import type { Logger } from '@repo/shared'; +import { CONFIG } from '../config'; import type { ValidationResult } from '../core/types'; export class SecurityService { - // Only port 3000 is truly reserved (SDK control plane) + // Only the control plane port is truly reserved (SDK control plane) // This is REAL security - prevents control plane interference - private static readonly RESERVED_PORTS = [ - 3000, // Container control plane (API endpoints) - MUST be protected - ]; + private getReservedPorts(): number[] { + return [CONFIG.CONTROL_PLANE_PORT]; + } constructor(private logger: Logger) {} @@ -74,7 +75,7 @@ export class SecurityService { /** * Validate port number - * - Protects port 3000 (SDK control plane) - CRITICAL! + * - Protects control plane port (SDK control plane) - CRITICAL! * - Range validation (1-65535) * - No arbitrary port restrictions (users control their sandbox!) */ @@ -91,7 +92,8 @@ export class SecurityService { } // CRITICAL: Protect SDK control plane - if (SecurityService.RESERVED_PORTS.includes(port)) { + const reservedPorts = this.getReservedPorts(); + if (reservedPorts.includes(port)) { errors.push(`Port ${port} is reserved for the sandbox API control plane`); } } diff --git a/packages/sandbox-container/src/validation/schemas.ts b/packages/sandbox-container/src/validation/schemas.ts index 163bf0e2..68c803e3 100644 --- a/packages/sandbox-container/src/validation/schemas.ts +++ b/packages/sandbox-container/src/validation/schemas.ts @@ -80,7 +80,7 @@ export const StartProcessRequestSchema = z.object({ }); // Port management schemas -// Phase 0: Allow all ports 1-65535 (services will validate - only port 3000 is blocked) +// Phase 0: Allow all ports 1-65535 (services will validate - only the control plane port is blocked, default: 3000) export const ExposePortRequestSchema = z.object({ port: z.number().int().min(1).max(65535, 'Port must be between 1 and 65535'), name: z.string().optional(), diff --git a/packages/sandbox/src/request-handler.ts b/packages/sandbox/src/request-handler.ts index 9b7a1765..b93c7372 100644 --- a/packages/sandbox/src/request-handler.ts +++ b/packages/sandbox/src/request-handler.ts @@ -1,5 +1,5 @@ -import { createLogger, type LogContext, TraceContext } from "@repo/shared"; import { switchPort } from "@cloudflare/containers"; +import { createLogger, type LogContext, TraceContext } from "@repo/shared"; import { getSandbox, type Sandbox } from "./sandbox"; import { sanitizeSandboxId, @@ -40,9 +40,33 @@ export async function proxyToSandbox( const { sandboxId, port, path, token } = routeInfo; const sandbox = getSandbox(env.Sandbox, sandboxId); + // Get control plane port from sandbox instance + const controlPlanePort = await sandbox.getControlPlanePort(); + + // Validate port with control plane port + if (!validatePort(port, controlPlanePort)) { + logger.warn('Invalid port access blocked', { + port, + controlPlanePort, + sandboxId, + reason: 'Reserved or invalid port' + }); + + return new Response( + JSON.stringify({ + error: `Port ${port} is not accessible`, + code: 'INVALID_PORT' + }), + { + status: 400, + headers: { 'Content-Type': 'application/json' } + } + ); + } + // Critical security check: Validate token (mandatory for all user ports) - // Skip check for control plane port 3000 - if (port !== 3000) { + // Skip check for control plane port + if (port !== controlPlanePort) { // Validate the token matches the port const isValidToken = await sandbox.validatePortToken(port, token); if (!isValidToken) { @@ -83,12 +107,12 @@ export async function proxyToSandbox( let proxyUrl: string; // Route based on the target port - if (port !== 3000) { + if (port !== controlPlanePort) { // Route directly to user's service on the specified port proxyUrl = `http://localhost:${port}${path}${url.search}`; } else { - // Port 3000 is our control plane - route normally - proxyUrl = `http://localhost:3000${path}${url.search}`; + // Control plane - route to configured control plane port + proxyUrl = `http://localhost:${controlPlanePort}${path}${url.search}`; } const proxyRequest = new Request(proxyUrl, { @@ -127,7 +151,10 @@ function extractSandboxRoute(url: URL): RouteInfo | null { const domain = subdomainMatch[4]; const port = parseInt(portStr, 10); - if (!validatePort(port)) { + + // Basic validation only (range check) + // Full validation (including control plane port check) happens after getSandbox + if (isNaN(port) || port < 1 || port > 65535) { return null; } diff --git a/packages/sandbox/src/sandbox.ts b/packages/sandbox/src/sandbox.ts index 8ea44296..82996140 100644 --- a/packages/sandbox/src/sandbox.ts +++ b/packages/sandbox/src/sandbox.ts @@ -53,9 +53,38 @@ export function getSandbox( } export class Sandbox extends Container implements ISandbox { + /** + * Default port for general container communication (from Container base class). + * Currently set to 3000, but this is semantically the "control plane" port. + * User applications should expose their services on different ports (e.g., 8080). + */ defaultPort = 3000; // Default port for the container's Bun server + sleepAfter: string | number = "10m"; // Sleep the sandbox if no requests are made in this timeframe + /** + * Control plane port for internal SDK-container API communication. + * This is the port where the container's Bun HTTP server listens for + * SDK commands (/api/execute, /api/files, etc.). + * + * Configurable via SANDBOX_CONTROL_PLANE_PORT environment variable. + * + * NOTE: For local development with wrangler dev, the Dockerfile EXPOSE + * directive must match this port. If you change the control plane port, + * update the EXPOSE line in your Dockerfile accordingly. + * + * Default: 3000 + */ + private controlPlanePort: number = 3000; + + /** + * Get the configured control plane port. + * @returns The control plane port number + */ + public getControlPlanePort(): number { + return this.controlPlanePort; + } + client: SandboxClient; private codeInterpreter: CodeInterpreter; private sandboxName: string | null = null; @@ -70,13 +99,37 @@ export class Sandbox extends Container implements ISandbox { const envObj = env as any; // Set sandbox environment variables from env object - const sandboxEnvKeys = ['SANDBOX_LOG_LEVEL', 'SANDBOX_LOG_FORMAT'] as const; + const sandboxEnvKeys = ['SANDBOX_LOG_LEVEL', 'SANDBOX_LOG_FORMAT', 'SANDBOX_CONTROL_PLANE_PORT'] as const; sandboxEnvKeys.forEach(key => { if (envObj?.[key]) { this.envVars[key] = envObj[key]; } }); + // Get control plane port from environment, default to 3000 + if (envObj?.SANDBOX_CONTROL_PLANE_PORT) { + const port = parseInt(envObj.SANDBOX_CONTROL_PLANE_PORT, 10); + + // Validate port range - throw error instead of silent fallback + if (isNaN(port) || port < 1 || port > 65535) { + throw new Error( + `Invalid SANDBOX_CONTROL_PLANE_PORT: ${envObj.SANDBOX_CONTROL_PLANE_PORT}. ` + + `Port must be between 1 and 65535.` + ); + } + + // Validate against reserved ports (8787 = wrangler dev port) + const reservedPorts = [8787]; + if (reservedPorts.includes(port)) { + throw new Error( + `SANDBOX_CONTROL_PLANE_PORT cannot use reserved port ${port}. ` + + `Reserved ports: ${reservedPorts.join(', ')}` + ); + } + + this.controlPlanePort = port; + } + this.logger = createLogger({ component: 'sandbox-do', sandboxId: this.ctx.id.toString() @@ -84,7 +137,7 @@ export class Sandbox extends Container implements ISandbox { this.client = new SandboxClient({ logger: this.logger, - port: 3000, // Control plane port + port: this.controlPlanePort, stub: this, }); @@ -263,9 +316,9 @@ export class Sandbox extends Container implements ISandbox { return parseInt(proxyMatch[1], 10); } - // All other requests go to control plane on port 3000 + // All other requests go to control plane // This includes /api/* endpoints and any other control requests - return 3000; + return this.controlPlanePort; } /** @@ -713,6 +766,11 @@ export class Sandbox extends Container implements ISandbox { } async exposePort(port: number, options: { name?: string; hostname: string }) { + // Validate port number + if (!validatePort(port, this.controlPlanePort)) { + throw new SecurityError(`Invalid port number: ${port}. Must be between 1024-65535 and not reserved.`); + } + // Check if hostname is workers.dev domain (doesn't support wildcard subdomains) if (options.hostname.endsWith('.workers.dev')) { const errorResponse: ErrorResponse = { @@ -748,7 +806,7 @@ export class Sandbox extends Container implements ISandbox { } async unexposePort(port: number) { - if (!validatePort(port)) { + if (!validatePort(port, this.controlPlanePort)) { throw new SecurityError(`Invalid port number: ${port}. Must be between 1024-65535 and not reserved.`); } diff --git a/packages/sandbox/src/security.ts b/packages/sandbox/src/security.ts index 6f8252f5..c497b87c 100644 --- a/packages/sandbox/src/security.ts +++ b/packages/sandbox/src/security.ts @@ -19,8 +19,10 @@ export class SecurityError extends Error { /** * Validates port numbers for sandbox services * Only allows non-system ports to prevent conflicts and security issues + * @param port The port number to validate + * @param controlPlanePort The configured control plane port (dynamic) */ -export function validatePort(port: number): boolean { +export function validatePort(port: number, controlPlanePort: number = 3000): boolean { // Must be a valid integer if (!Number.isInteger(port)) { return false; @@ -31,10 +33,10 @@ export function validatePort(port: number): boolean { return false; } - // Exclude ports reserved by our system + // Exclude ports reserved by our system (dynamic based on actual control plane port) const reservedPorts = [ - 3000, // Control plane port - 8787, // Common wrangler dev port + controlPlanePort, // Dynamic control plane port + 8787, // Common wrangler dev port ]; if (reservedPorts.includes(port)) { diff --git a/packages/sandbox/tests/control-plane-port.test.ts b/packages/sandbox/tests/control-plane-port.test.ts new file mode 100644 index 00000000..f8d5b16a --- /dev/null +++ b/packages/sandbox/tests/control-plane-port.test.ts @@ -0,0 +1,127 @@ +import type { DurableObjectState } from '@cloudflare/workers-types'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { Sandbox } from '../src/sandbox'; +import { validatePort } from '../src/security'; + +// Mock dependencies +vi.mock('../src/clients/sandbox-client', () => ({ + SandboxClient: vi.fn(), +})); + +vi.mock('../src/interpreter', () => ({ + CodeInterpreter: vi.fn(), +})); + +vi.mock('@repo/shared', () => ({ + createLogger: vi.fn(() => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + })), +})); + +vi.mock('@cloudflare/containers', () => { + const MockContainer = class Container { + ctx: any; + env: any; + constructor(ctx: any, env: any) { + this.ctx = ctx; + this.env = env; + } + async fetch(request: Request): Promise { + return new Response('Mock Container fetch'); + } + async containerFetch(request: Request, port: number): Promise { + return new Response('Mock Container HTTP fetch'); + } + }; + + return { + Container: MockContainer, + getContainer: vi.fn(), + }; +}); + +describe('Control plane port configuration', () => { + let mockCtx: Partial; + let mockEnv: any; + + beforeEach(() => { + mockCtx = { + id: { + toString: () => 'test-id', + equals: vi.fn(), + name: 'test-sandbox', + } as any, + storage: { + get: vi.fn().mockResolvedValue(null), + put: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + list: vi.fn().mockResolvedValue(new Map()), + } as any, + blockConcurrencyWhile: vi.fn((fn: () => Promise) => fn()), + waitUntil: vi.fn(), + }; + + mockEnv = {}; + }); + + describe('Sandbox configuration', () => { + test('should use default port 3000 when not configured', () => { + const sandbox = new Sandbox(mockCtx as DurableObjectState, mockEnv); + expect(sandbox.getControlPlanePort()).toBe(3000); + }); + + test('should use configured port from environment', () => { + mockEnv.SANDBOX_CONTROL_PLANE_PORT = '3001'; + const sandbox = new Sandbox(mockCtx as DurableObjectState, mockEnv); + expect(sandbox.getControlPlanePort()).toBe(3001); + }); + + test.each([ + ['invalid', 'non-numeric'], + ['0', 'out of range'], + ['99999', 'out of range'], + ['8787', 'reserved port'], + ])('should throw error for invalid value: %s (%s)', (value) => { + mockEnv.SANDBOX_CONTROL_PLANE_PORT = value; + expect(() => { + new Sandbox(mockCtx as DurableObjectState, mockEnv); + }).toThrow(); + }); + }); + + describe('validatePort with dynamic control plane', () => { + test('should block configured control plane port', () => { + expect(validatePort(3000, 3000)).toBe(false); + expect(validatePort(3001, 3001)).toBe(false); + }); + + test('should allow previous control plane port when changed', () => { + expect(validatePort(3000, 3001)).toBe(true); + }); + + test('should always block reserved ports', () => { + expect(validatePort(8787, 3000)).toBe(false); + expect(validatePort(8787, 3001)).toBe(false); + }); + + test.each([ + [1024, true, 'minimum valid port'], + [8080, true, 'common user port'], + [65535, true, 'maximum valid port'], + [1023, false, 'system port'], + [0, false, 'invalid port'], + [65536, false, 'out of range'], + [3000.5, false, 'non-integer'], + ])('validatePort(%i) should return %s (%s)', (port, expected) => { + expect(validatePort(port, 3000)).toBe(expected); + }); + + test('should use default control plane port 3000 when not provided', () => { + expect(validatePort(3000)).toBe(false); + expect(validatePort(8080)).toBe(true); + }); + }); +}); diff --git a/packages/sandbox/tests/request-handler.test.ts b/packages/sandbox/tests/request-handler.test.ts index f172c613..287ddf1f 100644 --- a/packages/sandbox/tests/request-handler.test.ts +++ b/packages/sandbox/tests/request-handler.test.ts @@ -23,6 +23,7 @@ describe('proxyToSandbox - WebSocket Support', () => { // Mock Sandbox with necessary methods mockSandbox = { + getControlPlanePort: vi.fn().mockResolvedValue(3000), validatePortToken: vi.fn().mockResolvedValue(true), fetch: vi.fn().mockResolvedValue(new Response('WebSocket response')), containerFetch: vi.fn().mockResolvedValue(new Response('HTTP response')), @@ -164,17 +165,17 @@ describe('proxyToSandbox - WebSocket Support', () => { }); it('should reject reserved port 3000', async () => { - // Port 3000 is reserved as control plane port and rejected by validatePort() + // Port 3000 is the default control plane port and should be rejected by validatePort() const request = new Request('https://3000-sandbox-anytoken12345678.example.com/status', { method: 'GET', }); const response = await proxyToSandbox(request, mockEnv); - // Port 3000 is reserved and should be rejected (extractSandboxRoute returns null) - expect(response).toBeNull(); - expect(mockSandbox.validatePortToken).not.toHaveBeenCalled(); - expect(mockSandbox.containerFetch).not.toHaveBeenCalled(); + // Port 3000 is reserved and should be rejected with 400 status + expect(response?.status).toBe(400); + const body = await response?.json(); + expect(body).toHaveProperty('code', 'INVALID_PORT'); }); }); diff --git a/packages/sandbox/tests/sandbox.test.ts b/packages/sandbox/tests/sandbox.test.ts index c6881cb0..604c6c60 100644 --- a/packages/sandbox/tests/sandbox.test.ts +++ b/packages/sandbox/tests/sandbox.test.ts @@ -1,7 +1,7 @@ +import { Container } from '@cloudflare/containers'; import type { DurableObjectState } from '@cloudflare/workers-types'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { Sandbox } from '../src/sandbox'; -import { Container } from '@cloudflare/containers'; // Mock dependencies before imports vi.mock('./interpreter', () => ({ diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index e6913625..693e7eff 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -54,9 +54,9 @@ export type { ExecOptions, ExecResult, ExecutionSession, - FileExistsResult, // File streaming types FileChunk, + FileExistsResult, FileInfo, FileMetadata, FileStreamEvent,