const db = require('../config/db'); const { alertWifiClient } = require('./tokenClientAlerts'); /** * Normalize any MAC format into the dashed canonical form (AA-BB-CC-DD-EE-FF) * the rest of the codebase uses for sessions.client_mac / tokens.locked_mac. */ function normalizeMac(mac) { return String(mac || '') .toUpperCase() .replace(/[^0-9A-F]/g, '') .match(/.{1,2}/g)?.join('-') || ''; } /** * Fetch live hotspot clients from Omada and index them by their voucherCode. * * Omada's /hotspot/sites/:site/clients response carries the authoritative * voucherCode <-> client MAC mapping for guests currently authorized via a * voucher. That field is what lets us reconnect a paid WiFiBiz token to the * device actually consuming it, even when the guest bypassed our /portal/auth * callback and entered the code straight into Omada. */ async function loadHotspotClientsByVoucher(siteId, omada) { if (!siteId) return new Map(); let clients = []; try { clients = await omada.getHotspotClients(siteId); } catch (err) { console.warn(`[voucherReconcile] getHotspotClients failed for site ${siteId}: ${err.message}`); return new Map(); } const byVoucher = new Map(); for (const client of clients) { const code = String(client.voucherCode || '').trim().toUpperCase(); if (!code) continue; const mac = normalizeMac(client.mac); if (!mac) continue; // First authorized (authStatus === 2) client for a code wins; Omada voucher // groups are single-user so a code should only ever map to one MAC anyway. if (byVoucher.has(code) && Number(client.authStatus) !== 2) continue; // Omada's start/end (epoch ms) are the authoritative validity window for // the voucher in use. We prefer them over now+duration_seconds so WiFiBiz's // remaining-time agrees with when Omada will actually cut the client off. const startMs = Number(client.start); const endMs = Number(client.end); byVoucher.set(code, { mac, ip: client.ip || null, voucherCode: code, omadaClientId: client.id || client.clientId || null, authStatus: client.authStatus ?? null, trafficDown: client.download ?? client.trafficDown ?? 0, trafficUp: client.upload ?? client.trafficUp ?? 0, startedAt: Number.isFinite(startMs) ? new Date(startMs) : null, endsAt: Number.isFinite(endMs) ? new Date(endMs) : null, }); } return byVoucher; } /** * Backfill a single WiFiBiz token so it reflects that its Omada voucher is * already in use by `client`. Mirrors the state transitions performed by * /portal/:siteId/omada-auth-record (recordOmadaVoucherUse) so the token ends * up active, MAC-locked, expiring, and linked to a session row. * * Returns a summary object describing what changed, or null if nothing applied. */ async function reconcileTokenToClient({ token, client, connection, now = new Date(), }) { if (!token || !client || !client.mac) return null; const conn = connection || db.pool; const mac = client.mac; // Authoritative window comes from Omada (voucher start/end). Fall back to the // token's stored values, and only then to now+duration, so a missing Omada // field never silently extends a session. const omadaStart = client.startedAt; const omadaEnd = client.endsAt; let expiresAt; if (omadaEnd && omadaEnd > now) { expiresAt = new Date(omadaEnd); } else if (token.expires_at && new Date(token.expires_at) > now) { expiresAt = new Date(token.expires_at); } else { // Omada didn't report end and the token has no live expiry yet: derive from // the plan duration, anchored at the voucher's real start if we have it. const anchor = (omadaStart && omadaStart < now) ? omadaStart : now; expiresAt = new Date(anchor.getTime() + Number(token.duration_seconds || 0) * 1000); } const activatedAt = (omadaStart && omadaStart < now) ? omadaStart : (token.activated_at || now); // Locked to a *different* MAC — never silently reassign. Omada accepted a // voucher that WiFiBiz believes belongs elsewhere; surface it for review. if (token.locked_mac && token.locked_mac.toUpperCase() !== mac) { console.warn('[voucherReconcile] token locked to another MAC; skipping', { tokenId: token.id, lockedMac: token.locked_mac, liveMac: mac, }); return null; } // Terminal tokens must not be reactivated by a live sighting. if (token.status === 'expired' || token.status === 'revoked') { console.warn('[voucherReconcile] Omada is consuming a terminal token', { tokenId: token.id, status: token.status, mac, }); return null; } // Already locked to this MAC: the MAC assignment is settled, but the validity // window may have drifted (e.g. an earlier reconcile set expires_at = now + // duration instead of Omada's real end, or Omada later extended the voucher). // Drift-correct expires_at/activated_at without touching status or session. // This unsticks tokens whose end time was frozen wrong and keeps future // Omada-side extensions in sync. Skip the write when there's no meaningful // drift (<= 1s) so steady state stays cheap. if (token.locked_mac && token.locked_mac.toUpperCase() === mac) { const currentExpiryMs = token.expires_at ? new Date(token.expires_at).getTime() : null; const driftMs = currentExpiryMs === null ? Infinity : Math.abs(currentExpiryMs - expiresAt.getTime()); if (driftMs <= 1000) return null; await conn.execute( `UPDATE access_tokens SET expires_at = ?, activated_at = COALESCE(activated_at, ?) WHERE id = ?`, [expiresAt, activatedAt, token.id], ); console.log('[voucherReconcile] drift-corrected token expiry', { tokenId: token.id, code: token.code, oldExpiresAt: token.expires_at, newExpiresAt: expiresAt, driftMs, }); return { tokenId: token.id, mac, wasUnused: false, expiresAt, driftCorrected: true }; } // Not yet locked — full activation path. await conn.execute( `UPDATE access_tokens SET status = 'active', locked_mac = ?, activated_at = COALESCE(activated_at, ?), expires_at = ? WHERE id = ?`, [mac, activatedAt, expiresAt, token.id], ); const wasUnused = token.status === 'unused'; // Attach (or create) an active session row for this MAC <-> token pair, // matching the logic in portal.routes.js#attachTokenSession. const [activeSessionRows] = await conn.execute( `SELECT id FROM sessions WHERE device_id = ? AND client_mac = ? AND is_active = 1 AND (access_token_id = ? OR access_token_id IS NULL) ORDER BY CASE WHEN access_token_id = ? THEN 0 ELSE 1 END LIMIT 1`, [token.device_id, mac, token.id, token.id], ); const activeSession = activeSessionRows[0]; if (activeSession) { await conn.execute( `UPDATE sessions SET access_token_id = ?, last_seen_at = NOW(), ended_at = NULL, is_active = 1 WHERE id = ?`, [token.id, activeSession.id], ); } else { await conn.execute( `INSERT INTO sessions (access_token_id, client_id, device_id, client_mac, started_at, last_seen_at, is_active) VALUES (?, ?, ?, ?, NOW(), NOW(), 1)`, [token.id, token.client_id, token.device_id, mac], ); } if (wasUnused) { alertWifiClient('token_started', token.id, { mac }).catch(() => {}); } return { tokenId: token.id, mac, wasUnused, expiresAt }; } /** * Reconcile every "unused / un-locked" token for a device against Omada's live * hotspot clients. Safe to run on every poll — it no-ops when states already * agree and only writes when a live voucherCode proves a token is in use. */ async function reconcileDeviceTokens({ device, omada, connection }) { const siteId = device.omada_site_id; if (!siteId) return { matched: 0, clients: new Map() }; const clientsByVoucher = await loadHotspotClientsByVoucher(siteId, omada); if (!clientsByVoucher.size) return { matched: 0, clients: clientsByVoucher }; const codes = [...clientsByVoucher.keys()]; const placeholders = codes.map(() => '?').join(','); // Tokens that could still be linked: unused, or active but not yet MAC-locked. const tokens = await db.query( `SELECT id, client_id, device_id, code, status, locked_mac, duration_seconds, expires_at, activated_at FROM access_tokens WHERE device_id = ? AND status IN ('unused', 'active') AND (locked_mac IS NULL OR expires_at IS NULL OR expires_at > NOW()) AND UPPER(code) IN (${placeholders})`, [device.id, ...codes], ); const conn = connection || db.pool; let matched = 0; for (const token of tokens) { const client = clientsByVoucher.get(String(token.code).trim().toUpperCase()); if (!client) continue; try { const result = await reconcileTokenToClient({ token, client, connection: conn }); if (result) { matched += 1; console.log('[voucherReconcile] linked token to live Omada client', { tokenId: token.id, code: token.code, mac: client.mac, wasUnused: result.wasUnused, }); } } catch (err) { console.error('[voucherReconcile] failed for token', token.id, err.message); } } return { matched, clients: clientsByVoucher }; } module.exports = { normalizeMac, loadHotspotClientsByVoucher, reconcileTokenToClient, reconcileDeviceTokens, };