feat(node): dashboard SSE y normalización de atributos REST\n\n- Agrega dashboard web (SSE) en / con historial y eventos en vivo\n- Normaliza formato JSON de rlm_rest y registra eventos\n- Responde Access-Accept con VLAN 2 + WISPr 10/10\n\nfix(radius): corrige URI rlm_rest y acepta en lab\n\n- REST URI fija a http://node:3000 (antes expandía mal)\n- default: Auth-Type := Accept para entorno de pruebas\n- clients: añade localhost y subred 192.168.87.0/24\n\nchore: añade .env para compose
This commit is contained in:
@@ -1,13 +1,35 @@
|
||||
import express from 'express';
|
||||
import morgan from 'morgan';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
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);
|
||||
|
||||
// In-memory request store + SSE clients
|
||||
const requests = [];
|
||||
const sseClients = new Set();
|
||||
|
||||
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 */ }
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: standard Accept with VLAN + bandwidth
|
||||
function buildAcceptPayload(extra = {}) {
|
||||
@@ -27,30 +49,75 @@ function buildAcceptPayload(extra = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
// 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));
|
||||
|
||||
// Por ahora aprobamos todas las solicitudes válidas (si traen User-Name)
|
||||
const attrs = (req.body && (req.body.attributes || req.body.request)) || {};
|
||||
if (!attrs['User-Name'] && !attrs['User-Name*0']) {
|
||||
// Responder vacío -> no cambia nada; o devolver 204
|
||||
return res.status(200).json({});
|
||||
}
|
||||
|
||||
return res.status(200).json(buildAcceptPayload());
|
||||
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({});
|
||||
});
|
||||
|
||||
// API: recent requests
|
||||
app.get('/api/requests', (req, res) => {
|
||||
res.json({ items: requests.slice(-MAX_REQUESTS) });
|
||||
});
|
||||
|
||||
// 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`);
|
||||
sseClients.add(res);
|
||||
req.on('close', () => sseClients.delete(res));
|
||||
});
|
||||
|
||||
// Root: serve dashboard
|
||||
app.get('/', (req, res) => {
|
||||
res.sendFile(path.join(__dirname, 'public', 'index.html'));
|
||||
});
|
||||
|
||||
const port = process.env.PORT || 3000;
|
||||
app.listen(port, () => {
|
||||
console.log(`Node RADIUS REST API listening on :${port}`);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user