diff --git a/docker-compose.yml b/docker-compose.yml index 22250f1..74d6b85 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -31,6 +31,7 @@ services: ports: - "1812:1812/udp" - "1813:1813/udp" + - "3799:3799/udp" environment: - RADIUS_CLIENTS_CIDR=${RADIUS_CLIENTS_CIDR:-0.0.0.0/0} - RADIUS_SHARED_SECRET=${RADIUS_SHARED_SECRET:-testing123} diff --git a/freeradius/sites-enabled/default b/freeradius/sites-enabled/default index be95728..b9ee583 100644 --- a/freeradius/sites-enabled/default +++ b/freeradius/sites-enabled/default @@ -11,6 +11,13 @@ server default { port = 1813 } + # Listen for incoming CoA/Disconnect-Request (for testing / tooling) + listen { + type = coa + ipaddr = * + port = 3799 + } + authorize { # Si es EAP (WPA-Enterprise) if (&EAP-Message) { diff --git a/node-api/index.js b/node-api/index.js index ccbe2f0..65d27f9 100644 --- a/node-api/index.js +++ b/node-api/index.js @@ -29,6 +29,8 @@ const RADIUS_SECRET = process.env.RADIUS_SECRET || process.env.RADIUS_SHARED_SEC const requests = []; const sseClients = new Set(); let radiusReloading = false; +// Active sessions indexed by Acct-Session-Id +const activeSessions = new Map(); function pushRequest(rec) { requests.push(rec); @@ -53,6 +55,7 @@ const PGDATABASE = process.env.PGDATABASE || 'radius'; const PGUSER = process.env.PGUSER || 'radius'; const PGPASSWORD = process.env.PGPASSWORD || 'radius'; const pool = new Pool({ host: PGHOST, port: PGPORT, database: PGDATABASE, user: PGUSER, password: PGPASSWORD }); +const SESSION_TIMEOUT = parseInt(process.env.SESSION_TIMEOUT || process.env.SESSION_TIMEOUT_SECONDS || '0', 10) || 0; // SQL helpers: users in radcheck/radreply async function readUsersFromDb() { @@ -107,8 +110,11 @@ async function upsertUserToDb(user) { ['WISPr-Bandwidth-Max-Down', String(MAX_DOWN)], ['WISPr-Bandwidth-Max-Up', String(MAX_UP)], ]; + if (SESSION_TIMEOUT > 0) { + attrs.push(['Session-Timeout', String(SESSION_TIMEOUT)]); + } await client.query( - "DELETE FROM radreply WHERE username = $1 AND attribute IN ('Tunnel-Type','Tunnel-Medium-Type','Tunnel-Private-Group-Id','WISPr-Bandwidth-Max-Down','WISPr-Bandwidth-Max-Up')", + "DELETE FROM radreply WHERE username = $1 AND attribute IN ('Tunnel-Type','Tunnel-Medium-Type','Tunnel-Private-Group-Id','WISPr-Bandwidth-Max-Down','WISPr-Bandwidth-Max-Up','Session-Timeout')", [username] ); for (const [attr, val] of attrs) { @@ -195,11 +201,34 @@ app.post('/authorize', (req, res) => { app.post('/accounting', (req, res) => { console.log('--- RADIUS Accounting ---'); console.log(JSON.stringify(req.body, null, 2)); + 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'] || ''); + if (sessionId && username) { + if (st === 'START' || st === 'ALIVE' || st === 'INTERIM-UPDATE' || st === 'INTERIM') { + activeSessions.set(sessionId, { + username, + sessionId, + nasIp: attrs['NAS-IP-Address'] || '', + nasId: attrs['NAS-Identifier'] || '', + callingStationId: attrs['Calling-Station-Id'] || '', + calledStationId: attrs['Called-Station-Id'] || '', + updatedAt: Date.now(), + }); + } else if (st === 'STOP') { + activeSessions.delete(sessionId); + } + } + } catch (e) { + console.error('accounting session update error:', e); + } pushRequest({ id: Date.now() + ':' + Math.random().toString(16).slice(2), ts: new Date().toISOString(), type: 'accounting', - attrs: normalizeAttributes(req.body), + attrs, }); return res.status(200).json({}); }); @@ -250,6 +279,9 @@ app.patch('/api/users/:username', async (req, res) => { disabled: disabled !== undefined ? !!disabled : current.disabled, }; await upsertUserToDb(next); + if (disabled === true) { + disconnectUserSessions(uname).catch(err => console.error('CoA disconnect error:', err)); + } res.json({ ok: true }); }); @@ -369,6 +401,90 @@ async function sendRadiusSelfTest() { }); } +// Send RADIUS Disconnect-Request (CoA) to NAS (UDP 3799) +async function sendDisconnectRequest({ nasIp, username, sessionId, callingStationId, nasId }) { + if (!nasIp) throw new Error('NAS IP required for Disconnect-Request'); + const packet = radius.encode({ + code: 'Disconnect-Request', + secret: RADIUS_SECRET, + attributes: { + 'User-Name': username, + 'Acct-Session-Id': sessionId, + 'Calling-Station-Id': callingStationId || undefined, + 'NAS-IP-Address': nasIp, + 'NAS-Identifier': nasId || undefined, + }, + }); + return new Promise((resolve, reject) => { + const client = dgram.createSocket('udp4'); + const timeout = setTimeout(() => { + client.close(); + reject(new Error('CoA timeout')); + }, 3000); + client.on('message', (msg) => { + clearTimeout(timeout); + client.close(); + try { + const res = radius.decode({ packet: msg, secret: RADIUS_SECRET }); + resolve({ code: res.code }); + } catch (e) { + resolve({ code: 'unknown' }); + } + }); + client.send(packet, 0, packet.length, 3799, nasIp, (err) => { + if (err) { + clearTimeout(timeout); + client.close(); + reject(err); + } + }); + }); +} + +async function disconnectUserSessions(username) { + const targets = []; + for (const sess of activeSessions.values()) { + if (sess.username === username && sess.nasIp) targets.push(sess); + } + if (targets.length === 0) return; + for (const sess of targets) { + try { + const result = await sendDisconnectRequest({ + nasIp: sess.nasIp, + username: sess.username, + sessionId: sess.sessionId, + callingStationId: sess.callingStationId, + nasId: sess.nasId, + }); + pushRequest({ + id: Date.now() + ':' + Math.random().toString(16).slice(2), + ts: new Date().toISOString(), + type: 'coa-disconnect', + attrs: { + 'User-Name': sess.username, + 'NAS-IP-Address': sess.nasIp, + 'Acct-Session-Id': sess.sessionId, + 'Calling-Station-Id': sess.callingStationId, + 'result': result.code, + }, + }); + } catch (e) { + pushRequest({ + id: Date.now() + ':' + Math.random().toString(16).slice(2), + ts: new Date().toISOString(), + type: 'coa-disconnect', + attrs: { + 'User-Name': sess.username, + 'NAS-IP-Address': sess.nasIp, + 'Acct-Session-Id': sess.sessionId, + 'Calling-Station-Id': sess.callingStationId, + 'error': String(e?.message || e), + }, + }); + } + } +} + app.post('/test/radius', async (req, res) => { try { const result = await sendRadiusSelfTest();