Wifiv2 / src /utils /voucherReconcile.js
Mbonea's picture
fix(reconcile): drift-correct token expiry instead of bailing when MAC locked
1990ab3
Raw
History Blame Contribute Delete
9.64 kB
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,
};