/** * Runner Dashboard — HTTP server * * What: Serves the CI runner status dashboard for peptide-repo org. * Who triggers: Node process (started by Docker CMD or local `node server.js`). * Deps: Node 20 built-ins only (http, https, crypto, fs, path). * * Routes: * GET /healthz → 200 "ok" (no auth, no GitHub call — for Coolify health check) * GET /api/runners → proxied GitHub runners JSON (auth required) * GET / → dashboard HTML (auth required) * * Env: * PORT default 3000 * DASH_USER / DASH_PASS HTTP Basic Auth credentials * GITHUB_RUNNERS_READ_TOKEN fine-grained PAT for org runners API */ 'use strict'; const http = require('http'); const https = require('https'); const fs = require('fs'); const path = require('path'); const PORT = parseInt(process.env.PORT || '3000', 10); const DASH_USER = process.env.DASH_USER || ''; const DASH_PASS = process.env.DASH_PASS || ''; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /** * Parse and validate HTTP Basic Auth header. * @param {http.IncomingMessage} req * @returns {boolean} */ function isAuthorised(req) { if (!DASH_USER || !DASH_PASS) return false; const header = req.headers['authorization'] || ''; if (!header.startsWith('Basic ')) return false; const [user, pass] = Buffer.from(header.slice(6), 'base64').toString().split(':'); return user === DASH_USER && pass === DASH_PASS; } /** * Send a 401 Unauthorized response demanding Basic Auth. * @param {http.ServerResponse} res */ function requireAuth(res) { res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="Runner Dashboard"', 'Content-Type': 'text/plain', }); res.end('Unauthorized'); } /** * Fetch the GitHub org runners list server-side. * Reads token from process.env at call time (never cached at module level). * @returns {Promise<{status:number, body:string}>} */ function fetchRunners() { return new Promise((resolve) => { const token = process.env.GITHUB_RUNNERS_READ_TOKEN; if (!token) { resolve({ status: 200, body: JSON.stringify({ error: 'token_missing' }) }); return; } const options = { hostname: 'api.github.com', path: '/orgs/peptide-repo/actions/runners', method: 'GET', headers: { 'Authorization': `Bearer ${token}`, 'Accept': 'application/vnd.github+json', 'User-Agent': 'runner-dashboard/1.0', 'X-GitHub-Api-Version': '2022-11-28', }, }; const req = https.request(options, (ghRes) => { let raw = ''; ghRes.on('data', (chunk) => { raw += chunk; }); ghRes.on('end', () => { if (ghRes.statusCode !== 200) { const body = JSON.stringify({ error: 'api_error', status: ghRes.statusCode }); resolve({ status: 200, body }); return; } try { const parsed = JSON.parse(raw); resolve({ status: 200, body: JSON.stringify(parsed.runners || parsed) }); } catch { resolve({ status: 200, body: JSON.stringify({ error: 'api_error', status: ghRes.statusCode }) }); } }); }); req.on('error', () => { resolve({ status: 200, body: JSON.stringify({ error: 'api_error', status: 0 }) }); }); req.setTimeout(10000, () => { req.destroy(); resolve({ status: 200, body: JSON.stringify({ error: 'api_error', status: 0 }) }); }); req.end(); }); } // --------------------------------------------------------------------------- // Request handler // --------------------------------------------------------------------------- /** * Main HTTP request handler. * @param {http.IncomingMessage} req * @param {http.ServerResponse} res */ async function handler(req, res) { const url = (req.url || '/').split('?')[0]; // Health check — no auth, no GitHub call if (url === '/healthz') { res.writeHead(200, { 'Content-Type': 'text/plain' }); res.end('ok'); return; } // All other routes require Basic Auth if (!isAuthorised(req)) { requireAuth(res); return; } if (url === '/api/runners') { const { status, body } = await fetchRunners(); res.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', }); res.end(body); return; } if (url === '/') { const htmlPath = path.join(__dirname, 'index.html'); try { const html = fs.readFileSync(htmlPath, 'utf8'); res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); res.end(html); } catch { res.writeHead(500, { 'Content-Type': 'text/plain' }); res.end('index.html not found'); } return; } res.writeHead(404, { 'Content-Type': 'text/plain' }); res.end('Not found'); } // --------------------------------------------------------------------------- // Start server // --------------------------------------------------------------------------- const server = http.createServer((req, res) => { handler(req, res).catch((err) => { console.error('Unhandled error:', err); if (!res.headersSent) { res.writeHead(500, { 'Content-Type': 'text/plain' }); res.end('Internal server error'); } }); }); server.listen(PORT, '0.0.0.0', () => { console.log(`Runner dashboard listening on port ${PORT}`); });