From 5290dbd91b7a60aee422693b0b05fe57a62c2bfd Mon Sep 17 00:00:00 2001 From: Leonard van Hemert Date: Mon, 20 Jul 2026 11:08:27 +0200 Subject: [PATCH] fix direct sandbox mounts and IPv6 binding --- api/src/config.test.ts | 13 +++++++++- api/src/config.ts | 11 ++++++++- api/src/index.ts | 3 +-- docker/start-direct-sandbox.sh | 24 +++++++++---------- helm/codeapi/README.md | 4 ++++ .../templates/worker-sandbox-deployment.yaml | 2 ++ helm/codeapi/values.yaml | 2 ++ tests/start-direct-sandbox.test.sh | 15 ++++++++++++ 8 files changed, 57 insertions(+), 17 deletions(-) create mode 100644 tests/start-direct-sandbox.test.sh diff --git a/api/src/config.test.ts b/api/src/config.test.ts index 1041a5f..6d1f2eb 100644 --- a/api/src/config.test.ts +++ b/api/src/config.test.ts @@ -1,5 +1,16 @@ import { describe, expect, test } from 'bun:test'; -import { legacyPackagesDirectory } from './config'; +import { formatBindAddress, legacyPackagesDirectory } from './config'; + +describe('bind address formatting', () => { + test('formats IPv4 listeners without brackets', () => { + expect(formatBindAddress('0.0.0.0', 2000)).toBe('0.0.0.0:2000'); + }); + + test('formats IPv6 listeners with brackets for logs and URLs', () => { + expect(formatBindAddress('::', 2000)).toBe('[::]:2000'); + expect(formatBindAddress('2001:db8::1', 3112)).toBe('[2001:db8::1]:3112'); + }); +}); describe('legacy package directory fallback', () => { test('preserves custom legacy data directories', () => { diff --git a/api/src/config.ts b/api/src/config.ts index f8e2aba..a390d3a 100644 --- a/api/src/config.ts +++ b/api/src/config.ts @@ -43,10 +43,19 @@ export function legacyPackagesDirectory(raw: string | undefined): string | undef : trimmed === '/' ? '/packages' : `${trimmed}/packages`; } +export function formatBindAddress(host: string, port: number): string { + return host.includes(':') ? `[${host}]:${port}` : `${host}:${port}`; +} + +const bindHost = process.env.SANDBOX_BIND_HOST?.trim() || '0.0.0.0'; +const bindPort = safeInt(process.env.PORT, 2000); + export const config = { hardened_sandbox_mode: process.env.CODEAPI_HARDENED_SANDBOX_MODE === 'true', log_level: process.env.SANDBOX_LOG_LEVEL ?? 'DEBUG', - bind_address: `0.0.0.0:${process.env.PORT ?? 2000}`, + bind_host: bindHost, + bind_port: bindPort, + bind_address: formatBindAddress(bindHost, bindPort), packages_directory: cleanDirectory(process.env.SANDBOX_PACKAGES_DIRECTORY) ?? legacyPackagesDirectory(process.env.SANDBOX_DATA_DIRECTORY) ?? '/pkgs', diff --git a/api/src/index.ts b/api/src/index.ts index 92c590d..b4a640a 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -65,9 +65,8 @@ async function main(): Promise { validateHardenedSandboxStartup(); await initializeSandboxWorkspaceIsolation(); - const [address, port] = config.bind_address.split(':'); const stopWorkspaceReaper = startWorkspaceReaper(); - const server = app.listen(Number(port), address, () => { + const server = app.listen(config.bind_port, config.bind_host, () => { logger.info({ address: config.bind_address }, 'Sandbox API started'); }); diff --git a/docker/start-direct-sandbox.sh b/docker/start-direct-sandbox.sh index a171a6d..7a547cb 100644 --- a/docker/start-direct-sandbox.sh +++ b/docker/start-direct-sandbox.sh @@ -48,24 +48,22 @@ export SANDBOX_ROOTFS="$ROOTFS" exec unshare --mount bash -c ' ROOTFS="${SANDBOX_ROOTFS:-/sandbox-rootfs}" - mount -o bind,ro "$ROOTFS/usr/sbin" /usr/sbin || { echo "FATAL: cannot bind /usr/sbin"; exit 1; } - mount -o bind,ro "$ROOTFS/usr/lib" /usr/lib || { echo "FATAL: cannot bind /usr/lib"; exit 1; } - - if [ -d "$ROOTFS/usr/lib64" ] && ! [ -L "$ROOTFS/usr/lib64" ]; then - mount -o bind,ro "$ROOTFS/usr/lib64" /usr/lib64 2>/dev/null || \ - echo "[sandbox] WARNING: could not bind /usr/lib64 - sandboxed binaries may fail to exec" - fi - - mount -o bind,ro "$ROOTFS/usr/local" /usr/local || { echo "FATAL: cannot bind /usr/local"; exit 1; } mount -o bind,ro "$ROOTFS/sandbox_api" /sandbox_api || { echo "FATAL: cannot bind /sandbox_api"; exit 1; } - mount -o bind,ro "$ROOTFS/pkgs" /pkgs || { echo "FATAL: cannot bind /pkgs"; exit 1; } + mount -o bind,ro "$ROOTFS/pkgs" /pkgs || { echo "FATAL: cannot bind /pkgs"; exit 1; } - if [ -d /host-packages ]; then + # Prefer a populated host/PVC package mount. A baked image keeps its own + # ROOTFS/pkgs as the fallback when the mount is absent or empty. + if [ -d /host-packages ] && [ "$(ls -A /host-packages 2>/dev/null)" ]; then mount --bind /host-packages /pkgs 2>/dev/null || \ - echo "WARNING: could not bind /host-packages - sandbox will run without packages" + echo "WARNING: could not bind /host-packages - using packages from $ROOTFS/pkgs" + else + echo "INFO: /host-packages is empty or missing - using packages from $ROOTFS/pkgs" fi - mount -o bind,ro "$ROOTFS/usr/bin" /usr/bin || { echo "FATAL: cannot bind /usr/bin"; exit 1; } + # Bind the complete /usr tree once. Fedora-style merged-/usr images can + # make /usr/bin and /usr/sbin the same tree; mounting those subdirectories + # separately can hide mount(8) before startup has finished. + mount -o bind,ro "$ROOTFS/usr" /usr || { echo "FATAL: cannot bind /usr"; exit 1; } multiarch_libdir=$(find /usr/lib -maxdepth 1 -type d -name "*-linux-gnu" -print -quit) if [ -n "$multiarch_libdir" ]; then diff --git a/helm/codeapi/README.md b/helm/codeapi/README.md index a60f89c..059c1fe 100644 --- a/helm/codeapi/README.md +++ b/helm/codeapi/README.md @@ -117,6 +117,10 @@ helm install codeapi . -f values-local.yaml The chart includes a **package-init Job** that runs as a Helm `pre-install` hook. It automatically compiles Python, downloads Node/Bun, installs offline package sets, and registers Bash into the packages PVC before the worker pods start. +For IPv6-only or dual-stack clusters, set `workerSandbox.sandbox.bindHost` to +`"::"`. The sandbox API keeps the bind host and port separate so IPv6 addresses +are not incorrectly split on their colons. + This happens automatically on `helm install`. To force a rebuild: ```bash diff --git a/helm/codeapi/templates/worker-sandbox-deployment.yaml b/helm/codeapi/templates/worker-sandbox-deployment.yaml index ff23008..7732b9c 100644 --- a/helm/codeapi/templates/worker-sandbox-deployment.yaml +++ b/helm/codeapi/templates/worker-sandbox-deployment.yaml @@ -256,6 +256,8 @@ spec: value: "INFO" - name: SANDBOX_PACKAGES_DIRECTORY value: "/pkgs" + - name: SANDBOX_BIND_HOST + value: {{ .Values.workerSandbox.sandbox.bindHost | default "0.0.0.0" | quote }} - name: SANDBOX_MAX_PROCESS_COUNT value: {{ .Values.workerSandbox.sandbox.maxProcessCount | quote }} - name: SANDBOX_RUN_CPU_TIME diff --git a/helm/codeapi/values.yaml b/helm/codeapi/values.yaml index ce16c34..964fb51 100644 --- a/helm/codeapi/values.yaml +++ b/helm/codeapi/values.yaml @@ -224,6 +224,8 @@ workerSandbox: # Sandbox configuration (NsJail-based, uses SANDBOX_* env vars) sandbox: port: 2000 + # Use :: for IPv6-only/dual-stack clusters; keep 0.0.0.0 for IPv4-only. + bindHost: "0.0.0.0" maxProcessCount: 100 runCpuTime: 15000 runTimeout: 15000 diff --git a/tests/start-direct-sandbox.test.sh b/tests/start-direct-sandbox.test.sh new file mode 100644 index 0000000..77d7f56 --- /dev/null +++ b/tests/start-direct-sandbox.test.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -euo pipefail + +script="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/docker/start-direct-sandbox.sh" + +grep -Fq 'mount -o bind,ro "$ROOTFS/usr" /usr' "$script" +grep -Fq '[ -d /host-packages ] && [ "$(ls -A /host-packages 2>/dev/null)" ]' "$script" +grep -Fq 'using packages from $ROOTFS/pkgs' "$script" +if grep -Fq 'mount -o bind,ro "$ROOTFS/usr/sbin" /usr/sbin' "$script" \ + || grep -Fq 'mount -o bind,ro "$ROOTFS/usr/bin" /usr/bin' "$script"; then + echo "start-direct-sandbox.sh still bind-mounts merged /usr subdirectories" >&2 + exit 1 +fi + +echo "merged /usr mount contract passed"