fix(tls): pivot per-runner TLS to path-routing on single subdomain
All checks were successful
Deploy to Production / deploy (push) Successful in 54s

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://<slug>.mcp.buildmymcpserver.com  →  https://mcp.buildmymcpserver.com/<slug>
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 = "<slug>") { set $bmm_port <port>; }
      (was: a map-entry pair "<slug>.<MCP_DOMAIN> <port>;")
- setup-runner-tls.sh:
    * nginx vhost is now single server_name mcp.buildmymcpserver.com
    * regex location captures (?<bmm_slug>...)(?<bmm_path>/.*)?
    * 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: /<slug>/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) <noreply@anthropic.com>
This commit is contained in:
Marco Sadjadi
2026-05-25 22:51:30 +02:00
parent 8c6f04f034
commit d0f3c202eb
2 changed files with 70 additions and 30 deletions

View File

@@ -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://<slug>.<MCP_DOMAIN> 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://<MCP_DOMAIN>/<slug>
* 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<void> {
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<void> {
}
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}`;
}