Skip to content
Open
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 .changeset/configurable-control-plane-port.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions packages/sandbox-container/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -90,4 +109,5 @@ export const CONFIG = {
MAX_OUTPUT_SIZE_BYTES,
STREAM_CHUNK_DELAY_MS,
DEFAULT_CWD,
CONTROL_PLANE_PORT,
} as const;
62 changes: 45 additions & 17 deletions packages/sandbox-container/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
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';

// 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<Response> }> {
// Initialize dependency injection container
const container = new Container();
Expand All @@ -29,24 +37,44 @@ async function createApplication(): Promise<{ fetch: (req: Request) => Promise<R
// Initialize the application
const app = await createApplication();

// Start the Bun server
const server = serve({
idleTimeout: 255,
fetch: app.fetch,
hostname: "0.0.0.0",
port: 3000,
// Enhanced WebSocket placeholder for future streaming features
websocket: {
async message() {
// WebSocket functionality can be added here in the future
}
},
});
// Start the Bun server with error handling for port conflicts
let server: ReturnType<typeof serve>;
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 () => {
Expand Down
16 changes: 9 additions & 7 deletions packages/sandbox-container/src/security/security-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {}

Expand Down Expand Up @@ -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!)
*/
Expand All @@ -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`);
}
}
Expand Down
2 changes: 1 addition & 1 deletion packages/sandbox-container/src/validation/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
41 changes: 34 additions & 7 deletions packages/sandbox/src/request-handler.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -40,9 +40,33 @@ export async function proxyToSandbox<E extends SandboxEnv>(
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) {
Expand Down Expand Up @@ -83,12 +107,12 @@ export async function proxyToSandbox<E extends SandboxEnv>(
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, {
Expand Down Expand Up @@ -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;
}

Expand Down
68 changes: 63 additions & 5 deletions packages/sandbox/src/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,38 @@ export function getSandbox(
}

export class Sandbox<Env = unknown> extends Container<Env> 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;
Expand All @@ -70,21 +99,45 @@ export class Sandbox<Env = unknown> extends Container<Env> 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()
});

this.client = new SandboxClient({
logger: this.logger,
port: 3000, // Control plane port
port: this.controlPlanePort,
stub: this,
});

Expand Down Expand Up @@ -263,9 +316,9 @@ export class Sandbox<Env = unknown> extends Container<Env> 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;
}

/**
Expand Down Expand Up @@ -713,6 +766,11 @@ export class Sandbox<Env = unknown> extends Container<Env> 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 = {
Expand Down Expand Up @@ -748,7 +806,7 @@ export class Sandbox<Env = unknown> extends Container<Env> 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.`);
}

Expand Down
Loading
Loading