import express from 'express'; import morgan from 'morgan'; import fs from 'fs/promises'; import path from 'path'; import { fileURLToPath } from 'url'; import dgram from 'dgram'; import radius from 'radius'; import pkgPg from 'pg'; const { Pool } = pkgPg; const app = express(); app.use(express.json()); app.use(morgan('dev')); // Static files for dashboard const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); app.use(express.static(path.join(__dirname, 'public'))); const VLAN_ID = process.env.VLAN_ID || '2'; const MAX_UP = process.env.MAX_UP || '10000000'; // bits per second const MAX_DOWN = process.env.MAX_DOWN || '10000000'; // bits per second const MAX_REQUESTS = parseInt(process.env.MAX_REQUESTS || '200', 10); const RADIUS_HOST = process.env.RADIUS_HOST || 'freeradius'; const RADIUS_AUTH_PORT = parseInt(process.env.RADIUS_AUTH_PORT || '1812', 10); const RADIUS_SECRET = process.env.RADIUS_SECRET || process.env.RADIUS_SHARED_SECRET || 'tamosbien'; // Requests store + SSE clients const requests = []; const sseClients = new Set(); let radiusReloading = false; function pushRequest(rec) { requests.push(rec); while (requests.length > MAX_REQUESTS) requests.shift(); // Broadcast via SSE const payload = `data: ${JSON.stringify(rec)}\n\n`; for (const res of sseClients) { try { res.write(payload); } catch { /* ignore */ } } } function broadcastStatus(payload) { const ev = `event: status\n` + `data: ${JSON.stringify(payload)}\n\n`; for (const res of sseClients) { try { res.write(ev); } catch {} } } // Postgres connection for user management (rlm_sql) const PGHOST = process.env.PGHOST || 'postgres'; const PGPORT = parseInt(process.env.PGPORT || '5432', 10); 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 }); // SQL helpers: users in radcheck/radreply async function readUsersFromDb() { const client = await pool.connect(); try { const q = ` SELECT rc.username, rc.value AS password, EXISTS ( SELECT 1 FROM radcheck r2 WHERE r2.username = rc.username AND r2.attribute = 'Auth-Type' AND r2.value = 'Reject' ) AS disabled, COALESCE(( SELECT rr.value FROM radreply rr WHERE rr.username = rc.username AND rr.attribute = 'Tunnel-Private-Group-Id' ORDER BY rr.id DESC LIMIT 1 ), $1) AS vlan FROM radcheck rc WHERE rc.attribute = 'Cleartext-Password' ORDER BY rc.username ASC`; const { rows } = await client.query(q, [String(VLAN_ID)]); return rows.map(r => ({ username: r.username, password: r.password, vlan: String(r.vlan), disabled: !!r.disabled })); } finally { client.release(); } } async function upsertUserToDb(user) { const { username, password, vlan, disabled } = user; const client = await pool.connect(); try { await client.query('BEGIN'); // Password await client.query("DELETE FROM radcheck WHERE username = $1 AND attribute = 'Cleartext-Password'", [username]); await client.query( "INSERT INTO radcheck (username, attribute, op, value) VALUES ($1,'Cleartext-Password',':=',$2)", [username, password] ); // Disabled flag await client.query("DELETE FROM radcheck WHERE username = $1 AND attribute = 'Auth-Type'", [username]); if (disabled) { await client.query( "INSERT INTO radcheck (username, attribute, op, value) VALUES ($1,'Auth-Type',':=','Reject')", [username] ); } // Reply attributes (VLAN and bandwidth) const attrs = [ ['Tunnel-Type', 'VLAN'], ['Tunnel-Medium-Type', 'IEEE-802'], ['Tunnel-Private-Group-Id', String(vlan || VLAN_ID)], ['WISPr-Bandwidth-Max-Down', String(MAX_DOWN)], ['WISPr-Bandwidth-Max-Up', String(MAX_UP)], ]; 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')", [username] ); for (const [attr, val] of attrs) { await client.query( "INSERT INTO radreply (username, attribute, op, value) VALUES ($1,$2,':=',$3)", [username, attr, String(val)] ); } await client.query('COMMIT'); } catch (e) { await client.query('ROLLBACK'); throw e; } finally { client.release(); } } async function deleteUserFromDb(username) { const client = await pool.connect(); try { await client.query('BEGIN'); await client.query('DELETE FROM radcheck WHERE username = $1', [username]); await client.query('DELETE FROM radreply WHERE username = $1', [username]); await client.query('COMMIT'); } catch (e) { await client.query('ROLLBACK'); throw e; } finally { client.release(); } } // Helper: standard Accept with VLAN + bandwidth function buildAcceptPayload(extra = {}) { return { control: { 'Auth-Type': 'Accept', ...extra.control, }, reply: { 'Tunnel-Type': 'VLAN', 'Tunnel-Medium-Type': 'IEEE-802', 'Tunnel-Private-Group-Id': String(VLAN_ID), 'WISPr-Bandwidth-Max-Down': String(MAX_DOWN), 'WISPr-Bandwidth-Max-Up': String(MAX_UP), ...extra.reply, }, }; } // Normalize attributes from FreeRADIUS rlm_rest JSON function normalizeAttributes(body = {}) { // Newer rlm_rest may send attributes at top-level as { Attr: { type, value: [..] } } // or under body.attributes / body.request as plain map. const src = body.attributes || body.request || body; const out = {}; for (const [k, v] of Object.entries(src || {})) { if (v && typeof v === 'object' && Array.isArray(v.value)) out[k] = v.value[0]; else out[k] = v; } return out; } // Authorize endpoint: FreeRADIUS rlm_rest calls this in authorize {} app.post('/authorize', (req, res) => { console.log('--- RADIUS Authorize Request ---'); console.log(JSON.stringify(req.body, null, 2)); const attrs = normalizeAttributes(req.body); const reply = buildAcceptPayload(); pushRequest({ id: Date.now() + ':' + Math.random().toString(16).slice(2), ts: new Date().toISOString(), type: 'authorize', attrs, decision: 'accept', vlan: VLAN_ID, bandwidth: { up: MAX_UP, down: MAX_DOWN }, }); return res.status(200).json(reply); }); // Accounting endpoint (opcional) app.post('/accounting', (req, res) => { console.log('--- RADIUS Accounting ---'); console.log(JSON.stringify(req.body, null, 2)); pushRequest({ id: Date.now() + ':' + Math.random().toString(16).slice(2), ts: new Date().toISOString(), type: 'accounting', attrs: normalizeAttributes(req.body), }); return res.status(200).json({}); }); // Authorize inner-tunnel (EAP): devolver Cleartext-Password para el usuario app.post('/authorize-inner', async (req, res) => res.status(410).json({})); // Post-auth: return reply attributes like VLAN based on user mapping // Post-auth events: log only (rlm_rest calls here) app.post('/post-auth', async (req, res) => { try { const attrs = normalizeAttributes(req.body); pushRequest({ id: Date.now() + ':' + Math.random().toString(16).slice(2), ts: new Date().toISOString(), type: 'post-auth', attrs, }); } catch (e) { console.error('post-auth log error:', e); } return res.status(200).json({}); }); // Users API app.get('/api/users', async (req, res) => { const items = await readUsersFromDb(); res.json({ items }); }); app.post('/api/users', async (req, res) => { const { username, password, vlan, disabled } = req.body || {}; if (!username || !password) return res.status(400).json({ ok: false, error: 'username and password required' }); const user = { username: String(username), password: String(password), vlan: String(vlan || VLAN_ID), disabled: !!disabled }; await upsertUserToDb(user); res.json({ ok: true }); }); app.patch('/api/users/:username', async (req, res) => { const uname = String(req.params.username); const { password, vlan, disabled } = req.body || {}; const current = (await readUsersFromDb()).find(u => u.username === uname); if (!current) return res.status(404).json({ ok: false, error: 'not_found' }); const next = { username: uname, password: password !== undefined ? String(password) : current.password, vlan: vlan !== undefined ? String(vlan) : current.vlan, disabled: disabled !== undefined ? !!disabled : current.disabled, }; await upsertUserToDb(next); res.json({ ok: true }); }); app.delete('/api/users/:username', async (req, res) => { const uname = String(req.params.username); await deleteUserFromDb(uname); res.json({ ok: true }); }); // API: recent requests app.get('/api/requests', (req, res) => { res.json({ items: requests.slice(-MAX_REQUESTS) }); }); // Clear recent requests app.delete('/api/requests', (req, res) => { requests.length = 0; // Notify live clients to refresh if they want const payload = `event: clear\n` + `data: {"ok":true}\n\n`; for (const resSse of sseClients) { try { resSse.write(payload); } catch {} } res.json({ ok: true }); }); // Export CSV of recent requests app.get('/api/requests.csv', (req, res) => { const cols = [ 'ts','type','user','nas','calling','called','decision','vlan','bw_down','bw_up' ]; const lines = [cols.join(',')]; for (const ev of requests) { const attrs = ev.attrs || {}; const row = [ ev.ts || '', ev.type || '', attrs['User-Name'] || attrs['User-Name*0'] || '', attrs['NAS-IP-Address'] || attrs['NAS-Identifier'] || '', attrs['Calling-Station-Id'] || '', attrs['Called-Station-Id'] || '', ev.decision || '', ev.vlan || '', (ev.bandwidth && ev.bandwidth.down) || '', (ev.bandwidth && ev.bandwidth.up) || '' ]; const esc = (v) => String(v).includes(',') || String(v).includes('"') || String(v).includes('\n') ? '"' + String(v).replace(/"/g, '""') + '"' : String(v); lines.push(row.map(esc).join(',')); } const csv = lines.join('\n'); 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="radius-events-${ts}.csv"`); res.send(csv); }); // SSE stream for live updates app.get('/events', (req, res) => { res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); res.flushHeaders?.(); // send a hello event res.write(`event: hello\n`); res.write(`data: {"ok":true}\n\n`); // send initial status res.write(`event: status\n`); res.write(`data: ${JSON.stringify({ radius_reloading: radiusReloading })}\n\n`); sseClients.add(res); req.on('close', () => sseClients.delete(res)); }); // Root: serve dashboard app.get('/', (req, res) => { res.sendFile(path.join(__dirname, 'public', 'index.html')); }); // Self-test: send a RADIUS Access-Request to FreeRADIUS async function sendRadiusSelfTest() { return new Promise((resolve, reject) => { try { const packet = radius.encode({ code: 'Access-Request', secret: RADIUS_SECRET, attributes: { 'User-Name': 'selftest-node', 'NAS-Identifier': 'node-dashboard', 'Calling-Station-Id': '001122334455', }, }); const client = dgram.createSocket('udp4'); const started = Date.now(); const timeout = setTimeout(() => { client.close(); reject(new Error('timeout')); }, 4000); client.on('message', (msg) => { clearTimeout(timeout); client.close(); const res = radius.decode({ packet: msg, secret: RADIUS_SECRET }); resolve({ code: res.code, rtt_ms: Date.now() - started, }); }); client.send(packet, 0, packet.length, RADIUS_AUTH_PORT, RADIUS_HOST, (err) => { if (err) { clearTimeout(timeout); client.close(); reject(err); } }); } catch (e) { reject(e); } }); } app.post('/test/radius', async (req, res) => { try { const result = await sendRadiusSelfTest(); pushRequest({ id: Date.now() + ':' + Math.random().toString(16).slice(2), ts: new Date().toISOString(), type: 'selftest', attrs: { 'User-Name': 'selftest-node' }, decision: result.code, }); res.json({ ok: true, result }); } catch (err) { pushRequest({ id: Date.now() + ':' + Math.random().toString(16).slice(2), ts: new Date().toISOString(), type: 'selftest', attrs: { 'User-Name': 'selftest-node' }, decision: 'error', error: String(err && err.message || err), }); res.status(500).json({ ok: false, error: String(err && err.message || err) }); } }); const port = process.env.PORT || 3000; app.listen(port, () => { console.log(`Node RADIUS REST API listening on :${port}`); });