diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..d14637597 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +.git +.github +.vscode +.conductor +node_modules +.next +out +build +coverage +test-results +readme-assets +.env +.env.* +*.md +Dockerfile +.dockerignore diff --git a/.env.example b/.env.example index a60fc5946..fe8ee6df0 100644 --- a/.env.example +++ b/.env.example @@ -21,9 +21,27 @@ NEXT_PUBLIC_E2B_DOMAIN=e2b.dev # NEXT_PUBLIC_INFRA_API_URL=http://localhost:3000 # NEXT_PUBLIC_DASHBOARD_API_URL=http://localhost:3001 +### Runtime API base URLs. Unlike the NEXT_PUBLIC_ variables above, these are +### read when the server starts rather than baked into the build, so one +### prebuilt image can serve any install. They take precedence over the +### NEXT_PUBLIC_ overrides. +# E2B_INFRA_API_URL=http://127.0.0.1:3000 +# E2B_DASHBOARD_API_URL=http://127.0.0.1:3010 + ### Optional sandbox traffic base URL for local development proxies. # NEXT_PUBLIC_E2B_SANDBOX_URL=http://sandbox.lvh.me:3002 +### Base URL the BROWSER uses to reach sandboxes (terminal and filesystem +### inspector). Unset on a runtime-configured install means "the host this +### page was served from, on port 3002". The value is handed to the browser, +### so it has to be reachable from the browser and not only from the server — +### the loopback below works only when the two are the same machine. +# E2B_SANDBOX_URL=http://127.0.0.1:3002 + +### Set to "false" when the dashboard is served over plain http (a LAN address +### or an IP), or the browser drops the api key cookie and the key form loops. +# DASHBOARD_COOKIE_SECURE=false + ### OpenTelemetry (disabled unless the endpoint is set). # OTEL_SERVICE_NAME=e2b-dashboard # OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 diff --git a/.github/workflows/container.yml b/.github/workflows/container.yml new file mode 100644 index 000000000..0982bf653 --- /dev/null +++ b/.github/workflows/container.yml @@ -0,0 +1,54 @@ +# Nothing else in CI builds the image, so a break in the Docker build would +# otherwise go unnoticed until someone builds it by hand. This job runs on the +# files that can break it, not on every PR — a full Next build in Docker is +# minutes, and application changes are already covered by Test / Code Quality. +name: Container + +on: + push: + branches: [main] + paths: + - Dockerfile + - .dockerignore + - next.config.ts + - tsconfig.json + - package.json + - bun.lock + - scripts/check-app-env.ts + - scripts/container-smoke.sh + - src/lib/env.ts + - .github/workflows/container.yml + pull_request: + branches: [main] + paths: + - Dockerfile + - .dockerignore + - next.config.ts + - tsconfig.json + - package.json + - bun.lock + - scripts/check-app-env.ts + - scripts/container-smoke.sh + - src/lib/env.ts + - .github/workflows/container.yml + workflow_dispatch: + +env: + FORCE_COLOR: "1" + CLICOLOR_FORCE: "1" + +permissions: + contents: read + +jobs: + smoke: + name: Build and Smoke-Test the Image + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Build the image and check the responses it serves + run: ./scripts/container-smoke.sh diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..1726f0779 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,63 @@ +# Three stages: Bun resolves the dependencies (bun.lock is the lockfile), Node +# runs the Next build, Node serves. The runtime stage carries only Next's +# standalone output, so the full dependency tree never ships in the image. +# +# The build runs under Node, not Bun: `bun run build` forks Next's page-data +# workers, and Bun's CommonJS interop throws "Expected CommonJS module to have +# a function wrapper" on the webpack output those workers load. +# +# The build fetches three Google Fonts families through next/font/google +# (src/app/fonts.ts): it needs outbound HTTPS to fonts.googleapis.com and +# fonts.gstatic.com, and fails there in an air-gapped environment. +FROM oven/bun:1.2.20 AS deps + +WORKDIR /app + +COPY package.json bun.lock ./ +RUN bun install --frozen-lockfile + +FROM node:22-bookworm-slim AS builder + +WORKDIR /app + +# Only to run the prebuild env check, which is a TypeScript entrypoint. +COPY --from=deps /usr/local/bin/bun /usr/local/bin/bun +COPY --from=deps /app/node_modules ./node_modules +COPY . . + +# Next inlines every NEXT_PUBLIC_* value into the bundles, so the domain is a +# build input, and the prebuild env check (scripts/check-app-env.ts) exits 1 +# without it. The default resolves nowhere on purpose: a container started +# with no configuration must fail loudly instead of reaching a deployment that +# is not yours. Point a container at an install with the runtime variables. +ARG NEXT_PUBLIC_E2B_DOMAIN=unset.invalid +ENV NEXT_PUBLIC_E2B_DOMAIN=${NEXT_PUBLIC_E2B_DOMAIN} +ENV NEXT_TELEMETRY_DISABLED=1 + +RUN bun scripts/check-app-env.ts +RUN node node_modules/next/dist/bin/next build --webpack + +FROM node:22-bookworm-slim AS runtime + +WORKDIR /app + +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 +# server.js reads PORT (default 3000) and HOSTNAME (default 0.0.0.0). The +# default is 3001 so the dashboard does not land on 3000, which an E2B install +# already uses for its API when both share a host network. +ENV PORT=3001 +ENV HOSTNAME=0.0.0.0 + +# Reported as service.version on OTEL traces (src/instrumentation.node.ts). +ARG BUILD=dev +ENV BUILD=${BUILD} + +COPY --from=builder --chown=node:node /app/.next/standalone ./ +COPY --from=builder --chown=node:node /app/.next/static ./.next/static +COPY --from=builder --chown=node:node /app/public ./public + +USER node +EXPOSE 3001 + +CMD ["node", "server.js"] diff --git a/README.md b/README.md index b855a51e7..f98cb1ee9 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,51 @@ Authentication is a single **team API key**: - Visiting `/` shows a form to enter the key. It is validated against infra-api and stored in an httpOnly `e2b_api_key` cookie. All upstream calls happen server-side with the `X-API-Key` header — the key never reaches client JavaScript. - Alternatively, set the `E2B_API_KEY` environment variable to pre-authenticate the whole deployment (single-user mode; the key form and sign-out are hidden). +### Configuration + +| Variable | Read | Purpose | +|---|---|---| +| `NEXT_PUBLIC_E2B_DOMAIN` | build | Derives `https://api.` and `https://dashboard-api.` | +| `NEXT_PUBLIC_INFRA_API_URL` / `NEXT_PUBLIC_DASHBOARD_API_URL` | build | Explicit overrides of the derived URLs | +| `E2B_INFRA_API_URL` / `E2B_DASHBOARD_API_URL` | server start | Explicit URLs for a prebuilt image; take precedence | +| `NEXT_PUBLIC_E2B_SANDBOX_URL` | build | Base URL the browser uses for sandbox traffic | +| `E2B_SANDBOX_URL` | per request | Same, for a prebuilt image; takes precedence, and is what the browser is told to use | +| `DASHBOARD_COOKIE_SECURE` | server start | `false` only for a plain-http install; the api key cookie then travels unencrypted. Defaults to secure in production builds | + +Each URL resolves in that order: the runtime variable, then the +`NEXT_PUBLIC_` override, then the value derived from the domain. Next inlines +`NEXT_PUBLIC_*` into the bundles at build time, so a prebuilt image is +configured with the runtime variables. Every explicit URL must carry an +`http://` or `https://` scheme, and the server rejects anything else naming +the variable. The infra and dashboard URLs are resolved at module scope, so a +malformed one fails on server start. The sandbox URL is resolved per request, +so a malformed one fails on first use, such as opening a terminal. + +The browser reads the sandbox URL from `GET /api/config`, which resolves it +per request. When `E2B_INFRA_API_URL` is set and no sandbox URL is given, it +defaults to the host the dashboard was reached on, port 3002. That default +routes only when the dashboard is reached over `localhost` or an IP address, +which is how the sandbox proxy accepts header-routed traffic. Reach the +dashboard on a domain name and you must set `E2B_SANDBOX_URL` yourself, to a +`localhost`, IP, or `sandbox.` base URL. `curl +http://:/api/config` shows what a deployment resolved. + +The dashboard's own server-side sandbox calls, such as killing a terminal's +pty when you leave the page, resolve the URL from the same request by the same +rule, so a self-hosted install needs no `E2B_SANDBOX_URL` unless the request +host is the wrong one for sandbox traffic. + +`/api/config` is unauthenticated and carries no secret. Behind a reverse +proxy, that proxy must set `X-Forwarded-Host` and `X-Forwarded-Proto` itself +rather than pass through whatever a client sent; `GET /api/config` trusts +them to describe the browser-facing origin. + +`E2B_SANDBOX_URL` is also read by the E2B SDK for its own connection config. +That is the same setting, so the dashboard deliberately shares the name. It +is served to the browser as-is, so the value has to be reachable from the +browser, not only from the server. A runtime-configured install should leave +it unset unless the port-3002 default is wrong. + ## Features - **Sandboxes**: paginated live list, per-sandbox monitoring (CPU/memory/disk), logs, filesystem inspector, and an in-browser terminal @@ -75,6 +120,35 @@ bun run build bun run start ``` +### Run it in a container + +The repository builds a self-contained image: Bun resolves the dependencies, +Node runs the Next build, and Node serves the standalone output; the runtime +stage carries no dev dependencies. + +```bash +docker build --build-arg NEXT_PUBLIC_E2B_DOMAIN=your-domain.com -t e2b-dashboard . +docker run --rm -p 3001:3001 e2b-dashboard +``` + +- `PORT` (default `3001`) and `HOSTNAME` (default `0.0.0.0`) are read by the + server at start. The default keeps the dashboard clear of port 3000, which + an E2B API already uses when both share a host network. +- `NEXT_PUBLIC_E2B_DOMAIN` is a **build** argument, not a runtime variable: + Next inlines `NEXT_PUBLIC_*` values into the bundles. It defaults to a + domain that resolves nowhere, so an unconfigured container fails loudly + instead of talking to a deployment that is not yours. +- An image built this way resolves both APIs from `NEXT_PUBLIC_E2B_DOMAIN` at + build time. A container configured through the runtime variables in + [Configuration](#configuration) resolves them at runtime instead, so it + needs no build-time value beyond the default. +- The build needs outbound HTTPS for the three Google Fonts families in + `src/app/fonts.ts`; an air-gapped build fails there. +- `GET /api/health` reports dashboard-api's health and answers 503 while + dashboard-api is unreachable, so use `GET /` as the container liveness + check. +- `scripts/container-smoke.sh` builds the image and asserts those responses. + ## Scripts | Command | Description | diff --git a/next.config.ts b/next.config.ts index e537e129b..b748851e6 100644 --- a/next.config.ts +++ b/next.config.ts @@ -17,6 +17,10 @@ const browserNodeModuleStubs = { const config: NextConfig = { reactStrictMode: true, reactCompiler: true, + // Emits .next/standalone: a server plus only the traced dependencies, which + // is what the container image runs. `next start` still works from .next for + // local previews, and platform builds ignore this output. + output: 'standalone', experimental: { useCache: true, turbopackFileSystemCacheForDev: true, diff --git a/scripts/container-smoke.sh b/scripts/container-smoke.sh new file mode 100755 index 000000000..af326157f --- /dev/null +++ b/scripts/container-smoke.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# Builds the container image and checks the three responses a self-hosted +# install depends on. Needs Docker and outbound HTTPS: the Next build pulls +# the Google Fonts faces declared in src/app/fonts.ts. +set -euo pipefail + +IMAGE="${IMAGE:-e2b-dashboard:smoke}" +PORT="${PORT:-3001}" +CONTAINER="e2b-dashboard-smoke-$$" +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +cleanup() { + docker rm -f "${CONTAINER}" >/dev/null 2>&1 || true +} +trap cleanup EXIT + +echo "==> building ${IMAGE}" +docker build -t "${IMAGE}" "${ROOT}" + +echo "==> starting ${CONTAINER} on port ${PORT}" +docker run -d --name "${CONTAINER}" -e PORT="${PORT}" -p "${PORT}:${PORT}" "${IMAGE}" >/dev/null + +ready=0 +for _ in $(seq 1 60); do + if curl -fs -o /dev/null "http://127.0.0.1:${PORT}/"; then + ready=1 + break + fi + sleep 1 +done + +if [ "${ready}" != 1 ]; then + echo "FAIL: nothing answered on port ${PORT} within 60s" >&2 + docker logs "${CONTAINER}" >&2 || true + exit 1 +fi + +fail=0 +check() { + if [ "$3" = "$2" ]; then + echo "ok $1: $3" + else + echo "FAIL $1: expected $2, got $3" >&2 + fail=1 + fi +} + +check "GET / serves the api key form" 200 \ + "$(curl -sS -o /dev/null -w '%{http_code}' "http://127.0.0.1:${PORT}/")" + +check "GET /sandboxes redirects to the key form" 307 \ + "$(curl -sS -o /dev/null -w '%{http_code}' "http://127.0.0.1:${PORT}/sandboxes")" + +check "GET /sandboxes redirect target" "http://127.0.0.1:${PORT}/?returnTo=%2Fsandboxes" \ + "$(curl -sS -o /dev/null -w '%{redirect_url}' "http://127.0.0.1:${PORT}/sandboxes")" + +# /api/health probes dashboard-api, which this run does not provide, so 503 is +# the correct answer here and proves route handlers are being served. +check "GET /api/health without a dashboard-api" 503 \ + "$(curl -sS -o /dev/null -w '%{http_code}' "http://127.0.0.1:${PORT}/api/health")" + +if [ "${fail}" != 0 ]; then + docker logs "${CONTAINER}" >&2 || true + exit 1 +fi + +echo "==> container smoke test passed" diff --git a/src/app/api/config/route.ts b/src/app/api/config/route.ts new file mode 100644 index 000000000..a6f46ebc5 --- /dev/null +++ b/src/app/api/config/route.ts @@ -0,0 +1,18 @@ +import { NextResponse } from 'next/server' +import { resolveBrowserRuntimeConfig } from '@/core/server/runtime-config' + +// Resolved from the environment and the request host on every call, so this +// must never be prerendered or cached. +export const dynamic = 'force-dynamic' + +export async function GET(request: Request) { + const config = resolveBrowserRuntimeConfig(request.headers, request.url) + + // Unauthenticated and readable by anyone who can reach the dashboard, so + // this payload must never grow a secret. + return NextResponse.json(config, { + headers: { + 'Cache-Control': 'no-store', + }, + }) +} diff --git a/src/configs/cookies.ts b/src/configs/cookies.ts index cf285c598..78e721a22 100644 --- a/src/configs/cookies.ts +++ b/src/configs/cookies.ts @@ -17,11 +17,32 @@ export const COOKIE_KEYS = { export const COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 365 // 1 year +/** + * Browsers drop a `Secure` cookie on a plain-http origin, so a self-hosted + * install served over http on a LAN address turns the key form into a login + * loop. DASHBOARD_COOKIE_SECURE overrides the flag; unset keeps the build-mode + * default, which is what every existing deployment already gets. + * + * The value is read case-insensitively rather than trusting the schema's + * narrowed type: a prebuilt image starts without the env check, so whatever + * the container was handed arrives here unvalidated. + */ +function isSecureCookie(): boolean { + const configured: string | undefined = + process.env.DASHBOARD_COOKIE_SECURE?.trim().toLowerCase() + + if (configured !== undefined && configured !== '') { + return configured !== 'false' + } + + return process.env.NODE_ENV === 'production' +} + const BASE_COOKIE_OPTIONS: Partial = { path: '/', maxAge: COOKIE_MAX_AGE_SECONDS, sameSite: 'lax', - secure: process.env.NODE_ENV === 'production', + secure: isSecureCookie(), } /** diff --git a/src/core/server/api/routers/sandbox.ts b/src/core/server/api/routers/sandbox.ts index caa02644d..ec28789a9 100644 --- a/src/core/server/api/routers/sandbox.ts +++ b/src/core/server/api/routers/sandbox.ts @@ -13,6 +13,10 @@ import { import { createSandboxesRepository } from '@/core/modules/sandboxes/repository.server' import { throwTRPCErrorFromRepoError } from '@/core/server/adapters/errors' import { withAuthedRequestRepository } from '@/core/server/api/middlewares/repository' +import { + resolveInfraApiUrl, + resolveServerSandboxUrl, +} from '@/core/server/runtime-config' import { createTRPCRouter } from '@/core/server/trpc/init' import { protectedProcedure } from '@/core/server/trpc/procedures' import { SandboxIdSchema } from '@/core/shared/schemas/api' @@ -229,9 +233,9 @@ export const sandboxRouter = createTRPCRouter({ } const connectionOpts = { - apiUrl: process.env.NEXT_PUBLIC_INFRA_API_URL, + apiUrl: resolveInfraApiUrl(), domain: process.env.NEXT_PUBLIC_E2B_DOMAIN, - sandboxUrl: process.env.NEXT_PUBLIC_E2B_SANDBOX_URL, + sandboxUrl: resolveServerSandboxUrl(ctx.headers, ctx.requestUrl), apiKey, } @@ -317,9 +321,9 @@ export const sandboxRouter = createTRPCRouter({ const { apiKey } = ctx const connectionOpts = { - apiUrl: process.env.NEXT_PUBLIC_INFRA_API_URL, + apiUrl: resolveInfraApiUrl(), domain: process.env.NEXT_PUBLIC_E2B_DOMAIN, - sandboxUrl: process.env.NEXT_PUBLIC_E2B_SANDBOX_URL, + sandboxUrl: resolveServerSandboxUrl(ctx.headers, ctx.requestUrl), apiKey, } @@ -373,9 +377,9 @@ export const sandboxRouter = createTRPCRouter({ const { apiKey } = ctx const connectionOpts = { - apiUrl: process.env.NEXT_PUBLIC_INFRA_API_URL, + apiUrl: resolveInfraApiUrl(), domain: process.env.NEXT_PUBLIC_E2B_DOMAIN, - sandboxUrl: process.env.NEXT_PUBLIC_E2B_SANDBOX_URL, + sandboxUrl: resolveServerSandboxUrl(ctx.headers, ctx.requestUrl), apiKey, } @@ -412,9 +416,9 @@ export const sandboxRouter = createTRPCRouter({ ) .mutation(async ({ ctx, input }) => { const sandbox = await Sandbox.connect(input.sandboxId, { - apiUrl: process.env.NEXT_PUBLIC_INFRA_API_URL, + apiUrl: resolveInfraApiUrl(), domain: process.env.NEXT_PUBLIC_E2B_DOMAIN, - sandboxUrl: process.env.NEXT_PUBLIC_E2B_SANDBOX_URL, + sandboxUrl: resolveServerSandboxUrl(ctx.headers, ctx.requestUrl), timeoutMs: TERMINAL_SANDBOX_TIMEOUT_MS, apiKey: ctx.apiKey, }) diff --git a/src/core/server/runtime-config.ts b/src/core/server/runtime-config.ts new file mode 100644 index 000000000..86bc48e04 --- /dev/null +++ b/src/core/server/runtime-config.ts @@ -0,0 +1,254 @@ +/** + * Where this deployment's APIs live. + * + * Hosted deployments are configured with NEXT_PUBLIC_* variables, which Next + * inlines into the bundles at build time — a prebuilt container image cannot + * use them. The E2B_* variables here carry no NEXT_PUBLIC_ prefix, so Node + * reads them from the environment when the server starts and one image can + * serve any install. The NEXT_PUBLIC_* values stay as the fallback, so a + * deployment that sets none of the new variables resolves exactly as before. + * + * The chosen value is validated here and not only by the schema in + * `src/lib/env.ts`, which runs in dev, prebuild and tests but never inside a + * running container. `api.ts` calls these resolvers at module scope, so a + * malformed URL fails on the first server import however the process was + * started, rather than surfacing later as an opaque fetch failure. + */ + +import 'server-only' +import type { BrowserRuntimeConfig } from '@/core/shared/runtime-config' + +const INFRA_API_DEFAULT_PORT = '3000' +const SANDBOX_DEFAULT_PORT = '3002' + +interface ResolvedValue { + name: string + value: string +} + +function trimmed(value: string | null | undefined): string | undefined { + return value?.trim() || undefined +} + +function firstSet( + ...candidates: Array<[name: string, value: string | undefined]> +): ResolvedValue | undefined { + for (const [name, value] of candidates) { + const cleaned = trimmed(value) + + if (cleaned) { + return { name, value: cleaned } + } + } + + return undefined +} + +function parsedUrl(value: string | undefined): URL | undefined { + if (!value) { + return undefined + } + + try { + return new URL(value) + } catch { + return undefined + } +} + +function isHttpUrl(value: string): boolean { + try { + const { protocol } = new URL(value) + + // A scheme-less "localhost:3010" parses as the scheme "localhost", so the + // protocol has to be checked as well. + return protocol === 'http:' || protocol === 'https:' + } catch { + return false + } +} + +function assertHttpUrl({ name, value }: ResolvedValue): string { + if (!isHttpUrl(value)) { + throw new Error( + `${name} is not an http(s) URL: "${value}" (include the scheme, e.g. http://127.0.0.1:3000)` + ) + } + + return value +} + +function configuredInfraApiUrl(): ResolvedValue | undefined { + return firstSet( + ['E2B_INFRA_API_URL', process.env.E2B_INFRA_API_URL], + ['NEXT_PUBLIC_INFRA_API_URL', process.env.NEXT_PUBLIC_INFRA_API_URL] + ) +} + +export function resolveInfraApiUrl(): string { + const configured = configuredInfraApiUrl() + + return configured + ? assertHttpUrl(configured) + : `https://api.${process.env.NEXT_PUBLIC_E2B_DOMAIN}` +} + +export function resolveDashboardApiUrl(): string { + const configured = firstSet( + ['E2B_DASHBOARD_API_URL', process.env.E2B_DASHBOARD_API_URL], + ['NEXT_PUBLIC_DASHBOARD_API_URL', process.env.NEXT_PUBLIC_DASHBOARD_API_URL] + ) + + return configured + ? assertHttpUrl(configured) + : `https://dashboard-api.${process.env.NEXT_PUBLIC_E2B_DOMAIN}` +} + +/** + * The base URL for sandbox traffic, or undefined to let the SDK derive one + * from the domain. E2B_SANDBOX_URL is also read by the SDK itself for the same + * purpose, so the shared name is deliberate. + */ +export function resolveSandboxUrl(): string | undefined { + const configured = firstSet( + ['E2B_SANDBOX_URL', process.env.E2B_SANDBOX_URL], + ['NEXT_PUBLIC_E2B_SANDBOX_URL', process.env.NEXT_PUBLIC_E2B_SANDBOX_URL] + ) + + return configured ? assertHttpUrl(configured) : undefined +} + +/** + * The forwarded protocol, accepted only when it is http or https. The result + * is served to the browser and handed to the SDK, so an unrecognised scheme + * from this header has to be dropped rather than echoed. + */ +function forwardedProtocol(headers: Headers): string | undefined { + const value = trimmed( + headers.get('x-forwarded-proto')?.split(',')[0] + )?.toLowerCase() + + return value === 'http' || value === 'https' ? value : undefined +} + +/** + * The hostname of `protocol://host`, or undefined when the host is absent or + * does not parse. A proxy header can carry anything, and an unparseable one + * must fall through to the next candidate rather than take the endpoint down. + */ +function hostnameOf( + protocol: string, + host: string | undefined +): string | undefined { + // Without this guard `http://undefined` parses, to the hostname + // "undefined". + if (!host) { + return undefined + } + + try { + // Through URL so an IPv6 literal keeps its brackets and any port on the + // incoming host is dropped before this one is appended. + return new URL(`${protocol}://${host}`).hostname || undefined + } catch { + return undefined + } +} + +/** + * The host the client reached this server on, with `port` substituted, or + * undefined when the request carries no usable host at all. Built from the + * proxy headers first so a reverse-proxied install advertises the public host + * rather than its own internal one, falling back to the request URL. + * + * The request URL is optional because a tRPC procedure called from a server + * component is given the request headers but no URL. http is the guess for a + * request that carries neither a forwarded protocol nor a URL, which is the + * plain self-hosted case; a proxied install sets X-Forwarded-Proto. + */ +function requestOrigin( + headers: Headers, + requestUrl: string | undefined, + port: string +): string | undefined { + const url = parsedUrl(requestUrl) + const protocol = + forwardedProtocol(headers) ?? url?.protocol.replace(/:$/, '') ?? 'http' + + // Each candidate is parsed in turn, so a malformed proxy header falls + // through to the next one instead of discarding a good host below it. + const hostname = + hostnameOf(protocol, trimmed(headers.get('x-forwarded-host'))) ?? + hostnameOf(protocol, trimmed(headers.get('host'))) ?? + url?.hostname + + return hostname ? `${protocol}://${hostname}:${port}` : undefined +} + +/** + * The base URL for sandbox traffic that a server-side SDK call should use, + * resolved per request: the configured value, then the request host on the + * sandbox port for a runtime-configured install, then undefined so the SDK + * derives the host from the domain. + * + * The request-host default applies only when E2B_INFRA_API_URL is set. Hosted + * deployments set none of the E2B_* variables and must keep passing no sandbox + * URL at all; a self-hosted install configured at runtime is the only + * deployment that wants "the host this request arrived on, on the sandbox + * port". + * + * The browser is told the same thing by `GET /api/config`, and the two have to + * agree. A self-hosted install leaves E2B_SANDBOX_URL unset precisely so every + * browser gets the host it reached the dashboard on, remote ones included — + * resolving the server side from the environment alone left it on the + * build-time domain, and calls such as killing a terminal's pty went to a host + * that does not exist. + */ +export function resolveServerSandboxUrl( + headers: Headers, + requestUrl: string | undefined +): string | undefined { + const configured = resolveSandboxUrl() + + if (configured) { + return configured + } + + if (!trimmed(process.env.E2B_INFRA_API_URL)) { + return undefined + } + + return requestOrigin(headers, requestUrl, SANDBOX_DEFAULT_PORT) +} + +/** + * The URLs a browser needs, resolved per request. The sandbox URL is whatever + * the server itself would use, so the browser and the server-side SDK calls + * never talk to different sandbox hosts. + * + * A null infra URL means the request carried no host to fall back to, which + * leaves the browser on its build-time value rather than on a guess. + */ +export function resolveBrowserRuntimeConfig( + headers: Headers, + requestUrl: string +): BrowserRuntimeConfig { + const domain = trimmed(process.env.NEXT_PUBLIC_E2B_DOMAIN) + const configured = configuredInfraApiUrl() + + let infraApiUrl: string | null + + if (configured) { + infraApiUrl = assertHttpUrl(configured) + } else if (domain) { + infraApiUrl = `https://api.${domain}` + } else { + infraApiUrl = + requestOrigin(headers, requestUrl, INFRA_API_DEFAULT_PORT) ?? null + } + + return { + infraApiUrl, + sandboxUrl: resolveServerSandboxUrl(headers, requestUrl) ?? null, + } +} diff --git a/src/core/shared/clients/api.ts b/src/core/shared/clients/api.ts index d6c23276f..54146b697 100644 --- a/src/core/shared/clients/api.ts +++ b/src/core/shared/clients/api.ts @@ -1,16 +1,16 @@ import createClient from 'openapi-fetch' +import { + resolveDashboardApiUrl, + resolveInfraApiUrl, +} from '@/core/server/runtime-config' import type { paths as DashboardPaths } from '@/core/shared/contracts/dashboard-api.types' import type { paths as InfraPaths } from '@/core/shared/contracts/infra-api.types' type CombinedPaths = InfraPaths -const INFRA_API_URL = - process.env.NEXT_PUBLIC_INFRA_API_URL || - `https://api.${process.env.NEXT_PUBLIC_E2B_DOMAIN}` +const INFRA_API_URL = resolveInfraApiUrl() -const DASHBOARD_API_URL = - process.env.NEXT_PUBLIC_DASHBOARD_API_URL || - `https://dashboard-api.${process.env.NEXT_PUBLIC_E2B_DOMAIN}` +const DASHBOARD_API_URL = resolveDashboardApiUrl() export const infra = createClient({ baseUrl: INFRA_API_URL, diff --git a/src/core/shared/runtime-config.ts b/src/core/shared/runtime-config.ts new file mode 100644 index 000000000..ee26a0773 --- /dev/null +++ b/src/core/shared/runtime-config.ts @@ -0,0 +1,79 @@ +/** + * Server-resolved URLs the browser needs. Delivered by `GET /api/config` + * rather than inlined at build time, so one prebuilt image works on any host. + */ +export interface BrowserRuntimeConfig { + infraApiUrl: string | null + sandboxUrl: string | null +} + +const RUNTIME_CONFIG_URL = '/api/config' + +/** + * What a hosted deployment bakes into the browser bundle. Also the fallback + * when the endpoint cannot be reached, so a browser is never worse off than + * before the endpoint existed. + */ +function buildTimeConfig(): BrowserRuntimeConfig { + return { + infraApiUrl: process.env.NEXT_PUBLIC_INFRA_API_URL ?? null, + sandboxUrl: process.env.NEXT_PUBLIC_E2B_SANDBOX_URL ?? null, + } +} + +let cached: Promise | null = null + +/** + * What the endpoint resolved, or null when it could not be reached. Never + * rejects, so the caller below always gets to decide what to cache. + */ +async function requestRuntimeConfig(): Promise { + try { + const response = await fetch(RUNTIME_CONFIG_URL, { cache: 'no-store' }) + + if (!response.ok) { + return null + } + + const body = (await response.json()) as Partial + const fallback = buildTimeConfig() + + return { + infraApiUrl: body.infraApiUrl ?? fallback.infraApiUrl, + sandboxUrl: body.sandboxUrl ?? fallback.sandboxUrl, + } + } catch { + return null + } +} + +export function fetchRuntimeConfig(): Promise { + if (!cached) { + const attempt: Promise = requestRuntimeConfig().then( + (resolved) => { + if (resolved) { + return resolved + } + + // The endpoint did not answer. Everyone already waiting on this + // attempt shares its fallback, but the cache is dropped so the next + // caller retries rather than being pinned to the build-time values + // for the life of the page. Guarded so a slow failure cannot clear a + // newer attempt that replaced it. + if (cached === attempt) { + cached = null + } + + return buildTimeConfig() + } + ) + + cached = attempt + } + + return cached +} + +export function resetRuntimeConfigCache(): void { + cached = null +} diff --git a/src/features/dashboard/sandbox/inspect/context.tsx b/src/features/dashboard/sandbox/inspect/context.tsx index 14c589ba0..b09c732c5 100644 --- a/src/features/dashboard/sandbox/inspect/context.tsx +++ b/src/features/dashboard/sandbox/inspect/context.tsx @@ -11,6 +11,7 @@ import { useState, } from 'react' import { createEnvdSandbox } from '@/core/shared/create-envd-sandbox' +import { fetchRuntimeConfig } from '@/core/shared/runtime-config' import { useSandboxInspectAnalytics } from '@/lib/hooks/use-analytics' import { getParentPath, normalizePath } from '@/lib/utils/filesystem' import { useTRPCClient } from '@/trpc/client' @@ -179,10 +180,12 @@ export default function SandboxInspectProvider({ sandboxManagerRef.current.stopWatching() } + const { sandboxUrl } = await fetchRuntimeConfig() + const sandbox = createEnvdSandbox({ ...creds, domain: process.env.NEXT_PUBLIC_E2B_DOMAIN, - sandboxUrl: process.env.NEXT_PUBLIC_E2B_SANDBOX_URL, + sandboxUrl: sandboxUrl ?? undefined, }) const manager = new SandboxManager(store, sandbox, rootPath) sandboxManagerRef.current = manager diff --git a/src/features/dashboard/terminal/sandbox-session.ts b/src/features/dashboard/terminal/sandbox-session.ts index e7815badb..a65b22c17 100644 --- a/src/features/dashboard/terminal/sandbox-session.ts +++ b/src/features/dashboard/terminal/sandbox-session.ts @@ -1,5 +1,6 @@ import type { Sandbox } from 'e2b' import { createEnvdSandbox } from '@/core/shared/create-envd-sandbox' +import { fetchRuntimeConfig } from '@/core/shared/runtime-config' import type { TRPCRouterOutputs } from '@/trpc/client' import { clearStoredTerminalSession, @@ -121,9 +122,11 @@ async function acquireTerminalSandbox( throw error instanceof Error ? error : new Error(fallbackMessage) } + const { sandboxUrl } = await fetchRuntimeConfig() + return createEnvdSandbox({ ...connection, domain: process.env.NEXT_PUBLIC_E2B_DOMAIN, - sandboxUrl: process.env.NEXT_PUBLIC_E2B_SANDBOX_URL, + sandboxUrl: sandboxUrl ?? undefined, }) } diff --git a/src/lib/env.ts b/src/lib/env.ts index 7ba741251..09a3e2881 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -5,6 +5,17 @@ export const serverSchema = z.object({ // key form on `/` is skipped entirely (single-user self-hosted deployments). E2B_API_KEY: z.string().min(1).optional(), + // Where this deployment reaches its APIs, read at runtime. Self-hosted + // installs set these; hosted deployments keep using the NEXT_PUBLIC_* + // variables below, which stay the fallback. + E2B_INFRA_API_URL: z.url().optional(), + E2B_DASHBOARD_API_URL: z.url().optional(), + E2B_SANDBOX_URL: z.url().optional(), + + // Overrides the api key cookie's Secure flag. Self-hosted installs served + // over plain http need "false", or the browser drops the cookie. + DASHBOARD_COOKIE_SECURE: z.enum(['true', 'false']).optional(), + OTEL_SERVICE_NAME: z.string().optional(), OTEL_EXPORTER_OTLP_ENDPOINT: z.url().optional(), OTEL_EXPORTER_OTLP_PROTOCOL: z diff --git a/tests/integration/config-route.test.ts b/tests/integration/config-route.test.ts new file mode 100644 index 000000000..d2a3ce6e5 --- /dev/null +++ b/tests/integration/config-route.test.ts @@ -0,0 +1,55 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { GET } from '@/app/api/config/route' + +const saved = new Map() +// tests/setup.ts loads .env files, so a developer's local sandbox URL would +// otherwise leak into these expectations. +const MANAGED_KEYS = [ + 'E2B_INFRA_API_URL', + 'E2B_SANDBOX_URL', + 'NEXT_PUBLIC_INFRA_API_URL', + 'NEXT_PUBLIC_E2B_SANDBOX_URL', +] as const + +beforeEach(() => { + for (const key of MANAGED_KEYS) { + saved.set(key, process.env[key]) + delete process.env[key] + } +}) + +afterEach(() => { + for (const key of MANAGED_KEYS) { + const value = saved.get(key) + if (value === undefined) { + delete process.env[key] + } else { + process.env[key] = value + } + } +}) + +describe('/api/config', () => { + it('serves the browser config resolved from the request', async () => { + process.env.E2B_INFRA_API_URL = 'http://127.0.0.1:3000' + + const response = await GET( + new Request('http://dash.example:3001/api/config') + ) + + expect(response.status).toBe(200) + expect(response.headers.get('cache-control')).toBe('no-store') + await expect(response.json()).resolves.toEqual({ + infraApiUrl: 'http://127.0.0.1:3000', + sandboxUrl: 'http://dash.example:3002', + }) + }) + + it('reports no sandbox url when nothing configures one', async () => { + const response = await GET( + new Request('http://dash.example:3001/api/config') + ) + + await expect(response.json()).resolves.toMatchObject({ sandboxUrl: null }) + }) +}) diff --git a/tests/unit/cookie-options.test.ts b/tests/unit/cookie-options.test.ts new file mode 100644 index 000000000..2315c0946 --- /dev/null +++ b/tests/unit/cookie-options.test.ts @@ -0,0 +1,89 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +async function loadApiKeyCookieOptions() { + vi.resetModules() + const cookies = await import('@/configs/cookies') + return cookies.COOKIE_OPTIONS[cookies.COOKIE_KEYS.API_KEY] +} + +afterEach(() => { + vi.unstubAllEnvs() +}) + +describe('api key cookie options', () => { + it('keeps the build-mode default when the flag is unset', async () => { + vi.stubEnv('DASHBOARD_COOKIE_SECURE', undefined) + vi.stubEnv('NODE_ENV', 'production') + + await expect(loadApiKeyCookieOptions()).resolves.toMatchObject({ + secure: true, + httpOnly: true, + sameSite: 'lax', + }) + }) + + it('is not secure outside production when the flag is unset', async () => { + vi.stubEnv('DASHBOARD_COOKIE_SECURE', undefined) + vi.stubEnv('NODE_ENV', 'development') + + await expect(loadApiKeyCookieOptions()).resolves.toMatchObject({ + secure: false, + }) + }) + + it('drops the Secure flag when DASHBOARD_COOKIE_SECURE is false', async () => { + vi.stubEnv('NODE_ENV', 'production') + vi.stubEnv('DASHBOARD_COOKIE_SECURE', 'false') + + await expect(loadApiKeyCookieOptions()).resolves.toMatchObject({ + secure: false, + }) + }) + + it('accepts the flag case-insensitively', async () => { + vi.stubEnv('NODE_ENV', 'production') + vi.stubEnv('DASHBOARD_COOKIE_SECURE', 'FALSE') + + await expect(loadApiKeyCookieOptions()).resolves.toMatchObject({ + secure: false, + }) + }) + + it('ignores whitespace around the flag', async () => { + vi.stubEnv('NODE_ENV', 'production') + vi.stubEnv('DASHBOARD_COOKIE_SECURE', ' false ') + + await expect(loadApiKeyCookieOptions()).resolves.toMatchObject({ + secure: false, + }) + }) + + // An orchestrator that always passes the variable sends "" when it is + // unset, which has to mean "unset" rather than "not false". + it('treats an empty flag as unset', async () => { + vi.stubEnv('NODE_ENV', 'production') + vi.stubEnv('DASHBOARD_COOKIE_SECURE', '') + + await expect(loadApiKeyCookieOptions()).resolves.toMatchObject({ + secure: true, + }) + }) + + it('treats a whitespace-only flag as unset', async () => { + vi.stubEnv('NODE_ENV', 'production') + vi.stubEnv('DASHBOARD_COOKIE_SECURE', ' ') + + await expect(loadApiKeyCookieOptions()).resolves.toMatchObject({ + secure: true, + }) + }) + + it('keeps the Secure flag when DASHBOARD_COOKIE_SECURE is true', async () => { + vi.stubEnv('NODE_ENV', 'development') + vi.stubEnv('DASHBOARD_COOKIE_SECURE', 'true') + + await expect(loadApiKeyCookieOptions()).resolves.toMatchObject({ + secure: true, + }) + }) +}) diff --git a/tests/unit/dashboard-terminal.test.ts b/tests/unit/dashboard-terminal.test.ts index 29a5748c0..36b6f10b2 100644 --- a/tests/unit/dashboard-terminal.test.ts +++ b/tests/unit/dashboard-terminal.test.ts @@ -20,6 +20,13 @@ vi.mock('@/core/shared/create-envd-sandbox', () => ({ createEnvdSandbox: mockCreateEnvdSandbox, })) +vi.mock('@/core/shared/runtime-config', () => ({ + fetchRuntimeConfig: vi.fn(async () => ({ + infraApiUrl: 'http://127.0.0.1:3000', + sandboxUrl: 'http://host.example:3002', + })), +})) + // The `sandbox.openTerminal` tRPC mutation is injected into // openTerminalSandbox, so the test passes this mock directly instead of // mocking a module. @@ -282,7 +289,7 @@ describe('dashboard terminal helpers', () => { envdVersion: '0.2.0', envdAccessToken: 'envd-token', domain: process.env.NEXT_PUBLIC_E2B_DOMAIN, - sandboxUrl: process.env.NEXT_PUBLIC_E2B_SANDBOX_URL, + sandboxUrl: 'http://host.example:3002', }) expect(readStoredTerminalSession()).toBeNull() expect(statuses).toEqual([ @@ -290,6 +297,29 @@ describe('dashboard terminal helpers', () => { ]) }) + it('prefers the runtime config sandbox url over the build-time one', async () => { + const savedSandboxUrl = process.env.NEXT_PUBLIC_E2B_SANDBOX_URL + process.env.NEXT_PUBLIC_E2B_SANDBOX_URL = 'http://build-time.example:3002' + + try { + await openTerminalSandbox({ + onStatus: () => {}, + openTerminal: mockOpenTerminal, + template: 'base', + }) + + expect(mockCreateEnvdSandbox).toHaveBeenCalledWith( + expect.objectContaining({ sandboxUrl: 'http://host.example:3002' }) + ) + } finally { + if (savedSandboxUrl === undefined) { + delete process.env.NEXT_PUBLIC_E2B_SANDBOX_URL + } else { + process.env.NEXT_PUBLIC_E2B_SANDBOX_URL = savedSandboxUrl + } + } + }) + it('connects to a tokenless (secure: false) sandbox without an envd access token', async () => { mockOpenTerminal.mockResolvedValueOnce({ sandboxId: 'insecure-sandbox', @@ -311,7 +341,7 @@ describe('dashboard terminal helpers', () => { envdVersion: '0.2.0', envdAccessToken: undefined, domain: process.env.NEXT_PUBLIC_E2B_DOMAIN, - sandboxUrl: process.env.NEXT_PUBLIC_E2B_SANDBOX_URL, + sandboxUrl: 'http://host.example:3002', }) }) diff --git a/tests/unit/runtime-config-client.test.ts b/tests/unit/runtime-config-client.test.ts new file mode 100644 index 000000000..7c267d859 --- /dev/null +++ b/tests/unit/runtime-config-client.test.ts @@ -0,0 +1,123 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + fetchRuntimeConfig, + resetRuntimeConfigCache, +} from '@/core/shared/runtime-config' + +const savedSandboxUrl = process.env.NEXT_PUBLIC_E2B_SANDBOX_URL +const savedInfraUrl = process.env.NEXT_PUBLIC_INFRA_API_URL + +beforeEach(() => { + resetRuntimeConfigCache() + delete process.env.NEXT_PUBLIC_E2B_SANDBOX_URL + delete process.env.NEXT_PUBLIC_INFRA_API_URL +}) + +afterEach(() => { + vi.unstubAllGlobals() + if (savedSandboxUrl === undefined) { + delete process.env.NEXT_PUBLIC_E2B_SANDBOX_URL + } else { + process.env.NEXT_PUBLIC_E2B_SANDBOX_URL = savedSandboxUrl + } + if (savedInfraUrl === undefined) { + delete process.env.NEXT_PUBLIC_INFRA_API_URL + } else { + process.env.NEXT_PUBLIC_INFRA_API_URL = savedInfraUrl + } +}) + +describe('fetchRuntimeConfig', () => { + it('returns what the config endpoint resolved', async () => { + const fetchMock = vi.fn(async () => + Response.json({ + infraApiUrl: 'http://127.0.0.1:3000', + sandboxUrl: 'http://host.example:3002', + }) + ) + vi.stubGlobal('fetch', fetchMock) + + await expect(fetchRuntimeConfig()).resolves.toEqual({ + infraApiUrl: 'http://127.0.0.1:3000', + sandboxUrl: 'http://host.example:3002', + }) + expect(fetchMock).toHaveBeenCalledWith('/api/config', { + cache: 'no-store', + }) + }) + + it('coalesces concurrent callers into one request', async () => { + const fetchMock = vi.fn(async () => + Response.json({ infraApiUrl: null, sandboxUrl: null }) + ) + vi.stubGlobal('fetch', fetchMock) + + await Promise.all([fetchRuntimeConfig(), fetchRuntimeConfig()]) + + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it('falls back to the build-time values when the endpoint fails', async () => { + process.env.NEXT_PUBLIC_E2B_SANDBOX_URL = 'http://sandbox.lvh.me:3002' + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response('nope', { status: 500 })) + ) + + await expect(fetchRuntimeConfig()).resolves.toEqual({ + infraApiUrl: null, + sandboxUrl: 'http://sandbox.lvh.me:3002', + }) + }) + + it('falls back to the build-time values when the request throws', async () => { + process.env.NEXT_PUBLIC_E2B_SANDBOX_URL = 'http://sandbox.lvh.me:3002' + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + throw new Error('offline') + }) + ) + + await expect(fetchRuntimeConfig()).resolves.toEqual({ + infraApiUrl: null, + sandboxUrl: 'http://sandbox.lvh.me:3002', + }) + }) + + it('retries after a failed attempt instead of pinning the fallback', async () => { + process.env.NEXT_PUBLIC_E2B_SANDBOX_URL = 'http://sandbox.lvh.me:3002' + const fetchMock = vi + .fn() + .mockResolvedValueOnce(new Response('nope', { status: 500 })) + .mockResolvedValueOnce( + Response.json({ + infraApiUrl: 'http://127.0.0.1:3000', + sandboxUrl: 'http://host.example:3002', + }) + ) + vi.stubGlobal('fetch', fetchMock) + + await expect(fetchRuntimeConfig()).resolves.toEqual({ + infraApiUrl: null, + sandboxUrl: 'http://sandbox.lvh.me:3002', + }) + await expect(fetchRuntimeConfig()).resolves.toEqual({ + infraApiUrl: 'http://127.0.0.1:3000', + sandboxUrl: 'http://host.example:3002', + }) + expect(fetchMock).toHaveBeenCalledTimes(2) + }) + + it('keeps caching a successful result', async () => { + const fetchMock = vi.fn(async () => + Response.json({ infraApiUrl: null, sandboxUrl: 'http://ok.example:3002' }) + ) + vi.stubGlobal('fetch', fetchMock) + + await fetchRuntimeConfig() + await fetchRuntimeConfig() + + expect(fetchMock).toHaveBeenCalledTimes(1) + }) +}) diff --git a/tests/unit/runtime-config.test.ts b/tests/unit/runtime-config.test.ts new file mode 100644 index 000000000..040990bcc --- /dev/null +++ b/tests/unit/runtime-config.test.ts @@ -0,0 +1,412 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + resolveBrowserRuntimeConfig, + resolveDashboardApiUrl, + resolveInfraApiUrl, + resolveSandboxUrl, + resolveServerSandboxUrl, +} from '@/core/server/runtime-config' + +const MANAGED_KEYS = [ + 'E2B_INFRA_API_URL', + 'E2B_DASHBOARD_API_URL', + 'E2B_SANDBOX_URL', + 'NEXT_PUBLIC_INFRA_API_URL', + 'NEXT_PUBLIC_DASHBOARD_API_URL', + 'NEXT_PUBLIC_E2B_SANDBOX_URL', + 'NEXT_PUBLIC_E2B_DOMAIN', +] as const + +const saved = new Map() + +beforeEach(() => { + for (const key of MANAGED_KEYS) { + saved.set(key, process.env[key]) + delete process.env[key] + } + process.env.NEXT_PUBLIC_E2B_DOMAIN = 'example.dev' +}) + +afterEach(() => { + for (const key of MANAGED_KEYS) { + const value = saved.get(key) + if (value === undefined) { + delete process.env[key] + } else { + process.env[key] = value + } + } +}) + +describe('resolveInfraApiUrl', () => { + it('derives the URL from the domain when nothing is set', () => { + expect(resolveInfraApiUrl()).toBe('https://api.example.dev') + }) + + it('falls back to the NEXT_PUBLIC override', () => { + process.env.NEXT_PUBLIC_INFRA_API_URL = 'https://api.public.example' + + expect(resolveInfraApiUrl()).toBe('https://api.public.example') + }) + + it('prefers the runtime variable over the NEXT_PUBLIC override', () => { + process.env.NEXT_PUBLIC_INFRA_API_URL = 'https://api.public.example' + process.env.E2B_INFRA_API_URL = 'http://127.0.0.1:3000' + + expect(resolveInfraApiUrl()).toBe('http://127.0.0.1:3000') + }) + + it('ignores an empty runtime variable', () => { + process.env.E2B_INFRA_API_URL = '' + process.env.NEXT_PUBLIC_INFRA_API_URL = 'https://api.public.example' + + expect(resolveInfraApiUrl()).toBe('https://api.public.example') + }) + + it('ignores a whitespace-only runtime variable', () => { + process.env.E2B_INFRA_API_URL = ' ' + process.env.NEXT_PUBLIC_INFRA_API_URL = 'https://api.public.example' + + expect(resolveInfraApiUrl()).toBe('https://api.public.example') + }) + + it('trims whitespace around the resolved value', () => { + process.env.E2B_INFRA_API_URL = ' http://127.0.0.1:3000\n' + + expect(resolveInfraApiUrl()).toBe('http://127.0.0.1:3000') + }) +}) + +describe('resolveDashboardApiUrl', () => { + it('derives the URL from the domain when nothing is set', () => { + expect(resolveDashboardApiUrl()).toBe('https://dashboard-api.example.dev') + }) + + it('falls back to the NEXT_PUBLIC override', () => { + process.env.NEXT_PUBLIC_DASHBOARD_API_URL = 'https://dash.public.example' + + expect(resolveDashboardApiUrl()).toBe('https://dash.public.example') + }) + + it('prefers the runtime variable over the NEXT_PUBLIC override', () => { + process.env.NEXT_PUBLIC_DASHBOARD_API_URL = 'https://dash.public.example' + process.env.E2B_DASHBOARD_API_URL = 'http://127.0.0.1:3010' + + expect(resolveDashboardApiUrl()).toBe('http://127.0.0.1:3010') + }) + + it('ignores an empty runtime variable', () => { + process.env.E2B_DASHBOARD_API_URL = '' + process.env.NEXT_PUBLIC_DASHBOARD_API_URL = 'https://dash.public.example' + + expect(resolveDashboardApiUrl()).toBe('https://dash.public.example') + }) +}) + +describe('resolveSandboxUrl', () => { + it('reports no sandbox url when nothing is set', () => { + expect(resolveSandboxUrl()).toBeUndefined() + }) + + it('falls back to the NEXT_PUBLIC override', () => { + process.env.NEXT_PUBLIC_E2B_SANDBOX_URL = 'http://sandbox.lvh.me:3002' + + expect(resolveSandboxUrl()).toBe('http://sandbox.lvh.me:3002') + }) + + it('prefers the runtime variable over the NEXT_PUBLIC override', () => { + process.env.NEXT_PUBLIC_E2B_SANDBOX_URL = 'http://sandbox.lvh.me:3002' + process.env.E2B_SANDBOX_URL = 'https://sandbox.internal.example' + + expect(resolveSandboxUrl()).toBe('https://sandbox.internal.example') + }) + + it('ignores a whitespace-only runtime variable', () => { + process.env.E2B_SANDBOX_URL = ' ' + process.env.NEXT_PUBLIC_E2B_SANDBOX_URL = 'http://sandbox.lvh.me:3002' + + expect(resolveSandboxUrl()).toBe('http://sandbox.lvh.me:3002') + }) +}) + +describe('resolveBrowserRuntimeConfig', () => { + const requestUrl = 'http://dash.example:3001/api/config' + const headers = (init: Record = {}) => + new Headers({ host: 'dash.example:3001', ...init }) + + it('reports no sandbox url for a deployment that sets no runtime variables', () => { + expect(resolveBrowserRuntimeConfig(headers(), requestUrl)).toEqual({ + infraApiUrl: 'https://api.example.dev', + sandboxUrl: null, + }) + }) + + it('falls back to the NEXT_PUBLIC sandbox url', () => { + process.env.NEXT_PUBLIC_E2B_SANDBOX_URL = 'http://sandbox.lvh.me:3002' + + expect(resolveBrowserRuntimeConfig(headers(), requestUrl).sandboxUrl).toBe( + 'http://sandbox.lvh.me:3002' + ) + }) + + it('prefers the runtime sandbox url over the NEXT_PUBLIC one', () => { + process.env.NEXT_PUBLIC_E2B_SANDBOX_URL = 'http://sandbox.lvh.me:3002' + process.env.E2B_SANDBOX_URL = 'https://sandbox.internal.example' + + expect(resolveBrowserRuntimeConfig(headers(), requestUrl).sandboxUrl).toBe( + 'https://sandbox.internal.example' + ) + }) + + it('defaults to the request host on 3002 for a runtime-configured install', () => { + process.env.E2B_INFRA_API_URL = 'http://127.0.0.1:3000' + + expect(resolveBrowserRuntimeConfig(headers(), requestUrl)).toEqual({ + infraApiUrl: 'http://127.0.0.1:3000', + sandboxUrl: 'http://dash.example:3002', + }) + }) + + it('honours x-forwarded-host and x-forwarded-proto', () => { + process.env.E2B_INFRA_API_URL = 'http://127.0.0.1:3000' + + const config = resolveBrowserRuntimeConfig( + headers({ + 'x-forwarded-host': 'public.example:8443', + 'x-forwarded-proto': 'https,http', + }), + requestUrl + ) + + expect(config.sandboxUrl).toBe('https://public.example:3002') + }) + + // A proxy header is attacker-controllable in a misconfigured deployment, and + // whatever lands here is served to the browser and handed to the SDK. + it('ignores a malformed x-forwarded-host and uses the host header', () => { + process.env.E2B_INFRA_API_URL = 'http://127.0.0.1:3000' + + const config = resolveBrowserRuntimeConfig( + headers({ host: 'other.example:3001', 'x-forwarded-host': 'foo bar' }), + requestUrl + ) + + expect(config.sandboxUrl).toBe('http://other.example:3002') + }) + + it('falls back to the request url when every host candidate is malformed', () => { + process.env.E2B_INFRA_API_URL = 'http://127.0.0.1:3000' + + const config = resolveBrowserRuntimeConfig( + headers({ host: 'also bad', 'x-forwarded-host': 'foo bar' }), + requestUrl + ) + + expect(config.sandboxUrl).toBe('http://dash.example:3002') + }) + + it('ignores an x-forwarded-host whose port is out of range', () => { + process.env.E2B_INFRA_API_URL = 'http://127.0.0.1:3000' + + const config = resolveBrowserRuntimeConfig( + headers({ 'x-forwarded-host': 'h:99999' }), + requestUrl + ) + + expect(config.sandboxUrl).toBe('http://dash.example:3002') + }) + + it('ignores an x-forwarded-proto that is not http(s)', () => { + process.env.E2B_INFRA_API_URL = 'http://127.0.0.1:3000' + + const config = resolveBrowserRuntimeConfig( + headers({ 'x-forwarded-proto': 'javascript' }), + requestUrl + ) + + expect(config.sandboxUrl).toBe('http://dash.example:3002') + }) + + it('accepts an uppercase x-forwarded-proto', () => { + process.env.E2B_INFRA_API_URL = 'http://127.0.0.1:3000' + + const config = resolveBrowserRuntimeConfig( + headers({ 'x-forwarded-proto': 'HTTPS' }), + requestUrl + ) + + expect(config.sandboxUrl).toBe('https://dash.example:3002') + }) + + it('falls back to the request host for the infra url with no domain set', () => { + delete process.env.NEXT_PUBLIC_E2B_DOMAIN + + expect(resolveBrowserRuntimeConfig(headers(), requestUrl).infraApiUrl).toBe( + 'http://dash.example:3000' + ) + }) + + it('reads the host from the request url when no host header is present', () => { + process.env.E2B_INFRA_API_URL = 'http://127.0.0.1:3000' + + expect( + resolveBrowserRuntimeConfig(new Headers(), requestUrl).sandboxUrl + ).toBe('http://dash.example:3002') + }) +}) + +/** + * The server-side SDK calls resolve the sandbox URL exactly as the browser + * does. A runtime-configured install leaves E2B_SANDBOX_URL unset so every + * browser is told the host it reached the dashboard on; a server that read + * only the environment would fall back to the build-time domain, and its envd + * calls would go nowhere. + */ +describe('resolveServerSandboxUrl', () => { + const requestUrl = 'http://dash.example:3001/api/trpc/sandbox.killTerminalPty' + const headers = (init: Record = {}) => + new Headers({ host: 'dash.example:3001', ...init }) + + it('reports no sandbox url for a deployment that sets no runtime variables', () => { + expect(resolveServerSandboxUrl(headers(), requestUrl)).toBeUndefined() + }) + + it('uses the explicit runtime value', () => { + process.env.E2B_SANDBOX_URL = 'https://sandbox.internal.example' + + expect(resolveServerSandboxUrl(headers(), requestUrl)).toBe( + 'https://sandbox.internal.example' + ) + }) + + it('falls back to the NEXT_PUBLIC sandbox url', () => { + process.env.NEXT_PUBLIC_E2B_SANDBOX_URL = 'http://sandbox.lvh.me:3002' + + expect(resolveServerSandboxUrl(headers(), requestUrl)).toBe( + 'http://sandbox.lvh.me:3002' + ) + }) + + it('defaults to the request host on 3002 for a runtime-configured install', () => { + process.env.E2B_INFRA_API_URL = 'http://127.0.0.1:3000' + + expect(resolveServerSandboxUrl(headers(), requestUrl)).toBe( + 'http://dash.example:3002' + ) + }) + + it('prefers the explicit value over the request-host default', () => { + process.env.E2B_INFRA_API_URL = 'http://127.0.0.1:3000' + process.env.E2B_SANDBOX_URL = 'https://sandbox.internal.example' + + expect(resolveServerSandboxUrl(headers(), requestUrl)).toBe( + 'https://sandbox.internal.example' + ) + }) + + it('honours x-forwarded-host and x-forwarded-proto', () => { + process.env.E2B_INFRA_API_URL = 'http://127.0.0.1:3000' + + expect( + resolveServerSandboxUrl( + headers({ + 'x-forwarded-host': 'public.example:8443', + 'x-forwarded-proto': 'https,http', + }), + requestUrl + ) + ).toBe('https://public.example:3002') + }) + + // A procedure called from a server component has the request headers but no + // request URL, so the host header has to carry the default on its own. + it('resolves the host from the headers when there is no request url', () => { + process.env.E2B_INFRA_API_URL = 'http://127.0.0.1:3000' + + expect(resolveServerSandboxUrl(headers(), undefined)).toBe( + 'http://dash.example:3002' + ) + }) + + it('honours x-forwarded-proto when there is no request url', () => { + process.env.E2B_INFRA_API_URL = 'http://127.0.0.1:3000' + + expect( + resolveServerSandboxUrl( + headers({ 'x-forwarded-proto': 'https' }), + undefined + ) + ).toBe('https://dash.example:3002') + }) + + it('reports no sandbox url when the request carries no host at all', () => { + process.env.E2B_INFRA_API_URL = 'http://127.0.0.1:3000' + + expect(resolveServerSandboxUrl(new Headers(), undefined)).toBeUndefined() + }) + + // The whole point of the helper: the two resolutions cannot drift. + it('resolves to what the browser is told', () => { + process.env.E2B_INFRA_API_URL = 'http://127.0.0.1:3000' + + expect(resolveServerSandboxUrl(headers(), requestUrl)).toBe( + resolveBrowserRuntimeConfig(headers(), requestUrl).sandboxUrl + ) + }) +}) + +// The schema in src/lib/env.ts runs in dev, prebuild and tests but never in a +// running container, so a malformed URL has to fail here instead. +describe('URL validation', () => { + it('rejects a scheme-less runtime variable, naming it and its value', () => { + process.env.E2B_INFRA_API_URL = '127.0.0.1:3000' + + expect(() => resolveInfraApiUrl()).toThrow(/E2B_INFRA_API_URL/) + expect(() => resolveInfraApiUrl()).toThrow(/127\.0\.0\.1:3000/) + }) + + it('rejects a value whose scheme is not http(s)', () => { + process.env.E2B_DASHBOARD_API_URL = 'localhost:3010' + + expect(() => resolveDashboardApiUrl()).toThrow(/E2B_DASHBOARD_API_URL/) + }) + + it('names the NEXT_PUBLIC variable when that is the malformed one', () => { + process.env.NEXT_PUBLIC_INFRA_API_URL = 'api.public.example' + + expect(() => resolveInfraApiUrl()).toThrow(/NEXT_PUBLIC_INFRA_API_URL/) + }) + + it('accepts valid http and https URLs', () => { + process.env.E2B_INFRA_API_URL = 'http://127.0.0.1:3000' + process.env.E2B_DASHBOARD_API_URL = 'https://dashboard-api.example.dev' + + expect(resolveInfraApiUrl()).toBe('http://127.0.0.1:3000') + expect(resolveDashboardApiUrl()).toBe('https://dashboard-api.example.dev') + }) + + it('leaves the domain-derived fallback unvalidated', () => { + expect(resolveInfraApiUrl()).toBe('https://api.example.dev') + expect(resolveDashboardApiUrl()).toBe('https://dashboard-api.example.dev') + }) + + it('rejects a malformed sandbox url, naming it and its value', () => { + process.env.E2B_SANDBOX_URL = 'sandbox.internal.example:3002' + + expect(() => resolveSandboxUrl()).toThrow(/E2B_SANDBOX_URL/) + expect(() => resolveSandboxUrl()).toThrow(/sandbox\.internal\.example:3002/) + }) + + // The request-host default is built from a parsed URL, not read from the + // environment, so it never reaches the validator. + it('leaves the request-host default unvalidated', () => { + process.env.E2B_INFRA_API_URL = 'http://127.0.0.1:3000' + + expect( + resolveBrowserRuntimeConfig( + new Headers({ host: 'dash.example:3001' }), + 'http://dash.example:3001/api/config' + ).sandboxUrl + ).toBe('http://dash.example:3002') + }) +}) diff --git a/tests/unit/sandbox-router-api-url.test.ts b/tests/unit/sandbox-router-api-url.test.ts new file mode 100644 index 000000000..5ed3ee4cc --- /dev/null +++ b/tests/unit/sandbox-router-api-url.test.ts @@ -0,0 +1,304 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createTRPCContext } from '@/core/server/trpc/init' + +/** + * The sandbox router builds the E2B SDK connection options per request. A + * prebuilt image is configured with E2B_INFRA_API_URL at server start, so the + * control-plane calls have to resolve their API URL the same way the API + * clients do — reading the build-time NEXT_PUBLIC_ value directly would point + * a self-hosted install at whatever host the image was built for. + */ + +const sdkMock = vi.hoisted(() => ({ + connect: vi.fn(), + create: vi.fn(), + getFullInfo: vi.fn(), + pause: vi.fn(), +})) + +vi.mock('e2b', () => ({ + Sandbox: { + connect: sdkMock.connect, + create: sdkMock.create, + getFullInfo: sdkMock.getFullInfo, + pause: sdkMock.pause, + }, + TimeoutError: class TimeoutError extends Error {}, +})) + +const authMock = vi.hoisted(() => ({ getApiKey: vi.fn() })) +vi.mock('@/core/server/auth', () => ({ + getApiKey: authMock.getApiKey, +})) + +const { createCallerFactory } = await import('@/core/server/trpc/init') +const { sandboxRouter } = await import('@/core/server/api/routers/sandbox') + +const createCaller = createCallerFactory(sandboxRouter) + +const REQUEST_HOST = 'dash.example:3001' +const REQUEST_URL = `http://${REQUEST_HOST}/api/trpc/sandbox.killTerminalPty` +const REQUEST_HOST_SANDBOX_URL = 'http://dash.example:3002' + +async function caller(opts: { headers?: Headers; requestUrl?: string } = {}) { + const ctx = await createTRPCContext({ + headers: opts.headers ?? new Headers(), + requestUrl: opts.requestUrl, + }) + return createCaller(ctx) +} + +// A caller for a mutation that arrived over HTTP from a browser on +// REQUEST_HOST, which is how every one of these procedures is reached. +function requestCaller() { + return caller({ + headers: new Headers({ host: REQUEST_HOST }), + requestUrl: REQUEST_URL, + }) +} + +const RUNTIME_API_URL = 'http://127.0.0.1:3000' +const MANAGED_KEYS = [ + 'E2B_INFRA_API_URL', + 'E2B_SANDBOX_URL', + 'NEXT_PUBLIC_INFRA_API_URL', + 'NEXT_PUBLIC_E2B_SANDBOX_URL', +] as const +const saved = new Map() + +const withRuntimeApiUrl = expect.objectContaining({ apiUrl: RUNTIME_API_URL }) +const withRequestHostSandboxUrl = expect.objectContaining({ + sandboxUrl: REQUEST_HOST_SANDBOX_URL, +}) + +beforeEach(() => { + vi.clearAllMocks() + + for (const key of MANAGED_KEYS) { + saved.set(key, process.env[key]) + delete process.env[key] + } + process.env.E2B_INFRA_API_URL = RUNTIME_API_URL + + authMock.getApiKey.mockResolvedValue('e2b_test_api_key') + sdkMock.connect.mockResolvedValue({ + sandboxId: 'sbxexisting', + pty: { kill: vi.fn().mockResolvedValue(true) }, + }) + sdkMock.create.mockResolvedValue({ sandboxId: 'sbxnew' }) + sdkMock.getFullInfo.mockResolvedValue({ + sandboxDomain: 'sandbox.example.com', + envdVersion: '0.2.0', + envdAccessToken: 'envd-token', + }) + sdkMock.pause.mockResolvedValue(true) +}) + +afterEach(() => { + for (const key of MANAGED_KEYS) { + const value = saved.get(key) + if (value === undefined) { + delete process.env[key] + } else { + process.env[key] = value + } + } +}) + +describe('sandbox router control-plane API URL', () => { + it('openTerminal connects through the runtime API URL', async () => { + const c = await caller() + await c.openTerminal({ template: 'base', sandboxId: 'sbxexisting' }) + + expect(sdkMock.connect).toHaveBeenCalledWith( + 'sbxexisting', + withRuntimeApiUrl + ) + }) + + it('openTerminal creates through the runtime API URL', async () => { + const c = await caller() + await c.openTerminal({ template: 'base' }) + + expect(sdkMock.create).toHaveBeenCalledWith('base', withRuntimeApiUrl) + }) + + it('resume connects and reads info through the runtime API URL', async () => { + const c = await caller() + await c.resume({ sandboxId: 'sbxexisting' }) + + expect(sdkMock.connect).toHaveBeenCalledWith( + 'sbxexisting', + withRuntimeApiUrl + ) + expect(sdkMock.getFullInfo).toHaveBeenCalledWith( + 'sbxexisting', + withRuntimeApiUrl + ) + }) + + it('pause pauses through the runtime API URL', async () => { + const c = await caller() + await c.pause({ sandboxId: 'sbxexisting' }) + + expect(sdkMock.pause).toHaveBeenCalledWith('sbxexisting', withRuntimeApiUrl) + }) + + it('killTerminalPty connects through the runtime API URL', async () => { + const c = await caller() + await c.killTerminalPty({ sandboxId: 'sbxexisting', pid: 42 }) + + expect(sdkMock.connect).toHaveBeenCalledWith( + 'sbxexisting', + withRuntimeApiUrl + ) + }) + + it('falls back to the NEXT_PUBLIC value when no runtime URL is set', async () => { + delete process.env.E2B_INFRA_API_URL + process.env.NEXT_PUBLIC_INFRA_API_URL = 'https://api.public.example' + + const c = await caller() + await c.resume({ sandboxId: 'sbxexisting' }) + + expect(sdkMock.connect).toHaveBeenCalledWith( + 'sbxexisting', + expect.objectContaining({ apiUrl: 'https://api.public.example' }) + ) + }) +}) + +/** + * The sandbox URL travels in the same connection options, so a prebuilt image + * has to read it the same way — otherwise the server talks to one sandbox host + * and the browser, which reads `GET /api/config`, talks to another. + */ +describe('sandbox router sandbox URL', () => { + it('passes the runtime sandbox URL to the control plane', async () => { + process.env.E2B_SANDBOX_URL = 'https://sandbox.internal.example' + + const c = await caller() + await c.openTerminal({ template: 'base', sandboxId: 'sbxexisting' }) + + expect(sdkMock.connect).toHaveBeenCalledWith( + 'sbxexisting', + expect.objectContaining({ + sandboxUrl: 'https://sandbox.internal.example', + }) + ) + }) + + it('prefers the runtime sandbox URL over the NEXT_PUBLIC one', async () => { + process.env.NEXT_PUBLIC_E2B_SANDBOX_URL = 'http://sandbox.lvh.me:3002' + process.env.E2B_SANDBOX_URL = 'https://sandbox.internal.example' + + const c = await caller() + await c.pause({ sandboxId: 'sbxexisting' }) + + expect(sdkMock.pause).toHaveBeenCalledWith( + 'sbxexisting', + expect.objectContaining({ + sandboxUrl: 'https://sandbox.internal.example', + }) + ) + }) + + it('falls back to the NEXT_PUBLIC sandbox URL', async () => { + process.env.NEXT_PUBLIC_E2B_SANDBOX_URL = 'http://sandbox.lvh.me:3002' + + const c = await caller() + await c.killTerminalPty({ sandboxId: 'sbxexisting', pid: 42 }) + + expect(sdkMock.connect).toHaveBeenCalledWith( + 'sbxexisting', + expect.objectContaining({ sandboxUrl: 'http://sandbox.lvh.me:3002' }) + ) + }) + + // With no sandbox URL configured, a runtime-configured install falls back to + // the host the request arrived on, which is what the browser is told too. + // Reading only the environment here left the SDK on the build-time domain, + // and every server-side envd call — killing a terminal's pty on leaving the + // page, above all — went to a host that does not exist. + it('defaults killTerminalPty to the request host on the sandbox port', async () => { + const c = await requestCaller() + await c.killTerminalPty({ sandboxId: 'sbxexisting', pid: 42 }) + + expect(sdkMock.connect).toHaveBeenCalledWith( + 'sbxexisting', + withRequestHostSandboxUrl + ) + }) + + it('defaults resume to the request host on the sandbox port', async () => { + const c = await requestCaller() + await c.resume({ sandboxId: 'sbxexisting' }) + + expect(sdkMock.connect).toHaveBeenCalledWith( + 'sbxexisting', + withRequestHostSandboxUrl + ) + expect(sdkMock.getFullInfo).toHaveBeenCalledWith( + 'sbxexisting', + withRequestHostSandboxUrl + ) + }) + + it('defaults openTerminal to the request host on the sandbox port', async () => { + const c = await requestCaller() + await c.openTerminal({ template: 'base' }) + + expect(sdkMock.create).toHaveBeenCalledWith( + 'base', + withRequestHostSandboxUrl + ) + }) + + it('defaults pause to the request host on the sandbox port', async () => { + const c = await requestCaller() + await c.pause({ sandboxId: 'sbxexisting' }) + + expect(sdkMock.pause).toHaveBeenCalledWith( + 'sbxexisting', + withRequestHostSandboxUrl + ) + }) + + it('prefers the explicit sandbox URL over the request host', async () => { + process.env.E2B_SANDBOX_URL = 'https://sandbox.internal.example' + + const c = await requestCaller() + await c.killTerminalPty({ sandboxId: 'sbxexisting', pid: 42 }) + + expect(sdkMock.connect).toHaveBeenCalledWith( + 'sbxexisting', + expect.objectContaining({ + sandboxUrl: 'https://sandbox.internal.example', + }) + ) + }) + + // Hosted deployments set none of the E2B_* variables and must keep passing + // no sandbox URL at all, so the SDK derives the host from the domain. + it('passes no sandbox URL when no runtime variable is set', async () => { + delete process.env.E2B_INFRA_API_URL + + const c = await requestCaller() + await c.killTerminalPty({ sandboxId: 'sbxexisting', pid: 42 }) + + expect(sdkMock.connect).toHaveBeenCalledWith( + 'sbxexisting', + expect.objectContaining({ sandboxUrl: undefined }) + ) + }) + + it('passes no sandbox URL when the call carries no request host', async () => { + const c = await caller() + await c.resume({ sandboxId: 'sbxexisting' }) + + expect(sdkMock.connect).toHaveBeenCalledWith( + 'sbxexisting', + expect.objectContaining({ sandboxUrl: undefined }) + ) + }) +})