From b7bce381315f7de0b77c13d6587f0a0ddbbf5db7 Mon Sep 17 00:00:00 2001 From: Alireza Hassani Date: Wed, 26 Aug 2026 01:20:03 +0330 Subject: [PATCH] Issue dashboard SSL per tenant instead of full cert sync. Per-row Issue SSL now runs ssl-issue-tenant.sh for business/customer hosts only, with nginx cert map support, so one broken tenant cannot block others. Co-authored-by: Cursor --- deploy/meshkee-dashboard-certs-map.conf | 9 ++ deploy/nginx-dashboards-ssl.conf | 8 +- deploy/ssl-issue-tenant.sh | 109 +++++++++++++++++ deploy/ssl-sync-agent.mjs | 149 ++++++++++++++---------- docs/DEPLOY.md | 21 ++-- 5 files changed, 226 insertions(+), 70 deletions(-) create mode 100644 deploy/meshkee-dashboard-certs-map.conf create mode 100755 deploy/ssl-issue-tenant.sh diff --git a/deploy/meshkee-dashboard-certs-map.conf b/deploy/meshkee-dashboard-certs-map.conf new file mode 100644 index 0000000..e3cad20 --- /dev/null +++ b/deploy/meshkee-dashboard-certs-map.conf @@ -0,0 +1,9 @@ +# Per-tenant dashboard cert paths (default → shared meshkee-dashboards cert). +# Tenant rows are appended by deploy/ssl-issue-tenant.sh when Issue SSL runs for one domain. +map $host $meshkee_dashboard_ssl_cert { + default /etc/letsencrypt/live/meshkee-dashboards/fullchain.pem; +} + +map $host $meshkee_dashboard_ssl_key { + default /etc/letsencrypt/live/meshkee-dashboards/privkey.pem; +} diff --git a/deploy/nginx-dashboards-ssl.conf b/deploy/nginx-dashboards-ssl.conf index 7523327..7485265 100644 --- a/deploy/nginx-dashboards-ssl.conf +++ b/deploy/nginx-dashboards-ssl.conf @@ -46,8 +46,8 @@ server { server { listen 443 ssl; server_name ~^business\.(?.+)$; - ssl_certificate /etc/letsencrypt/live/meshkee-dashboards/fullchain.pem; - ssl_certificate_key /etc/letsencrypt/live/meshkee-dashboards/privkey.pem; + ssl_certificate $meshkee_dashboard_ssl_cert; + ssl_certificate_key $meshkee_dashboard_ssl_key; ssl_protocols TLSv1.2 TLSv1.3; location / { @@ -59,8 +59,8 @@ server { server { listen 443 ssl; server_name ~^customer\.(?.+)$; - ssl_certificate /etc/letsencrypt/live/meshkee-dashboards/fullchain.pem; - ssl_certificate_key /etc/letsencrypt/live/meshkee-dashboards/privkey.pem; + ssl_certificate $meshkee_dashboard_ssl_cert; + ssl_certificate_key $meshkee_dashboard_ssl_key; ssl_protocols TLSv1.2 TLSv1.3; location / { diff --git a/deploy/ssl-issue-tenant.sh b/deploy/ssl-issue-tenant.sh new file mode 100755 index 0000000..72de4ff --- /dev/null +++ b/deploy/ssl-issue-tenant.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +# Issue / renew Let's Encrypt for one tenant's dashboard hosts only: +# business.{apex} + customer.{apex} +# Updates nginx cert map and reloads. Does NOT touch other domains. +set -euo pipefail + +CONF=/etc/meshkee/ssl-sync.env +# shellcheck disable=SC1090 +source "$CONF" + +APEX="${SSL_TENANT_APEX:-}" +if [[ -z "$APEX" ]]; then + echo "$(date -Is) ERROR: SSL_TENANT_APEX not set" + exit 1 +fi + +APEX="${APEX,,}" +CERT_NAME="${SSL_TENANT_CERT_NAME:-meshkee-dash-${APEX//./-}}" +EMAIL="${SSL_EMAIL:-info@meshkee.com}" +WEBROOT="${SSL_WEBROOT:-/var/www/certbot}" +MAP_FILE="${SSL_DASHBOARD_CERT_MAP:-/etc/nginx/conf.d/meshkee-dashboard-certs-map.conf}" + +BUSINESS_HOST="business.${APEX}" +CUSTOMER_HOST="customer.${APEX}" + +mkdir -p "$WEBROOT" + +echo "$(date -Is) Issuing tenant dashboard cert ${CERT_NAME} for ${BUSINESS_HOST} ${CUSTOMER_HOST}" + +certbot certonly \ + --webroot -w "$WEBROOT" \ + --cert-name "$CERT_NAME" \ + --email "$EMAIL" \ + --agree-tos \ + --non-interactive \ + -d "$BUSINESS_HOST" \ + -d "$CUSTOMER_HOST" + +CERT_DIR="/etc/letsencrypt/live/${CERT_NAME}" +if [[ ! -f "${CERT_DIR}/fullchain.pem" ]]; then + echo "$(date -Is) ERROR: cert not found at ${CERT_DIR}" + exit 1 +fi + +python3 - "$MAP_FILE" "$APEX" "$CERT_NAME" <<'PY' +import pathlib +import sys + +map_path = pathlib.Path(sys.argv[1]) +apex = sys.argv[2].strip().lower() +cert_name = sys.argv[3].strip() +cert_dir = f"/etc/letsencrypt/live/{cert_name}" +business = f"business.{apex}" +customer = f"customer.{apex}" + +default_cert = "/etc/letsencrypt/live/meshkee-dashboards/fullchain.pem" +default_key = "/etc/letsencrypt/live/meshkee-dashboards/privkey.pem" +tenant_cert = f"{cert_dir}/fullchain.pem" +tenant_key = f"{cert_dir}/privkey.pem" + +entries_cert: dict[str, str] = {"default": default_cert} +entries_key: dict[str, str] = {"default": default_key} + +if map_path.is_file(): + mode = None + for raw in map_path.read_text().splitlines(): + line = raw.strip() + if line.startswith("map $host $meshkee_dashboard_ssl_cert"): + mode = "cert" + continue + if line.startswith("map $host $meshkee_dashboard_ssl_key"): + mode = "key" + continue + if mode and line and not line.startswith("#") and " " in line: + host, path = line.split(None, 1) + host = host.rstrip(";") + path = path.rstrip(";") + if host != "default": + if mode == "cert": + entries_cert[host] = path + else: + entries_key[host] = path + +entries_cert[business] = tenant_cert +entries_cert[customer] = tenant_cert +entries_key[business] = tenant_key +entries_key[customer] = tenant_key + +def render_map(name: str, entries: dict[str, str]) -> str: + lines = [f"map $host ${name} {{"] + lines.append(f" default {entries['default']};") + for host in sorted(h for h in entries if h != "default"): + lines.append(f" {host} {entries[host]};") + lines.append("}") + return "\n".join(lines) + +content = ( + "# Managed by ssl-issue-tenant.sh — per-tenant dashboard TLS paths\n" + + render_map("meshkee_dashboard_ssl_cert", entries_cert) + + "\n\n" + + render_map("meshkee_dashboard_ssl_key", entries_key) + + "\n" +) +map_path.write_text(content) +print(f"Updated {map_path} for {business} / {customer}") +PY + +nginx -t && systemctl reload nginx +echo "$(date -Is) Tenant cert ${CERT_NAME} installed and nginx reloaded" diff --git a/deploy/ssl-sync-agent.mjs b/deploy/ssl-sync-agent.mjs index 6673eb9..37ab328 100644 --- a/deploy/ssl-sync-agent.mjs +++ b/deploy/ssl-sync-agent.mjs @@ -1,16 +1,19 @@ #!/usr/bin/env node /** * Tiny HTTP agent on the dashboards VPS. - * Super Admin → Nest API → POST here → runs ssl-sync.sh + * Super Admin → Nest API → POST here → runs ssl-sync.sh or ssl-issue-tenant.sh * * Env (/etc/meshkee/ssl-sync-agent.env): * SSL_SYNC_AGENT_TOKEN=... * SSL_SYNC_SCRIPT=/opt/meshkee/dashboards/deploy/ssl-sync.sh + * SSL_TENANT_SCRIPT=/opt/meshkee/dashboards/deploy/ssl-issue-tenant.sh * PORT=9051 * BIND=0.0.0.0 * * POST /ssl-sync - * Body (optional JSON): { "wait": true } + * Body (optional JSON): + * { "wait": true } — full dashboard cert sync (all tenants, cron / Sync SSL button) + * { "wait": true, "tenantApex": "example.com" } — only business./customer. for one domain * wait=false (default): accept and run in background → 202 * wait=true: run script and wait for exit → 200 / 500 */ @@ -21,6 +24,11 @@ import { accessSync, constants } from 'node:fs' const TOKEN = (process.env.SSL_SYNC_AGENT_TOKEN || '').trim() const SCRIPT = (process.env.SSL_SYNC_SCRIPT || '/opt/meshkee/dashboards/deploy/ssl-sync.sh').trim() +const TENANT_SCRIPT = + ( + process.env.SSL_TENANT_SCRIPT || + '/opt/meshkee/dashboards/deploy/ssl-issue-tenant.sh' + ).trim() const PORT = Number(process.env.PORT || 9051) const BIND = (process.env.BIND || '0.0.0.0').trim() @@ -29,11 +37,13 @@ if (!TOKEN) { process.exit(1) } -try { - accessSync(SCRIPT, constants.X_OK) -} catch { - console.error(`SSL sync script missing or not executable: ${SCRIPT}`) - process.exit(1) +for (const path of [SCRIPT, TENANT_SCRIPT]) { + try { + accessSync(path, constants.X_OK) + } catch { + console.error(`SSL script missing or not executable: ${path}`) + process.exit(1) + } } let running = false @@ -64,12 +74,58 @@ function readJson(req) { }) } -function startSyncDetached() { +function spawnSync(body, wait) { + const tenantApex = + typeof body.tenantApex === 'string' ? body.tenantApex.trim().toLowerCase() : '' + const script = tenantApex ? TENANT_SCRIPT : SCRIPT + const env = { ...process.env } + if (tenantApex) { + env.SSL_TENANT_APEX = tenantApex + } + + if (wait) { + return new Promise((resolve) => { + running = true + const child = spawn(script, [], { + stdio: ['ignore', 'pipe', 'pipe'], + env, + }) + let stdout = '' + let stderr = '' + child.stdout.on('data', (d) => { + stdout += d.toString() + }) + child.stderr.on('data', (d) => { + stderr += d.toString() + }) + child.on('error', (err) => { + running = false + resolve({ + ok: false, + code: 1, + log: err.message, + }) + }) + child.on('exit', (code, signal) => { + running = false + const log = `${stdout}${stderr}`.trim().slice(-4000) + console.log( + `${new Date().toISOString()} ssl-sync waited script=${script} code=${code} signal=${signal ?? ''}`, + ) + resolve({ + ok: code === 0, + code: code ?? 1, + log, + }) + }) + }) + } + running = true - const child = spawn(SCRIPT, [], { + const child = spawn(script, [], { detached: true, stdio: 'ignore', - env: process.env, + env, }) child.on('error', (err) => { console.error(`${new Date().toISOString()} spawn error:`, err.message) @@ -77,49 +133,12 @@ function startSyncDetached() { }) child.on('exit', (code, signal) => { console.log( - `${new Date().toISOString()} ssl-sync finished code=${code} signal=${signal ?? ''}`, + `${new Date().toISOString()} ssl-sync finished script=${script} code=${code} signal=${signal ?? ''}`, ) running = false }) child.unref() -} - -function runSyncAndWait() { - return new Promise((resolve) => { - running = true - const child = spawn(SCRIPT, [], { - stdio: ['ignore', 'pipe', 'pipe'], - env: process.env, - }) - let stdout = '' - let stderr = '' - child.stdout.on('data', (d) => { - stdout += d.toString() - }) - child.stderr.on('data', (d) => { - stderr += d.toString() - }) - child.on('error', (err) => { - running = false - resolve({ - ok: false, - code: 1, - log: err.message, - }) - }) - child.on('exit', (code, signal) => { - running = false - const log = `${stdout}${stderr}`.trim().slice(-4000) - console.log( - `${new Date().toISOString()} ssl-sync waited code=${code} signal=${signal ?? ''}`, - ) - resolve({ - ok: code === 0, - code: code ?? 1, - log, - }) - }) - }) + return null } const server = createServer(async (req, res) => { @@ -148,30 +167,42 @@ const server = createServer(async (req, res) => { } const wait = body && body.wait === true + const tenantApex = + typeof body.tenantApex === 'string' ? body.tenantApex.trim().toLowerCase() : '' if (wait) { - console.log(`${new Date().toISOString()} ssl-sync wait start`) - const result = await runSyncAndWait() - if (!result.ok) { + console.log( + `${new Date().toISOString()} ssl-sync wait start tenantApex=${tenantApex || '(all)'}`, + ) + const result = await spawnSync(body, true) + if (!result?.ok) { return json(res, 500, { status: 'failed', - message: 'SSL sync script failed', - code: result.code, - log: result.log, + message: tenantApex + ? 'Tenant dashboard SSL issue failed' + : 'SSL sync script failed', + code: result?.code ?? 1, + log: result?.log ?? '', }) } return json(res, 200, { status: 'ok', - message: 'SSL sync completed', + message: tenantApex + ? `Tenant dashboard SSL issued for ${tenantApex}` + : 'SSL sync completed', log: result.log, }) } - startSyncDetached() - console.log(`${new Date().toISOString()} ssl-sync accepted`) + spawnSync(body, false) + console.log( + `${new Date().toISOString()} ssl-sync accepted tenantApex=${tenantApex || '(all)'}`, + ) return json(res, 202, { status: 'accepted', - message: 'SSL sync started', + message: tenantApex + ? `Tenant dashboard SSL started for ${tenantApex}` + : 'SSL sync started', }) }) diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index c3c4ec3..f73d7f4 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -28,18 +28,20 @@ Cron (root): Small Node agent on the dashboards VPS; Nest calls it after the button is clicked. -1. Files under `/opt/meshkee/dashboards/deploy/`: `ssl-sync.sh`, `ssl-sync-agent.mjs`, `meshkee-ssl-sync-agent.service` -2. Make the script executable: `chmod +x /opt/meshkee/dashboards/deploy/ssl-sync.sh` -3. Agent env `/etc/meshkee/ssl-sync-agent.env`: +1. Files under `/opt/meshkee/dashboards/deploy/`: `ssl-sync.sh`, `ssl-issue-tenant.sh`, `ssl-sync-agent.mjs`, `meshkee-ssl-sync-agent.service`, `meshkee-dashboard-certs-map.conf`, `nginx-dashboards-ssl.conf` +2. Make scripts executable: `chmod +x /opt/meshkee/dashboards/deploy/ssl-sync.sh /opt/meshkee/dashboards/deploy/ssl-issue-tenant.sh` +3. Install nginx cert map (http context): `cp /opt/meshkee/dashboards/deploy/meshkee-dashboard-certs-map.conf /etc/nginx/conf.d/` — business/customer server blocks must use `$meshkee_dashboard_ssl_cert` / `$meshkee_dashboard_ssl_key` (see `nginx-dashboards-ssl.conf`) +4. Agent env `/etc/meshkee/ssl-sync-agent.env`: ``` SSL_SYNC_AGENT_TOKEN= SSL_SYNC_SCRIPT=/opt/meshkee/dashboards/deploy/ssl-sync.sh +SSL_TENANT_SCRIPT=/opt/meshkee/dashboards/deploy/ssl-issue-tenant.sh PORT=9051 BIND=0.0.0.0 ``` -4. Install + start: +5. Install + start: ```bash cp /opt/meshkee/dashboards/deploy/meshkee-ssl-sync-agent.service /etc/systemd/system/ @@ -55,11 +57,16 @@ SSL_SYNC_AGENT_URL=http://45.149.76.52:9051/ssl-sync SSL_SYNC_AGENT_TOKEN= ``` -Endpoint used by the UI: `POST /api/v1/domains/ssl-sync` (super-admin JWT). +Endpoint used by the UI: `POST /api/v1/domains/ssl-sync` (super-admin JWT) — runs **full** `ssl-sync.sh` (all dashboard hosts + shared `meshkee-dashboards` cert). Use for cron and the global **Sync SSL** button. -Body on the agent (optional): `{ "wait": true }` — run `ssl-sync.sh` and wait for exit (used by per-row Ensure SSL). Default is fire-and-forget `202`. +Agent body: -Per-row Ensure SSL: `POST /api/v1/domains/:id/issue-ssl` probes `host`, `business.host`, `customer.host` and issues only failures. +| Action | Body | +|--------|------| +| Global sync (cron / Sync SSL) | `{ "wait": false }` or `{ "wait": true }` | +| Per-row Issue SSL (dashboard hosts only) | `{ "wait": true, "tenantApex": "example.com" }` → runs `ssl-issue-tenant.sh` for `business.example.com` + `customer.example.com` only | + +Per-row **Issue SSL**: `POST /api/v1/domains/:id/issue-ssl` probes apex, www, business, and customer. Storefront (apex/www) is issued on the **websites** VM for that domain only. Dashboard hosts use the per-tenant script above — other tenants are not validated. ## Redeploy frontends