441 lines
14 KiB
JavaScript
441 lines
14 KiB
JavaScript
import express from 'express';
|
|
import morgan from 'morgan';
|
|
import fs from 'fs/promises';
|
|
import path from 'path';
|
|
import { fileURLToPath } from 'url';
|
|
import http from 'http';
|
|
import dgram from 'dgram';
|
|
import radius from 'radius';
|
|
|
|
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';
|
|
const DOCKER_SOCK = process.env.DOCKER_SOCK || '/var/run/docker.sock';
|
|
const FREERADIUS_CONTAINER = process.env.FREERADIUS_CONTAINER || 'radiusnucleo-freeradius-1';
|
|
|
|
// 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 {}
|
|
}
|
|
}
|
|
|
|
const AUTH_FILE = process.env.AUTH_FILE || '/shared/authorize';
|
|
|
|
function parseUsersFromText(text) {
|
|
const users = [];
|
|
const lines = text.split(/\r?\n/);
|
|
let i = 0;
|
|
while (i < lines.length) {
|
|
const line = lines[i];
|
|
const m = line.match(/^\s*(#?)\s*([^\s#]+)\s+Cleartext-Password\s*:=\s*"([^"]+)"/);
|
|
if (m) {
|
|
const disabled = m[1] === '#';
|
|
const username = m[2];
|
|
const password = m[3];
|
|
let vlan = undefined;
|
|
i++;
|
|
while (i < lines.length && lines[i].trim() !== '') {
|
|
const l = lines[i].replace(/^\s*#\s*/, '');
|
|
const mv = l.match(/Tunnel-Private-Group-Id\s*=\s*"?(\d+)"?/i);
|
|
if (mv) vlan = mv[1];
|
|
i++;
|
|
}
|
|
users.push({ username, password, vlan: vlan || VLAN_ID, disabled });
|
|
while (i < lines.length && lines[i].trim() === '') i++;
|
|
continue;
|
|
}
|
|
i++;
|
|
}
|
|
return users;
|
|
}
|
|
|
|
async function readUsersFromFile() {
|
|
try {
|
|
const data = await fs.readFile(AUTH_FILE, 'utf8').catch(() => '');
|
|
return parseUsersFromText(data);
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
async function writeUsersToFile(users) {
|
|
const header = '# Managed by Node dashboard; do not edit manually\n';
|
|
const chunks = [];
|
|
for (const u of users) {
|
|
const lines = [
|
|
`${u.username} Cleartext-Password := "${u.password}"`,
|
|
` Tunnel-Type = VLAN,`,
|
|
` Tunnel-Medium-Type = IEEE-802,`,
|
|
` Tunnel-Private-Group-Id = "${String(u.vlan || VLAN_ID)}"`,
|
|
''
|
|
];
|
|
if (u.disabled) {
|
|
chunks.push(lines.map(l => l ? `# ${l}` : '').join('\n'));
|
|
} else {
|
|
chunks.push(lines.join('\n'));
|
|
}
|
|
}
|
|
await fs.writeFile(AUTH_FILE, header + chunks.join('\n'));
|
|
}
|
|
|
|
async function persistUsersToFreeradius(users) {
|
|
try {
|
|
await writeUsersToFile(users);
|
|
triggerRadiusReload().catch(() => {});
|
|
} catch (e) {
|
|
console.error('Failed to persist users to FreeRADIUS files:', e);
|
|
}
|
|
}
|
|
|
|
async function triggerRadiusReload() {
|
|
try {
|
|
radiusReloading = true;
|
|
broadcastStatus({ radius_reloading: true });
|
|
// Call Docker Engine API over unix socket: POST /containers/{id}/kill?signal=HUP
|
|
await new Promise((resolve, reject) => {
|
|
const req = http.request({
|
|
method: 'POST',
|
|
socketPath: DOCKER_SOCK,
|
|
path: `/v1.41/containers/${encodeURIComponent(FREERADIUS_CONTAINER)}/kill?signal=HUP`,
|
|
}, (res) => {
|
|
res.resume();
|
|
res.on('end', resolve);
|
|
});
|
|
req.on('error', reject);
|
|
req.end();
|
|
});
|
|
} catch (e) {
|
|
console.error('Failed to HUP FreeRADIUS:', e?.message || e);
|
|
} finally {
|
|
setTimeout(() => {
|
|
radiusReloading = false;
|
|
broadcastStatus({ radius_reloading: false });
|
|
}, 1500);
|
|
}
|
|
}
|
|
|
|
// 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', (req, res) => {
|
|
console.log('--- RADIUS Authorize (inner) ---');
|
|
console.log(JSON.stringify(req.body, null, 2));
|
|
|
|
const attrs = normalizeAttributes(req.body);
|
|
const username = (attrs['User-Name'] || '').toString();
|
|
const list = await readUsersFromFile();
|
|
const entry = list.find(u => u.username === username && !u.disabled);
|
|
const password = entry?.password;
|
|
|
|
if (!password) {
|
|
pushRequest({
|
|
id: Date.now() + ':' + Math.random().toString(16).slice(2),
|
|
ts: new Date().toISOString(),
|
|
type: 'authorize-inner',
|
|
attrs,
|
|
decision: 'notfound',
|
|
});
|
|
// No devolvemos nada -> FreeRADIUS seguirá su flujo y probablemente rechace
|
|
return res.status(200).json({});
|
|
}
|
|
|
|
pushRequest({
|
|
id: Date.now() + ':' + Math.random().toString(16).slice(2),
|
|
ts: new Date().toISOString(),
|
|
type: 'authorize-inner',
|
|
attrs,
|
|
decision: 'provide-password',
|
|
});
|
|
|
|
return res.status(200).json({
|
|
control: [
|
|
{ name: 'Cleartext-Password', value: String(password) }
|
|
]
|
|
});
|
|
});
|
|
|
|
// Post-auth: return reply attributes like VLAN based on user mapping
|
|
app.post('/post-auth', (req, res) => {
|
|
const attrs = normalizeAttributes(req.body);
|
|
const username = (attrs['User-Name'] || '').toString();
|
|
const list = await readUsersFromFile();
|
|
const entry = list.find(u => u.username === username && !u.disabled);
|
|
const vlan = entry?.vlan || VLAN_ID;
|
|
return res.status(200).json({
|
|
reply: [
|
|
{ name: 'Tunnel-Type', value: 'VLAN' },
|
|
{ name: 'Tunnel-Medium-Type', value: 'IEEE-802' },
|
|
{ name: 'Tunnel-Private-Group-Id', value: String(vlan) },
|
|
{ name: 'WISPr-Bandwidth-Max-Down', value: String(MAX_DOWN) },
|
|
{ name: 'WISPr-Bandwidth-Max-Up', value: String(MAX_UP) }
|
|
]
|
|
});
|
|
});
|
|
|
|
// Users API
|
|
app.get('/api/users', async (req, res) => {
|
|
const items = await readUsersFromFile();
|
|
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 items = await readUsersFromFile();
|
|
const idx = items.findIndex(u => u.username === String(username));
|
|
const user = { username: String(username), password: String(password), vlan: String(vlan || VLAN_ID), disabled: !!disabled };
|
|
if (idx >= 0) items[idx] = user; else items.push(user);
|
|
await persistUsersToFreeradius(items);
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
app.patch('/api/users/:username', async (req, res) => {
|
|
const uname = req.params.username;
|
|
const { password, vlan, disabled } = req.body || {};
|
|
const items = await readUsersFromFile();
|
|
const idx = items.findIndex(u => u.username === uname);
|
|
if (idx < 0) return res.status(404).json({ ok: false, error: 'not_found' });
|
|
if (password !== undefined) items[idx].password = String(password);
|
|
if (vlan !== undefined) items[idx].vlan = String(vlan);
|
|
if (disabled !== undefined) items[idx].disabled = !!disabled;
|
|
await persistUsersToFreeradius(items);
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
app.delete('/api/users/:username', async (req, res) => {
|
|
const uname = req.params.username;
|
|
const items = await readUsersFromFile();
|
|
const next = items.filter(u => u.username !== uname);
|
|
if (next.length === items.length) return res.status(404).json({ ok: false, error: 'not_found' });
|
|
await persistUsersToFreeradius(next);
|
|
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}`);
|
|
});
|