// tplink-session.js — Playwright-based TP-Link ONU session manager // Uses headless Chromium because TP-Link XX230v GDPR_ENCRYPT requires // the modem's own JS to handle RSA/AES key exchange natively. const { chromium } = require('playwright'); const sessions = new Map(); const IDLE_TIMEOUT_MS = 5 * 60 * 1000; const LOGIN_TIMEOUT_MS = 15 * 1000; const NAV_TIMEOUT_MS = 20 * 1000; let browser = null; async function getBrowser() { if (!browser || !browser.isConnected()) { browser = await chromium.launch({ headless: true, args: [ '--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-gpu', '--ignore-certificate-errors', '--disable-web-security', ] }); console.log('[modem-bridge] Chromium launched'); } return browser; } function resetIdleTimer(ip) { const s = sessions.get(ip); if (!s) return; clearTimeout(s.idleTimer); s.idleTimer = setTimeout(() => { console.log(`[modem-bridge] Session expired: ${ip}`); closeSession(ip); }, IDLE_TIMEOUT_MS); } function getSession(ip) { const s = sessions.get(ip); if (!s || !s.loggedIn) throw new Error(`No active session for ${ip}`); s.lastActivity = Date.now(); resetIdleTimer(ip); return s; } async function login(ip, user, pass, path = '/superadmin') { if (sessions.has(ip)) await closeSession(ip); const b = await getBrowser(); const context = await b.newContext({ ignoreHTTPSErrors: true, viewport: { width: 1280, height: 720 }, userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' }); const page = await context.newPage(); const url = `https://${ip}${path}`; await page.goto(url, { waitUntil: 'networkidle', timeout: NAV_TIMEOUT_MS }); await page.waitForSelector('#pc-login-password', { timeout: LOGIN_TIMEOUT_MS }); const hasUsername = await page.evaluate(() => { const el = document.getElementById('pc-login-user-div'); return el && !el.classList.contains('nd'); }); if (hasUsername) await page.fill('#pc-login-user', user); await page.fill('#pc-login-password', pass); await page.waitForTimeout(300); const navigationPromise = page.waitForNavigation({ waitUntil: 'networkidle', timeout: LOGIN_TIMEOUT_MS }).catch(() => null); await page.click('#pc-login-btn'); await navigationPromise; await page.waitForTimeout(2000); // Handle force-logout dialog if another session was active const forceLogoutBtn = await page.$('#confirm-yes'); if (forceLogoutBtn && await forceLogoutBtn.isVisible()) { console.log(`[modem-bridge] Force logout dialog for ${ip}, confirming...`); const navP2 = page.waitForNavigation({ waitUntil: 'networkidle', timeout: LOGIN_TIMEOUT_MS }).catch(() => null); await forceLogoutBtn.click(); await navP2; await page.waitForTimeout(3000); } const loginResult = await page.evaluate(() => { const loginDiv = document.getElementById('pc-login'); if (loginDiv && !loginDiv.classList.contains('nd')) { const errEl = document.querySelector('.errDiv .errText, .errDivP .errTextP'); return { success: false, error: errEl ? errEl.textContent.trim() : 'Login failed - still on login page' }; } return { success: true, hasDm: typeof $ !== 'undefined' && $ && $.dm && typeof $.dm.get === 'function' }; }); if (!loginResult.success) { await context.close(); throw new Error(`Login failed for ${ip}: ${loginResult.error}`); } const session = { ip, context, page, loggedIn: true, loginTime: Date.now(), lastActivity: Date.now(), hasDm: loginResult.hasDm }; sessions.set(ip, session); resetIdleTimer(ip); console.log(`[modem-bridge] Logged in to ${ip} (hasDm: ${loginResult.hasDm})`); return { ip, loggedIn: true, loginTime: session.loginTime, hasDm: loginResult.hasDm }; } async function dmGet(ip, oid, opts = {}) { const s = getSession(ip); const stack = opts.stack || '0,0,0,0,0,0'; const attrs = opts.attrs || null; return await s.page.evaluate(({ oid, stack, attrs }) => { return new Promise((resolve, reject) => { if (!$ || !$.dm || !$.dm.get) { reject(new Error('Data manager not available')); return; } const req = { oid, data: { stack }, callback: { success: (data, others) => resolve({ success: true, data, others }), fail: (errorcode, others, data) => resolve({ success: false, errorcode, data }), error: (status) => reject(new Error('Request error: ' + status)) } }; if (attrs) req.data.attrs = attrs; try { $.dm.get(req); } catch(e) { reject(e); } setTimeout(() => reject(new Error('DM request timeout')), 8000); }); }, { oid, stack, attrs }); } async function cgiRequest(ip, oidPath) { const s = getSession(ip); return await s.page.evaluate((oidPath) => { return new Promise((resolve, reject) => { if (!$ || !$.dm || !$.dm.cgi) { reject(new Error('CGI not available')); return; } $.dm.cgi({ oid: oidPath, callback: { success: (data) => resolve({ success: true, data }), fail: (errorcode) => resolve({ success: false, errorcode }) } }); setTimeout(() => reject(new Error('CGI request timeout')), 8000); }); }, oidPath); } async function getStatus(ip) { const s = getSession(ip); return await s.page.evaluate(() => { const status = {}; try { if (typeof deviceInfo !== 'undefined') status.deviceInfo = deviceInfo; } catch(e) {} try { if ($ && $.status) status.cached = $.status; } catch(e) {} try { status.dom = {}; document.querySelectorAll('[id*="status"], [id*="info"], [class*="status"]').forEach(el => { if (el.textContent.trim().length < 200) status.dom[el.id || el.className] = el.textContent.trim(); }); } catch(e) {} return status; }); } async function screenshot(ip) { const s = getSession(ip); return await s.page.screenshot({ type: 'png', fullPage: false }); } async function wifiDiagnostic(ip) { const s = getSession(ip); return await s.page.evaluate(() => { const start = Date.now(); // Factory for $.dm query calls — handles both getSubList and get function dmQuery(method, oid, extraData) { return new Promise((resolve) => { if (!$ || !$.dm || !$.dm[method]) { resolve({ oid, error: `${method} not available` }); return; } const req = { oid, callback: { success: (data) => resolve({ oid, data }), fail: (code) => resolve({ oid, error: code }), } }; if (extraData) req.data = extraData; $.dm[method](req); setTimeout(() => resolve({ oid, error: 'timeout' }), 8000); }); } return Promise.allSettled([ dmQuery('getSubList', 'DEV2_WIFI_RADIO'), dmQuery('getSubList', 'DEV2_WIFI_APDEV'), dmQuery('getSubList', 'DEV2_WIFI_APDEV_RADIO'), dmQuery('getSubList', 'DEV2_WIFI_APDEV_ASSOCDEV'), dmQuery('getSubList', 'DEV2_WIFI_DE_STA'), dmQuery('getSubList', 'DEV2_WIFI_APDEV_STATS'), dmQuery('getSubList', 'DEV2_IP_INTF_V4ADDR'), dmQuery('get', 'DEV2_ONLINESTATUS', { stack: '0,0,0,0,0,0' }), ]).then(results => { const keys = ['radios', 'meshNodes', 'nodeRadios', 'clients', 'clientStats', 'packetStats', 'wanIPs', 'onlineStatus']; const out = { fetchedAt: new Date().toISOString(), durationMs: Date.now() - start }; results.forEach((r, i) => { const val = r.status === 'fulfilled' ? r.value : { error: r.reason }; out[keys[i]] = val.error ? { error: val.error } : val.data; }); return out; }); }); } async function evaluate(ip, code) { const s = getSession(ip); return await s.page.evaluate(new Function('return (' + code + ')')); } async function getPageContent(ip) { const s = getSession(ip); return await s.page.content(); } async function navigate(ip, path) { const s = getSession(ip); await s.page.goto(`https://${ip}${path}`, { waitUntil: 'networkidle', timeout: NAV_TIMEOUT_MS }); } async function closeSession(ip) { const s = sessions.get(ip); if (!s) return; clearTimeout(s.idleTimer); try { await s.context.close(); } catch(e) { console.warn(`[modem-bridge] Error closing session for ${ip}:`, e.message); } sessions.delete(ip); console.log(`[modem-bridge] Session closed for ${ip}`); } async function shutdown() { for (const [ip] of sessions) await closeSession(ip); if (browser) { await browser.close(); browser = null; } console.log('[modem-bridge] Shutdown complete'); } function listSessions() { const result = []; for (const [ip, s] of sessions) { result.push({ ip, loggedIn: s.loggedIn, loginTime: s.loginTime, lastActivity: s.lastActivity, idleMs: Date.now() - s.lastActivity, hasDm: s.hasDm }); } return result; } module.exports = { login, dmGet, cgiRequest, getStatus, wifiDiagnostic, evaluate, screenshot, getPageContent, navigate, closeSession, shutdown, listSessions };