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>
212 lines
5.6 KiB
JavaScript
212 lines
5.6 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Tiny HTTP agent on the dashboards VPS.
|
|
* 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 } — 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
|
|
*/
|
|
import { createServer } from 'node:http'
|
|
import { spawn } from 'node:child_process'
|
|
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()
|
|
|
|
if (!TOKEN) {
|
|
console.error('SSL_SYNC_AGENT_TOKEN is required')
|
|
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
|
|
|
|
function json(res, status, body) {
|
|
const payload = JSON.stringify(body)
|
|
res.writeHead(status, {
|
|
'Content-Type': 'application/json',
|
|
'Content-Length': Buffer.byteLength(payload),
|
|
})
|
|
res.end(payload)
|
|
}
|
|
|
|
function readJson(req) {
|
|
return new Promise((resolve, reject) => {
|
|
const chunks = []
|
|
req.on('data', (c) => chunks.push(c))
|
|
req.on('end', () => {
|
|
const raw = Buffer.concat(chunks).toString('utf8').trim()
|
|
if (!raw) return resolve({})
|
|
try {
|
|
resolve(JSON.parse(raw))
|
|
} catch (err) {
|
|
reject(err)
|
|
}
|
|
})
|
|
req.on('error', reject)
|
|
})
|
|
}
|
|
|
|
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, [], {
|
|
detached: true,
|
|
stdio: 'ignore',
|
|
env,
|
|
})
|
|
child.on('error', (err) => {
|
|
console.error(`${new Date().toISOString()} spawn error:`, err.message)
|
|
running = false
|
|
})
|
|
child.on('exit', (code, signal) => {
|
|
console.log(
|
|
`${new Date().toISOString()} ssl-sync finished script=${script} code=${code} signal=${signal ?? ''}`,
|
|
)
|
|
running = false
|
|
})
|
|
child.unref()
|
|
return null
|
|
}
|
|
|
|
const server = createServer(async (req, res) => {
|
|
if (req.method === 'GET' && req.url === '/health') {
|
|
return json(res, 200, { ok: true, running })
|
|
}
|
|
|
|
if (req.method !== 'POST' || req.url !== '/ssl-sync') {
|
|
return json(res, 404, { error: 'not found' })
|
|
}
|
|
|
|
const provided = String(req.headers['x-ssl-sync-agent-token'] ?? '').trim()
|
|
if (!provided || provided !== TOKEN) {
|
|
return json(res, 401, { error: 'unauthorized' })
|
|
}
|
|
|
|
if (running) {
|
|
return json(res, 409, { error: 'ssl sync already running' })
|
|
}
|
|
|
|
let body = {}
|
|
try {
|
|
body = await readJson(req)
|
|
} catch {
|
|
return json(res, 400, { error: 'invalid json' })
|
|
}
|
|
|
|
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 tenantApex=${tenantApex || '(all)'}`,
|
|
)
|
|
const result = await spawnSync(body, true)
|
|
if (!result?.ok) {
|
|
return json(res, 500, {
|
|
status: 'failed',
|
|
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: tenantApex
|
|
? `Tenant dashboard SSL issued for ${tenantApex}`
|
|
: 'SSL sync completed',
|
|
log: result.log,
|
|
})
|
|
}
|
|
|
|
spawnSync(body, false)
|
|
console.log(
|
|
`${new Date().toISOString()} ssl-sync accepted tenantApex=${tenantApex || '(all)'}`,
|
|
)
|
|
return json(res, 202, {
|
|
status: 'accepted',
|
|
message: tenantApex
|
|
? `Tenant dashboard SSL started for ${tenantApex}`
|
|
: 'SSL sync started',
|
|
})
|
|
})
|
|
|
|
server.listen(PORT, BIND, () => {
|
|
console.log(`ssl-sync-agent listening on ${BIND}:${PORT}`)
|
|
})
|