diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 56d9de2..2a1a3a5 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -19,8 +19,10 @@ const { toast } = useToast(); const users = ref([]); const requests = ref([]); -const loading = reactive({ users: false, requests: false }); +const loading = reactive({ users: false, requests: false, sessions: false }); const devices = ref([]); +const sessions = ref([]); +const sessionStats = ref({ active: 0, stopped: 0, stale: 0, total: 0 }); const userExpanded = reactive({}); const deviceExpanded = reactive({}); @@ -91,6 +93,22 @@ async function fetchDevices() { } } +async function fetchSessions() { + loading.sessions = true; + try { + const res = await fetch('/api/sessions'); + if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`); + const data = await res.json(); + sessions.value = data.items || []; + sessionStats.value = data.stats || { active: 0, stopped: 0, stale: 0, total: 0 }; + } catch (error) { + if (isAuthError(error)) handleAuthError(); + else console.error('Error fetching sessions:', error); + } finally { + loading.sessions = false; + } +} + async function toggleDisable(u) { await fetch(`/api/users/${encodeURIComponent(u.username)}`, { method: 'PATCH', @@ -135,7 +153,7 @@ function setupSse() { function scheduleRefresh() { if (refreshTimer) clearTimeout(refreshTimer); refreshTimer = setTimeout(async () => { - await Promise.all([fetchUsers(), fetchDevices()]); + await Promise.all([fetchUsers(), fetchDevices(), fetchSessions()]); refreshTimer = null; }, 3000); // Debounce de 3 segundos para evitar parpadeos } @@ -164,6 +182,7 @@ onMounted(async () => { await fetchUsers(); await fetchDevices(); await fetchRequests(); + await fetchSessions(); setupSse(); applyTheme(); checkPWAStatus(); @@ -241,6 +260,10 @@ const devicePage = ref(0); const pagedDevices = computed(() => devicesAll.value.slice(devicePage.value*pageSize, devicePage.value*pageSize + pageSize)); watch([devicesAll, () => layoutMode.value], () => { devicePage.value = 0; }); +const sessionPage = ref(0); +const pagedSessions = computed(() => sessions.value.slice(sessionPage.value*pageSize, sessionPage.value*pageSize + pageSize)); +watch([sessions, () => layoutMode.value], () => { sessionPage.value = 0; }); + const filteredRequestsAll = computed(() => filteredRequests.value); const reqPage = ref(0); const pagedRequests = computed(() => filteredRequestsAll.value.slice(reqPage.value*pageSize, reqPage.value*pageSize + pageSize)); @@ -462,15 +485,25 @@ async function handleUserFormSubmit(data) { Usuarios y Dispositivos Página {{ userPage+1 }} / {{ Math.max(1, Math.ceil(filteredUsersAll.length / pageSize)) }} - Página {{ devicePage+1 }} / {{ Math.max(1, Math.ceil(devicesAll.length / pageSize)) }} - - + Página {{ devicePage+1 }} / {{ Math.max(1, Math.ceil(devicesAll.length / pageSize)) }} + Página {{ sessionPage+1 }} / {{ Math.max(1, Math.ceil(sessions.length / pageSize)) }} + + + @@ -489,12 +522,80 @@ async function handleUserFormSubmit(data) { @toggleDisable="toggleDisable" @remove="removeUser" @edit="openEditUser" @disconnect="disconnectUser" @edit-device="openDeviceForm" /> -
+
+ +
+ +
+ + + + + + Activas: {{ sessionStats.active }} + + Finalizadas: {{ sessionStats.stopped }} + Stale: {{ sessionStats.stale }} + + + +
+ +
Cargando sesiones...
+
No hay sesiones activas
+
+ +
+ + {{ s.status }} + + {{ s.username }} + + {{ new Date(s.started_at).toLocaleString('es-HN', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' }) }} +
+
+
+ MAC: + {{ s.mac || s.calling_station_id || '-' }} +
+
+ Dispositivo: + {{ s.device_name || 'Sin nombre' }} +
+
+ NAS: + {{ s.nas_id || s.nas_ip || '-' }} +
+
+ Duración: + {{ s.session_time ? (Math.floor(s.session_time/3600) + 'h ' + Math.floor((s.session_time%3600)/60) + 'm') : '-' }} +
+
+ Datos: + ↓ {{ ((s.bytes_in || 0) / 1024 / 1024).toFixed(1) }} MB / ↑ {{ ((s.bytes_out || 0) / 1024 / 1024).toFixed(1) }} MB +
+
+
+
+
diff --git a/frontend/src/components/DispositivoCard.vue b/frontend/src/components/DispositivoCard.vue index 21a18b5..6fbe124 100644 --- a/frontend/src/components/DispositivoCard.vue +++ b/frontend/src/components/DispositivoCard.vue @@ -2,6 +2,7 @@ import { computed } from 'vue'; import { Card, Badge, Button } from '@/components/ui'; import UserCard from './UserCard.vue'; +import SessionHistory from './SessionHistory.vue'; const props = defineProps({ device: { type: Object, required: true }, @@ -88,5 +89,10 @@ const disconnectedUsers = computed(() => { />
+ + +
+ +
diff --git a/frontend/src/components/SessionHistory.vue b/frontend/src/components/SessionHistory.vue new file mode 100644 index 0000000..a2bcd0b --- /dev/null +++ b/frontend/src/components/SessionHistory.vue @@ -0,0 +1,152 @@ + + + diff --git a/frontend/src/components/UserCard.vue b/frontend/src/components/UserCard.vue index a9ca4c6..11fe18f 100644 --- a/frontend/src/components/UserCard.vue +++ b/frontend/src/components/UserCard.vue @@ -3,6 +3,7 @@ import { computed } from 'vue'; import { Card, Badge, Button } from '@/components/ui'; import { cn } from '@/lib/utils'; import DispositivoCard from './DispositivoCard.vue'; +import SessionHistory from './SessionHistory.vue'; const props = defineProps({ item: { type: Object, required: true }, @@ -125,6 +126,11 @@ const userInitial = computed(() => { /> + + +
+ +
diff --git a/node-api/index.js b/node-api/index.js index e0f5393..1dc470a 100644 --- a/node-api/index.js +++ b/node-api/index.js @@ -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) diff --git a/node-api/src/routes/api.js b/node-api/src/routes/api.js index 0cde4a6..a4acde2 100644 --- a/node-api/src/routes/api.js +++ b/node-api/src/routes/api.js @@ -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; diff --git a/node-api/src/routes/radius.js b/node-api/src/routes/radius.js index 3b8eb5d..4052f47 100644 --- a/node-api/src/routes/radius.js +++ b/node-api/src/routes/radius.js @@ -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(), diff --git a/node-api/src/services/db.js b/node-api/src/services/db.js index cced7e3..818ec30 100644 --- a/node-api/src/services/db.js +++ b/node-api/src/services/db.js @@ -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]; +} diff --git a/postgres/init/03-sesiones.sql b/postgres/init/03-sesiones.sql new file mode 100644 index 0000000..152543a --- /dev/null +++ b/postgres/init/03-sesiones.sql @@ -0,0 +1,49 @@ +-- Session history table for tracking all RADIUS connections +-- This table persists session data that was previously only in memory + +CREATE TABLE IF NOT EXISTS sesiones ( + id SERIAL PRIMARY KEY, + + -- RADIUS session identifiers + session_id VARCHAR(64) UNIQUE NOT NULL, -- Acct-Session-Id from FreeRADIUS + username VARCHAR(64) NOT NULL, -- User-Name + dispositivo_id INTEGER REFERENCES dispositivos(id) ON DELETE SET NULL, + + -- NAS (Network Access Server) information + nas_ip VARCHAR(45), -- NAS-IP-Address + nas_id VARCHAR(64), -- NAS-Identifier + calling_station_id VARCHAR(32), -- MAC address of client device + called_station_id VARCHAR(64), -- SSID or AP identifier + + -- Session timestamps + started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + ended_at TIMESTAMPTZ, -- NULL if session is active + last_update TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Session state: active, stopped, stale + status VARCHAR(16) NOT NULL DEFAULT 'active', + stop_reason VARCHAR(64), -- Acct-Terminate-Cause + + -- Traffic statistics (from Interim-Updates and Stop) + bytes_in BIGINT DEFAULT 0, -- Acct-Input-Octets + bytes_out BIGINT DEFAULT 0, -- Acct-Output-Octets + packets_in BIGINT DEFAULT 0, -- Acct-Input-Packets + packets_out BIGINT DEFAULT 0, -- Acct-Output-Packets + session_time INTEGER DEFAULT 0, -- Acct-Session-Time (seconds) + + -- For stale detection + interim_interval INTEGER -- Expected Acct-Interim-Interval +); + +-- Indexes for efficient queries +CREATE INDEX IF NOT EXISTS idx_sesiones_username ON sesiones(username); +CREATE INDEX IF NOT EXISTS idx_sesiones_dispositivo ON sesiones(dispositivo_id); +CREATE INDEX IF NOT EXISTS idx_sesiones_status ON sesiones(status); +CREATE INDEX IF NOT EXISTS idx_sesiones_started ON sesiones(started_at DESC); +CREATE INDEX IF NOT EXISTS idx_sesiones_last_update ON sesiones(last_update); +CREATE INDEX IF NOT EXISTS idx_sesiones_calling_station ON sesiones(calling_station_id); + +-- Partial index for active sessions (frequently queried) +CREATE INDEX IF NOT EXISTS idx_sesiones_active + ON sesiones(username, dispositivo_id) + WHERE status = 'active';