// PON Fleet web server — a multi-user, reverse-proxy-friendly front end for // the same tools as the Electron app. It reuses the Electron app's core // (../src/*) and serves the Electron app's renderer (../renderer/*) with an // on-the-fly transformed index.html + a browser window.api shim. // // Pure Node `http` — the only runtime dependency is tough-cookie, pulled in // transitively by ../src/mcms-api (already installed in ../node_modules). // // Each browser session gets its OWN McmsClient (its own cookie jar), keyed // by an httpOnly session cookie, so multiple operators can use it at once on // separate MCMS credentials. Bind to localhost and put TLS on the proxy. // // Env: // PORT (default 8080), HOST (default 127.0.0.1) // PFW_IDLE_MINUTES (default 30) — idle session expiry // PFW_SECURE_COOKIE=1 — force the Secure cookie flag (else inferred from // X-Forwarded-Proto: https) // PFW_VERBOSE=1 — verbose MCMS request logging (off by default; noisy) const http = require('http'); const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const { McmsClient, McmsApiError, extractOnuHealth, summarizeOltNniNetworks, planFloodChange, } = require('../src/mcms-api'); const { planUpgrade } = require('../src/bank-strategy'); const { summarizeDashboard } = require('../src/dashboard'); const PORT = Number(process.env.PORT) || 8080; const HOST = process.env.HOST || '127.0.0.1'; const IDLE_MS = (Number(process.env.PFW_IDLE_MINUTES) || 30) * 60 * 1000; const VERBOSE = process.env.PFW_VERBOSE === '1'; const ROOT = __dirname; const RENDERER = path.join(ROOT, '..', 'renderer'); const PUBLIC = path.join(ROOT, 'public'); const SMALL_BODY = 8 * 1024 * 1024; // 8 MB for normal JSON requests const UPLOAD_BODY = 256 * 1024 * 1024; // 256 MB for base64 firmware uploads // ---- Sessions -------------------------------------------------------- // sid -> { client, baseUrl, user, lastSeen, sid } const sessions = new Map(); function toWireError(err) { if (err instanceof McmsApiError) return { message: err.message, status: err.status, body: err.body }; return { message: err?.message || String(err) }; } function parseCookies(req) { const out = {}; const raw = req.headers.cookie; if (!raw) return out; for (const part of raw.split(';')) { const i = part.indexOf('='); if (i < 0) continue; out[part.slice(0, i).trim()] = decodeURIComponent(part.slice(i + 1).trim()); } return out; } function isSecure(req) { if (process.env.PFW_SECURE_COOKIE === '1') return true; return String(req.headers['x-forwarded-proto'] || '').split(',')[0].trim() === 'https'; } function setSidCookie(res, sid, req) { const flags = ['Path=/', 'HttpOnly', 'SameSite=Lax']; if (isSecure(req)) flags.push('Secure'); res.setHeader('Set-Cookie', `pfw_sid=${sid}; ${flags.join('; ')}`); } function clearSidCookie(res, req) { const flags = ['Path=/', 'HttpOnly', 'SameSite=Lax', 'Max-Age=0']; if (isSecure(req)) flags.push('Secure'); res.setHeader('Set-Cookie', `pfw_sid=; ${flags.join('; ')}`); } function getSession(req) { const sid = parseCookies(req).pfw_sid; if (!sid) return null; const s = sessions.get(sid); if (!s) return null; if (Date.now() - s.lastSeen > IDLE_MS) { disposeSession(sid); return null; } return s; } function disposeSession(sid) { const s = sessions.get(sid); if (!s) return; sessions.delete(sid); if (s.client) s.client.logout().catch(() => {}); } // Idle sweep. setInterval(() => { const now = Date.now(); for (const [sid, s] of sessions) if (now - s.lastSeen > IDLE_MS) disposeSession(sid); }, 60 * 1000).unref(); // ---- HTTP helpers ---------------------------------------------------- function sendJson(res, status, obj) { const body = JSON.stringify(obj); res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' }); res.end(body); } function readBody(req, limit) { return new Promise((resolve, reject) => { const chunks = []; let size = 0; req.on('data', (c) => { size += c.length; if (size > limit) { reject(new Error('request body too large')); req.destroy(); return; } chunks.push(c); }); req.on('end', () => { const raw = Buffer.concat(chunks).toString('utf8'); if (!raw) return resolve({}); try { resolve(JSON.parse(raw)); } catch (e) { reject(new Error('invalid JSON body')); } }); req.on('error', reject); }); } // Bounded-concurrency worker pool over a queue, used by the streaming // endpoints. `task(id)` resolves to a result entry; `onProgress(entry, done, // total)` streams each as it lands. Returns the results array. async function runPool(ids, concurrency, task, onProgress) { const queue = [...ids]; const results = []; let done = 0; const total = ids.length; async function worker() { for (;;) { const id = queue.shift(); if (id === undefined) return; const entry = await task(id); results.push(entry); done += 1; onProgress(entry, done, total); } } const workers = Array.from({ length: Math.min(concurrency, total) || 1 }, () => worker()); await Promise.all(workers); return results; } // ---- Static serving -------------------------------------------------- const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'application/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.json': 'application/json; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png', '.ico': 'image/x-icon', }; function sendFile(res, filePath, contentType) { fs.readFile(filePath, (err, buf) => { if (err) { res.writeHead(404); res.end('not found'); return; } res.writeHead(200, { 'Content-Type': contentType || MIME[path.extname(filePath)] || 'application/octet-stream' }); res.end(buf); }); } // Serve the Electron renderer's index.html, rewired for the browser: // - add a mobile viewport // - load shared app.css from /shared/ plus the web overlay /web.css // - load the window.api shim (/api-web.js) before the shared app.js function serveIndex(res) { let html; try { html = fs.readFileSync(path.join(RENDERER, 'index.html'), 'utf8'); } catch (e) { res.writeHead(500); res.end('renderer/index.html missing'); return; } if (!html.includes('name="viewport"')) { html = html.replace(//i, '\n '); } html = html.replace(//i, '\n '); html = html.replace(/\n '); res.writeHead(200, { 'Content-Type': MIME['.html'] }); res.end(html); } function serveStatic(pathname, res) { if (pathname === '/' || pathname === '/index.html') return serveIndex(res); if (pathname === '/shared/app.js') return sendFile(res, path.join(RENDERER, 'app.js'), MIME['.js']); if (pathname === '/shared/app.css') return sendFile(res, path.join(RENDERER, 'app.css'), MIME['.css']); if (pathname === '/api-web.js') return sendFile(res, path.join(PUBLIC, 'api-web.js'), MIME['.js']); if (pathname === '/web.css') return sendFile(res, path.join(PUBLIC, 'web.css'), MIME['.css']); if (pathname === '/favicon.ico') { res.writeHead(204); res.end(); return; } res.writeHead(404); res.end('not found'); } // ---- Non-streaming API handlers (session required) ------------------- // Each returns the same { ok, data } / { ok:false, error } envelope the // renderer already expects. const handlers = { async listOnuConfigs(s, body) { const data = await s.client.listOnuConfigs({ limit: body?.limit, skip: body?.skip }); return { ok: true, data }; }, async listFleet(s) { const c = s.client; const configs = await c.listAllOnuConfigs(); let states = [], statesError = null; try { states = await c.listAllOnuStates(); } catch (e) { statesError = toWireError(e); } let oltStates = [], oltStatesError = null; try { oltStates = await c.listAllOltStates(); } catch (e) { oltStatesError = toWireError(e); } const statusByOnu = new Map(); for (const olt of oltStates) { const buckets = olt?.['ONU States'] || {}; const oltMac = olt?._id; for (const [bucket, ids] of Object.entries(buckets)) { if (!Array.isArray(ids)) continue; for (const id of ids) { const existing = statusByOnu.get(id); if (!existing || existing.status === 'Registered') statusByOnu.set(id, { status: bucket, oltMac }); } } } const stateById = new Map(states.map((st) => [st._id, st])); const merged = configs.map((cfg) => ({ ...cfg, _state: stateById.get(cfg._id) || null, _status: statusByOnu.get(cfg._id) || null, })); return { ok: true, data: merged, meta: { configCount: configs.length, stateCount: states.length, oltStateCount: oltStates.length, statesError, oltStatesError, }, }; }, async getOnuConfig(s, body) { return { ok: true, data: await s.client.getOnuConfig(body.onuId) }; }, async listOnuFirmware(s) { return { ok: true, data: await s.client.listOnuFirmware() }; }, // Browser sent the .bin as base64 (no server-side file dialog). async uploadFirmware(s, body) { const { filename, base64 } = body || {}; if (!filename || !base64) throw new Error('filename and base64 are required'); const versionMatch = String(filename).match(/([A-Z]{2}\d{5}[A-Z]?)/); const metadata = { 'Compatible Manufacturer': 'TIBITCOM', 'Compatible Model': ['MicroPlug ONU'], Version: versionMatch ? versionMatch[1] : '', }; const bodyResp = await s.client.uploadOnuFirmware(filename, base64, metadata); return { ok: true, data: { filename, metadata, body: bodyResp } }; }, async planUpgrade(s, body) { const { onuIds, targetFile, targetVersion } = body; const plans = []; for (const onuId of onuIds) { const cfg = await s.client.getOnuConfig(onuId); if (!cfg) { plans.push({ onuId, error: 'ONU config not found' }); continue; } const plan = planUpgrade(cfg, { targetFile, targetVersion }); plans.push({ onuId, name: cfg?.ONU?.Name || '', address: cfg?.ONU?.Address || '', ponMode: cfg?.ONU?.['PON Mode'] || '', ...plan.summary, writeSlot: plan.writeSlot, }); } return { ok: true, data: plans }; }, async executeBulkTask(s, body) { return { ok: true, data: await s.client.createFirmwareTask(body) }; }, async getTaskConfig(s, body) { return { ok: true, data: await s.client.getTaskConfig(body.taskId) }; }, async getUpgradeStatus(s, body) { return { ok: true, data: await s.client.getOnuUpgradeStatus(body.onuId) }; }, async dashboardStats(s, body) { const c = s.client; const extraStats = !!(body && body.extraStats); const olts = await c.listAllOltStates(); const onuStates = extraStats ? await c.listAllOnuStates() : null; let controllerCount = null; try { controllerCount = (await c.listAllControllerConfigs()).length; } catch (_) { /* best-effort */ } return { ok: true, data: summarizeDashboard({ olts, onuStates, controllerCount }) }; }, async listOlts(s, body) { const c = s.client; const tagPattern = (body && body.tagPattern) || ''; const cfgs = await c.listAllOltConfigs(); const rows = cfgs.map((cfg) => { const summary = summarizeOltNniNetworks(cfg, tagPattern); const counts = { private: 0, auto: 0, unknown: 0 }; for (const x of summary) counts[x.mode] = (counts[x.mode] || 0) + 1; return { _id: cfg._id, name: cfg?.OLT?.Name || '', location: cfg?.OLT?.Location || '', ponMode: cfg?.OLT?.['PON Mode'] || '', activeFwVersion: cfg?.OLT?.['FW Bank Versions']?.[cfg?.OLT?.['FW Bank Ptr']] || '', matchingNni: summary, counts, }; }); return { ok: true, data: rows }; }, }; // ---- Streaming API handlers (NDJSON: progress lines + final) --------- // `write(obj)` emits one progress line; the dispatcher appends the final // result line. Returns the results array. const streamHandlers = { async fetchStates(s, body, write) { const { onuIds, concurrency = 20 } = body; return runPool(onuIds, concurrency, async (id) => { let st = null; try { st = await s.client.getOnuState(id); } catch (_) { st = null; } return { onuId: id, state: st }; }, (entry, done, total) => write({ onuId: entry.onuId, state: entry.state, done, total })); }, async executePerOnu(s, body, write) { const { onuIds, targetFile, targetVersion } = body; const results = []; for (let i = 0; i < onuIds.length; i++) { const onuId = onuIds[i]; write({ index: i, total: onuIds.length, onuId, phase: 'fetching' }); try { const cfg = await s.client.getOnuConfig(onuId); if (!cfg) throw new Error('ONU config not found'); const plan = planUpgrade(cfg, { targetFile, targetVersion }); const mutated = { ...cfg, ONU: { ...cfg.ONU, ...plan.fwFields } }; write({ index: i, total: onuIds.length, onuId, phase: 'writing', writeSlot: plan.writeSlot }); await s.client.putOnuConfig(onuId, mutated); results.push({ onuId, ok: true, writeSlot: plan.writeSlot }); } catch (err) { results.push({ onuId, ok: false, error: toWireError(err) }); } } return results; }, async fetchFecHealth(s, body, write) { const { onuIds, concurrency = 8 } = body; return runPool(onuIds, concurrency, async (id) => { try { const stateDoc = await s.client.getOnuState(id); const health = extractOnuHealth(stateDoc); return { onuId: id, flag: health.flag, detail: health.detail || '', counters: health.counters || [], optical: health.optical || {}, sampleTime: stateDoc?.Time || '', }; } catch (e) { return { onuId: id, flag: 'error', error: toWireError(e) }; } }, (entry, done, total) => write({ ...entry, done, total })); }, async fetchOnuSnapshot(s, body, write) { const { onuIds, concurrency = 8 } = body; return runPool(onuIds, concurrency, async (id) => { try { const [stateDoc, cfgDoc] = await Promise.all([ s.client.getOnuState(id).catch(() => null), s.client.getOnuConfig(id).catch(() => null), ]); const health = extractOnuHealth(stateDoc); return { onuId: id, flag: health.flag, counters: health.counters || [], optical: health.optical || {}, sampleTime: stateDoc?.Time || '', cfg: cfgDoc, }; } catch (e) { return { onuId: id, flag: 'error', error: toWireError(e) }; } }, (entry, done, total) => write({ onuId: entry.onuId, done, total, flag: entry.flag })); }, async deleteOnus(s, body, write) { const { onuIds, concurrency = 5 } = body; return runPool(onuIds, concurrency, async (id) => { let ok = false, error = null; try { await s.client.deleteOnuConfig(id); ok = true; try { await s.client.deleteOnuState(id); } catch (_) { /* best-effort */ } } catch (e) { error = toWireError(e); } return { onuId: id, ok, error }; }, (entry, done, total) => write({ onuId: entry.onuId, ok: entry.ok, error: entry.error, done, total })); }, async executeFloodChange(s, body, write) { const { oltIds, tagPattern, fromMode = 'private', targetMode = 'auto', ponFloodIdValue = 0, concurrency = 3, } = body; return runPool(oltIds, concurrency, async (id) => { try { const cfg = await s.client.getOltConfig(id); if (!cfg) throw new Error('OLT not found'); const plan = planFloodChange(cfg, { tagPattern, fromMode, targetMode, ponFloodIdValue }); if (plan.changes.length === 0) return { oltId: id, ok: true, noop: true, changes: [], skipped: plan.unchanged }; await s.client.putOltConfig(id, plan.newDoc); return { oltId: id, ok: true, changes: plan.changes, skipped: plan.unchanged }; } catch (e) { return { oltId: id, ok: false, error: toWireError(e) }; } }, (entry, done, total) => write({ ...entry, done, total })); }, }; // ---- Request router -------------------------------------------------- const server = http.createServer(async (req, res) => { try { const pathname = new URL(req.url, 'http://localhost').pathname; if (req.method === 'GET') return serveStatic(pathname, res); if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: { message: 'method not allowed' } }); if (!pathname.startsWith('/api/')) return sendJson(res, 404, { ok: false, error: { message: 'not found' } }); const name = pathname.slice(5).replace(/\/$/, ''); // Login establishes a session + its own McmsClient. if (name === 'login') { const body = await readBody(req, SMALL_BODY); const { baseUrl, username, password, acceptSelfSigned } = body || {}; const client = new McmsClient({ baseUrl, rejectUnauthorized: !acceptSelfSigned, verbose: VERBOSE }); try { const result = await client.login(username, password); const sid = crypto.randomUUID(); sessions.set(sid, { client, baseUrl, user: username, lastSeen: Date.now(), sid }); setSidCookie(res, sid, req); return sendJson(res, 200, { ok: true, data: result }); } catch (err) { return sendJson(res, 200, { ok: false, error: toWireError(err) }); } } // Everything else requires a live session. const session = getSession(req); if (!session) return sendJson(res, 401, { ok: false, error: { message: 'Not logged in — session expired or missing.' } }); session.lastSeen = Date.now(); if (name === 'logout') { try { await session.client.logout(); } catch (_) { /* best-effort */ } sessions.delete(session.sid); clearSidCookie(res, req); return sendJson(res, 200, { ok: true }); } if (handlers[name]) { const limit = name === 'uploadFirmware' ? UPLOAD_BODY : SMALL_BODY; const body = await readBody(req, limit); try { return sendJson(res, 200, await handlers[name](session, body)); } catch (err) { return sendJson(res, 200, { ok: false, error: toWireError(err) }); } } if (streamHandlers[name]) { const body = await readBody(req, SMALL_BODY); res.writeHead(200, { 'Content-Type': 'application/x-ndjson; charset=utf-8', 'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no', // ask nginx not to buffer the stream }); const write = (obj) => { try { res.write(JSON.stringify(obj) + '\n'); } catch (_) { /* client gone */ } }; try { const data = await streamHandlers[name](session, body, write); write({ __final: true, ok: true, data }); } catch (err) { write({ __final: true, ok: false, error: toWireError(err) }); } return res.end(); } return sendJson(res, 404, { ok: false, error: { message: 'unknown endpoint: ' + name } }); } catch (err) { if (!res.headersSent) sendJson(res, 500, { ok: false, error: { message: err?.message || String(err) } }); else try { res.end(); } catch (_) { /* ignore */ } } }); server.listen(PORT, HOST, () => { console.log(`[pon-fleet-web] listening on http://${HOST}:${PORT}`); console.log(`[pon-fleet-web] idle session timeout: ${IDLE_MS / 60000} min · verbose MCMS: ${VERBOSE}`); console.log('[pon-fleet-web] put a TLS-terminating reverse proxy (Caddy/nginx) in front for remote use.'); });