Feature: Sistema de tracking de sesiones RADIUS en tiempo real
All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 30s

- Nueva tabla `sesiones` para historial persistente de conexiones
- Job de detección de stale cada 2 min (10 min idle = desconectado)
- Inicialización resiliente desde BD al arrancar el servidor
- Nuevos endpoints: /api/sessions, /api/sessions/history, /api/sessions.csv
- Nueva vista "Sesiones" en el dashboard con estadísticas
- Historial integrado en UserCard y DispositivoCard
- Estadísticas de bytes in/out y duración por sesión
- Retención configurable de historial (90 días por defecto)
This commit is contained in:
2025-11-25 00:49:14 -06:00
parent 4da390c963
commit 06707df581
9 changed files with 869 additions and 15 deletions

View File

@@ -1,17 +1,113 @@
import { createApp } from './src/app.js';
import { ensureSchema, disableGuestsFromYesterday } from './src/services/db.js';
import {
ensureSchema,
disableGuestsFromYesterday,
getActiveSessions,
markStaleSessions,
syncConnectedDevicesFromSessions,
cleanOldSessions
} from './src/services/db.js';
import { activeSessions } from './src/services/radius.js';
import { broadcastStatus } from './src/sse.js';
const app = createApp();
const port = process.env.PORT || 3000;
// Configuration from environment
const STALE_CHECK_INTERVAL = parseInt(process.env.STALE_CHECK_INTERVAL || '120000', 10); // 2 min
const MAX_IDLE_MINUTES = parseInt(process.env.MAX_IDLE_MINUTES || '10', 10);
const SESSION_HISTORY_RETENTION_DAYS = parseInt(process.env.SESSION_HISTORY_RETENTION_DAYS || '90', 10);
try {
await ensureSchema();
} catch (e) {
console.error('Database schema ensure failed:', e?.message || e);
}
// Initialize sessions from database on startup
async function initializeSessionsFromDb() {
try {
// Sync dispositivos_conectados from active sessions
await syncConnectedDevicesFromSessions();
// Load active sessions into memory Map for CoA
const active = await getActiveSessions();
for (const sess of active) {
activeSessions.set(sess.session_id, {
username: sess.username,
sessionId: sess.session_id,
nasIp: sess.nas_ip,
nasId: sess.nas_id,
callingStationId: sess.calling_station_id,
calledStationId: sess.called_station_id,
updatedAt: new Date(sess.last_update).getTime(),
});
}
console.log(`[init] Loaded ${active.length} active sessions from database`);
} catch (e) {
console.error('[init] Failed to load sessions:', e?.message || e);
}
}
// Job to detect and mark stale sessions
function scheduleStaleSessionsJob() {
setInterval(async () => {
try {
const result = await markStaleSessions(MAX_IDLE_MINUTES);
if (result.count > 0) {
console.log(`[stale-sessions] Marked ${result.count} sessions as stale`);
// Remove stale sessions from memory Map
for (const sess of result.sessions) {
activeSessions.delete(sess.session_id);
}
// Notify SSE clients
broadcastStatus({ type: 'sessions-updated', staleCount: result.count });
}
} catch (e) {
console.error('[stale-sessions] Error:', e?.message || e);
}
}, STALE_CHECK_INTERVAL);
}
// Job to clean old sessions (runs daily at 3:00 AM local)
function scheduleSessionCleanupJob() {
function schedule() {
const now = new Date();
const next = new Date(now);
next.setUTCHours(9, 0, 0, 0); // 9:00 UTC = 3:00 AM Honduras (UTC-6)
if (next <= now) next.setUTCDate(next.getUTCDate() + 1);
const delay = next - now;
setTimeout(async () => {
try {
const deleted = await cleanOldSessions(SESSION_HISTORY_RETENTION_DAYS);
if (deleted > 0) {
console.log(`[session-cleanup] Deleted ${deleted} old sessions (>${SESSION_HISTORY_RETENTION_DAYS} days)`);
}
} catch (e) {
console.error('[session-cleanup] Error:', e?.message || e);
} finally {
schedule(); // Re-schedule for tomorrow
}
}, delay);
}
schedule();
}
// Initialize sessions from database
try {
await initializeSessionsFromDb();
} catch (e) {
console.error('Session initialization failed:', e?.message || e);
}
// Start maintenance jobs
scheduleStaleSessionsJob();
scheduleSessionCleanupJob();
app.listen(port, () => {
console.log(`Node RADIUS REST API listening on :${port}`);
console.log(`[config] Stale check: every ${STALE_CHECK_INTERVAL/1000}s, idle timeout: ${MAX_IDLE_MINUTES} min, retention: ${SESSION_HISTORY_RETENTION_DAYS} days`);
});
// Schedule daily guest disable at 4:00 AM America/Tegucigalpa (UTC-6 -> 10:00 UTC)

View File

@@ -1,7 +1,18 @@
import { Router } from 'express';
import { VLAN_ID } from '../config/env.js';
import { clearRequests, getRecentRequests, registerSse } from '../sse.js';
import { deleteUserFromDb, readUsersFromDb, upsertUserToDb, pool, disableGuestsFromYesterday } from '../services/db.js';
import {
deleteUserFromDb,
readUsersFromDb,
upsertUserToDb,
pool,
disableGuestsFromYesterday,
getActiveSessions,
getSessionHistory,
syncConnectedDevicesFromSessions,
markStaleSessions,
getSessionStats
} from '../services/db.js';
import { disconnectUserSessions } from '../services/radius.js';
const router = Router();
@@ -358,4 +369,133 @@ router.post('/guests/disable-yesterday', async (_req, res) => {
}
});
// ==================== SESSION ENDPOINTS ====================
// GET /api/sessions - Active sessions
router.get('/sessions', async (_req, res) => {
try {
const sessions = await getActiveSessions();
const stats = await getSessionStats();
res.json({ items: sessions, count: sessions.length, stats });
} catch (e) {
console.error('GET /api/sessions error:', e?.message || e);
res.status(500).json({ ok: false, error: 'db_error' });
}
});
// GET /api/sessions/history - Session history with filters
router.get('/sessions/history', async (req, res) => {
try {
const { username, dispositivo_id, mac, status, limit, offset, desde, hasta } = req.query;
const sessions = await getSessionHistory({
username: username || undefined,
dispositivoId: dispositivo_id ? parseInt(dispositivo_id, 10) : undefined,
mac: mac || undefined,
status: status || undefined,
limit: Math.min(parseInt(limit || '50', 10), 500),
offset: parseInt(offset || '0', 10),
desde: desde || undefined,
hasta: hasta || undefined
});
res.json({ items: sessions });
} catch (e) {
console.error('GET /api/sessions/history error:', e?.message || e);
res.status(500).json({ ok: false, error: 'db_error' });
}
});
// GET /api/users/:username/sessions - Sessions for a specific user
router.get('/users/:username/sessions', async (req, res) => {
try {
const username = String(req.params.username);
const sessions = await getSessionHistory({ username, limit: 100 });
const active = sessions.filter(s => s.status === 'active');
res.json({
active,
recent: sessions.slice(0, 20),
activeCount: active.length,
totalCount: sessions.length
});
} catch (e) {
console.error('GET /api/users/:username/sessions error:', e?.message || e);
res.status(500).json({ ok: false, error: 'db_error' });
}
});
// GET /api/devices/:id/sessions - Session history for a specific device
router.get('/devices/:id/sessions', async (req, res) => {
try {
const id = parseInt(String(req.params.id), 10);
if (!Number.isInteger(id) || id <= 0) {
return res.status(400).json({ ok: false, error: 'invalid_id' });
}
const sessions = await getSessionHistory({ dispositivoId: id, limit: 100 });
const active = sessions.filter(s => s.status === 'active');
res.json({
active,
history: sessions,
activeCount: active.length,
totalCount: sessions.length
});
} catch (e) {
console.error('GET /api/devices/:id/sessions error:', e?.message || e);
res.status(500).json({ ok: false, error: 'db_error' });
}
});
// POST /api/sessions/sync - Force sync of sessions
router.post('/sessions/sync', async (_req, res) => {
try {
await syncConnectedDevicesFromSessions();
const staleResult = await markStaleSessions(10);
res.json({ ok: true, staleMarked: staleResult.count });
} catch (e) {
console.error('POST /api/sessions/sync error:', e?.message || e);
res.status(500).json({ ok: false, error: 'sync_error' });
}
});
// GET /api/sessions.csv - Export session history as CSV
router.get('/sessions.csv', async (req, res) => {
try {
const { desde, hasta, status } = req.query;
const sessions = await getSessionHistory({
limit: 10000,
desde: desde || undefined,
hasta: hasta || undefined,
status: status || undefined
});
const cols = ['session_id','username','mac','device_name','nas_ip','nas_id','started_at','ended_at','status','stop_reason','session_time','bytes_in','bytes_out'];
const esc = (v) => {
const s = v == null ? '' : String(v);
return /[",\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
};
const lines = [cols.join(',')];
for (const s of sessions) {
lines.push([
s.session_id,
s.username,
s.mac || s.calling_station_id,
s.device_name || '',
s.nas_ip || '',
s.nas_id || '',
s.started_at || '',
s.ended_at || '',
s.status,
s.stop_reason || '',
s.session_time || 0,
s.bytes_in || 0,
s.bytes_out || 0
].map(esc).join(','));
}
const ts = new Date().toISOString().replace(/[:T]/g, '-').split('.')[0];
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="sessions-${ts}.csv"`);
res.send(lines.join('\n'));
} catch (e) {
console.error('GET /api/sessions.csv error:', e?.message || e);
res.status(500).json({ ok: false, error: 'export_error' });
}
});
export default router;

View File

@@ -3,7 +3,7 @@ import { VLAN_ID } from '../config/env.js';
import { buildAcceptPayload, normalizeAttributes } from '../utils/attrs.js';
import { pushRequest } from '../sse.js';
import { activeSessions, sendRadiusSelfTest } from '../services/radius.js';
import { addDeviceToUser, connectDeviceForUser, disconnectDeviceForUser, getOrCreateDevice } from '../services/db.js';
import { addDeviceToUser, connectDeviceForUser, disconnectDeviceForUser, getOrCreateDevice, upsertSession, endSession } from '../services/db.js';
const router = Router();
@@ -29,25 +29,49 @@ router.post('/authorize', (req, res) => {
return res.status(200).json(reply);
});
router.post('/accounting', (req, res) => {
router.post('/accounting', async (req, res) => {
const attrs = normalizeAttributes(req.body);
try {
const st = String(attrs['Acct-Status-Type'] || attrs['Acct-Status-Type*0'] || '').toUpperCase();
const sessionId = String(attrs['Acct-Session-Id'] || '');
const username = String(attrs['User-Name'] || '');
const mac = attrs['Calling-Station-Id'] || '';
// Extract statistics from RADIUS attributes
const stats = {
bytesIn: parseInt(attrs['Acct-Input-Octets'] || '0', 10),
bytesOut: parseInt(attrs['Acct-Output-Octets'] || '0', 10),
packetsIn: parseInt(attrs['Acct-Input-Packets'] || '0', 10),
packetsOut: parseInt(attrs['Acct-Output-Packets'] || '0', 10),
sessionTime: parseInt(attrs['Acct-Session-Time'] || '0', 10),
interimInterval: parseInt(attrs['Acct-Interim-Interval'] || '0', 10) || null,
};
if (sessionId && username) {
if (st === 'START' || st === 'ALIVE' || st === 'INTERIM-UPDATE' || st === 'INTERIM') {
// Keep Map in memory for CoA compatibility
activeSessions.set(sessionId, {
username,
sessionId,
nasIp: attrs['NAS-IP-Address'] || '',
nasId: attrs['NAS-Identifier'] || '',
callingStationId: attrs['Calling-Station-Id'] || '',
callingStationId: mac,
calledStationId: attrs['Called-Station-Id'] || '',
updatedAt: Date.now(),
});
// upsert device and link as connected
const mac = attrs['Calling-Station-Id'] || '';
// Persist session to database
await upsertSession({
sessionId,
username,
nasIp: attrs['NAS-IP-Address'] || '',
nasId: attrs['NAS-Identifier'] || '',
callingStationId: mac,
calledStationId: attrs['Called-Station-Id'] || '',
...stats
}).catch(e => console.error('[accounting] upsertSession error:', e?.message || e));
// Upsert device and link as connected
if (mac) {
getOrCreateDevice({ mac: String(mac) }).then(async (id) => {
await addDeviceToUser(String(username), id);
@@ -56,7 +80,13 @@ router.post('/accounting', (req, res) => {
}
} else if (st === 'STOP') {
activeSessions.delete(sessionId);
const mac = attrs['Calling-Station-Id'] || '';
// End session in database
const stopReason = attrs['Acct-Terminate-Cause'] || 'Unknown';
await endSession(sessionId, stopReason, stats)
.catch(e => console.error('[accounting] endSession error:', e?.message || e));
// Disconnect device
if (mac) {
getOrCreateDevice({ mac: String(mac) }).then(async (id) => {
await disconnectDeviceForUser(String(username), id);
@@ -64,7 +94,9 @@ router.post('/accounting', (req, res) => {
}
}
}
} catch {}
} catch (e) {
console.error('[accounting] error:', e?.message || e);
}
pushRequest({
id: Date.now() + ':' + Math.random().toString(16).slice(2),
ts: new Date().toISOString(),

View File

@@ -93,6 +93,36 @@ export async function ensureSchema() {
END IF;
END$$;
`);
// Sessions table for persistent connection tracking
await client.query(`
CREATE TABLE IF NOT EXISTS sesiones (
id SERIAL PRIMARY KEY,
session_id VARCHAR(64) UNIQUE NOT NULL,
username VARCHAR(64) NOT NULL,
dispositivo_id INTEGER REFERENCES dispositivos(id) ON DELETE SET NULL,
nas_ip VARCHAR(45),
nas_id VARCHAR(64),
calling_station_id VARCHAR(32),
called_station_id VARCHAR(64),
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
ended_at TIMESTAMPTZ,
last_update TIMESTAMPTZ NOT NULL DEFAULT NOW(),
status VARCHAR(16) NOT NULL DEFAULT 'active',
stop_reason VARCHAR(64),
bytes_in BIGINT DEFAULT 0,
bytes_out BIGINT DEFAULT 0,
packets_in BIGINT DEFAULT 0,
packets_out BIGINT DEFAULT 0,
session_time INTEGER DEFAULT 0,
interim_interval INTEGER
);
`);
await client.query(`CREATE INDEX IF NOT EXISTS idx_sesiones_username ON sesiones(username);`);
await client.query(`CREATE INDEX IF NOT EXISTS idx_sesiones_dispositivo ON sesiones(dispositivo_id);`);
await client.query(`CREATE INDEX IF NOT EXISTS idx_sesiones_status ON sesiones(status);`);
await client.query(`CREATE INDEX IF NOT EXISTS idx_sesiones_started ON sesiones(started_at DESC);`);
await client.query(`CREATE INDEX IF NOT EXISTS idx_sesiones_last_update ON sesiones(last_update);`);
await client.query(`CREATE INDEX IF NOT EXISTS idx_sesiones_calling_station ON sesiones(calling_station_id);`);
await client.query('COMMIT');
} catch (e) {
await client.query('ROLLBACK');
@@ -318,3 +348,245 @@ export async function deleteUserFromDb(username) {
client.release();
}
}
// ==================== SESSION FUNCTIONS ====================
// Create or update session on START/INTERIM-UPDATE
export async function upsertSession({
sessionId,
username,
nasIp,
nasId,
callingStationId,
calledStationId,
bytesIn,
bytesOut,
packetsIn,
packetsOut,
sessionTime,
interimInterval
}) {
const client = await pool.connect();
try {
// Get dispositivo_id if device exists
let dispositivoId = null;
if (callingStationId) {
const devRes = await client.query(
'SELECT id FROM dispositivos WHERE mac = $1',
[callingStationId]
);
if (devRes.rows.length > 0) {
dispositivoId = devRes.rows[0].id;
}
}
await client.query(`
INSERT INTO sesiones (
session_id, username, dispositivo_id, nas_ip, nas_id,
calling_station_id, called_station_id, status, last_update,
bytes_in, bytes_out, packets_in, packets_out, session_time,
interim_interval
) VALUES ($1, $2, $3, $4, $5, $6, $7, 'active', NOW(), $8, $9, $10, $11, $12, $13)
ON CONFLICT (session_id) DO UPDATE SET
last_update = NOW(),
bytes_in = COALESCE($8, sesiones.bytes_in),
bytes_out = COALESCE($9, sesiones.bytes_out),
packets_in = COALESCE($10, sesiones.packets_in),
packets_out = COALESCE($11, sesiones.packets_out),
session_time = COALESCE($12, sesiones.session_time),
interim_interval = COALESCE($13, sesiones.interim_interval)
`, [
sessionId, username, dispositivoId, nasIp, nasId,
callingStationId, calledStationId,
bytesIn || 0, bytesOut || 0, packetsIn || 0, packetsOut || 0,
sessionTime || 0, interimInterval
]);
} finally {
client.release();
}
}
// End session on STOP
export async function endSession(sessionId, stopReason, stats = {}) {
await pool.query(`
UPDATE sesiones SET
status = 'stopped',
ended_at = NOW(),
last_update = NOW(),
stop_reason = $2,
bytes_in = COALESCE($3, bytes_in),
bytes_out = COALESCE($4, bytes_out),
session_time = COALESCE($5, session_time)
WHERE session_id = $1 AND status = 'active'
`, [sessionId, stopReason, stats.bytesIn, stats.bytesOut, stats.sessionTime]);
}
// Get all active sessions
export async function getActiveSessions() {
const { rows } = await pool.query(`
SELECT s.*, d.mac, d.nombre as device_name, d.vendor as device_vendor
FROM sesiones s
LEFT JOIN dispositivos d ON d.id = s.dispositivo_id
WHERE s.status = 'active'
ORDER BY s.started_at DESC
`);
return rows;
}
// Get session history with filters and pagination
export async function getSessionHistory({
username,
dispositivoId,
mac,
status,
limit = 50,
offset = 0,
desde,
hasta
} = {}) {
let query = `
SELECT s.*, d.mac, d.nombre as device_name, d.vendor as device_vendor
FROM sesiones s
LEFT JOIN dispositivos d ON d.id = s.dispositivo_id
WHERE 1=1
`;
const params = [];
let paramIdx = 1;
if (username) {
query += ` AND s.username = $${paramIdx++}`;
params.push(username);
}
if (dispositivoId) {
query += ` AND s.dispositivo_id = $${paramIdx++}`;
params.push(dispositivoId);
}
if (mac) {
query += ` AND s.calling_station_id = $${paramIdx++}`;
params.push(mac);
}
if (status) {
query += ` AND s.status = $${paramIdx++}`;
params.push(status);
}
if (desde) {
query += ` AND s.started_at >= $${paramIdx++}`;
params.push(desde);
}
if (hasta) {
query += ` AND s.started_at <= $${paramIdx++}`;
params.push(hasta);
}
query += ` ORDER BY s.started_at DESC LIMIT $${paramIdx++} OFFSET $${paramIdx}`;
params.push(limit, offset);
const { rows } = await pool.query(query, params);
return rows;
}
// Mark stale sessions (called by cleanup job)
export async function markStaleSessions(maxIdleMinutes = 10) {
const client = await pool.connect();
try {
await client.query('BEGIN');
// Get sessions that will be marked as stale (for disconnect logic)
const { rows: staleSessions } = await client.query(`
SELECT session_id, username, dispositivo_id
FROM sesiones
WHERE status = 'active'
AND last_update < NOW() - INTERVAL '1 minute' * $1
`, [maxIdleMinutes]);
// Mark them as stale
const { rowCount } = await client.query(`
UPDATE sesiones SET
status = 'stale',
ended_at = last_update,
stop_reason = 'Stale-Detected'
WHERE status = 'active'
AND last_update < NOW() - INTERVAL '1 minute' * $1
`, [maxIdleMinutes]);
// Disconnect devices for users with stale sessions
for (const sess of staleSessions) {
if (sess.dispositivo_id) {
await client.query(
`UPDATE users SET dispositivos_conectados = array_remove(coalesce(dispositivos_conectados, '{}'::int[]), $2::int)
WHERE username = $1`,
[sess.username, sess.dispositivo_id]
);
}
}
await client.query('COMMIT');
return { count: rowCount, sessions: staleSessions };
} catch (e) {
await client.query('ROLLBACK');
throw e;
} finally {
client.release();
}
}
// Rebuild dispositivos_conectados from active sessions
export async function syncConnectedDevicesFromSessions() {
const client = await pool.connect();
try {
await client.query('BEGIN');
// Clear all dispositivos_conectados
await client.query(`UPDATE users SET dispositivos_conectados = '{}'`);
// Get active sessions grouped by username
const { rows } = await client.query(`
SELECT username, array_agg(DISTINCT dispositivo_id) as device_ids
FROM sesiones
WHERE status = 'active' AND dispositivo_id IS NOT NULL
GROUP BY username
`);
// Update each user with their connected devices
for (const row of rows) {
const deviceIds = row.device_ids.filter(id => id != null);
if (deviceIds.length > 0) {
await client.query(
`UPDATE users SET dispositivos_conectados = $2 WHERE username = $1`,
[row.username, deviceIds]
);
}
}
await client.query('COMMIT');
return rows.length;
} catch (e) {
await client.query('ROLLBACK');
throw e;
} finally {
client.release();
}
}
// Clean old sessions (data retention)
export async function cleanOldSessions(retentionDays = 90) {
const { rowCount } = await pool.query(`
DELETE FROM sesiones
WHERE status != 'active'
AND started_at < NOW() - INTERVAL '1 day' * $1
`, [retentionDays]);
return rowCount;
}
// Get session counts by status
export async function getSessionStats() {
const { rows } = await pool.query(`
SELECT
COUNT(*) FILTER (WHERE status = 'active') as active,
COUNT(*) FILTER (WHERE status = 'stopped') as stopped,
COUNT(*) FILTER (WHERE status = 'stale') as stale,
COUNT(*) as total
FROM sesiones
`);
return rows[0];
}