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 <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
202ac7d4bd
commit
b7bce38131
@@ -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;
|
||||
}
|
||||
@@ -46,8 +46,8 @@ server {
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name ~^business\.(?<apex>.+)$;
|
||||
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\.(?<apex>.+)$;
|
||||
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 / {
|
||||
|
||||
Executable
+109
@@ -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"
|
||||
+90
-59
@@ -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',
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
+14
-7
@@ -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=<secret>
|
||||
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=<same secret>
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user