#!/usr/bin/env node /** * DashCaddy Pylon — Lightweight Health Relay Agent * * Runs on a remote network to relay health checks for services * that the main DashCaddy instance can't reach directly. * * Zero dependencies — uses only Node.js built-ins. * * Usage: * node dashcaddy-pylon.js # port 3002, no auth * PYLON_PORT=3002 PYLON_KEY=secret node dashcaddy-pylon.js * * Endpoints: * GET /probe?url= Health check a URL (returns status) * GET /health Pylon's own health * GET /info Pylon metadata */ const http = require('http'); const https = require('https'); const { URL } = require('url'); const os = require('os'); const PORT = parseInt(process.env.PYLON_PORT || '3002', 10); const API_KEY = process.env.PYLON_KEY || ''; const PROBE_TIMEOUT = parseInt(process.env.PYLON_TIMEOUT || '8000', 10); const PYLON_NAME = process.env.PYLON_NAME || os.hostname(); function json(res, status, data) { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(data)); } function probe(targetUrl) { return new Promise((resolve) => { let parsed; try { parsed = new URL(targetUrl); } catch (e) { return resolve({ status: 'error', reason: 'Invalid URL', url: targetUrl }); } const mod = parsed.protocol === 'https:' ? https : http; const start = Date.now(); const req = mod.request({ hostname: parsed.hostname, port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80), path: parsed.pathname + parsed.search, method: 'HEAD', timeout: PROBE_TIMEOUT, rejectUnauthorized: false, }, (res) => { // Consume the response so the socket is freed res.resume(); const ms = Date.now() - start; // HEAD returned 501/405 — retry with GET if (res.statusCode === 501 || res.statusCode === 405) { const getReq = mod.request({ hostname: parsed.hostname, port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80), path: parsed.pathname + parsed.search, method: 'GET', timeout: PROBE_TIMEOUT, rejectUnauthorized: false, }, (getRes) => { getRes.resume(); resolve({ status: getRes.statusCode < 500 ? 'healthy' : 'unhealthy', statusCode: getRes.statusCode, responseTime: Date.now() - start, url: targetUrl, checkedAt: new Date().toISOString(), }); }); getReq.on('error', (e) => resolve({ status: 'unhealthy', reason: e.message, responseTime: Date.now() - start, url: targetUrl, checkedAt: new Date().toISOString() })); getReq.on('timeout', () => { getReq.destroy(); resolve({ status: 'unhealthy', reason: 'Timeout', responseTime: Date.now() - start, url: targetUrl, checkedAt: new Date().toISOString() }); }); getReq.end(); return; } const healthy = res.statusCode < 500; resolve({ status: healthy ? 'healthy' : 'unhealthy', statusCode: res.statusCode, responseTime: ms, url: targetUrl, checkedAt: new Date().toISOString(), }); }); req.on('error', (err) => { // HEAD not supported — retry with GET if (err.code === 'ERR_HTTP_INVALID_STATUS_CODE' || err.message?.includes('HEAD')) { const getReq = mod.request({ hostname: parsed.hostname, port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80), path: parsed.pathname + parsed.search, method: 'GET', timeout: PROBE_TIMEOUT, rejectUnauthorized: false, }, (getRes) => { getRes.resume(); const ms = Date.now() - start; resolve({ status: getRes.statusCode < 500 ? 'healthy' : 'unhealthy', statusCode: getRes.statusCode, responseTime: ms, url: targetUrl, checkedAt: new Date().toISOString(), }); }); getReq.on('error', (getErr) => { resolve({ status: 'unhealthy', reason: getErr.message, responseTime: Date.now() - start, url: targetUrl, checkedAt: new Date().toISOString(), }); }); getReq.on('timeout', () => { getReq.destroy(); resolve({ status: 'unhealthy', reason: 'Timeout', responseTime: Date.now() - start, url: targetUrl, checkedAt: new Date().toISOString(), }); }); getReq.end(); return; } resolve({ status: 'unhealthy', reason: err.message, responseTime: Date.now() - start, url: targetUrl, checkedAt: new Date().toISOString(), }); }); req.on('timeout', () => { req.destroy(); resolve({ status: 'unhealthy', reason: 'Timeout', responseTime: Date.now() - start, url: targetUrl, checkedAt: new Date().toISOString(), }); }); req.end(); }); } const server = http.createServer(async (req, res) => { const url = new URL(req.url, `http://localhost:${PORT}`); // API key check if (API_KEY) { const provided = url.searchParams.get('key') || req.headers['x-pylon-key']; if (provided !== API_KEY) { return json(res, 401, { error: 'Invalid API key' }); } } // Routes if (url.pathname === '/health') { return json(res, 200, { status: 'ok', pylon: PYLON_NAME, uptime: process.uptime() }); } if (url.pathname === '/info') { return json(res, 200, { pylon: PYLON_NAME, hostname: os.hostname(), platform: os.platform(), uptime: process.uptime(), version: '1.0.0', }); } if (url.pathname === '/probe') { const targetUrl = url.searchParams.get('url'); if (!targetUrl) { return json(res, 400, { error: 'Missing ?url= parameter' }); } // Block obviously dangerous URLs (localhost probing from the pylon itself) const parsed = new URL(targetUrl); if (parsed.hostname === 'localhost' && parsed.port === String(PORT)) { return json(res, 400, { error: 'Cannot probe self' }); } const result = await probe(targetUrl); return json(res, 200, result); } // Batch probe: POST /probe with JSON body { urls: [...] } if (url.pathname === '/probe' && req.method === 'POST') { let body = ''; req.on('data', c => body += c); req.on('end', async () => { try { const { urls } = JSON.parse(body); if (!Array.isArray(urls) || urls.length === 0) { return json(res, 400, { error: 'Missing urls array' }); } if (urls.length > 50) { return json(res, 400, { error: 'Max 50 URLs per batch' }); } const results = await Promise.all(urls.map(u => probe(u))); return json(res, 200, { results }); } catch (e) { return json(res, 400, { error: 'Invalid JSON body' }); } }); return; } json(res, 404, { error: 'Not found' }); }); server.listen(PORT, '0.0.0.0', () => { console.log(`[Pylon] ${PYLON_NAME} listening on port ${PORT}`); if (API_KEY) console.log('[Pylon] API key authentication enabled'); });