fix(tls): pivot per-runner TLS to path-routing on single subdomain
All checks were successful
Deploy to Production / deploy (push) Successful in 54s
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:
parent
8c6f04f034
commit
d0f3c202eb
@ -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}`;
|
||||
}
|
||||
|
||||
|
||||
@ -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../<slug>/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://<slug>.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 = "<slug>") { set $bmm_port <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 ~^(?<bmm_slug>[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) {
|
||||
# Cheap health probe for monitoring — doesn't go through the slug router.
|
||||
location = /health {
|
||||
return 200 "ok\n";
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
|
||||
# /<slug>/<rest> → 127.0.0.1:<port>/<rest>
|
||||
location ~ ^/(?<bmm_slug>[a-z0-9][a-z0-9-]*)(?<bmm_path>/.*)?$ {
|
||||
set $bmm_port "";
|
||||
include /opt/buildmymcpserver/runner-map.combined;
|
||||
|
||||
# Unknown slug — return 404 instead of a confusing default.
|
||||
if ($bmm_port = "") {
|
||||
return 404;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:$bmm_runner_port;
|
||||
# Default sub-path to / when client asked for /<slug> 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"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user