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:
3
.env
Normal file
3
.env
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
RADIUS_CLIENTS_CIDR=192.168.87.0/24
|
||||||
|
RADIUS_SHARED_SECRET=tamosbien
|
||||||
|
|
||||||
@@ -1,7 +1,12 @@
|
|||||||
client unifi {
|
client unifi {
|
||||||
ipaddr = %{env:RADIUS_CLIENTS_CIDR}
|
ipaddr = 192.168.87.0/24
|
||||||
secret = %{env:RADIUS_SHARED_SECRET}
|
secret = tamosbien
|
||||||
require_message_authenticator = no
|
require_message_authenticator = no
|
||||||
nastype = other
|
nastype = other
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Cliente local para pruebas con radclient dentro del contenedor
|
||||||
|
client localhost {
|
||||||
|
ipaddr = 127.0.0.1
|
||||||
|
secret = tamosbien
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ rest {
|
|||||||
|
|
||||||
# Authorize: llama al API Node
|
# Authorize: llama al API Node
|
||||||
authorize {
|
authorize {
|
||||||
uri = "%{env:REST_ENDPOINT:-http://node:3000}/authorize"
|
uri = "http://node:3000/authorize"
|
||||||
method = "post"
|
method = "post"
|
||||||
body = "json"
|
body = "json"
|
||||||
# send_all = yes -> envía todos los atributos del paquete
|
# send_all = yes -> envía todos los atributos del paquete
|
||||||
@@ -14,9 +14,8 @@ rest {
|
|||||||
|
|
||||||
# Accounting: opcional
|
# Accounting: opcional
|
||||||
accounting {
|
accounting {
|
||||||
uri = "%{env:REST_ENDPOINT:-http://node:3000}/accounting"
|
uri = "http://node:3000/accounting"
|
||||||
method = "post"
|
method = "post"
|
||||||
body = "json"
|
body = "json"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,14 +14,11 @@ server default {
|
|||||||
authorize {
|
authorize {
|
||||||
# Llama a la API REST para decidir y añadir atributos
|
# Llama a la API REST para decidir y añadir atributos
|
||||||
rest
|
rest
|
||||||
|
# Para laboratorio: aceptar todo siempre (MAC-Auth / pruebas)
|
||||||
# Si la API no estableció Auth-Type, aceptamos por defecto (demo)
|
|
||||||
if (!&control:Auth-Type) {
|
|
||||||
update control {
|
update control {
|
||||||
Auth-Type := Accept
|
Auth-Type := Accept
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
authenticate {
|
authenticate {
|
||||||
# Aceptar todo cuando control:Auth-Type := Accept
|
# Aceptar todo cuando control:Auth-Type := Accept
|
||||||
@@ -40,4 +37,3 @@ server default {
|
|||||||
# rest
|
# rest
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,35 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
import morgan from 'morgan';
|
import morgan from 'morgan';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use(morgan('dev'));
|
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 VLAN_ID = process.env.VLAN_ID || '2';
|
||||||
const MAX_UP = process.env.MAX_UP || '10000000'; // bits per second
|
const MAX_UP = process.env.MAX_UP || '10000000'; // bits per second
|
||||||
const MAX_DOWN = process.env.MAX_DOWN || '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
|
// Helper: standard Accept with VLAN + bandwidth
|
||||||
function buildAcceptPayload(extra = {}) {
|
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 {}
|
// Authorize endpoint: FreeRADIUS rlm_rest calls this in authorize {}
|
||||||
app.post('/authorize', (req, res) => {
|
app.post('/authorize', (req, res) => {
|
||||||
console.log('--- RADIUS Authorize Request ---');
|
console.log('--- RADIUS Authorize Request ---');
|
||||||
console.log(JSON.stringify(req.body, null, 2));
|
console.log(JSON.stringify(req.body, null, 2));
|
||||||
|
|
||||||
// Por ahora aprobamos todas las solicitudes válidas (si traen User-Name)
|
const attrs = normalizeAttributes(req.body);
|
||||||
const attrs = (req.body && (req.body.attributes || req.body.request)) || {};
|
const reply = buildAcceptPayload();
|
||||||
if (!attrs['User-Name'] && !attrs['User-Name*0']) {
|
pushRequest({
|
||||||
// Responder vacío -> no cambia nada; o devolver 204
|
id: Date.now() + ':' + Math.random().toString(16).slice(2),
|
||||||
return res.status(200).json({});
|
ts: new Date().toISOString(),
|
||||||
}
|
type: 'authorize',
|
||||||
|
attrs,
|
||||||
return res.status(200).json(buildAcceptPayload());
|
decision: 'accept',
|
||||||
|
vlan: VLAN_ID,
|
||||||
|
bandwidth: { up: MAX_UP, down: MAX_DOWN },
|
||||||
|
});
|
||||||
|
return res.status(200).json(reply);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Accounting endpoint (opcional)
|
// Accounting endpoint (opcional)
|
||||||
app.post('/accounting', (req, res) => {
|
app.post('/accounting', (req, res) => {
|
||||||
console.log('--- RADIUS Accounting ---');
|
console.log('--- RADIUS Accounting ---');
|
||||||
console.log(JSON.stringify(req.body, null, 2));
|
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({});
|
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;
|
const port = process.env.PORT || 3000;
|
||||||
app.listen(port, () => {
|
app.listen(port, () => {
|
||||||
console.log(`Node RADIUS REST API listening on :${port}`);
|
console.log(`Node RADIUS REST API listening on :${port}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
92
node-api/public/index.html
Normal file
92
node-api/public/index.html
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>RADIUS Dashboard</title>
|
||||||
|
<style>
|
||||||
|
:root { color-scheme: light dark; }
|
||||||
|
body { font-family: system-ui, sans-serif; margin: 0; }
|
||||||
|
header { padding: 12px 16px; background: #0b5; color: #fff; }
|
||||||
|
main { padding: 16px; }
|
||||||
|
.meta { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
|
||||||
|
.chip { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 12px; background: #eef; color: #334; }
|
||||||
|
.list { margin-top: 12px; }
|
||||||
|
.item { border: 1px solid #ccc; border-radius: 8px; padding: 8px 12px; margin-bottom: 8px; }
|
||||||
|
.type-auth { border-left: 4px solid #0b5; }
|
||||||
|
.type-acct { border-left: 4px solid #09f; }
|
||||||
|
.attrs { font-family: ui-monospace, monospace; font-size: 12px; white-space: pre-wrap; background: rgba(0,0,0,0.04); padding: 8px; border-radius: 6px; }
|
||||||
|
.toolbar { display: flex; gap: 8px; align-items: center; margin: 8px 0; }
|
||||||
|
button { padding: 6px 10px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>RADIUS Dashboard</h1>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
<div class="meta">
|
||||||
|
<span class="chip" id="status">Conectando…</span>
|
||||||
|
<span class="chip" id="count">0 eventos</span>
|
||||||
|
<span class="chip" id="band">VLAN 2 • 10/10 Mbps</span>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar">
|
||||||
|
<button id="refresh">Recargar historial</button>
|
||||||
|
<label><input type="checkbox" id="autoScroll" checked /> Autoscroll</label>
|
||||||
|
</div>
|
||||||
|
<div id="list" class="list"></div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const list = document.getElementById('list');
|
||||||
|
const statusEl = document.getElementById('status');
|
||||||
|
const countEl = document.getElementById('count');
|
||||||
|
const btnRefresh = document.getElementById('refresh');
|
||||||
|
const autoScroll = document.getElementById('autoScroll');
|
||||||
|
let total = 0;
|
||||||
|
|
||||||
|
function renderItem(ev) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
const isAuth = ev.type === 'authorize';
|
||||||
|
div.className = 'item ' + (isAuth ? 'type-auth' : 'type-acct');
|
||||||
|
const user = ev.attrs?.['User-Name'] || ev.attrs?.['User-Name*0'] || '-';
|
||||||
|
const nas = ev.attrs?.['NAS-IP-Address'] || '-';
|
||||||
|
const calling = ev.attrs?.['Calling-Station-Id'] || '-';
|
||||||
|
const called = ev.attrs?.['Called-Station-Id'] || '-';
|
||||||
|
div.innerHTML = `
|
||||||
|
<div><strong>${isAuth ? 'Authorize' : 'Accounting'}</strong> • <small>${new Date(ev.ts).toLocaleString()}</small></div>
|
||||||
|
<div>Usuario: <code>${user}</code> • NAS: <code>${nas}</code> • STA: <code>${calling}</code> • AP: <code>${called}</code></div>
|
||||||
|
${isAuth ? `<div>Decisión: <strong>${ev.decision}</strong> • VLAN: <code>${ev.vlan}</code> • BW: <code>${(ev.bandwidth?.down/1e6)||10}↓ / ${(ev.bandwidth?.up/1e6)||10}↑ Mbps</code></div>` : ''}
|
||||||
|
<div class="attrs">${JSON.escape ? JSON.escape(JSON.stringify(ev.attrs, null, 2)) : JSON.stringify(ev.attrs, null, 2)}</div>
|
||||||
|
`;
|
||||||
|
list.appendChild(div);
|
||||||
|
total += 1;
|
||||||
|
countEl.textContent = `${total} eventos`;
|
||||||
|
if (autoScroll.checked) div.scrollIntoView({ behavior: 'smooth', block: 'end' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadHistory() {
|
||||||
|
const r = await fetch('/api/requests');
|
||||||
|
const data = await r.json();
|
||||||
|
list.innerHTML = '';
|
||||||
|
total = 0;
|
||||||
|
data.items.forEach(renderItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
btnRefresh.addEventListener('click', loadHistory);
|
||||||
|
|
||||||
|
// SSE live events
|
||||||
|
function connectSSE() {
|
||||||
|
const es = new EventSource('/events');
|
||||||
|
es.addEventListener('open', () => statusEl.textContent = 'Conectado');
|
||||||
|
es.addEventListener('error', () => statusEl.textContent = 'Reintentando…');
|
||||||
|
es.addEventListener('message', (e) => {
|
||||||
|
try { const ev = JSON.parse(e.data); renderItem(ev); } catch {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
loadHistory().then(connectSSE);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
Reference in New Issue
Block a user