diff --git a/dashcaddy-api/pylon/dashcaddy-pylon.js b/dashcaddy-api/pylon/dashcaddy-pylon.js new file mode 100644 index 0000000..d8539ae --- /dev/null +++ b/dashcaddy-api/pylon/dashcaddy-pylon.js @@ -0,0 +1,232 @@ +#!/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'); +}); diff --git a/dashcaddy-api/pylon/dashcaddy-pylon.service b/dashcaddy-api/pylon/dashcaddy-pylon.service new file mode 100644 index 0000000..c7b2493 --- /dev/null +++ b/dashcaddy-api/pylon/dashcaddy-pylon.service @@ -0,0 +1,19 @@ +[Unit] +Description=DashCaddy Pylon — Health Check Relay +After=network.target + +[Service] +Type=simple +ExecStart=/usr/bin/node /opt/dashcaddy-pylon/dashcaddy-pylon.js +Restart=always +RestartSec=5 +Environment=PYLON_PORT=3002 +Environment=PYLON_NAME=%H +# Uncomment and set a shared key for authentication: +#Environment=PYLON_KEY=your-shared-secret +StandardOutput=journal +StandardError=journal +SyslogIdentifier=dashcaddy-pylon + +[Install] +WantedBy=multi-user.target diff --git a/dashcaddy-api/routes/health.js b/dashcaddy-api/routes/health.js index ac1cbe5..9f34fe2 100644 --- a/dashcaddy-api/routes/health.js +++ b/dashcaddy-api/routes/health.js @@ -15,6 +15,70 @@ module.exports = function(ctx) { let serviceHealthCache = {}; let lastHealthCheck = null; + /** + * Check a URL directly via fetch. Returns health object or null on failure. + */ + async function checkDirect(url) { + // Try HEAD first + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 5000); + const response = await ctx.fetchT(url, { method: 'HEAD', signal: controller.signal, redirect: 'follow' }); + clearTimeout(timeout); + return { + status: response.ok || response.status < 500 ? 'healthy' : 'unhealthy', + statusCode: response.status, + url, + checkedAt: new Date().toISOString() + }; + } catch (_) {} + + // Fallback to GET + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 5000); + const response = await ctx.fetchT(url, { method: 'GET', signal: controller.signal, redirect: 'follow' }); + clearTimeout(timeout); + return { + status: response.ok || response.status < 500 ? 'healthy' : 'unhealthy', + statusCode: response.status, + url, + checkedAt: new Date().toISOString() + }; + } catch (e) { + return null; // Direct check completely failed + } + } + + /** + * Check a URL through a Pylon relay. Returns health object or null on failure. + */ + async function checkViaPylon(pylonConfig, url) { + if (!pylonConfig?.url) return null; + try { + const probeUrl = `${pylonConfig.url}/probe?url=${encodeURIComponent(url)}`; + const headers = {}; + if (pylonConfig.key) headers['x-pylon-key'] = pylonConfig.key; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 12000); + const response = await ctx.fetchT(probeUrl, { method: 'GET', signal: controller.signal, headers }); + clearTimeout(timeout); + if (!response.ok) return null; + const data = await response.json(); + return { + status: data.status || 'unhealthy', + statusCode: data.statusCode, + responseTime: data.responseTime, + reason: data.reason, + url, + via: 'pylon', + checkedAt: data.checkedAt || new Date().toISOString() + }; + } catch (_) { + return null; + } + } + // ===== HEALTH / SERVICES ===== // Check health of all services (performs live checks) @@ -26,6 +90,7 @@ module.exports = function(ctx) { const servicesData = await ctx.servicesStateManager.read(); const services = Array.isArray(servicesData) ? servicesData : servicesData.services || []; const health = {}; + const pylonConfig = ctx.siteConfig?.pylon; // Check each service await Promise.all(services.map(async (service) => { @@ -33,65 +98,35 @@ module.exports = function(ctx) { if (!serviceId) return; try { - let url = null; - let checkType = 'http'; - - // Determine URL to check - url = resolveServiceUrl(serviceId, service, ctx.siteConfig, ctx.buildServiceUrl); - + const url = resolveServiceUrl(serviceId, service, ctx.siteConfig, ctx.buildServiceUrl); if (!url) { health[serviceId] = { status: 'unknown', reason: 'No URL configured' }; return; } - // Perform health check with timeout - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 5000); + // 1. Try direct check + let result = await checkDirect(url); + if (result) { + health[serviceId] = result; + return; + } - try { - const response = await ctx.fetchT(url, { - method: 'HEAD', - signal: controller.signal, - redirect: 'follow' - }); - clearTimeout(timeout); - - health[serviceId] = { - status: response.ok || response.status < 500 ? 'healthy' : 'unhealthy', - statusCode: response.status, - url, - checkedAt: new Date().toISOString() - }; - } catch (fetchError) { - clearTimeout(timeout); - - // Try GET if HEAD fails - try { - const getController = new AbortController(); - const getTimeout = setTimeout(() => getController.abort(), 5000); - - const getResponse = await ctx.fetchT(url, { - method: 'GET', - signal: getController.signal, - redirect: 'follow' - }); - clearTimeout(getTimeout); - - health[serviceId] = { - status: getResponse.ok || getResponse.status < 500 ? 'healthy' : 'unhealthy', - statusCode: getResponse.status, - url, - checkedAt: new Date().toISOString() - }; - } catch (e) { - health[serviceId] = { - status: 'unhealthy', - reason: e.name === 'AbortError' ? 'Timeout' : e.message, - url, - checkedAt: new Date().toISOString() - }; + // 2. Direct failed — try through pylon relay + if (pylonConfig) { + result = await checkViaPylon(pylonConfig, url); + if (result) { + health[serviceId] = result; + return; } } + + // 3. Both failed + health[serviceId] = { + status: 'unhealthy', + reason: pylonConfig ? 'Unreachable (direct + pylon)' : 'fetch failed', + url, + checkedAt: new Date().toISOString() + }; } catch (e) { health[serviceId] = { status: 'error', @@ -148,44 +183,62 @@ module.exports = function(ctx) { // Determine URL const url = resolveServiceUrl(serviceId, service, ctx.siteConfig, ctx.buildServiceUrl); + const pylonConfig = ctx.siteConfig?.pylon; - // Check health - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 5000); - - try { - const response = await ctx.fetchT(url, { - method: 'GET', - signal: controller.signal, - redirect: 'follow' - }); - clearTimeout(timeout); - - res.json({ - success: true, - serviceId, - health: { - status: response.ok || response.status < 500 ? 'healthy' : 'unhealthy', - statusCode: response.status, - url, - checkedAt: new Date().toISOString() - } - }); - } catch (e) { - clearTimeout(timeout); - res.json({ - success: true, - serviceId, - health: { - status: 'unhealthy', - reason: e.name === 'AbortError' ? 'Timeout' : e.message, - url, - checkedAt: new Date().toISOString() - } - }); + // Try direct, then pylon relay + let result = await checkDirect(url); + if (!result && pylonConfig) { + result = await checkViaPylon(pylonConfig, url); } + if (!result) { + result = { + status: 'unhealthy', + reason: pylonConfig ? 'Unreachable (direct + pylon)' : 'fetch failed', + url, + checkedAt: new Date().toISOString() + }; + } + + res.json({ success: true, serviceId, health: result }); }, 'health-service')); + // ===== HEALTH / PROBE (Pylon-compatible) ===== + + // Probe endpoint — lets this DashCaddy act as a pylon for other instances + router.get('/health/probe', ctx.asyncHandler(async (req, res) => { + const targetUrl = req.query.url; + if (!targetUrl) { + return ctx.errorResponse(res, 400, 'Missing ?url= parameter'); + } + const result = await checkDirect(targetUrl); + res.json(result || { + status: 'unhealthy', + reason: 'fetch failed', + url: targetUrl, + checkedAt: new Date().toISOString() + }); + }, 'health-probe')); + + // Pylon status — check if the configured pylon is reachable + router.get('/health/pylon', ctx.asyncHandler(async (req, res) => { + const pylonConfig = ctx.siteConfig?.pylon; + if (!pylonConfig?.url) { + return res.json({ success: true, configured: false }); + } + try { + const headers = {}; + if (pylonConfig.key) headers['x-pylon-key'] = pylonConfig.key; + const response = await ctx.fetchT(`${pylonConfig.url}/health`, { + method: 'GET', + headers + }, 5000); + const data = await response.json(); + res.json({ success: true, configured: true, reachable: true, pylon: data }); + } catch (e) { + res.json({ success: true, configured: true, reachable: false, error: e.message }); + } + }, 'health-pylon')); + // ===== HEALTH / CA ===== // Get CA certificate health status diff --git a/dashcaddy-api/server.js b/dashcaddy-api/server.js index 400e1cd..f04f281 100644 --- a/dashcaddy-api/server.js +++ b/dashcaddy-api/server.js @@ -157,6 +157,7 @@ function loadSiteConfig() { siteConfig.configurationType = raw.configurationType || 'homelab'; siteConfig.domain = raw.domain || ''; siteConfig.routingMode = raw.routingMode || 'subdomain'; + siteConfig.pylon = raw.pylon || null; // { url, key? } — health check relay } } catch (e) { // log.error may not be assigned yet during initial module load