codigo refactorizado y ordenado, listo para siguiente fase

This commit is contained in:
2025-09-26 15:37:06 -06:00
parent 7b1935537a
commit 4783f51454
17 changed files with 1900 additions and 511 deletions

29
node-api/src/app.js Normal file
View File

@@ -0,0 +1,29 @@
import express from 'express';
import morgan from 'morgan';
import path from 'path';
import { fileURLToPath } from 'url';
import apiRouter from './routes/api.js';
import radiusRouter from './routes/radius.js';
export function createApp() {
const app = express();
app.use(express.json());
app.use(morgan('dev'));
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
app.use(express.static(path.join(__dirname, '..', 'public')));
// RADIUS hooks (rlm_rest)
app.use('/', radiusRouter);
// REST API
app.use('/api', apiRouter);
app.get('/', (_req, res) => {
res.sendFile(path.join(__dirname, '..', 'public', 'index.html'));
});
return app;
}

View File

@@ -0,0 +1,15 @@
export const VLAN_ID = process.env.VLAN_ID || '2';
export const MAX_UP = process.env.MAX_UP || '10000000';
export const MAX_DOWN = process.env.MAX_DOWN || '10000000';
export const MAX_REQUESTS = parseInt(process.env.MAX_REQUESTS || '200', 10);
export const RADIUS_HOST = process.env.RADIUS_HOST || 'freeradius';
export const RADIUS_AUTH_PORT = parseInt(process.env.RADIUS_AUTH_PORT || '1812', 10);
export const RADIUS_SECRET = process.env.RADIUS_SECRET || process.env.RADIUS_SHARED_SECRET || 'tamosbien';
export const PGHOST = process.env.PGHOST || 'postgres';
export const PGPORT = parseInt(process.env.PGPORT || '5432', 10);
export const PGDATABASE = process.env.PGDATABASE || 'radius';
export const PGUSER = process.env.PGUSER || 'radius';
export const PGPASSWORD = process.env.PGPASSWORD || 'radius';
export const SESSION_TIMEOUT = parseInt(process.env.SESSION_TIMEOUT || process.env.SESSION_TIMEOUT_SECONDS || '0', 10) || 0;

View File

@@ -0,0 +1,92 @@
import { Router } from 'express';
import { VLAN_ID } from '../config/env.js';
import { clearRequests, getRecentRequests, registerSse } from '../sse.js';
import { deleteUserFromDb, readUsersFromDb, upsertUserToDb } from '../services/db.js';
import { disconnectUserSessions } from '../services/radius.js';
const router = Router();
// Users
router.get('/users', async (_req, res) => {
const items = await readUsersFromDb();
res.json({ items });
});
router.post('/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 });
});
router.patch('/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);
if (disabled === true) {
disconnectUserSessions(uname).catch(err => console.error('CoA disconnect error:', err));
}
res.json({ ok: true });
});
router.delete('/users/:username', async (req, res) => {
const uname = String(req.params.username);
await deleteUserFromDb(uname);
res.json({ ok: true });
});
// Requests
router.get('/requests', (_req, res) => {
res.json({ items: getRecentRequests() });
});
router.delete('/requests', (_req, res) => {
clearRequests();
res.json({ ok: true });
});
router.get('/requests.csv', (_req, res) => {
const items = getRecentRequests();
const cols = ['ts','type','user','nas','calling','called','decision','vlan','bw_down','bw_up'];
const lines = [cols.join(',')];
for (const ev of items) {
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 events
router.get('/events', (req, res) => {
registerSse(req, res, {});
});
export default router;

View File

@@ -0,0 +1,94 @@
import { Router } from 'express';
import { VLAN_ID } from '../config/env.js';
import { buildAcceptPayload, normalizeAttributes } from '../utils/attrs.js';
import { pushRequest } from '../sse.js';
import { activeSessions, sendRadiusSelfTest } from '../services/radius.js';
const router = Router();
router.post('/authorize', (req, res) => {
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,
});
return res.status(200).json(reply);
});
router.post('/accounting', (req, res) => {
const attrs = normalizeAttributes(req.body);
try {
const st = String(attrs['Acct-Status-Type'] || attrs['Acct-Status-Type*0'] || '').toUpperCase();
const sessionId = String(attrs['Acct-Session-Id'] || '');
const username = String(attrs['User-Name'] || '');
if (sessionId && username) {
if (st === 'START' || st === 'ALIVE' || st === 'INTERIM-UPDATE' || st === 'INTERIM') {
activeSessions.set(sessionId, {
username,
sessionId,
nasIp: attrs['NAS-IP-Address'] || '',
nasId: attrs['NAS-Identifier'] || '',
callingStationId: attrs['Calling-Station-Id'] || '',
calledStationId: attrs['Called-Station-Id'] || '',
updatedAt: Date.now(),
});
} else if (st === 'STOP') {
activeSessions.delete(sessionId);
}
}
} catch {}
pushRequest({
id: Date.now() + ':' + Math.random().toString(16).slice(2),
ts: new Date().toISOString(),
type: 'accounting',
attrs,
});
return res.status(200).json({});
});
router.post('/authorize-inner', async (_req, res) => res.status(410).json({}));
router.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 {}
return res.status(200).json({});
});
router.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) });
}
});
export default router;

View File

@@ -0,0 +1,92 @@
import pkgPg from 'pg';
import { PGDATABASE, PGHOST, PGPASSWORD, PGPORT, PGUSER, SESSION_TIMEOUT, VLAN_ID, MAX_DOWN, MAX_UP } from '../config/env.js';
const { Pool } = pkgPg;
export const pool = new Pool({ host: PGHOST, port: PGPORT, database: PGDATABASE, user: PGUSER, password: PGPASSWORD });
export 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();
}
}
export async function upsertUserToDb(user) {
const { username, password, vlan, disabled } = user;
const client = await pool.connect();
try {
await client.query('BEGIN');
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]
);
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]
);
}
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)],
];
if (SESSION_TIMEOUT > 0) {
attrs.push(['Session-Timeout', String(SESSION_TIMEOUT)]);
}
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','Session-Timeout')",
[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();
}
}
export 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();
}
}

View File

@@ -0,0 +1,127 @@
import dgram from 'dgram';
import radius from 'radius';
import { RADIUS_AUTH_PORT, RADIUS_HOST, RADIUS_SECRET } from '../config/env.js';
import { pushRequest } from '../sse.js';
export const activeSessions = new Map();
export 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);
}
});
}
export async function sendDisconnectRequest({ nasIp, username, sessionId, callingStationId, nasId }) {
if (!nasIp) throw new Error('NAS IP required for Disconnect-Request');
const packet = radius.encode({
code: 'Disconnect-Request',
secret: RADIUS_SECRET,
attributes: {
'User-Name': username,
'Acct-Session-Id': sessionId,
'Calling-Station-Id': callingStationId || undefined,
'NAS-IP-Address': nasIp,
'NAS-Identifier': nasId || undefined,
},
});
return new Promise((resolve, reject) => {
const client = dgram.createSocket('udp4');
const timeout = setTimeout(() => {
client.close();
reject(new Error('CoA timeout'));
}, 3000);
client.on('message', (msg) => {
clearTimeout(timeout);
client.close();
try {
const res = radius.decode({ packet: msg, secret: RADIUS_SECRET });
resolve({ code: res.code });
} catch (e) {
resolve({ code: 'unknown' });
}
});
client.send(packet, 0, packet.length, 3799, nasIp, (err) => {
if (err) {
clearTimeout(timeout);
client.close();
reject(err);
}
});
});
}
export async function disconnectUserSessions(username) {
const targets = [];
for (const sess of activeSessions.values()) {
if (sess.username === username && sess.nasIp) targets.push(sess);
}
if (targets.length === 0) return;
for (const sess of targets) {
try {
const result = await sendDisconnectRequest({
nasIp: sess.nasIp,
username: sess.username,
sessionId: sess.sessionId,
callingStationId: sess.callingStationId,
nasId: sess.nasId,
});
pushRequest({
id: Date.now() + ':' + Math.random().toString(16).slice(2),
ts: new Date().toISOString(),
type: 'coa-disconnect',
attrs: {
'User-Name': sess.username,
'NAS-IP-Address': sess.nasIp,
'Acct-Session-Id': sess.sessionId,
'Calling-Station-Id': sess.callingStationId,
'result': result.code,
},
});
} catch (e) {
pushRequest({
id: Date.now() + ':' + Math.random().toString(16).slice(2),
ts: new Date().toISOString(),
type: 'coa-disconnect',
attrs: {
'User-Name': sess.username,
'NAS-IP-Address': sess.nasIp,
'Acct-Session-Id': sess.sessionId,
'Calling-Station-Id': sess.callingStationId,
'error': String(e?.message || e),
},
});
}
}
}

48
node-api/src/sse.js Normal file
View File

@@ -0,0 +1,48 @@
import { MAX_REQUESTS } from './config/env.js';
const sseClients = new Set();
const requests = [];
export function registerSse(req, res, initialStatus = {}) {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.flushHeaders?.();
res.write(`event: hello\n`);
res.write(`data: {"ok":true}\n\n`);
if (initialStatus && Object.keys(initialStatus).length) {
res.write(`event: status\n`);
res.write(`data: ${JSON.stringify(initialStatus)}\n\n`);
}
sseClients.add(res);
req.on('close', () => sseClients.delete(res));
}
export function pushRequest(rec) {
requests.push(rec);
while (requests.length > MAX_REQUESTS) requests.shift();
const payload = `data: ${JSON.stringify(rec)}\n\n`;
for (const res of sseClients) {
try { res.write(payload); } catch { /* ignore */ }
}
}
export function broadcastStatus(payload) {
const ev = `event: status\n` + `data: ${JSON.stringify(payload)}\n\n`;
for (const res of sseClients) {
try { res.write(ev); } catch { /* ignore */ }
}
}
export function clearRequests() {
requests.length = 0;
const payload = `event: clear\n` + `data: {"ok":true}\n\n`;
for (const res of sseClients) {
try { res.write(payload); } catch { /* ignore */ }
}
}
export function getRecentRequests() {
return requests.slice(-MAX_REQUESTS);
}

View File

@@ -0,0 +1,29 @@
import { MAX_DOWN, MAX_UP, VLAN_ID } from '../config/env.js';
export 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 || {}),
},
};
}
export function normalizeAttributes(body = {}) {
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;
}