gigafibre-fsm/services/targo-hub/lib/modem-bridge.js
louispaulb 1b7bad1686 security: harden payments/hub auth + remove leaked ERPNext token from source
Auth hardening:
- payments: per-customer JWT authorization on dual-use /payments routes
  (balance/methods/invoice/checkout/setup/portal/toggle-ppa) via authorizeCustomer
  + PAYMENTS_AUTH=enforce; portal retains+sends magic-link JWT (sessionStorage)
- hub: fail-closed Stripe webhook, /accept/doc-pdf IDOR gate, telephony field-name
  guard (SQLi), modem-bridge private-IP guard (SSRF), ALWAYS_ENFORCE expansion

Leaked-credential cleanup (token already rotated in ERPNext):
- de-hardcode ERPNext API token -> env in bulk_submit.py, import_items.py,
  apps/client/deploy.sh; placeholder in apps/ops/infra/nginx.conf (nginx injects)
- ops prod build no longer bakes VITE_ERP_TOKEN (.env.production empty)
- de-hardcode legacy DB password -> env in import_items.py
- gitignore legacy migration PII exports (tsv/json)

Conversations/UI:
- floating (un-docked) conversation panel; full-width mailbox
- per-message real sender from email headers; unified scroll; header spacing
- campaign pre-send review (subject/from/channel), Gmail send channel, clone-as-draft

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 06:17:17 -04:00

315 lines
12 KiB
JavaScript

/**
* 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' });
}
// Anti-SSRF : le bridge se connecte à cette IP → exiger une adresse de GESTION
// PRIVÉE (RFC1918 / CGNAT 100.64/10), jamais un hôte public/arbitraire/metadata.
if (!/^(?:10\.|172\.(?:1[6-9]|2\d|3[01])\.|192\.168\.|100\.(?:6[4-9]|[7-9]\d|1[01]\d|12[0-7])\.)\d{1,3}\.\d{1,3}$/.test(String(ip))) {
return jsonResp(res, 400, { error: 'IP de gestion privée requise (RFC1918/CGNAT)' });
}
// 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 };