| … | … | @@ -20,13 +20,42 @@ |
| 20 | 20 | */ |
| 21 | 21 | |
| 22 | 22 | const http = require('http') |
| 23 | | const { exec } = require('child_process') |
| 23 | const crypto = require('crypto') |
| 24 | const { exec, execFile } = require('child_process') |
| 24 | 25 | const fs = require('fs') |
| 25 | 26 | const path = require('path') |
| 26 | 27 | const { createController } = require('./controller.cjs') |
| 27 | 28 | |
| 28 | 29 | const INTERVAL_S = parseInt(process.env.FLEET_INTERVAL || '60', 10) |
| 29 | 30 | const PORT = parseInt(process.env.PORT || '3458', 10) |
| 31 | |
| 32 | // This process is a fleet-root control plane: it holds every relay's SSH key and |
| 33 | // its endpoints restart services, run `git pull && npm install`, rewrite |
| 34 | // config.json and edit /etc/fstab. Treat it accordingly. |
| 35 | // |
| 36 | // It previously bound 0.0.0.0 with no authentication, which made it remote root |
| 37 | // on all 13 relays for anyone on the same network. Two independent guards now: |
| 38 | // bind loopback, and require a shared secret on every request. |
| 39 | const HOST = process.env.HIVERELAY_ADMIN_HOST || '127.0.0.1' |
| 40 | const TOKEN = process.env.HIVERELAY_ADMIN_TOKEN || crypto.randomBytes(24).toString('hex') |
| 41 | const TOKEN_WAS_GENERATED = !process.env.HIVERELAY_ADMIN_TOKEN |
| 42 | |
| 43 | // Timing-safe compare so the token can't be recovered byte-by-byte. |
| 44 | function tokenOk (presented) { |
| 45 | if (typeof presented !== 'string' || presented.length !== TOKEN.length) return false |
| 46 | return crypto.timingSafeEqual(Buffer.from(presented), Buffer.from(TOKEN)) |
| 47 | } |
| 48 | |
| 49 | function presentedToken (req) { |
| 50 | const auth = req.headers.authorization || '' |
| 51 | if (auth.startsWith('Bearer ')) return auth.slice(7) |
| 52 | if (req.headers['x-admin-token']) return String(req.headers['x-admin-token']) |
| 53 | try { |
| 54 | return new URL(req.url, 'http://localhost').searchParams.get('token') || '' |
| 55 | } catch (_) { |
| 56 | return '' |
| 57 | } |
| 58 | } |
| 30 | 59 | const POLICY_PATH = process.env.HIVERELAY_CONTROL_POLICY |
| 31 | 60 | || path.join(__dirname, 'control-policy.json') |
| 32 | 61 | |
| … | … | @@ -74,6 +103,15 @@ function loadRelays () { |
| 74 | 103 | return JSON.parse(fs.readFileSync(RELAYS_JSON, 'utf8')).relays |
| 75 | 104 | } |
| 76 | 105 | |
| 106 | // Hostname/IP allowlist, applied before any value from the inventory reaches a |
| 107 | // child process. Deliberately strict: letters, digits, dot, colon (IPv6) and |
| 108 | // hyphen. Rejects everything a shell would treat as syntax, so an inventory |
| 109 | // entry can never become a command even if a future call site drops execFile. |
| 110 | function isSafeHost (host) { |
| 111 | return typeof host === 'string' && host.length > 0 && host.length <= 255 && |
| 112 | /^[A-Za-z0-9.:-]+$/.test(host) |
| 113 | } |
| 114 | |
| 77 | 115 | function probeNode (relay) { |
| 78 | 116 | return new Promise((resolve) => { |
| 79 | 117 | const ip = relay.publicIp |
| … | … | @@ -103,14 +141,31 @@ function probeNode (relay) { |
| 103 | 141 | 'printf \'{"version":"%s","diskPct":%s,"diskGB":%s,"swapMB":%s,"ramUsedPct":%s,"loadAvg":%s,"uptime":"%s","restarts":%s,"memCurrentMB":%s,"tasks":%s,"env":"%s","watchdog":"%s","healthOk":%s,"config":%s,"status":%s}\\n\' "$V" "${D:-0}" "${DG:-0}" "${SW:-0}" "${RM:-0}" "${LA:-0}" "$UP" "${RT:-0}" "${MC:-0}" "${TS:-0}" "$ENV" "$WD" "$HO" "$CFG" "$STATUS"' |
| 104 | 142 | ].join('\n') |
| 105 | 143 | |
| 144 | // `ip` comes from the inventory, and POST /api/relay appends to that |
| 145 | // inventory after validating only truthiness — so it is caller-controlled. |
| 146 | // Interpolating it into a shell string made this a remote command-execution |
| 147 | // sink, re-triggered every 60s by the probe interval. execFile takes an argv |
| 148 | // array and spawns no shell, so the value can only ever be an argument. |
| 149 | if (!isSafeHost(ip)) { |
| 150 | return resolve({ ...makeOffline(relay), note: 'invalid host in inventory' }) |
| 151 | } |
| 152 | |
| 106 | 153 | // timeout is required: macOS nc -w is unreliable and can hang forever on dead hosts |
| 107 | | exec(`nc -z -G 3 -w 3 ${ip} 22 2>&1`, { timeout: 6000 }, (tcpErr) => { |
| 154 | execFile('nc', ['-z', '-G', '3', '-w', '3', ip, '22'], { timeout: 6000 }, (tcpErr) => { |
| 108 | 155 | if (tcpErr) { |
| 109 | 156 | return resolve(makeOffline(relay)) |
| 110 | 157 | } |
| 111 | 158 | |
| 112 | | const sshBase = `ssh ${keyArg.join(' ')} -o IdentitiesOnly=yes -o ConnectTimeout=8 -o BatchMode=yes -o StrictHostKeyChecking=accept-new root@${ip}` |
| 113 | | exec(`${sshBase} '${remote.replace(/'/g, "'\\''")}' 2>/dev/null`, { timeout: 25000 }, (err, stdout) => { |
| 159 | const sshArgs = [ |
| 160 | ...keyArg, |
| 161 | '-o', 'IdentitiesOnly=yes', |
| 162 | '-o', 'ConnectTimeout=8', |
| 163 | '-o', 'BatchMode=yes', |
| 164 | '-o', 'StrictHostKeyChecking=accept-new', |
| 165 | `root@${ip}`, |
| 166 | remote |
| 167 | ] |
| 168 | execFile('ssh', sshArgs, { timeout: 25000 }, (err, stdout) => { |
| 114 | 169 | if (err || !stdout || !stdout.trim().startsWith('{')) { |
| 115 | 170 | return resolve({ ...makeOffline(relay), reachable: true, status: 'degraded' }) |
| 116 | 171 | } |
| … | … | @@ -295,14 +350,23 @@ function sshAction (relay, command, label) { |
| 295 | 350 | // --- HTTP server ---------------------------------------------------------- |
| 296 | 351 | |
| 297 | 352 | const server = http.createServer(async (req, res) => { |
| 353 | // No wildcard CORS. A browser on any origin could otherwise drive this |
| 354 | // control plane with the operator's own loopback access. |
| 298 | 355 | if (req.method === 'OPTIONS') { |
| 299 | | res.writeHead(204, { 'access-control-allow-origin': '*', 'access-control-allow-methods': 'GET, POST, OPTIONS', 'access-control-allow-headers': 'content-type' }) |
| 356 | res.writeHead(204, { 'access-control-allow-methods': 'GET, POST, OPTIONS', 'access-control-allow-headers': 'content-type, authorization, x-admin-token' }) |
| 300 | 357 | return res.end() |
| 301 | 358 | } |
| 302 | 359 | |
| 360 | // Auth gate ahead of every route, including the static UI and the SSE stream: |
| 361 | // the stream leaks the full inventory and action log. |
| 362 | if (!tokenOk(presentedToken(req))) { |
| 363 | res.writeHead(401, { 'content-type': 'application/json' }) |
| 364 | return res.end(JSON.stringify({ error: 'unauthorized' })) |
| 365 | } |
| 366 | |
| 303 | 367 | // SSE |
| 304 | 368 | if (req.url === '/api/events') { |
| 305 | | res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', 'connection': 'keep-alive', 'access-control-allow-origin': '*' }) |
| 369 | res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', 'connection': 'keep-alive' }) |
| 306 | 370 | res.write(`data: ${JSON.stringify({ type: 'fleet', data: fleetState, timestamp: lastProbe })}\n\n`) |
| 307 | 371 | res.write(`data: ${JSON.stringify({ type: 'actions', data: actionLog })}\n\n`) |
| 308 | 372 | sseClients.push(res) |
| … | … | @@ -395,6 +459,15 @@ const server = http.createServer(async (req, res) => { |
| 395 | 459 | if (req.url === '/api/relay' && req.method === 'POST') { |
| 396 | 460 | const body = await readBody(req) |
| 397 | 461 | if (!body.name || !body.publicIp) return sendJson(res, 400, { error: 'name and publicIp required' }) |
| 462 | // Validate at the boundary, not only at the call site. This value is |
| 463 | // persisted to the inventory and then handed to ssh every 60s; a truthiness |
| 464 | // check is not enough to let it become an argv element. |
| 465 | if (!isSafeHost(body.publicIp)) { |
| 466 | return sendJson(res, 400, { error: 'publicIp must be a hostname or IP address' }) |
| 467 | } |
| 468 | if (!/^[A-Za-z0-9._-]{1,64}$/.test(body.name)) { |
| 469 | return sendJson(res, 400, { error: 'name must be 1-64 chars of [A-Za-z0-9._-]' }) |
| 470 | } |
| 398 | 471 | |
| 399 | 472 | const relays = loadRelays() |
| 400 | 473 | if (relays.find(r => r.name === body.name)) { |
| … | … | @@ -619,8 +692,16 @@ async function main () { |
| 619 | 692 | console.log(`[admin-operator] ${relays.length} nodes configured`) |
| 620 | 693 | // Listen first so UI is available while the (slow) fleet probe runs |
| 621 | 694 | await new Promise((resolve, reject) => { |
| 622 | | server.listen(PORT, () => { |
| 623 | | console.log(`\n [admin-operator] Dashboard Admin Operator → http://localhost:${PORT}\n`) |
| 695 | server.listen(PORT, HOST, () => { |
| 696 | console.log(`\n [admin-operator] Dashboard Admin Operator → http://${HOST}:${PORT}/?token=${TOKEN}\n`) |
| 697 | if (TOKEN_WAS_GENERATED) { |
| 698 | console.log(' [admin-operator] Generated a one-off token for this run.') |
| 699 | console.log(' [admin-operator] Set HIVERELAY_ADMIN_TOKEN to pin it across restarts.\n') |
| 700 | } |
| 701 | if (HOST !== '127.0.0.1' && HOST !== 'localhost') { |
| 702 | console.log(` [admin-operator] WARNING: bound to ${HOST}, not loopback. This process can`) |
| 703 | console.log(' [admin-operator] run commands as root on every relay in the inventory.\n') |
| 704 | } |
| 624 | 705 | resolve() |
| 625 | 706 | }) |
| 626 | 707 | server.on('error', reject) |