/** * modem-bridge.js — Client for the modem-bridge Playwright service * * DEPENDENCY: modem-bridge container (port 3301 on Docker proxy network) * * Provides targo-hub with access to TP-Link ONU web GUI data * by proxying requests to the modem-bridge headless Chromium service. * * ENDPOINTS EXPOSED (on targo-hub, port 3300): * GET /modem/status?ip=X&user=Y&pass=Z — Full status (auto-login) * GET /modem/dm/:oid?ip=X&user=Y&pass=Z — Raw DM read * GET /modem/diagnostic?ip=X&user=Y&pass=Z — Full WiFi/mesh/WAN diagnostic * GET /modem/screenshot?ip=X&user=Y&pass=Z — PNG screenshot * GET /modem/sessions — List active sessions * POST /modem/close — Close a session */ const http = require('http'); const { log, json: jsonResp, parseBody, erpFetch } = require('./helpers'); const BRIDGE_HOST = process.env.BRIDGE_HOST || 'modem-bridge'; const BRIDGE_PORT = parseInt(process.env.BRIDGE_PORT || '3301'); const BRIDGE_TOKEN = process.env.BRIDGE_TOKEN || ''; const DEFAULT_MODEM_USER = 'superadmin'; const DEFAULT_MODEM_PASS = process.env.DEFAULT_MODEM_PASS || ''; // Concurrency limiter for Playwright sessions (512MB container limit) const MAX_CONCURRENT = 3; let activeCount = 0; const waitQueue = []; function withConcurrencyLimit(fn) { return new Promise((resolve, reject) => { function run() { activeCount++; fn().then(resolve).catch(reject).finally(() => { activeCount--; if (waitQueue.length > 0) waitQueue.shift()(); }); } if (activeCount >= MAX_CONCURRENT) { waitQueue.push(run); } else { run(); } }); } // Diagnostic result cache (in-memory, TTL-based) const diagCache = new Map(); const DIAG_CACHE_TTL = 5 * 60 * 1000; // 5 minutes /** * Lookup equipment credentials from ERPNext by serial number */ async function getEquipmentCredentials(serial) { // Find equipment by serial const filters = encodeURIComponent(JSON.stringify([['serial_number', '=', serial]])); const fields = encodeURIComponent(JSON.stringify(['name', 'ip_address', 'login_user', 'brand', 'model', 'serial_number'])); const res = await erpFetch(`/api/resource/Service Equipment?filters=${filters}&fields=${fields}&limit_page_length=1`); if (res.status !== 200 || !res.data?.data?.length) return null; const eq = res.data.data[0]; // Get password via Frappe's password API let pass = DEFAULT_MODEM_PASS; try { const passRes = await erpFetch(`/api/method/frappe.client.get_password?doctype=Service%20Equipment&name=${encodeURIComponent(eq.name)}&fieldname=login_password`); if (passRes.status === 200 && passRes.data?.message) { pass = passRes.data.message; } } catch (e) { log('getEquipmentCredentials: password fetch failed for', eq.name, e.message); } return { ip: (typeof eq.ip_address === 'string' && eq.ip_address.trim()) ? eq.ip_address.trim() : null, user: eq.login_user || DEFAULT_MODEM_USER, pass, brand: eq.brand, model: eq.model, serial: eq.serial_number, equipmentName: eq.name, }; } /** * Make HTTP request to modem-bridge service */ function bridgeRequest(method, path, body = null) { return new Promise((resolve, reject) => { const opts = { hostname: BRIDGE_HOST, port: BRIDGE_PORT, path, method, headers: { 'Content-Type': 'application/json', } }; if (BRIDGE_TOKEN) { opts.headers['Authorization'] = `Bearer ${BRIDGE_TOKEN}`; } const bodyStr = body ? JSON.stringify(body) : ''; if (body) opts.headers['Content-Length'] = Buffer.byteLength(bodyStr); const req = http.request(opts, res => { let data = ''; res.on('data', c => data += c); res.on('end', () => { try { // Screenshots return binary if (res.headers['content-type']?.includes('image/png')) { resolve({ status: res.statusCode, body: Buffer.from(data, 'binary'), isPng: true }); } else { resolve({ status: res.statusCode, body: JSON.parse(data) }); } } catch(e) { resolve({ status: res.statusCode, body: data }); } }); }); req.on('error', reject); if (body) req.write(bodyStr); req.end(); }); } /** * Ensure a session exists for the given modem, login if needed */ async function ensureSession(ip, user, pass, path) { // Check if session already exists const listRes = await bridgeRequest('GET', '/session/list'); if (listRes.status === 200 && Array.isArray(listRes.body)) { const existing = listRes.body.find(s => s.ip === ip && s.loggedIn); if (existing) return existing; } // Login const loginRes = await bridgeRequest('POST', '/session/login', { ip, user, pass, path: path || '/superadmin' }); if (loginRes.status !== 200) { throw new Error(`Modem login failed: ${loginRes.body?.error || 'Unknown error'}`); } return loginRes.body; } /** * Route handler for targo-hub */ async function handleModemRequest(req, res, path) { try { // GET /modem/sessions — list active sessions if (path === '/modem/sessions' && req.method === 'GET') { const result = await bridgeRequest('GET', '/session/list'); return jsonResp(res, result.status, result.body); } // POST /modem/close — close a session if (path === '/modem/close' && req.method === 'POST') { const body = await parseBody(req); if (!body.ip) return jsonResp(res, 400, { error: 'Missing ip' }); const result = await bridgeRequest('DELETE', `/session/${body.ip}`); return jsonResp(res, result.status, result.body); } // GET /modem/health — bridge health (no auth needed) if (path === '/modem/health' && req.method === 'GET') { const result = await bridgeRequest('GET', '/health'); return jsonResp(res, result.status, result.body); } // GET /modem/identify?ip=X — auto-detect modem type (no login needed) if (path === '/modem/identify' && req.method === 'GET') { const url = new URL(req.url, `http://${req.headers.host}`); const ip = url.searchParams.get('ip'); if (!ip) return jsonResp(res, 400, { error: 'Missing ip param' }); const result = await bridgeRequest('GET', `/identify/${ip}`); return jsonResp(res, result.status, result.body); } // GET /modem/manage-ip?serial=X — get management IP from OLT via SNMP // Optionally pass olt_ip, slot, port, ontid if not in SNMP cache if (path === '/modem/manage-ip' && req.method === 'GET') { const url = new URL(req.url, `http://${req.headers.host}`); const serial = url.searchParams.get('serial'); const opts = {}; if (url.searchParams.get('olt_ip')) opts.oltIp = url.searchParams.get('olt_ip'); if (url.searchParams.get('slot')) opts.slot = parseInt(url.searchParams.get('slot')); if (url.searchParams.get('port')) opts.port = parseInt(url.searchParams.get('port')); if (url.searchParams.get('ontid')) opts.ontId = parseInt(url.searchParams.get('ontid')); try { const { getManageIp } = require('./olt-snmp'); const result = await getManageIp(serial, opts); if (!result) return jsonResp(res, 404, { error: 'No management IP found' }); return jsonResp(res, 200, result); } catch(e) { return jsonResp(res, 502, { error: e.message }); } } // GET /modem/diagnostic/auto?serial=X — auto-fetch credentials from ERPNext, run diagnostic if (path === '/modem/diagnostic/auto' && req.method === 'GET') { const url = new URL(req.url, `http://${req.headers.host}`); const serial = url.searchParams.get('serial'); if (!serial) return jsonResp(res, 400, { error: 'Missing serial param' }); // Check cache first const cached = diagCache.get(serial); if (cached && (Date.now() - cached.ts) < DIAG_CACHE_TTL) { return jsonResp(res, 200, { ...cached.data, cached: true }); } // Lookup credentials from ERPNext const creds = await getEquipmentCredentials(serial); if (!creds) return jsonResp(res, 404, { error: 'Equipment not found in ERPNext', serial }); // Resolve management IP from OLT SNMP if not stored on equipment let ip = creds.ip; if (!ip) { try { const { getManageIp } = require('./olt-snmp'); const mgmt = await getManageIp(serial); if (mgmt) ip = mgmt.manageIp || (typeof mgmt === 'string' ? mgmt : null); } catch (e) { log('diagnostic/auto: OLT IP resolve failed for', serial, e.message); } } if (!ip || typeof ip !== 'string') return jsonResp(res, 404, { error: 'No management IP found', serial }); if (!creds.pass) return jsonResp(res, 400, { error: 'No password configured for equipment', serial, equipment: creds.equipmentName }); log('diagnostic/auto: running for', serial, 'ip:', ip, 'user:', creds.user); // Stateless oneshot: login → scrape → logout → close (no session reuse) const result = await withConcurrencyLimit(async () => { const diag = await bridgeRequest('POST', '/diagnostic/oneshot', { ip: String(ip), user: creds.user, pass: creds.pass, }); if (diag.status !== 200) throw new Error(diag.body?.error || `Bridge returned ${diag.status}`); return diag.body; }); // Cache result diagCache.set(serial, { data: result, ts: Date.now() }); return jsonResp(res, 200, result); } // All other /modem/* routes need ip, user, pass const url = new URL(req.url, `http://${req.headers.host}`); const ip = url.searchParams.get('ip'); const user = url.searchParams.get('user') || 'superadmin'; const pass = url.searchParams.get('pass'); const loginPath = url.searchParams.get('path') || '/superadmin'; if (!ip || !pass) { return jsonResp(res, 400, { error: 'Missing required params: ip, pass' }); } // Ensure session exists await ensureSession(ip, user, pass, loginPath); // GET /modem/status — full status if (path === '/modem/status' && req.method === 'GET') { const result = await bridgeRequest('GET', `/modem/${ip}/status`); return jsonResp(res, result.status, result.body); } // GET /modem/dm/:oid — raw data manager read const dmMatch = path.match(/^\/modem\/dm\/(.+)$/); if (dmMatch && req.method === 'GET') { const oid = dmMatch[1]; let queryStr = `?`; if (url.searchParams.get('stack')) queryStr += `stack=${url.searchParams.get('stack')}&`; if (url.searchParams.get('attrs')) queryStr += `attrs=${url.searchParams.get('attrs')}&`; const result = await bridgeRequest('GET', `/modem/${ip}/dm/${oid}${queryStr}`); return jsonResp(res, result.status, result.body); } // GET /modem/diagnostic — unified diagnostic via stateless oneshot if (path === '/modem/diagnostic' && req.method === 'GET') { const result = await bridgeRequest('POST', '/diagnostic/oneshot', { ip, user, pass }); return jsonResp(res, result.status, result.body); } // GET /modem/screenshot — PNG if (path === '/modem/screenshot' && req.method === 'GET') { const result = await bridgeRequest('GET', `/modem/${ip}/screenshot`); if (result.isPng) { res.writeHead(200, { 'Content-Type': 'image/png', 'Content-Length': result.body.length }); return res.end(result.body); } return jsonResp(res, result.status, result.body); } return jsonResp(res, 404, { error: 'Unknown modem endpoint' }); } catch(e) { log('modem-bridge error:', e.message); return jsonResp(res, 502, { error: e.message }); } } module.exports = { handleModemRequest };