// lib/paas.js — spawn helpers around the paas CLI, PM2 and the per-app // shared/.env files. The paas CLI is the only writer of server state // (Caddy/Varnish/PM2/releases); the panel only shells out to it. // All spawns use array args with shell:false — the single deliberate // exception (bash -lc for the admin-authored build_cmd) lives in queue.js. import { spawn } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import { config, NAME_RE } from './config.js'; export function runCmd(cmd, args, opts = {}) { const { timeout = 0, env, cwd, onData, input } = opts; return new Promise((resolve) => { let stdout = ''; let stderr = ''; let output = ''; let timedOut = false; let child; try { child = spawn(cmd, args, { cwd, env: env || process.env, shell: false, stdio: [input === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'], }); } catch (err) { const s = String(err); resolve({ code: -1, stdout: '', stderr: s, output: s, timedOut: false }); return; } if (input !== undefined && child.stdin) { child.stdin.write(input); child.stdin.end(); } let timer = null; if (timeout > 0) { timer = setTimeout(() => { timedOut = true; try { child.kill('SIGKILL'); } catch { /* already gone */ } }, timeout); } child.stdout.on('data', (d) => { const s = d.toString('utf8'); stdout += s; output += s; if (onData) onData(s); }); child.stderr.on('data', (d) => { const s = d.toString('utf8'); stderr += s; output += s; if (onData) onData(s); }); child.on('error', (err) => { const s = `\n${err}\n`; stderr += s; output += s; if (onData) onData(s); }); child.on('close', (code) => { if (timer) clearTimeout(timer); if (timedOut) { const s = `\n[panel] command timed out after ${timeout}ms\n`; stderr += s; output += s; if (onData) onData(s); } resolve({ code: code === null ? -1 : code, stdout, stderr, output, timedOut }); }); }); } // ---- paas CLI ---- export function paasCreate(app, { domains = [], cache = false, noindex = false, startCmd = '', nodeVersion = '', runtime = 'node', phpVersion = '', health = '' } = {}) { const args = ['create', app]; // domains are optional — paas generates an always-available system domain if (domains.length) args.push('--domains', domains.join(',')); if (cache) args.push('--cache'); if (noindex) args.push('--noindex'); if (startCmd && runtime !== 'laravel') args.push('--start', startCmd); if (nodeVersion && nodeVersion !== 'system') args.push('--node', nodeVersion); if (runtime === 'laravel') { args.push('--runtime', 'laravel'); if (phpVersion) args.push('--php', phpVersion); } // only when explicitly chosen — paas picks the runtime default (/ or /up) otherwise if (health && health.startsWith('/')) args.push('--health', health); return runCmd(config.paasBin, args, { timeout: 120000 }); } // No timeout by design — paas deploy health-gates and can take a while. // With DEPLOY_ID set, paas updates that deployments row itself. export function paasDeploy(app, builtOutputDir, { deployId, source = 'panel', commit = '', onData } = {}) { const env = { ...process.env, DEPLOY_ID: String(deployId), PAAS_SOURCE: String(source || ''), PAAS_COMMIT: String(commit || ''), }; return runCmd(config.paasBin, ['deploy', app, builtOutputDir], { env, onData }); } export function paasRollback(app) { return runCmd(config.paasBin, ['rollback', app], { timeout: 60000 }); } export function paasRemove(app) { return runCmd(config.paasBin, ['remove', app, '--yes', '--purge'], { timeout: 60000 }); } export function paasBan(app) { return runCmd(config.paasBin, ['ban', app], { timeout: 30000 }); } export function paasWarm(app) { return runCmd(config.paasBin, ['warm', app], { timeout: 120000 }); } export function paasDomains(app, args) { return runCmd(config.paasBin, ['domains', app, ...args], { timeout: 60000 }); } // Enumerate the paas registry directly (covers CLI-created apps without a // panel project row). Used by the /cmk-sites endpoint for website monitoring. export function listServedSites() { const regDir = '/srv/paas/apps.d'; const sites = []; let files = []; try { files = fs.readdirSync(regDir).filter((f) => f.endsWith('.conf')); } catch { return sites; } for (const f of files) { try { const raw = fs.readFileSync(path.join(regDir, f), 'utf8'); const kv = {}; for (const line of raw.split('\n')) { const m = /^([A-Z_]+)=("?)(.*)\2$/.exec(line.trim()); if (m) kv[m[1]] = m[3]; } const domains = [ ...String(kv.DOMAINS || '').split(',').map((s) => s.trim()).filter(Boolean), ...(kv.SYSTEM_DOMAIN ? [kv.SYSTEM_DOMAIN.trim()] : []), ]; if (String(kv.ENABLED || 'on') === 'off') continue; // deactivated: no public monitoring if (String(kv.BASIC_AUTH_USER || '').trim()) continue; // protected: external checks would 401 const declaredMain = String(kv.MAIN_DOMAIN || '').trim(); const main = domains.includes(declaredMain) ? declaredMain : domains[0]; if (kv.APP && domains.length) sites.push({ app: kv.APP, main, domains }); } catch { /* skip unreadable conf */ } } return sites; } // Access flags straight from the registry — display only, never cached so // the dashboard always reflects what Caddy actually serves. export function appAccessFlags(app) { try { const raw = fs.readFileSync(`/srv/paas/apps.d/${app}.conf`, 'utf8'); const kv = {}; for (const line of raw.split('\n')) { const m = /^([A-Z_]+)=("?)(.*)\2$/.exec(line.trim()); if (m) kv[m[1]] = m[3]; } return { enabled: String(kv.ENABLED || 'on') !== 'off', protected: Boolean(String(kv.BASIC_AUTH_USER || '').trim()), }; } catch { return { enabled: true, protected: false }; } } // Installed selectable Node versions (/opt/node/vNN); 'system' is always first. export function nodeVersions() { const versions = ['system']; try { for (const d of fs.readdirSync('/opt/node')) { const m = /^v(\d+)$/.exec(d); if (m) versions.push(m[1]); } } catch { /* dir absent on dev machines */ } return versions; } export function paasEnable(app, on) { return runCmd(config.paasBin, [on ? 'enable' : 'disable', app], { timeout: 60000 }); } /** flags: { cache: 'on'|'off'|undefined, noindex: 'on'|'off'|undefined, * health: '/path'|undefined, warmSitemap: '/path'|undefined, * geoExemptGoogle: 'on'|'off'|undefined } */ export function paasSet(app, flags = {}) { const args = ['set', app]; if (flags.cache === 'on' || flags.cache === 'off') args.push('--cache', flags.cache); if (flags.noindex === 'on' || flags.noindex === 'off') args.push('--noindex', flags.noindex); if (typeof flags.health === 'string' && flags.health.startsWith('/')) args.push('--health', flags.health); if (typeof flags.warmSitemap === 'string') args.push('--warm-sitemap', flags.warmSitemap); if (flags.geoExemptGoogle === 'on' || flags.geoExemptGoogle === 'off') args.push('--geo-exempt-google', flags.geoExemptGoogle); // laravel-only switches for (const [k, flag] of [['migrate', '--migrate'], ['queue', '--queue'], ['scheduler', '--scheduler']]) { if (flags[k] === 'on' || flags[k] === 'off') args.push(flag, flags[k]); } if (/^\d{1,3}$/.test(String(flags.maxChildren || ''))) args.push('--max-children', String(flags.maxChildren)); return runCmd(config.paasBin, args, { timeout: 120000 }); } // ---- runtimes / server capabilities ---- function parseKv(file) { const kv = {}; try { for (const line of fs.readFileSync(file, 'utf8').split('\n')) { const m = /^([A-Z_]+)=("?)(.*)\2$/.exec(line.trim()); if (m) kv[m[1]] = m[3]; } } catch { /* absent */ } return kv; } // Installed selectable PHP versions (/srv/paas/php/bin/, written by // bootstrap WITH_LARAVEL=1). Empty on node-only servers. export function phpVersions() { const versions = []; try { for (const d of fs.readdirSync('/srv/paas/php/bin')) { if (/^\d+\.\d+$/.test(d)) versions.push(d); } } catch { /* node-only server or dev machine */ } return versions.sort((a, b) => parseFloat(a) - parseFloat(b)); } // What this server can run — gates the Laravel runtime option and the // Database card. Read fresh (cheap) so a bootstrap re-run shows up without // a panel restart. export function capabilities() { const conf = parseKv('/srv/paas/paas.conf'); const runtimes = String(conf.RUNTIMES || 'node').split(',').map((s) => s.trim()).filter(Boolean); const php = phpVersions(); return { runtimes, laravel: runtimes.includes('laravel') && php.length > 0, db: fs.existsSync('/srv/paas/db-admin.cnf'), phpVersions: php, phpDefault: conf.PHP_DEFAULT || php[0] || '', }; } // Laravel app process state without spawning anything: the fpm pool socket // exists iff the master loaded the pool. (Service liveness is the checkmk // job; for the dashboard chip the socket is the right signal.) export function fpmState(app) { if (!NAME_RE.test(app)) return 'unknown'; try { return fs.statSync(`/run/php/paas-${app}.sock`).isSocket() ? 'online' : 'stopped'; } catch { return 'stopped'; } } export function paasRestart(app) { return runCmd(config.paasBin, ['restart', app], { timeout: 180000 }); } export function paasPhpSet(app, version) { return runCmd(config.paasBin, ['php', app, '--set', version], { timeout: 60000 }); } /** action: create | show | drop | backup (drop passes --yes; create may pass forceEnv) */ export function paasDb(app, action, { forceEnv = false } = {}) { const args = ['db', action, app]; if (action === 'drop') args.push('--yes'); if (action === 'create' && forceEnv) args.push('--force-env'); return runCmd(config.paasBin, args, { timeout: 600000 }); } // Database credentials as provisioned by `paas db create` (from shared/.env). export function dbInfoFromEnv(app) { const env = parseEnv(readAppEnv(app)); const expected = app.replace(/-/g, '_'); if (!env.DB_DATABASE) return null; return { database: env.DB_DATABASE, username: env.DB_USERNAME || '', password: env.DB_PASSWORD || '', host: env.DB_HOST || '127.0.0.1', port: env.DB_PORT || '3306', managed: env.DB_DATABASE === expected && (env.DB_HOST || '127.0.0.1') === '127.0.0.1', url: `mysql://${env.DB_USERNAME || ''}:${env.DB_PASSWORD || ''}@${env.DB_HOST || '127.0.0.1'}:${env.DB_PORT || '3306'}/${env.DB_DATABASE}`, }; } // Laravel runtime logs: app log (single or daily channel) + the pool's // php-fpm error log + the scheduler log, newest first, tail of each. export function readRuntimeLog(app, lines = 200) { if (!NAME_RE.test(app)) return ''; const dir = path.join(config.appsDir, app, 'shared', 'storage', 'logs'); let files = []; try { files = fs.readdirSync(dir) .filter((f) => /^(laravel(-\d{4}-\d{2}-\d{2})?|php-fpm|scheduler)\.log$/.test(f)) .map((f) => ({ f, m: fs.statSync(path.join(dir, f)).mtimeMs })) .sort((a, b) => b.m - a.m) .slice(0, 4) .map((x) => x.f); } catch { return ''; } const out = []; for (const f of files) { try { const text = fs.readFileSync(path.join(dir, f), 'utf8'); const tail = text.split('\n').slice(-lines).join('\n'); out.push(`==> ${f} <==\n${tail}`); } catch { /* skip */ } } return out.join('\n\n'); } export function paasProtect(app, user, password) { return runCmd(config.paasBin, ['protect', app, '--user', user, '--password', password], { timeout: 60000 }); } export function paasUnprotect(app) { return runCmd(config.paasBin, ['protect', app, '--off'], { timeout: 60000 }); } export function paasNodeSet(app, version) { return runCmd(config.paasBin, ['node', app, '--set', version], { timeout: 30000 }); } export function paasSsl(app, domain) { const args = ['ssl', app]; if (domain) args.push(domain); return runCmd(config.paasBin, args, { timeout: 90000 }); } export function paasGeoSet(app, rulesJson) { return runCmd(config.paasBin, ['geo', app, '--set-json', '-'], { timeout: 60000, input: rulesJson }); } export function readGeoRules(app) { if (!NAME_RE.test(app)) return []; try { const raw = fs.readFileSync(path.join('/srv/apps', app, 'shared', 'geo.json'), 'utf8'); const parsed = JSON.parse(raw); return Array.isArray(parsed.rules) ? parsed.rules : []; } catch { return []; } } export function paasDomainRulesSet(app, rulesJson) { return runCmd(config.paasBin, ['domain-rules', app, '--set-json', '-'], { timeout: 60000, input: rulesJson }); } export function readDomainRules(app) { if (!NAME_RE.test(app)) return []; try { const raw = fs.readFileSync(path.join('/srv/apps', app, 'shared', 'domain-rules.json'), 'utf8'); const parsed = JSON.parse(raw); return Array.isArray(parsed.rules) ? parsed.rules : []; } catch { return []; } } // `paas info ` prints KEY=VALUE lines (APP, PORT, DOMAINS, CACHE, START_CMD, NOINDEX). export async function paasInfo(app) { const res = await runCmd(config.paasBin, ['info', app], { timeout: 10000 }); if (res.code !== 0) return null; const info = {}; for (const line of res.stdout.split('\n')) { const m = /^([A-Z_]+)=(.*)$/.exec(line.trim()); if (m) info[m[1]] = m[2]; } return info; } const infoCache = new Map(); // app -> { at, data } export async function paasInfoCached(app, ttlMs = 30000) { const hit = infoCache.get(app); if (hit && Date.now() - hit.at < ttlMs) return hit.data; const data = await paasInfo(app); infoCache.set(app, { at: Date.now(), data }); return data; } // ---- PM2 ---- let pm2Cache = { at: 0, data: null }; // name -> status ('online' | 'stopped' | 'errored' | ...), cached ~5s. export async function pm2Status() { if (pm2Cache.data && Date.now() - pm2Cache.at < 5000) return pm2Cache.data; const res = await runCmd('pm2', ['jlist'], { timeout: 10000 }); const data = {}; if (res.code === 0) { try { const idx = res.stdout.indexOf('['); const list = JSON.parse(idx >= 0 ? res.stdout.slice(idx) : res.stdout); for (const proc of list) { if (proc && proc.name) data[proc.name] = (proc.pm2_env && proc.pm2_env.status) || 'unknown'; } } catch { /* leave map empty on parse failure */ } } pm2Cache = { at: Date.now(), data }; return data; } export function pm2Logs(app) { return runCmd('pm2', ['logs', app, '--lines', '200', '--nostream'], { timeout: 10000 }); } export function pm2Restart(app) { return runCmd('pm2', ['restart', app, '--update-env'], { timeout: 30000 }); } // ---- app env files: /srv/apps//shared/.env ---- export function appEnvPath(app) { if (!NAME_RE.test(app)) throw new Error(`invalid app name: ${app}`); return path.join(config.appsDir, app, 'shared', '.env'); } export function readAppEnv(app) { try { return fs.readFileSync(appEnvPath(app), 'utf8'); } catch { return ''; } } export function writeAppEnv(app, content) { const file = appEnvPath(app); fs.mkdirSync(path.dirname(file), { recursive: true }); const text = String(content).replace(/\r\n/g, '\n').trim(); fs.writeFileSync(file, text ? text + '\n' : '', { mode: 0o600 }); } export function parseEnv(text) { const out = {}; for (const rawLine of String(text).split('\n')) { const line = rawLine.trim(); if (!line || line.startsWith('#')) continue; const eq = line.indexOf('='); if (eq <= 0) continue; const key = line.slice(0, eq).trim(); let val = line.slice(eq + 1).trim(); if ( (val.startsWith('"') && val.endsWith('"') && val.length >= 2) || (val.startsWith("'") && val.endsWith("'") && val.length >= 2) ) { val = val.slice(1, -1); } if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) out[key] = val; } return out; }