import fs from 'node:fs/promises'; import net from 'node:net'; import path from 'node:path'; import { createDb, eq, isNotNull, mcpServers } from '@bmm/db'; import { config } from '../config.js'; /** * Per-runner TLS via path-routing on mcp.buildmymcpserver.com. When * MCP_DOMAIN is set, the generator publishes each container at * https:/// * and writes a one-line nginx snippet per server into RUNNER_MAP_DIR. * A host-side systemd inotify watcher combines the snippets into a single * file that the nginx vhost includes inside its location block, mapping * the captured slug to its local runner port. * * Path-routing (instead of per-subdomain) is the bootstrap-friendly choice: * mcp.buildmymcpserver.com is covered by Cloudflare's free Universal SSL, * whereas *.mcp.buildmymcpserver.com would need CF Advanced Cert Manager * ($10/mo) or a custom Let's-Encrypt wildcard via DNS-01 (free but more * ops). See scripts/setup-runner-tls.sh for the one-time host setup. * * If MCP_DOMAIN is unset, both the URL formatter and the map writer no-op * and we fall back to the legacy http://host:port URL — zero behaviour * change without the host-side infra in place. */ function runnerMapPath(slug: string): string { return path.join(config.RUNNER_MAP_DIR, `${slug}.conf`); } async function writeRunnerMapEntry(slug: string, port: number): Promise { if (!config.MCP_DOMAIN) return; // nginx snippet — included inside a `location ~` block that captures // $bmm_slug. Each runner contributes one line; the systemd watcher // concatenates them into /opt/buildmymcpserver/runner-map.combined. const line = `if ($bmm_slug = "${slug}") { set $bmm_port ${port}; }\n`; try { await fs.mkdir(config.RUNNER_MAP_DIR, { recursive: true }); await fs.writeFile(runnerMapPath(slug), line, 'utf8'); } catch (err) { // Don't fail the deploy if the map dir isn't mounted yet — runner still // serves on http://host:port and the user can manually proxy. console.warn(`[runner-tls] could not write map entry for ${slug}:`, err); } } async function removeRunnerMapEntry(slug: string): Promise { if (!config.MCP_DOMAIN) return; try { await fs.rm(runnerMapPath(slug), { force: true }); } catch { // Idempotent — missing file is fine. } } export function computePublicUrl(slug: string, port: number): string { if (config.MCP_DOMAIN) return `https://${config.MCP_DOMAIN}/${slug}`; return `http://${config.RUNNER_HOST}:${port}`; } /** * Container hardening flags applied on every runner deployment on Linux * production hosts. Skipped only when explicitly disabled (dev/Windows * Docker Desktop, which doesn't fully honour --read-only on bind mounts). * * Without these, a tenant container runs as root with full capabilities on * the shared host — combined with the LLM static-check being a regex * blacklist (Z2-001), this would let a malicious tenant execute arbitrary * code on the host. With them, the blast radius collapses to "within the * container", which holds only that tenant's own decrypted secrets. */ const HARDENING_FLAGS = [ '--read-only', '--cap-drop=ALL', '--security-opt=no-new-privileges:true', '--pids-limit=100', '--memory=512m', '--memory-swap=512m', '--cpus=0.5', // /tmp needs writable space — runner-template uses it for build/cache. '--tmpfs=/tmp:rw,nosuid,nodev,size=64m', ]; function shouldHarden(): boolean { // Explicit opt-out for local dev on Windows where --read-only conflicts // with how Docker Desktop binds volumes. Production must always harden. if (process.env.RUNNER_DISABLE_HARDENING === '1') return false; const env = process.env.NODE_ENV; return env === 'production' || env === 'staging'; } const db = createDb(); async function portFree(port: number, host = '127.0.0.1'): Promise { return new Promise((resolve) => { const tester = net .createServer() .once('error', () => resolve(false)) .once('listening', () => tester.close(() => resolve(true))) .listen(port, host); }); } export async function allocatePort(): Promise { const used = new Set( ( await db .select({ port: mcpServers.hostPort }) .from(mcpServers) .where(isNotNull(mcpServers.hostPort)) ) .map((r) => r.port) .filter((p): p is number => typeof p === 'number'), ); for (let port = config.RUNNER_PORT_RANGE_START; port <= config.RUNNER_PORT_RANGE_END; port++) { if (used.has(port)) continue; if (await portFree(port)) return port; } throw new Error('no_free_port'); } export interface DeployHandle { containerId: string; publicUrl: string; hostPort: number; } export interface DeployInput { serverId: string; slug: string; hostPort: number; imageTag: string; envVars: Record; } export async function deployContainer(input: DeployInput): Promise { // Docker CLI is portable across linux/mac/win — sufficient for now; future // iteration will switch to the engine API via UNIX socket. const { spawn } = await import('node:child_process'); const containerName = `bmm-mcp-${input.slug}-${Date.now().toString(36)}`; const args = [ 'run', '-d', '--name', containerName, '-p', `${input.hostPort}:3000`, ]; if (shouldHarden()) { args.push(...HARDENING_FLAGS); } for (const [k, v] of Object.entries(input.envVars)) { args.push('-e', `${k}=${v}`); } args.push('--restart=unless-stopped', input.imageTag); return await new Promise((resolve, reject) => { const child = spawn('docker', args, { stdio: ['ignore', 'pipe', 'pipe'] }); let out = ''; let err = ''; child.stdout.on('data', (d) => { out += d.toString(); }); child.stderr.on('data', (d) => { err += d.toString(); }); child.on('error', (e) => reject(e)); child.on('close', async (code) => { if (code !== 0) { reject(new Error(`docker_run_failed (exit ${code}): ${err.trim() || out.trim()}`)); return; } const containerId = out.trim().slice(0, 64); const publicUrl = computePublicUrl(input.slug, input.hostPort); // Drop the nginx map fragment BEFORE persisting publicUrl so the // user-visible URL is reachable by the time the wizard polls "live". await writeRunnerMapEntry(input.slug, input.hostPort); await db .update(mcpServers) .set({ containerId, hostPort: input.hostPort, publicUrl, status: 'live', updatedAt: new Date(), }) .where(eq(mcpServers.id, input.serverId)); resolve({ containerId, publicUrl, hostPort: input.hostPort }); }); }); } export async function stopContainer( containerId: string, slug?: string, ): Promise<{ ok: boolean; detail: string }> { if (!containerId || containerId.length < 4) { return { ok: false, detail: 'invalid_container_id' }; } // Remove the nginx map fragment first so the slug stops serving 502 from // the proxy as soon as the container goes down. Idempotent — called // multiple times with the same slug is fine. if (slug) await removeRunnerMapEntry(slug); const { spawn } = await import('node:child_process'); return await new Promise<{ ok: boolean; detail: string }>((resolve) => { const child = spawn('docker', ['rm', '-f', containerId], { stdio: ['ignore', 'pipe', 'pipe'], }); let err = ''; child.stderr?.on('data', (d: Buffer) => { err += d.toString(); }); child.on('error', () => resolve({ ok: false, detail: 'spawn_failed' })); child.on('close', (code) => resolve(code === 0 ? { ok: true, detail: '' } : { ok: false, detail: err.trim() || `exit ${code}` }), ); }); } export async function dockerAvailable(): Promise { const { spawn } = await import('node:child_process'); return await new Promise((resolve) => { const child = spawn('docker', ['version'], { stdio: 'ignore' }); child.on('error', () => resolve(false)); child.on('close', (code) => resolve(code === 0)); }); }