gigafibre-fsm/services/modem-bridge/lib/tplink-session.js
louispaulb 607ea54b5c refactor: reduce token count, DRY code, consolidate docs
Backend services:
- targo-hub: extract deepGetValue to helpers.js, DRY disconnect reasons
  lookup map, compact CAPABILITIES, consolidate vision.js prompts/schemas,
  extract dispatch scoring weights, trim section dividers across 9 files
- modem-bridge: extract getSession() helper (6 occurrences), resetIdleTimer(),
  consolidate DM query factory, fix duplicate username fill bug, trim headers
  (server.js -36%, tplink-session.js -47%, docker-compose.yml -57%)

Frontend:
- useWifiDiagnostic: extract THRESHOLDS const, split processDiagnostic into
  6 focused helpers (processOnlineStatus, processWanIPs, processRadios,
  processMeshNodes, processClients, checkRadioIssues)
- EquipmentDetail: merge duplicate ROLE_LABELS, remove verbose comments

Documentation (17 → 13 files, -1,400 lines):
- New consolidated README.md (architecture, services, dependencies, auth)
- Merge ECOSYSTEM-OVERVIEW into ARCHITECTURE.md
- Merge MIGRATION-PLAN + ARCHITECTURE-COMPARE + FIELD-GAP + CHANGELOG → MIGRATION.md
- Merge COMPETITIVE-ANALYSIS into PLATFORM-STRATEGY.md
- Update ROADMAP.md with current phase status
- Delete CONTEXT.md (absorbed into README)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 08:39:58 -04:00

276 lines
8.9 KiB
JavaScript

// 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
};