From d0f3c202eb9884a29d3432b684793d7006e0915d Mon Sep 17 00:00:00 2001 From: Marco Sadjadi Date: Mon, 25 May 2026 22:51:30 +0200 Subject: [PATCH] fix(tls): pivot per-runner TLS to path-routing on single subdomain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-subdomain approach (*.mcp.buildmymcpserver.com) failed at the Cloudflare edge — Universal SSL only covers ONE-level wildcards, so the TLS handshake on slug.mcp.buildmymcpserver.com hits SSL alert 40 handshake_failure. The two paths to fix that (CF Advanced Cert Manager at $10/mo, or a Let's-Encrypt wildcard via DNS-01 with certbot) both trade either money or ops for the URL aesthetic. Pivot to path-routing on the single subdomain mcp.buildmymcpserver.com, which IS covered by free Universal SSL. publicUrl format changes from https://.mcp.buildmymcpserver.com → https://mcp.buildmymcpserver.com/ No recurring cost, works with the existing CF setup, MCP clients don't care about the URL shape (it comes from the wizard's install snippet). Code changes: - generator/lib/deploy.ts: * publicUrl computed as `${MCP_DOMAIN}/${slug}` instead of `${slug}.${MCP_DOMAIN}` * writeRunnerMapEntry writes one-line nginx snippet: if ($bmm_slug = "") { set $bmm_port ; } (was: a map-entry pair ". ;") - setup-runner-tls.sh: * nginx vhost is now single server_name mcp.buildmymcpserver.com * regex location captures (?...)(?/.*)? * includes runner-map.combined inside the location block so the generated if-snippets set $bmm_port; unknown slug → 404 * proxy_pass strips the slug prefix: //foo → 127.0.0.1:port/foo * Prereq docs updated: just A-record for mcp (no wildcard needed), same Origin CA cert reused * Added /health endpoint at vhost root for monitoring Systemd watcher + map dir + volume mounts unchanged — same file paths, just different snippet content. Re-running setup-runner-tls.sh on the host overwrites the wildcard vhost with the new path-based one. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/generator/src/lib/deploy.ts | 25 ++++++++--- scripts/setup-runner-tls.sh | 75 ++++++++++++++++++++++---------- 2 files changed, 70 insertions(+), 30 deletions(-) diff --git a/apps/generator/src/lib/deploy.ts b/apps/generator/src/lib/deploy.ts index 9f378ce..8515b36 100644 --- a/apps/generator/src/lib/deploy.ts +++ b/apps/generator/src/lib/deploy.ts @@ -5,11 +5,19 @@ import { createDb, eq, isNotNull, mcpServers } from '@bmm/db'; import { config } from '../config.js'; /** - * Per-runner subdomain TLS support. When MCP_DOMAIN is set, the generator - * publishes each container under https://. via a host-side - * nginx that reads a slug→port map. The generator writes a tiny config - * fragment per server; a systemd inotify watcher combines them and reloads - * nginx. See scripts/setup-runner-tls.sh for the one-time host setup. + * 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 @@ -21,7 +29,10 @@ function runnerMapPath(slug: string): string { async function writeRunnerMapEntry(slug: string, port: number): Promise { if (!config.MCP_DOMAIN) return; - const line = `${slug}.${config.MCP_DOMAIN} ${port};\n`; + // 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'); @@ -42,7 +53,7 @@ async function removeRunnerMapEntry(slug: string): Promise { } function computePublicUrl(slug: string, port: number): string { - if (config.MCP_DOMAIN) return `https://${slug}.${config.MCP_DOMAIN}`; + if (config.MCP_DOMAIN) return `https://${config.MCP_DOMAIN}/${slug}`; return `http://${config.RUNNER_HOST}:${port}`; } diff --git a/scripts/setup-runner-tls.sh b/scripts/setup-runner-tls.sh index 73b78c9..310e2cd 100644 --- a/scripts/setup-runner-tls.sh +++ b/scripts/setup-runner-tls.sh @@ -1,8 +1,17 @@ #!/usr/bin/env bash # setup-runner-tls.sh # -# One-time host setup for per-runner subdomain TLS. Run as root on the BMM -# host AFTER you've done the prereqs below. Idempotent — safe to re-run. +# One-time host setup for per-runner TLS via path-routing on +# mcp.buildmymcpserver.com. Run as root on the BMM host AFTER you've done +# the prereqs below. Idempotent — safe to re-run. +# +# Why path-routing instead of per-subdomain: Cloudflare's free Universal +# SSL only covers ONE-level wildcards (*.buildmymcpserver.com). A +# two-level wildcard (*.mcp.buildmymcpserver.com) needs CF Advanced +# Certificate Manager ($10/mo) OR a Let's Encrypt wildcard via certbot +# + DNS-01. To keep recurring cost at zero, we serve every runner under +# the single subdomain mcp.buildmymcpserver.com — which IS covered by +# Universal SSL — and route by path: https://mcp..//mcp. # # What it does: # 1. Creates /opt/buildmymcpserver/runner-map/ (volume-mounted into bmm-api @@ -23,15 +32,17 @@ # - From now on every deployed runner gets https://.mcp.buildmymcpserver.com # # ─── PREREQS (do these in Cloudflare dashboard before running) ─────────── -# A. DNS: Add an A-record '*.mcp.buildmymcpserver.com' → 213.239.213.217 +# A. DNS: Add an A-record 'mcp.buildmymcpserver.com' → 213.239.213.217 # Proxy status: Proxied (orange cloud) +# (The wildcard *.mcp record from earlier setup is no longer +# needed for path-routing — safe to leave or delete.) # B. SSL: Cloudflare → SSL/TLS → Origin Server → Create Certificate -# Hostnames: *.mcp.buildmymcpserver.com, mcp.buildmymcpserver.com -# Save the .crt and .key to: -# /etc/ssl/buildmymcpserver/mcp-runners.crt (mode 644) -# /etc/ssl/buildmymcpserver/mcp-runners.key (mode 600, root:root) -# C. SSL mode: Cloudflare → SSL/TLS → Overview → set to "Full (strict)" -# (you've likely already set this for api.* — same setting) +# (likely already done — the same Origin cert from the prior +# setup attempt covers mcp.buildmymcpserver.com too, since +# you generated it with all four hostnames in the SAN list). +# Path: /etc/ssl/buildmymcpserver/mcp-runners.crt (644) +# /etc/ssl/buildmymcpserver/mcp-runners.key (600 root:root) +# C. SSL mode: Cloudflare → SSL/TLS → Overview → "Full (strict)" # # Run: sudo bash scripts/setup-runner-tls.sh set -euo pipefail @@ -70,34 +81,47 @@ chmod 644 "$COMBINED" echo "─── writing nginx vhost ──────────────────────────────────" cat > "$VHOST_DST" <<'NGINX' -# BMM per-runner subdomain proxy. Map file (slug→port) is regenerated by -# the bmm-api and bmm-generator containers; a systemd inotify watcher -# combines them into the included file and runs `nginx -s reload`. - -map $http_host $bmm_runner_port { - default 0; - include /opt/buildmymcpserver/runner-map.combined; -} +# BMM per-runner proxy via PATH routing on mcp.buildmymcpserver.com. +# The combined snippet file (regenerated by the bmm-api and bmm-generator +# containers + reloaded by the systemd inotify watcher) is a sequence of +# `if ($bmm_slug = "") { set $bmm_port ; }` lines that map the +# slug captured from the URL path to the local runner port. server { listen 80; listen [::]:80; listen 443 ssl http2; listen [::]:443 ssl http2; - server_name ~^(?[a-z0-9][a-z0-9-]*)\.mcp\.buildmymcpserver\.com$; + server_name mcp.buildmymcpserver.com; ssl_certificate /etc/ssl/buildmymcpserver/mcp-runners.crt; ssl_certificate_key /etc/ssl/buildmymcpserver/mcp-runners.key; client_max_body_size 4M; - # Unknown slugs land here — return 404 instead of a confusing default vhost. - if ($bmm_runner_port = 0) { - return 404; + # Cheap health probe for monitoring — doesn't go through the slug router. + location = /health { + return 200 "ok\n"; + add_header Content-Type text/plain; } - location / { - proxy_pass http://127.0.0.1:$bmm_runner_port; + # // → 127.0.0.1:/ + location ~ ^/(?[a-z0-9][a-z0-9-]*)(?/.*)?$ { + set $bmm_port ""; + include /opt/buildmymcpserver/runner-map.combined; + + # Unknown slug — return 404 instead of a confusing default. + if ($bmm_port = "") { + return 404; + } + + # Default sub-path to / when client asked for / with no trailing slash. + set $bmm_target_path $bmm_path; + if ($bmm_target_path = "") { + set $bmm_target_path "/"; + } + + proxy_pass http://127.0.0.1:$bmm_port$bmm_target_path; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; @@ -108,6 +132,11 @@ server { proxy_cache off; proxy_read_timeout 600s; } + + # Root of mcp.buildmymcpserver.com — nothing to serve here. + location = / { + return 404; + } } NGINX ln -sf "$VHOST_DST" "$VHOST_LNK"