| const db = require('../config/db'); |
| const { alertWifiClient } = require('./tokenClientAlerts'); |
|
|
| |
| |
| |
| |
| function normalizeMac(mac) { |
| return String(mac || '') |
| .toUpperCase() |
| .replace(/[^0-9A-F]/g, '') |
| .match(/.{1,2}/g)?.join('-') || ''; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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; |
|
|
| |
| |
| if (byVoucher.has(code) && Number(client.authStatus) !== 2) continue; |
|
|
| |
| |
| |
| 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; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| 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; |
|
|
| |
| |
| |
| 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 { |
| |
| |
| 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); |
|
|
| |
| |
| 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; |
| } |
|
|
| |
| 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; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| 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 }; |
| } |
|
|
| |
| 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'; |
|
|
| |
| |
| 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 }; |
| } |
|
|
| |
| |
| |
| |
| |
| 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(','); |
|
|
| |
| 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, |
| }; |
|
|