UNPKG

@mesh-tech/mesh-cli

Version:

CLI for Mesh platform development utilities

201 lines (200 loc) 6.55 kB
import * as crypto from 'node:crypto'; import * as fs from 'node:fs'; import * as net from 'node:net'; import * as path from 'node:path'; import { execFileSync } from 'node:child_process'; export function mintRunnerToken() { return crypto.randomBytes(16).toString('hex'); } export function readRunnerManifest(file) { if (!fs.existsSync(file)) return null; try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; } } export function writeRunnerManifest(file, manifest) { fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, JSON.stringify(manifest, null, 2), { mode: 0o600 }); } export function clearRunnerManifest(file, token) { if (token !== undefined && readRunnerManifest(file)?.token !== token) return; try { fs.unlinkSync(file); } catch { } } export function getProcessGroupId(pid) { try { const out = execFileSync('ps', ['-o', 'pgid=', '-p', String(pid)], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], }).trim(); const pgid = Number(out); return Number.isInteger(pgid) && pgid > 0 ? pgid : null; } catch { return null; } } export function parseRunnerPidsFromPs(psOutput, tenant, exclude) { const pids = []; for (const line of psOutput.split('\n')) { if (!/vpn\s+tunnel\s+(?:__run|__supervise)\s+/.test(line)) continue; const m = line.trim().match(/^(\d+)\s+(.*)$/); if (!m) continue; const argv = m[2].split(/\s+/); const i = argv.findIndex((a) => a === '__run' || a === '__supervise'); if (i === -1 || argv[i + 1] !== tenant) continue; const pid = Number(m[1]); if (pid !== exclude) pids.push(pid); } return pids; } export function discoverRunnerPids(tenant, exclude) { try { const out = execFileSync('ps', ['ax', '-o', 'pid=,command='], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], }); return parseRunnerPidsFromPs(out, tenant, exclude); } catch { return []; } } export function startControlServer(payload) { return new Promise((resolve, reject) => { const server = net.createServer((socket) => { socket.on('error', () => { }); try { socket.end(`${JSON.stringify(payload())}\n`); } catch { socket.destroy(); } }); server.on('error', reject); server.listen(0, '127.0.0.1', () => { resolve({ server, port: server.address().port }); }); }); } export function queryControl(port, timeoutMs = 700) { return new Promise((resolve) => { const s = new net.Socket(); let buf = ''; let done = false; const fin = (v) => { if (done) return; done = true; s.destroy(); resolve(v); }; s.setTimeout(timeoutMs); s.once('timeout', () => fin(null)); s.once('error', () => fin(null)); s.on('data', (d) => { buf += d.toString(); }); s.once('close', () => { try { fin(JSON.parse(buf)); } catch { fin(null); } }); s.connect(port, '127.0.0.1'); }); } function defaultPidAlive(pid) { try { process.kill(pid, 0); return true; } catch { return false; } } export async function verifyRunnerOwnership(args) { const { manifest } = args; if (!manifest) return { ok: false, reason: 'no-manifest' }; const alive = args.isPidAlive ?? defaultPidAlive; if (!alive(manifest.pid)) return { ok: false, reason: 'runner-dead' }; const resp = await (args.query ?? queryControl)(manifest.controlPort); if (!resp) return { ok: false, reason: 'no-control-answer' }; if (resp.token !== manifest.token) return { ok: false, reason: 'token-mismatch', resp }; if (resp.socksPort !== args.expectedSocksPort) { return { ok: false, reason: 'socks-port-mismatch', resp }; } const listening = new Set(resp.listening); const missing = args.expectedPorts.filter((p) => !listening.has(p)); if (missing.length > 0) { return { ok: false, reason: `ports-not-listening:${missing.join(',')}`, resp }; } return { ok: true, reason: '', resp }; } export async function pollOwnership(check, deadlineMs) { let last = { ok: false, reason: 'not-checked' }; const end = Date.now() + deadlineMs; do { last = await check(); if (last.ok) return last; await new Promise((r) => setTimeout(r, 250)); } while (Date.now() < end); return last; } export function describePortOwner(port) { try { const out = execFileSync('lsof', ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN', '-Fcp'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], }); const pid = out.match(/^p(\d+)$/m)?.[1]; const cmd = out.match(/^c(.+)$/m)?.[1]; if (!pid && !cmd) return null; return `${cmd ?? '?'} (pid ${pid ?? '?'})`; } catch { return null; } } export async function findSquattedPorts(ports, accepts) { const squatted = []; for (const port of ports) { if (await accepts(port)) squatted.push({ port, owner: describePortOwner(port) }); } return squatted; } export function buildSquatterError(tenant, squatted, detail) { const lines = squatted.map((s) => ` 127.0.0.1:${s.port} held by ${s.owner ?? 'an unidentified process'}`); const first = squatted[0]?.port ?? '<port>'; return [ `Tunnel port(s) for tenant '${tenant}' are bound by a process mesh does not own${detail ? ` (${detail})` : ''}:`, ...lines, ` Refusing to reuse a foreign listener — traffic would silently flow to the wrong upstream.`, ` To fix:`, ` mesh vpn -t ${tenant} tunnel down --stop # tear down mesh-owned runners + daemon`, ` lsof -nP -iTCP:${first} -sTCP:LISTEN # identify what still holds the port`, ` then stop that process and re-run.`, ` (If lsof names another mesh runner, it belongs to a different tenant — tear that one down instead.)`, ].join('\n'); }