/** * 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 } = 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 || ''; /** * 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); } // 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 — full WiFi/mesh/WAN diagnostic if (path === '/modem/diagnostic' && req.method === 'GET') { const result = await bridgeRequest('GET', `/modem/${ip}/wifi/diagnostic`); 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 };