Major additions accumulated over 9 days — single commit per request. Flow editor (new): - Generic visual editor for step trees, usable by project wizard + agent flows - PROJECT_KINDS / AGENT_KINDS catalogs decouple UI from domain - Drag-and-drop reorder via vuedraggable with scope isolation per peer group - Chain-aware depends_on rewrite on reorder (sequential only — DAGs preserved) - Variable picker with per-applies_to catalog (Customer / Quotation / Service Contract / Issue / Subscription), insert + copy-clipboard modes - trigger_condition helper with domain-specific JSONLogic examples - Global FlowEditorDialog mounted once in MainLayout, Odoo inline pattern - Server: targo-hub flow-runtime.js, flow-api.js, flow-templates.js - ERPNext: Flow Template/Run doctypes, scheduler, 5 seeded system templates - depends_on chips resolve to step labels instead of opaque "s4" ids QR/OCR scanner (field app): - Camera capture → Gemini Vision via targo-hub with 8s timeout - IndexedDB offline queue retries photos when signal returns - Watcher merges late-arriving scan results into the live UI Dispatch: - Planning mode (draft → publish) with offer pool for unassigned jobs - Shared presets, recurrence selector, suggested-slots dialog - PublishScheduleModal, unassign confirmation Ops app: - ClientDetailPage composables extraction (useClientData, useDeviceStatus, useWifiDiagnostic, useModemDiagnostic) - Project wizard: shared detail sections, wizard catalog/publish composables - Address pricing composable + pricing-mock data - Settings redesign hosting flow templates Targo-hub: - Contract acceptance (JWT residential + DocuSeal commercial tracks) - Referral system - Modem-bridge diagnostic normalizer - Device extractors consolidated Migration scripts: - Invoice/quote print format setup, Jinja rendering - Additional import + fix scripts (reversals, dates, customers, payments) Docs: - Consolidated: old scattered MDs → HANDOFF, ARCHITECTURE, DATA_AND_FLOWS, FLOW_EDITOR_ARCHITECTURE, BILLING_AND_PAYMENTS, CPE_MANAGEMENT, APP_DESIGN_GUIDELINES - Archived legacy wizard PHP for reference - STATUS snapshots for 2026-04-18/19 Cleanup: - Removed ~40 generated PDFs/HTMLs (invoice_preview*, rendered_jinja*) - .gitignore now covers invoice preview output + nested .DS_Store Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
613 lines
25 KiB
JavaScript
613 lines
25 KiB
JavaScript
// modem-session.js — Playwright-based multi-vendor modem session manager
|
|
// Supports: TP-Link XX230v (GDPR encrypt), Raisecom BOA, Raisecom PHP
|
|
|
|
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;
|
|
}
|
|
|
|
// --- Modem identification ---
|
|
// Probes the device via HTTP/HTTPS to detect vendor and firmware type
|
|
const MODEM_TYPES = {
|
|
TPLINK_XX230V: 'tplink_xx230v',
|
|
RAISECOM_BOA: 'raisecom_boa', // Type A: /admin/login.asp, BOA form, subcustom=raisecom
|
|
RAISECOM_PHP: 'raisecom_php', // Type B: /, PHP form, encrypt() JS
|
|
UNKNOWN: 'unknown',
|
|
};
|
|
|
|
async function identify(ip) {
|
|
const b = await getBrowser();
|
|
const context = await b.newContext({ ignoreHTTPSErrors: true, viewport: { width: 1280, height: 720 } });
|
|
const page = await context.newPage();
|
|
|
|
const result = { ip, type: MODEM_TYPES.UNKNOWN, title: '', protocol: 'https', loginPath: '/' };
|
|
|
|
// Try HTTPS first (TP-Link uses HTTPS)
|
|
for (const proto of ['https', 'http']) {
|
|
for (const path of ['/', '/superadmin', '/admin/login.asp']) {
|
|
try {
|
|
const resp = await page.goto(`${proto}://${ip}${path}`, { waitUntil: 'domcontentloaded', timeout: 8000 });
|
|
if (!resp || resp.status() >= 400) continue;
|
|
|
|
const html = await page.content();
|
|
const title = await page.title();
|
|
result.title = title;
|
|
result.protocol = proto;
|
|
result.loginPath = path;
|
|
|
|
// TP-Link XX230v: has $.tpLang or pc-login-password
|
|
if (html.includes('pc-login-password') || html.includes('tpLang') || html.includes('INCLUDE_LOGIN')) {
|
|
result.type = MODEM_TYPES.TPLINK_XX230V;
|
|
result.loginPath = path;
|
|
await context.close();
|
|
return result;
|
|
}
|
|
|
|
// Raisecom BOA: title "Home Gateway", subcustom="raisecom", formLogin action
|
|
if (html.includes('subcustom') && html.includes('raisecom') && html.includes('formLogin')) {
|
|
result.type = MODEM_TYPES.RAISECOM_BOA;
|
|
await context.close();
|
|
return result;
|
|
}
|
|
|
|
// Raisecom PHP: title "Web user login", encrypt() function, user_name field
|
|
if ((title.includes('Web user login') || html.includes('Web user login')) && html.includes('user_name') && html.includes('encrypt')) {
|
|
result.type = MODEM_TYPES.RAISECOM_PHP;
|
|
await context.close();
|
|
return result;
|
|
}
|
|
} catch { continue; }
|
|
}
|
|
}
|
|
|
|
await context.close();
|
|
return result;
|
|
}
|
|
|
|
// --- Login handlers per modem type ---
|
|
|
|
async function loginTplink(ip, user, pass, path) {
|
|
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();
|
|
|
|
await page.goto(`https://${ip}${path}`, { 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 navP = page.waitForNavigation({ waitUntil: 'networkidle', timeout: LOGIN_TIMEOUT_MS }).catch(() => null);
|
|
await page.click('#pc-login-btn');
|
|
await navP;
|
|
await page.waitForTimeout(2000);
|
|
|
|
// Force-logout dialog
|
|
const forceBtn = await page.$('#confirm-yes');
|
|
if (forceBtn && await forceBtn.isVisible()) {
|
|
const navP2 = page.waitForNavigation({ waitUntil: 'networkidle', timeout: LOGIN_TIMEOUT_MS }).catch(() => null);
|
|
await forceBtn.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' };
|
|
}
|
|
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}`); }
|
|
return { context, page, hasDm: loginResult.hasDm, modemType: MODEM_TYPES.TPLINK_XX230V };
|
|
}
|
|
|
|
async function loginRaisecomBoa(ip, user, pass, path) {
|
|
const b = await getBrowser();
|
|
const context = await b.newContext({ ignoreHTTPSErrors: true, viewport: { width: 1280, height: 720 } });
|
|
const page = await context.newPage();
|
|
|
|
await page.goto(`http://${ip}${path}`, { waitUntil: 'domcontentloaded', timeout: NAV_TIMEOUT_MS });
|
|
await page.waitForSelector('input[name="username"]', { timeout: LOGIN_TIMEOUT_MS });
|
|
|
|
await page.fill('input[name="username"]', user);
|
|
await page.fill('input[name="psd"]', pass);
|
|
await page.waitForTimeout(200);
|
|
|
|
// BOA form submits to /boaform/admin/formLogin — triggers page reload
|
|
const navP = page.waitForNavigation({ waitUntil: 'domcontentloaded', timeout: LOGIN_TIMEOUT_MS }).catch(() => null);
|
|
await page.evaluate(() => document.forms[0].submit());
|
|
await navP;
|
|
await page.waitForTimeout(2000);
|
|
|
|
// Check if we're past login
|
|
const url = page.url();
|
|
const html = await page.content();
|
|
const stillOnLogin = html.includes('formLogin') || html.includes('User Login');
|
|
if (stillOnLogin) { await context.close(); throw new Error(`Raisecom BOA login failed for ${ip}`); }
|
|
|
|
return { context, page, hasDm: false, modemType: MODEM_TYPES.RAISECOM_BOA };
|
|
}
|
|
|
|
async function loginRaisecomPhp(ip, user, pass, protocol = 'https') {
|
|
const b = await getBrowser();
|
|
const context = await b.newContext({ ignoreHTTPSErrors: true, viewport: { width: 1280, height: 720 } });
|
|
const page = await context.newPage();
|
|
|
|
// WS2 models use HTTPS, WS1 PHP variants use HTTP — auto-detected by identify()
|
|
await page.goto(`${protocol}://${ip}/`, { waitUntil: 'networkidle', timeout: NAV_TIMEOUT_MS });
|
|
await page.waitForSelector('input[name="user_name"]', { timeout: LOGIN_TIMEOUT_MS });
|
|
|
|
await page.fill('input[name="user_name"]', user);
|
|
await page.fill('input[name="password"]', pass);
|
|
await page.waitForTimeout(300);
|
|
|
|
// Click the submit button to trigger JS encrypt() chain (mySubmit -> encrypt -> CryptoJS)
|
|
const navP = page.waitForNavigation({ waitUntil: 'domcontentloaded', timeout: LOGIN_TIMEOUT_MS }).catch(() => null);
|
|
await page.click('input[name="login"]').catch(() => {
|
|
// Fallback: evaluate mySubmit or native submit
|
|
return page.evaluate(() => {
|
|
if (typeof mySubmit === 'function') mySubmit(document.forms[0]);
|
|
else document.forms[0].submit();
|
|
});
|
|
});
|
|
await navP;
|
|
await page.waitForTimeout(3000);
|
|
|
|
const html = await page.content();
|
|
const stillOnLogin = html.includes('Web user login') && html.includes('user_name');
|
|
if (stillOnLogin) { await context.close(); throw new Error(`Raisecom PHP login failed for ${ip}`); }
|
|
|
|
return { context, page, hasDm: false, modemType: MODEM_TYPES.RAISECOM_PHP };
|
|
}
|
|
|
|
// --- Unified login ---
|
|
|
|
async function login(ip, user, pass, path = '/superadmin') {
|
|
if (sessions.has(ip)) await closeSession(ip);
|
|
|
|
let result;
|
|
|
|
// Always identify modem type first — don't assume from path alone
|
|
const id = await identify(ip);
|
|
console.log(`[modem-bridge] Identified ${ip} as ${id.type} (title: "${id.title}", path: ${id.loginPath})`);
|
|
|
|
if (id.type === MODEM_TYPES.TPLINK_XX230V) {
|
|
result = await loginTplink(ip, user, pass, id.loginPath || path);
|
|
} else if (id.type === MODEM_TYPES.RAISECOM_BOA) {
|
|
result = await loginRaisecomBoa(ip, user, pass, id.loginPath || '/');
|
|
} else if (id.type === MODEM_TYPES.RAISECOM_PHP) {
|
|
result = await loginRaisecomPhp(ip, user, pass, id.protocol || 'https');
|
|
} else {
|
|
throw new Error(`Unknown modem type at ${ip} (title: "${id.title}")`);
|
|
}
|
|
|
|
const session = {
|
|
ip, context: result.context, page: result.page,
|
|
loggedIn: true, loginTime: Date.now(), lastActivity: Date.now(),
|
|
hasDm: result.hasDm, modemType: result.modemType,
|
|
};
|
|
sessions.set(ip, session);
|
|
resetIdleTimer(ip);
|
|
|
|
console.log(`[modem-bridge] Logged in to ${ip} (type: ${result.modemType}, hasDm: ${result.hasDm})`);
|
|
return { ip, loggedIn: true, loginTime: session.loginTime, hasDm: result.hasDm, modemType: result.modemType };
|
|
}
|
|
|
|
// --- Data access (TP-Link specific: $.dm) ---
|
|
|
|
async function dmGet(ip, oid, opts = {}) {
|
|
const s = getSession(ip);
|
|
if (!s.hasDm) throw new Error(`Data manager not available on ${ip} (type: ${s.modemType})`);
|
|
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);
|
|
if (!s.hasDm) throw new Error(`CGI not available on ${ip} (type: ${s.modemType})`);
|
|
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);
|
|
}
|
|
|
|
// --- TP-Link WiFi diagnostic ($.dm based) ---
|
|
|
|
async function wifiDiagnostic(ip) {
|
|
const s = getSession(ip);
|
|
if (s.modemType !== MODEM_TYPES.TPLINK_XX230V) throw new Error(`wifiDiagnostic not supported on ${s.modemType}`);
|
|
|
|
return await s.page.evaluate(() => {
|
|
const start = Date.now();
|
|
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, modemType: 'tplink_xx230v' };
|
|
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;
|
|
});
|
|
});
|
|
}
|
|
|
|
// --- Raisecom diagnostic (targeted JS extraction) ---
|
|
|
|
// Raisecom BOA pages embed data as JS arrays: var arr=[]; arr.push(new it_nr("idx", new it("key", val), ...))
|
|
// We fetch each page's raw HTML and return it for server-side parsing by diagnostic-normalizer.js
|
|
|
|
const RAISECOM_BOA_PAGES = [
|
|
{ path: '/status_ethernet_info.asp', key: 'ethernet' }, // Ethernet ports + DHCP clients
|
|
{ path: '/status_device_basic_info.asp', key: 'deviceInfo' }, // Model, serial, firmware, CPU, uptime
|
|
{ path: '/status_wlan_info_11n.asp', key: 'wlan' }, // WiFi clients with RSSI, rates
|
|
{ path: '/status_net_connet_info.asp', key: 'wan' }, // WAN interfaces, IPs, gateways
|
|
{ path: '/status_gpon.asp', key: 'gpon' }, // GPON optical power, link state
|
|
];
|
|
|
|
// WS2 (PHP) uses /sys_manager/list_status.php?parts=X and /interface/ pages
|
|
const RAISECOM_PHP_PAGES = [
|
|
{ path: '/sys_manager/list_status.php?parts=hostinfo', key: 'deviceInfo' },
|
|
{ path: '/sys_manager/list_status.php?parts=waninfo', key: 'wan' },
|
|
{ path: '/sys_manager/list_status.php?parts=laninfo', key: 'ethernet' },
|
|
{ path: '/sys_manager/list_status.php?parts=wlaninfo', key: 'wlan' },
|
|
{ path: '/sys_manager/list_status.php?parts=poninfo', key: 'gpon' },
|
|
{ path: '/sys_manager/list_session.php', key: 'sessions' },
|
|
];
|
|
|
|
async function raisecomDiagnostic(ip) {
|
|
const s = getSession(ip);
|
|
if (!s.modemType.startsWith('raisecom')) throw new Error(`raisecomDiagnostic not supported on ${s.modemType}`);
|
|
|
|
const start = Date.now();
|
|
const pages = s.modemType === MODEM_TYPES.RAISECOM_BOA ? RAISECOM_BOA_PAGES : RAISECOM_PHP_PAGES;
|
|
const proto = s.modemType === MODEM_TYPES.RAISECOM_PHP ? 'https' : 'http';
|
|
const baseUrl = `${proto}://${ip}`;
|
|
const rawPages = {};
|
|
|
|
for (const pg of pages) {
|
|
try {
|
|
// Fetch page HTML directly (faster than navigating + waiting for render)
|
|
const html = await s.page.evaluate((url) => fetch(url).then(r => r.text()), `${baseUrl}${pg.path}`);
|
|
rawPages[pg.key] = html;
|
|
} catch (e) {
|
|
rawPages[pg.key] = null;
|
|
}
|
|
}
|
|
|
|
return {
|
|
fetchedAt: new Date().toISOString(),
|
|
durationMs: Date.now() - start,
|
|
modemType: s.modemType,
|
|
rawPages,
|
|
};
|
|
}
|
|
|
|
// --- Unified diagnostic (routes to correct handler, returns normalized data) ---
|
|
|
|
async function unifiedDiagnostic(ip) {
|
|
const s = getSession(ip);
|
|
const { normalizeRaisecomBoa, normalizeRaisecomPhp, normalizeTplink } = require('./diagnostic-normalizer');
|
|
|
|
if (s.modemType === MODEM_TYPES.TPLINK_XX230V) {
|
|
const raw = await wifiDiagnostic(ip);
|
|
const result = normalizeTplink(raw);
|
|
result.fetchedAt = raw.fetchedAt;
|
|
result.durationMs = raw.durationMs;
|
|
return result;
|
|
}
|
|
|
|
if (s.modemType.startsWith('raisecom')) {
|
|
const raw = await raisecomDiagnostic(ip);
|
|
const normalize = s.modemType === MODEM_TYPES.RAISECOM_PHP ? normalizeRaisecomPhp : normalizeRaisecomBoa;
|
|
const result = normalize(raw.rawPages);
|
|
result.fetchedAt = raw.fetchedAt;
|
|
result.durationMs = raw.durationMs;
|
|
|
|
// WS2 (PHP) only allows 1 session — auto-logout to free it for the user
|
|
if (s.modemType === MODEM_TYPES.RAISECOM_PHP) {
|
|
const proto = 'https';
|
|
s.page.evaluate((url) => fetch(url).catch(() => {}), `${proto}://${ip}/top_operation.php?top_parts=login_out_time`).catch(() => {});
|
|
closeSession(ip).catch(() => {});
|
|
console.log(`[modem-bridge] Auto-logout WS2 ${ip} after diagnostic`);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
throw new Error(`Unsupported modem type: ${s.modemType}`);
|
|
}
|
|
|
|
// --- Stateless one-shot diagnostic (no session reuse) ---
|
|
// Single browser context: open page → detect type → login → scrape → logout → close
|
|
// No separate identify() call — avoids WS2 session-slot consumption
|
|
|
|
async function oneshotDiagnostic(ip, user, pass) {
|
|
const start = Date.now();
|
|
const { normalizeRaisecomBoa, normalizeRaisecomPhp, normalizeTplink } = require('./diagnostic-normalizer');
|
|
|
|
// Kill any existing session for this IP first
|
|
if (sessions.has(ip)) {
|
|
await closeSession(ip).catch(() => {});
|
|
}
|
|
|
|
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();
|
|
|
|
try {
|
|
// --- Phase 1: detect modem type from the same page/context we'll login with ---
|
|
let modemType = MODEM_TYPES.UNKNOWN;
|
|
let proto = 'https';
|
|
|
|
for (const p of ['https', 'http']) {
|
|
try {
|
|
await page.goto(`${p}://${ip}/`, { waitUntil: 'domcontentloaded', timeout: 10000 });
|
|
const html = await page.content();
|
|
const title = await page.title();
|
|
|
|
if (html.includes('pc-login-password') || html.includes('tpLang') || html.includes('INCLUDE_LOGIN')) {
|
|
modemType = MODEM_TYPES.TPLINK_XX230V; proto = p; break;
|
|
}
|
|
if (html.includes('subcustom') && html.includes('raisecom') && html.includes('formLogin')) {
|
|
modemType = MODEM_TYPES.RAISECOM_BOA; proto = p; break;
|
|
}
|
|
if ((title.includes('Web user login') || html.includes('Web user login')) && html.includes('user_name') && html.includes('encrypt')) {
|
|
modemType = MODEM_TYPES.RAISECOM_PHP; proto = p; break;
|
|
}
|
|
} catch { continue; }
|
|
}
|
|
|
|
console.log(`[modem-bridge] oneshot ${ip}: type=${modemType} proto=${proto}`);
|
|
if (modemType === MODEM_TYPES.UNKNOWN) throw new Error(`Unknown modem type at ${ip}`);
|
|
|
|
// --- Phase 2: login on the SAME page (no new context) ---
|
|
let hasDm = false;
|
|
|
|
if (modemType === MODEM_TYPES.TPLINK_XX230V) {
|
|
// TP-Link: navigate to superadmin, fill password
|
|
await page.goto(`${proto}://${ip}/superadmin`, { waitUntil: 'networkidle', timeout: NAV_TIMEOUT_MS });
|
|
await page.waitForSelector('#pc-login-password', { timeout: LOGIN_TIMEOUT_MS });
|
|
const hasUser = await page.evaluate(() => { const el = document.getElementById('pc-login-user-div'); return el && !el.classList.contains('nd'); });
|
|
if (hasUser) await page.fill('#pc-login-user', user);
|
|
await page.fill('#pc-login-password', pass);
|
|
await page.waitForTimeout(300);
|
|
const navP = page.waitForNavigation({ waitUntil: 'networkidle', timeout: LOGIN_TIMEOUT_MS }).catch(() => null);
|
|
await page.click('#pc-login-btn');
|
|
await navP;
|
|
await page.waitForTimeout(2000);
|
|
const forceBtn = await page.$('#confirm-yes');
|
|
if (forceBtn && await forceBtn.isVisible()) {
|
|
const navP2 = page.waitForNavigation({ waitUntil: 'networkidle', timeout: LOGIN_TIMEOUT_MS }).catch(() => null);
|
|
await forceBtn.click();
|
|
await navP2;
|
|
await page.waitForTimeout(3000);
|
|
}
|
|
hasDm = await page.evaluate(() => typeof $ !== 'undefined' && $ && $.dm && typeof $.dm.get === 'function');
|
|
|
|
} else if (modemType === MODEM_TYPES.RAISECOM_BOA) {
|
|
// BOA: already on login page from detection, fill form
|
|
await page.waitForSelector('input[name="username"]', { timeout: LOGIN_TIMEOUT_MS });
|
|
await page.fill('input[name="username"]', user);
|
|
await page.fill('input[name="psd"]', pass);
|
|
await page.waitForTimeout(200);
|
|
const navP = page.waitForNavigation({ waitUntil: 'domcontentloaded', timeout: LOGIN_TIMEOUT_MS }).catch(() => null);
|
|
await page.evaluate(() => document.forms[0].submit());
|
|
await navP;
|
|
await page.waitForTimeout(2000);
|
|
const html = await page.content();
|
|
if (html.includes('formLogin') || html.includes('User Login')) throw new Error(`Raisecom BOA login failed for ${ip}`);
|
|
|
|
} else if (modemType === MODEM_TYPES.RAISECOM_PHP) {
|
|
// WS2: already on login page from detection, fill and submit
|
|
await page.waitForSelector('input[name="user_name"]', { timeout: LOGIN_TIMEOUT_MS });
|
|
await page.fill('input[name="user_name"]', user);
|
|
await page.fill('input[name="password"]', pass);
|
|
await page.waitForTimeout(300);
|
|
const navP = page.waitForNavigation({ waitUntil: 'domcontentloaded', timeout: LOGIN_TIMEOUT_MS }).catch(() => null);
|
|
await page.click('input[name="login"]').catch(() => {
|
|
return page.evaluate(() => { if (typeof mySubmit === 'function') mySubmit(document.forms[0]); else document.forms[0].submit(); });
|
|
});
|
|
await navP;
|
|
await page.waitForTimeout(3000);
|
|
const html = await page.content();
|
|
if (html.includes('Web user login') && html.includes('user_name')) throw new Error(`Raisecom PHP login failed for ${ip}`);
|
|
}
|
|
|
|
console.log(`[modem-bridge] oneshot ${ip}: logged in (${modemType})`);
|
|
|
|
// --- Phase 3: register temp session, run diagnostic ---
|
|
const session = {
|
|
ip, context, page, loggedIn: true, loginTime: Date.now(),
|
|
lastActivity: Date.now(), hasDm, modemType,
|
|
};
|
|
sessions.set(ip, session);
|
|
|
|
let result;
|
|
if (modemType === MODEM_TYPES.TPLINK_XX230V) {
|
|
const raw = await wifiDiagnostic(ip);
|
|
result = normalizeTplink(raw);
|
|
result.fetchedAt = raw.fetchedAt;
|
|
} else {
|
|
const raw = await raisecomDiagnostic(ip);
|
|
const normalize = modemType === MODEM_TYPES.RAISECOM_PHP ? normalizeRaisecomPhp : normalizeRaisecomBoa;
|
|
result = normalize(raw.rawPages);
|
|
result.fetchedAt = new Date().toISOString();
|
|
}
|
|
result.durationMs = Date.now() - start;
|
|
|
|
// --- Phase 4: logout + close ---
|
|
if (modemType === MODEM_TYPES.RAISECOM_PHP) {
|
|
try {
|
|
// Navigate to logout page (not fetch — ensures modem processes it fully)
|
|
await page.goto(`${proto}://${ip}/top_operation.php?top_parts=login_out_time`, { waitUntil: 'domcontentloaded', timeout: 8000 }).catch(() => {});
|
|
// Give the modem time to release the session slot
|
|
await page.waitForTimeout(2000);
|
|
} catch {}
|
|
console.log(`[modem-bridge] oneshot ${ip}: WS2 logged out`);
|
|
}
|
|
|
|
return result;
|
|
|
|
} finally {
|
|
sessions.delete(ip);
|
|
await context.close().catch(() => {});
|
|
console.log(`[modem-bridge] oneshot ${ip}: done (${Date.now() - start}ms)`);
|
|
}
|
|
}
|
|
|
|
// --- Generic status (works for all types) ---
|
|
|
|
async function getStatus(ip) {
|
|
const s = getSession(ip);
|
|
if (s.modemType === MODEM_TYPES.TPLINK_XX230V) {
|
|
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;
|
|
});
|
|
}
|
|
// Raisecom: return page scrape
|
|
return raisecomDiagnostic(ip);
|
|
}
|
|
|
|
async function screenshot(ip) {
|
|
const s = getSession(ip);
|
|
return await s.page.screenshot({ type: 'png', fullPage: false });
|
|
}
|
|
|
|
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);
|
|
const proto = s.modemType.startsWith('raisecom') ? 'http' : 'https';
|
|
await s.page.goto(`${proto}://${ip}${path}`, { waitUntil: 'domcontentloaded', 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 ${ip}:`, e.message); }
|
|
sessions.delete(ip);
|
|
}
|
|
|
|
async function shutdown() {
|
|
for (const [ip] of sessions) await closeSession(ip);
|
|
if (browser) { await browser.close(); browser = null; }
|
|
}
|
|
|
|
function listSessions() {
|
|
return [...sessions.entries()].map(([ip, s]) => ({
|
|
ip, loggedIn: s.loggedIn, loginTime: s.loginTime,
|
|
lastActivity: s.lastActivity, idleMs: Date.now() - s.lastActivity,
|
|
hasDm: s.hasDm, modemType: s.modemType,
|
|
}));
|
|
}
|
|
|
|
module.exports = {
|
|
identify, login, dmGet, cgiRequest, getStatus,
|
|
wifiDiagnostic, raisecomDiagnostic, unifiedDiagnostic, oneshotDiagnostic,
|
|
evaluate, screenshot, getPageContent, navigate,
|
|
closeSession, shutdown, listSessions,
|
|
MODEM_TYPES,
|
|
};
|