codigo refactorizado y ordenado, listo para siguiente fase
This commit is contained in:
@@ -19,7 +19,8 @@ services:
|
|||||||
- PGPASSWORD=radius
|
- PGPASSWORD=radius
|
||||||
volumes:
|
volumes:
|
||||||
- ./node-api/index.js:/app/index.js:ro
|
- ./node-api/index.js:/app/index.js:ro
|
||||||
- ./node-api/public:/app/public:ro
|
- ./node-api/src:/app/src:ro
|
||||||
|
- ./frontend/dist:/app/public:ro
|
||||||
networks:
|
networks:
|
||||||
- radius_net
|
- radius_net
|
||||||
|
|
||||||
|
|||||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>RADIUS Dashboard</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
1162
frontend/package-lock.json
generated
Normal file
1162
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
19
frontend/package.json
Normal file
19
frontend/package.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "radius-frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"vue": "^3.4.38"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5.0.5",
|
||||||
|
"vite": "^5.4.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
149
frontend/src/App.vue
Normal file
149
frontend/src/App.vue
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
<template>
|
||||||
|
<main style="font-family: system-ui, sans-serif; padding: 16px; max-width: 980px; margin: 0 auto;">
|
||||||
|
<h1>RADIUS Dashboard</h1>
|
||||||
|
<section style="display: grid; grid-template-columns: 1fr 1fr; gap: 16px; align-items: start;">
|
||||||
|
<div>
|
||||||
|
<h2>Usuarios</h2>
|
||||||
|
<form @submit.prevent="createUser" style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 12px;">
|
||||||
|
<input v-model="form.username" placeholder="usuario" required />
|
||||||
|
<input v-model="form.password" placeholder="contraseña" required />
|
||||||
|
<input v-model="form.vlan" placeholder="VLAN" />
|
||||||
|
<label><input type="checkbox" v-model="form.disabled" /> deshabilitado</label>
|
||||||
|
<button type="submit">Crear / Actualizar</button>
|
||||||
|
</form>
|
||||||
|
<div v-if="loading.users">Cargando usuarios…</div>
|
||||||
|
<table v-else style="width: 100%; border-collapse: collapse;">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="text-align:left">Usuario</th>
|
||||||
|
<th style="text-align:left">VLAN</th>
|
||||||
|
<th style="text-align:left">Estado</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="u in users" :key="u.username">
|
||||||
|
<td>{{ u.username }}</td>
|
||||||
|
<td>{{ u.vlan }}</td>
|
||||||
|
<td>{{ u.disabled ? 'deshabilitado' : 'activo' }}</td>
|
||||||
|
<td>
|
||||||
|
<button @click="toggleDisable(u)">{{ u.disabled ? 'Habilitar' : 'Deshabilitar' }}</button>
|
||||||
|
<button @click="removeUser(u)" style="margin-left: 6px">Eliminar</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2>Eventos</h2>
|
||||||
|
<div style="margin-bottom: 8px; display:flex; gap:8px;">
|
||||||
|
<button @click="refreshRequests">Refrescar</button>
|
||||||
|
<button @click="clearRequests">Limpiar</button>
|
||||||
|
<button @click="selfTest">Self test</button>
|
||||||
|
<a :href="'/api/requests.csv'" target="_blank">Descargar CSV</a>
|
||||||
|
</div>
|
||||||
|
<div v-if="loading.requests">Cargando eventos…</div>
|
||||||
|
<div v-else style="max-height: 420px; overflow: auto; border: 1px solid #ddd; padding: 8px;">
|
||||||
|
<div v-for="ev in requests" :key="ev.id" style="border-bottom: 1px dashed #ddd; padding: 6px 0;">
|
||||||
|
<div><b>{{ ev.ts }}</b> — {{ ev.type }}</div>
|
||||||
|
<div v-if="ev.attrs" style="font-size: 12px; color: #444;">
|
||||||
|
<span>User: {{ ev.attrs['User-Name'] || ev.attrs['User-Name*0'] }}</span>
|
||||||
|
<span v-if="ev.attrs['NAS-IP-Address']"> — NAS: {{ ev.attrs['NAS-IP-Address'] }}</span>
|
||||||
|
<span v-if="ev.attrs['Calling-Station-Id']"> — STA: {{ ev.attrs['Calling-Station-Id'] }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="ev.decision">Decision: {{ ev.decision }}</div>
|
||||||
|
<div v-if="ev.error" style="color: #a00;">Error: {{ ev.error }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { onMounted, reactive, ref } from 'vue';
|
||||||
|
|
||||||
|
const users = ref([]);
|
||||||
|
const requests = ref([]);
|
||||||
|
const loading = reactive({ users: false, requests: false });
|
||||||
|
const form = reactive({ username: '', password: '', vlan: '', disabled: false });
|
||||||
|
|
||||||
|
async function fetchUsers() {
|
||||||
|
loading.users = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/users');
|
||||||
|
const data = await res.json();
|
||||||
|
users.value = data.items || [];
|
||||||
|
} finally { loading.users = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchRequests() {
|
||||||
|
loading.requests = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/requests');
|
||||||
|
const data = await res.json();
|
||||||
|
requests.value = data.items || [];
|
||||||
|
} finally { loading.requests = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createUser() {
|
||||||
|
const payload = { ...form };
|
||||||
|
if (!payload.vlan) delete payload.vlan;
|
||||||
|
await fetch('/api/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });
|
||||||
|
form.username = '';
|
||||||
|
form.password = '';
|
||||||
|
form.vlan = '';
|
||||||
|
form.disabled = false;
|
||||||
|
await fetchUsers();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleDisable(u) {
|
||||||
|
await fetch(`/api/users/${encodeURIComponent(u.username)}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ disabled: !u.disabled })
|
||||||
|
});
|
||||||
|
await fetchUsers();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeUser(u) {
|
||||||
|
if (!confirm(`Eliminar ${u.username}?`)) return;
|
||||||
|
await fetch(`/api/users/${encodeURIComponent(u.username)}`, { method: 'DELETE' });
|
||||||
|
await fetchUsers();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshRequests() { await fetchRequests(); }
|
||||||
|
|
||||||
|
async function clearRequests() {
|
||||||
|
await fetch('/api/requests', { method: 'DELETE' });
|
||||||
|
await fetchRequests();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selfTest() {
|
||||||
|
await fetch('/test/radius', { method: 'POST' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupSse() {
|
||||||
|
const ev = new EventSource('/api/events');
|
||||||
|
ev.addEventListener('message', (e) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(e.data);
|
||||||
|
if (data && data.ts) requests.value.push(data);
|
||||||
|
} catch {}
|
||||||
|
});
|
||||||
|
ev.addEventListener('clear', () => { requests.value = []; });
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await fetchUsers();
|
||||||
|
await fetchRequests();
|
||||||
|
setupSse();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
html, body { margin: 0; padding: 0; }
|
||||||
|
table th, table td { padding: 4px 6px; border-bottom: 1px solid #eee; }
|
||||||
|
button { padding: 6px 10px; cursor: pointer; }
|
||||||
|
input { padding: 6px 8px; }
|
||||||
|
</style>
|
||||||
5
frontend/src/main.js
Normal file
5
frontend/src/main.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { createApp } from 'vue';
|
||||||
|
import App from './App.vue';
|
||||||
|
|
||||||
|
createApp(App).mount('#app');
|
||||||
|
|
||||||
19
frontend/vite.config.js
Normal file
19
frontend/vite.config.js
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import vue from '@vitejs/plugin-vue';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': { target: 'http://localhost:3000', changeOrigin: true },
|
||||||
|
'/events': { target: 'http://localhost:3000', changeOrigin: true, ws: false },
|
||||||
|
'/authorize': { target: 'http://localhost:3000', changeOrigin: true },
|
||||||
|
'/accounting': { target: 'http://localhost:3000', changeOrigin: true },
|
||||||
|
'/post-auth': { target: 'http://localhost:3000', changeOrigin: true },
|
||||||
|
'/authorize-inner': { target: 'http://localhost:3000', changeOrigin: true },
|
||||||
|
'/test': { target: 'http://localhost:3000', changeOrigin: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
@@ -1,515 +1,8 @@
|
|||||||
import express from 'express';
|
import { createApp } from './src/app.js';
|
||||||
import morgan from 'morgan';
|
|
||||||
import fs from 'fs/promises';
|
|
||||||
import path from 'path';
|
|
||||||
import { fileURLToPath } from 'url';
|
|
||||||
import dgram from 'dgram';
|
|
||||||
import radius from 'radius';
|
|
||||||
import pkgPg from 'pg';
|
|
||||||
const { Pool } = pkgPg;
|
|
||||||
|
|
||||||
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';
|
|
||||||
|
|
||||||
// Requests store + SSE clients
|
|
||||||
const requests = [];
|
|
||||||
const sseClients = new Set();
|
|
||||||
let radiusReloading = false;
|
|
||||||
// Active sessions indexed by Acct-Session-Id
|
|
||||||
const activeSessions = new Map();
|
|
||||||
|
|
||||||
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 {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Postgres connection for user management (rlm_sql)
|
|
||||||
const PGHOST = process.env.PGHOST || 'postgres';
|
|
||||||
const PGPORT = parseInt(process.env.PGPORT || '5432', 10);
|
|
||||||
const PGDATABASE = process.env.PGDATABASE || 'radius';
|
|
||||||
const PGUSER = process.env.PGUSER || 'radius';
|
|
||||||
const PGPASSWORD = process.env.PGPASSWORD || 'radius';
|
|
||||||
const pool = new Pool({ host: PGHOST, port: PGPORT, database: PGDATABASE, user: PGUSER, password: PGPASSWORD });
|
|
||||||
const SESSION_TIMEOUT = parseInt(process.env.SESSION_TIMEOUT || process.env.SESSION_TIMEOUT_SECONDS || '0', 10) || 0;
|
|
||||||
|
|
||||||
// SQL helpers: users in radcheck/radreply
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function upsertUserToDb(user) {
|
|
||||||
const { username, password, vlan, disabled } = user;
|
|
||||||
const client = await pool.connect();
|
|
||||||
try {
|
|
||||||
await client.query('BEGIN');
|
|
||||||
// Password
|
|
||||||
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]
|
|
||||||
);
|
|
||||||
// Disabled flag
|
|
||||||
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]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// Reply attributes (VLAN and bandwidth)
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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));
|
|
||||||
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 (e) {
|
|
||||||
console.error('accounting session update error:', e);
|
|
||||||
}
|
|
||||||
pushRequest({
|
|
||||||
id: Date.now() + ':' + Math.random().toString(16).slice(2),
|
|
||||||
ts: new Date().toISOString(),
|
|
||||||
type: 'accounting',
|
|
||||||
attrs,
|
|
||||||
});
|
|
||||||
return res.status(200).json({});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Authorize inner-tunnel (EAP): devolver Cleartext-Password para el usuario
|
|
||||||
app.post('/authorize-inner', async (req, res) => res.status(410).json({}));
|
|
||||||
|
|
||||||
// Post-auth: return reply attributes like VLAN based on user mapping
|
|
||||||
// Post-auth events: log only (rlm_rest calls here)
|
|
||||||
app.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 (e) {
|
|
||||||
console.error('post-auth log error:', e);
|
|
||||||
}
|
|
||||||
return res.status(200).json({});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Users API
|
|
||||||
app.get('/api/users', async (req, res) => {
|
|
||||||
const items = await readUsersFromDb();
|
|
||||||
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 user = { username: String(username), password: String(password), vlan: String(vlan || VLAN_ID), disabled: !!disabled };
|
|
||||||
await upsertUserToDb(user);
|
|
||||||
res.json({ ok: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
app.patch('/api/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 });
|
|
||||||
});
|
|
||||||
|
|
||||||
app.delete('/api/users/:username', async (req, res) => {
|
|
||||||
const uname = String(req.params.username);
|
|
||||||
await deleteUserFromDb(uname);
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send RADIUS Disconnect-Request (CoA) to NAS (UDP 3799)
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 app = createApp();
|
||||||
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}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
29
node-api/src/app.js
Normal file
29
node-api/src/app.js
Normal 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;
|
||||||
|
}
|
||||||
|
|
||||||
15
node-api/src/config/env.js
Normal file
15
node-api/src/config/env.js
Normal 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;
|
||||||
|
|
||||||
92
node-api/src/routes/api.js
Normal file
92
node-api/src/routes/api.js
Normal 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;
|
||||||
94
node-api/src/routes/radius.js
Normal file
94
node-api/src/routes/radius.js
Normal 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;
|
||||||
|
|
||||||
92
node-api/src/services/db.js
Normal file
92
node-api/src/services/db.js
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
127
node-api/src/services/radius.js
Normal file
127
node-api/src/services/radius.js
Normal 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
48
node-api/src/sse.js
Normal 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);
|
||||||
|
}
|
||||||
|
|
||||||
29
node-api/src/utils/attrs.js
Normal file
29
node-api/src/utils/attrs.js
Normal 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;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -3,7 +3,9 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"predev": "npm --prefix frontend install && npm --prefix frontend run build",
|
||||||
"dev": "docker compose up -d --build",
|
"dev": "docker compose up -d --build",
|
||||||
|
"prerestart": "npm --prefix frontend install && npm --prefix frontend run build",
|
||||||
"restart": "docker compose up -d --build --force-recreate",
|
"restart": "docker compose up -d --build --force-recreate",
|
||||||
"hot": "docker compose up -d node",
|
"hot": "docker compose up -d node",
|
||||||
"logs": "docker compose logs -f",
|
"logs": "docker compose logs -f",
|
||||||
|
|||||||
Reference in New Issue
Block a user