servidor funcionando dashboard 80%
This commit is contained in:
@@ -9,6 +9,9 @@ services:
|
|||||||
- VLAN_ID=2
|
- VLAN_ID=2
|
||||||
- MAX_UP=10000000
|
- MAX_UP=10000000
|
||||||
- MAX_DOWN=10000000
|
- MAX_DOWN=10000000
|
||||||
|
- RADIUS_HOST=freeradius
|
||||||
|
- RADIUS_AUTH_PORT=1812
|
||||||
|
- RADIUS_SECRET=${RADIUS_SHARED_SECRET:-testing123}
|
||||||
networks:
|
networks:
|
||||||
- radius_net
|
- radius_net
|
||||||
|
|
||||||
@@ -35,4 +38,3 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
radius_net:
|
radius_net:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
client unifi {
|
client unifi {
|
||||||
ipaddr = 192.168.87.0/24
|
ipaddr = 0.0.0.0/0
|
||||||
secret = tamosbien
|
secret = tamosbien
|
||||||
require_message_authenticator = no
|
require_message_authenticator = no
|
||||||
nastype = other
|
nastype = other
|
||||||
@@ -10,3 +10,9 @@ client localhost {
|
|||||||
ipaddr = 127.0.0.1
|
ipaddr = 127.0.0.1
|
||||||
secret = tamosbien
|
secret = tamosbien
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Permitir contenedores en el bridge de Docker (laboratorio)
|
||||||
|
client docker_net_lab {
|
||||||
|
ipaddr = 172.16.0.0/12
|
||||||
|
secret = tamosbien
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import express from 'express';
|
|||||||
import morgan from 'morgan';
|
import morgan from 'morgan';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
|
import dgram from 'dgram';
|
||||||
|
import radius from 'radius';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
@@ -16,6 +18,9 @@ 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);
|
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';
|
||||||
|
|
||||||
// In-memory request store + SSE clients
|
// In-memory request store + SSE clients
|
||||||
const requests = [];
|
const requests = [];
|
||||||
@@ -99,6 +104,49 @@ app.get('/api/requests', (req, res) => {
|
|||||||
res.json({ items: requests.slice(-MAX_REQUESTS) });
|
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
|
// SSE stream for live updates
|
||||||
app.get('/events', (req, res) => {
|
app.get('/events', (req, res) => {
|
||||||
res.setHeader('Content-Type', 'text/event-stream');
|
res.setHeader('Content-Type', 'text/event-stream');
|
||||||
@@ -117,6 +165,71 @@ app.get('/', (req, res) => {
|
|||||||
res.sendFile(path.join(__dirname, 'public', 'index.html'));
|
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;
|
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}`);
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"express": "^4.19.2",
|
"express": "^4.19.2",
|
||||||
"morgan": "^1.10.0"
|
"morgan": "^1.10.0",
|
||||||
|
"radius": "^1.1.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,11 @@
|
|||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<button id="refresh">Recargar historial</button>
|
<button id="refresh">Recargar historial</button>
|
||||||
<label><input type="checkbox" id="autoScroll" checked /> Autoscroll</label>
|
<label><input type="checkbox" id="autoScroll" checked /> Autoscroll</label>
|
||||||
|
<button id="selfTest">Self‑Test RADIUS</button>
|
||||||
|
<span class="chip" id="selfTestStatus">Idle</span>
|
||||||
|
<button id="clear">Limpiar</button>
|
||||||
|
<button id="copy">Copiar</button>
|
||||||
|
<button id="exportCsv">Exportar CSV</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="list" class="list"></div>
|
<div id="list" class="list"></div>
|
||||||
</main>
|
</main>
|
||||||
@@ -42,8 +47,14 @@ const list = document.getElementById('list');
|
|||||||
const statusEl = document.getElementById('status');
|
const statusEl = document.getElementById('status');
|
||||||
const countEl = document.getElementById('count');
|
const countEl = document.getElementById('count');
|
||||||
const btnRefresh = document.getElementById('refresh');
|
const btnRefresh = document.getElementById('refresh');
|
||||||
|
const btnSelfTest = document.getElementById('selfTest');
|
||||||
|
const selfTestStatus = document.getElementById('selfTestStatus');
|
||||||
const autoScroll = document.getElementById('autoScroll');
|
const autoScroll = document.getElementById('autoScroll');
|
||||||
let total = 0;
|
let total = 0;
|
||||||
|
const btnClear = document.getElementById('clear');
|
||||||
|
const btnCopy = document.getElementById('copy');
|
||||||
|
const btnExportCsv = document.getElementById('exportCsv');
|
||||||
|
let history = [];
|
||||||
|
|
||||||
function renderItem(ev) {
|
function renderItem(ev) {
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
@@ -69,12 +80,49 @@ async function loadHistory() {
|
|||||||
const r = await fetch('/api/requests');
|
const r = await fetch('/api/requests');
|
||||||
const data = await r.json();
|
const data = await r.json();
|
||||||
list.innerHTML = '';
|
list.innerHTML = '';
|
||||||
|
history = data.items || [];
|
||||||
total = 0;
|
total = 0;
|
||||||
data.items.forEach(renderItem);
|
history.forEach(renderItem);
|
||||||
}
|
}
|
||||||
|
|
||||||
btnRefresh.addEventListener('click', loadHistory);
|
btnRefresh.addEventListener('click', loadHistory);
|
||||||
|
|
||||||
|
btnSelfTest.addEventListener('click', async () => {
|
||||||
|
selfTestStatus.textContent = 'Running…';
|
||||||
|
try {
|
||||||
|
const r = await fetch('/test/radius', { method: 'POST' });
|
||||||
|
const data = await r.json();
|
||||||
|
if (data.ok) selfTestStatus.textContent = `OK (${data.result.code}, ${data.result.rtt_ms}ms)`;
|
||||||
|
else selfTestStatus.textContent = `Error (${data.error||r.status})`;
|
||||||
|
} catch (e) {
|
||||||
|
selfTestStatus.textContent = `Error (${e.message})`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
btnClear.addEventListener('click', async () => {
|
||||||
|
if (!confirm('¿Limpiar historial de eventos?')) return;
|
||||||
|
await fetch('/api/requests', { method: 'DELETE' });
|
||||||
|
await loadHistory();
|
||||||
|
});
|
||||||
|
|
||||||
|
btnCopy.addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(JSON.stringify(history, null, 2));
|
||||||
|
alert('Copiado al portapapeles');
|
||||||
|
} catch (e) {
|
||||||
|
alert('No se pudo copiar: ' + e.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
btnExportCsv.addEventListener('click', () => {
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = '/api/requests.csv';
|
||||||
|
a.download = '';
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
});
|
||||||
|
|
||||||
// SSE live events
|
// SSE live events
|
||||||
function connectSSE() {
|
function connectSSE() {
|
||||||
const es = new EventSource('/events');
|
const es = new EventSource('/events');
|
||||||
@@ -83,10 +131,10 @@ function connectSSE() {
|
|||||||
es.addEventListener('message', (e) => {
|
es.addEventListener('message', (e) => {
|
||||||
try { const ev = JSON.parse(e.data); renderItem(ev); } catch {}
|
try { const ev = JSON.parse(e.data); renderItem(ev); } catch {}
|
||||||
});
|
});
|
||||||
|
es.addEventListener('clear', () => { loadHistory(); });
|
||||||
}
|
}
|
||||||
|
|
||||||
loadHistory().then(connectSSE);
|
loadHistory().then(connectSSE);
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user