agregado de nuevos usuarios listo

This commit is contained in:
2025-09-24 17:51:05 -06:00
parent 85886f1fed
commit 11b95c97a7
11 changed files with 275 additions and 42 deletions

View File

@@ -12,6 +12,11 @@ services:
- RADIUS_HOST=freeradius - RADIUS_HOST=freeradius
- RADIUS_AUTH_PORT=1812 - RADIUS_AUTH_PORT=1812
- RADIUS_SECRET=${RADIUS_SHARED_SECRET:-testing123} - RADIUS_SECRET=${RADIUS_SHARED_SECRET:-testing123}
volumes:
- ./node-api/index.js:/app/index.js:ro
- ./node-api/public:/app/public:ro
- /var/run/docker.sock:/var/run/docker.sock
- ./freeradius/mods-config/files/authorize:/shared/authorize
networks: networks:
- radius_net - radius_net
@@ -29,8 +34,11 @@ services:
volumes: volumes:
- ./freeradius/mods-available/rest:/etc/freeradius/mods-available/rest:ro - ./freeradius/mods-available/rest:/etc/freeradius/mods-available/rest:ro
- ./freeradius/mods-available/rest:/etc/freeradius/mods-enabled/rest:ro - ./freeradius/mods-available/rest:/etc/freeradius/mods-enabled/rest:ro
- ./freeradius/mods-available/rest_inner:/etc/freeradius/mods-available/rest_inner:ro
- ./freeradius/mods-available/rest_inner:/etc/freeradius/mods-enabled/rest_inner:ro
- ./freeradius/mods-enabled/eap:/etc/freeradius/mods-enabled/eap:ro
- ./freeradius/sites-enabled/default:/etc/freeradius/sites-enabled/default:ro - ./freeradius/sites-enabled/default:/etc/freeradius/sites-enabled/default:ro
# inner-tunnel: usar el archivo por defecto del contenedor - ./freeradius/sites-enabled/inner-tunnel:/etc/freeradius/sites-enabled/inner-tunnel:ro
- ./freeradius/mods-config/files/authorize:/etc/freeradius/mods-config/files/authorize:ro - ./freeradius/mods-config/files/authorize:/etc/freeradius/mods-config/files/authorize:ro
- ./freeradius/clients.conf:/etc/freeradius/clients.conf:ro - ./freeradius/clients.conf:/etc/freeradius/clients.conf:ro
command: ["-X"] command: ["-X"]

View File

@@ -25,4 +25,11 @@ rest {
method = "post" method = "post"
body = "json" body = "json"
} }
# Post-auth: obtener atributos de respuesta (VLAN, etc.)
post-auth {
uri = "http://node:3000/post-auth"
method = "post"
body = "json"
}
} }

View File

@@ -0,0 +1,11 @@
rest rest_inner {
connect_timeout = 4
read_timeout = 8
authorize {
uri = "http://node:3000/authorize-inner"
method = "post"
body = "json"
}
}

View File

@@ -1,3 +1,15 @@
# Managed by Node dashboard; do not edit manually
user1 Cleartext-Password := "contra1" user1 Cleartext-Password := "contra1"
user2 Cleartext-Password := "contra2" Tunnel-Type = VLAN,
Tunnel-Medium-Type = IEEE-802,
Tunnel-Private-Group-Id = "2"
user2 Cleartext-Password := "contra2"
Tunnel-Type = VLAN,
Tunnel-Medium-Type = IEEE-802,
Tunnel-Private-Group-Id = "3"
prueba2 Cleartext-Password := "contra2"
Tunnel-Type = VLAN,
Tunnel-Medium-Type = IEEE-802,
Tunnel-Private-Group-Id = "2"

View File

@@ -1,2 +1,30 @@
$INCLUDE /etc/freeradius/mods-available/eap eap {
default_eap_type = peap
tls-config tls-common {
private_key_password = whatever
private_key_file = ${certdir}/server.pem
certificate_file = ${certdir}/server.pem
ca_file = ${cadir}/ca.pem
dh_file = ${certdir}/dh
random_file = /dev/urandom
fragment_size = 1024
include_length = yes
auto_chain = yes
}
tls {
tls = tls-common
}
peap {
tls = tls-common
default_eap_type = mschapv2
copy_request_to_tunnel = yes
use_tunneled_reply = yes
virtual_server = "inner-tunnel"
}
mschapv2 {
}
}

View File

@@ -40,28 +40,7 @@ server default {
} }
post-auth { post-auth {
# Asignación de VLAN dinámica por usuario # Obtener atributos de VLAN/otros desde el API
if (&User-Name == "user1") { rest.post-auth
update reply {
Tunnel-Type := VLAN
Tunnel-Medium-Type := IEEE-802
Tunnel-Private-Group-Id := "2"
}
}
elsif (&User-Name == "user2") {
update reply {
Tunnel-Type := VLAN
Tunnel-Medium-Type := IEEE-802
Tunnel-Private-Group-Id := "5"
}
}
else {
# Fallback opcional: comentar si no quieres valor por defecto
update reply {
Tunnel-Type := VLAN
Tunnel-Medium-Type := IEEE-802
Tunnel-Private-Group-Id := "2"
}
}
} }
} }

View File

@@ -6,16 +6,16 @@ server inner-tunnel {
} }
authorize { authorize {
# Primero obtenemos credenciales del usuario desde el API # Obtener credenciales del usuario desde el API (debe devolver Cleartext-Password)
rest.authorize_inner_tunnel rest_inner
# Luego dejamos que EAP procese (PEAP/MSCHAPv2) # Fallback/local: también consultar backend 'files' (user1/user2)
files
# Procesar EAP (PEAP) y MS-CHAPv2
eap eap
# mschap puede establecer Auth-Type si procede
mschap mschap
} }
authenticate { authenticate {
# Autenticación EAP (PEAP/MSCHAPv2)
eap eap
Auth-Type MS-CHAP { Auth-Type MS-CHAP {
mschap mschap
@@ -23,7 +23,6 @@ server inner-tunnel {
} }
post-auth { post-auth {
# Aquí podríamos añadir lógica adicional de auditoría si se desea # Nada aquí; el outer post-auth añadirá VLAN
# No agregamos atributos de reply aquí; se añadirán en el outer post-auth
} }
} }

29
freeradius/startup.sh Executable file
View File

@@ -0,0 +1,29 @@
#!/bin/sh
set -eu
AUTH_FILE="/etc/freeradius/mods-config/files/authorize"
# Start FreeRADIUS in foreground
# Start in debug/foreground mode for logs
freeradius -X &
PID=$!
prev_mtime=""
poll_reload() {
while true; do
if [ -f "$AUTH_FILE" ]; then
mtime=$(stat -c %Y "$AUTH_FILE" 2>/dev/null || stat -f %m "$AUTH_FILE" 2>/dev/null || echo "")
if [ "${mtime}" != "${prev_mtime}" ] && [ -n "$mtime" ]; then
# File changed: send HUP to reload users
kill -HUP "$PID" 2>/dev/null || true
prev_mtime="$mtime"
fi
fi
sleep 2
done
}
poll_reload &
wait "$PID"

View File

@@ -1,7 +1,9 @@
import express from 'express'; import express from 'express';
import morgan from 'morgan'; import morgan from 'morgan';
import fs from 'fs/promises';
import path from 'path'; import path from 'path';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import http from 'http';
import dgram from 'dgram'; import dgram from 'dgram';
import radius from 'radius'; import radius from 'radius';
@@ -21,10 +23,18 @@ const MAX_REQUESTS = parseInt(process.env.MAX_REQUESTS || '200', 10);
const RADIUS_HOST = process.env.RADIUS_HOST || 'freeradius'; const RADIUS_HOST = process.env.RADIUS_HOST || 'freeradius';
const RADIUS_AUTH_PORT = parseInt(process.env.RADIUS_AUTH_PORT || '1812', 10); 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 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';
// In-memory request store + SSE clients // In-memory request store + SSE clients
const requests = []; const requests = [];
// In-memory users: username -> { password, vlan }
const users = new Map([
['user1', { password: 'contra1', vlan: '2' }],
['user2', { password: 'contra2', vlan: '3' }],
]);
const sseClients = new Set(); const sseClients = new Set();
let radiusReloading = false;
function pushRequest(rec) { function pushRequest(rec) {
requests.push(rec); requests.push(rec);
@@ -36,6 +46,60 @@ function pushRequest(rec) {
} }
} }
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';
async function persistUsersToFreeradius() {
try {
const header = '# Managed by Node dashboard; do not edit manually\n';
const blocks = [];
for (const [username, { password, vlan }] of users.entries()) {
const v = String(vlan || VLAN_ID);
blocks.push(`${username} Cleartext-Password := "${password}"
Tunnel-Type = VLAN,
Tunnel-Medium-Type = IEEE-802,
Tunnel-Private-Group-Id = "${v}"\n`);
}
await fs.writeFile(AUTH_FILE, header + blocks.join('\n'));
// Trigger FreeRADIUS reload via Docker API (HUP)
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 // Helper: standard Accept with VLAN + bandwidth
function buildAcceptPayload(extra = {}) { function buildAcceptPayload(extra = {}) {
return { return {
@@ -105,12 +169,9 @@ app.post('/authorize-inner', (req, res) => {
console.log(JSON.stringify(req.body, null, 2)); console.log(JSON.stringify(req.body, null, 2));
const attrs = normalizeAttributes(req.body); const attrs = normalizeAttributes(req.body);
const users = {
'user1': 'contra1',
'user2': 'contra2',
};
const username = (attrs['User-Name'] || '').toString(); const username = (attrs['User-Name'] || '').toString();
const password = users[username]; const entry = users.get(username);
const password = entry?.password;
if (!password) { if (!password) {
pushRequest({ pushRequest({
@@ -133,12 +194,43 @@ app.post('/authorize-inner', (req, res) => {
}); });
return res.status(200).json({ return res.status(200).json({
control: { control: [
'Cleartext-Password': password, { 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 vlan = users.get(username)?.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', (req, res) => {
const items = Array.from(users.entries()).map(([username, { password, vlan }]) => ({ username, password, vlan }));
res.json({ items });
});
app.post('/api/users', (req, res) => {
const { username, password, vlan } = req.body || {};
if (!username || !password) return res.status(400).json({ ok: false, error: 'username and password required' });
const vlanStr = vlan ? String(vlan) : VLAN_ID;
users.set(String(username), { password: String(password), vlan: vlanStr });
persistUsersToFreeradius().then(() => console.log('Users synced to FreeRADIUS files'));
res.json({ ok: true });
});
// API: recent requests // API: recent requests
app.get('/api/requests', (req, res) => { app.get('/api/requests', (req, res) => {
res.json({ items: requests.slice(-MAX_REQUESTS) }); res.json({ items: requests.slice(-MAX_REQUESTS) });
@@ -196,6 +288,9 @@ app.get('/events', (req, res) => {
// send a hello event // send a hello event
res.write(`event: hello\n`); res.write(`event: hello\n`);
res.write(`data: {"ok":true}\n\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); sseClients.add(res);
req.on('close', () => sseClients.delete(res)); req.on('close', () => sseClients.delete(res));
}); });

View File

@@ -29,6 +29,7 @@
<span class="chip" id="status">Conectando…</span> <span class="chip" id="status">Conectando…</span>
<span class="chip" id="count">0 eventos</span> <span class="chip" id="count">0 eventos</span>
<span class="chip" id="band">VLAN 2 • 10/10 Mbps</span> <span class="chip" id="band">VLAN 2 • 10/10 Mbps</span>
<span class="chip" id="radiusState" style="background:#fee; color:#900; display:none;">RADIUS: Recargando…</span>
</div> </div>
<div class="toolbar"> <div class="toolbar">
<button id="refresh">Recargar historial</button> <button id="refresh">Recargar historial</button>
@@ -38,8 +39,23 @@
<button id="clear">Limpiar</button> <button id="clear">Limpiar</button>
<button id="copy">Copiar</button> <button id="copy">Copiar</button>
<button id="exportCsv">Exportar CSV</button> <button id="exportCsv">Exportar CSV</button>
<button id="addUser">Añadir usuario</button>
</div> </div>
<div id="list" class="list"></div> <div id="list" class="list"></div>
<div id="modal" style="display:none; position:fixed; inset:0; background:rgba(0,0,0,0.4); align-items:center; justify-content:center;">
<div style="background:#fff; color:#222; padding:16px; border-radius:8px; min-width:300px; max-width:90%;">
<h3 style="margin-top:0;">Nuevo usuario</h3>
<div style="display:flex; flex-direction:column; gap:8px;">
<label>Usuario <input id="u_username" /></label>
<label>Contraseña <input id="u_password" type="password" /></label>
<label>VLAN <input id="u_vlan" type="number" min="1" value="2" /></label>
</div>
<div style="display:flex; gap:8px; margin-top:12px;">
<button id="saveUser">Guardar</button>
<button id="cancelUser">Cancelar</button>
</div>
</div>
</div>
</main> </main>
<script> <script>
@@ -50,10 +66,18 @@ const btnRefresh = document.getElementById('refresh');
const btnSelfTest = document.getElementById('selfTest'); const btnSelfTest = document.getElementById('selfTest');
const selfTestStatus = document.getElementById('selfTestStatus'); const selfTestStatus = document.getElementById('selfTestStatus');
const autoScroll = document.getElementById('autoScroll'); const autoScroll = document.getElementById('autoScroll');
const radiusState = document.getElementById('radiusState');
let total = 0; let total = 0;
const btnClear = document.getElementById('clear'); const btnClear = document.getElementById('clear');
const btnCopy = document.getElementById('copy'); const btnCopy = document.getElementById('copy');
const btnExportCsv = document.getElementById('exportCsv'); const btnExportCsv = document.getElementById('exportCsv');
const btnAddUser = document.getElementById('addUser');
const modal = document.getElementById('modal');
const u_username = document.getElementById('u_username');
const u_password = document.getElementById('u_password');
const u_vlan = document.getElementById('u_vlan');
const saveUser = document.getElementById('saveUser');
const cancelUser = document.getElementById('cancelUser');
let history = []; let history = [];
function renderItem(ev) { function renderItem(ev) {
@@ -142,6 +166,41 @@ btnExportCsv.addEventListener('click', () => {
a.remove(); a.remove();
}); });
btnAddUser.addEventListener('click', () => {
modal.style.display = 'flex';
u_username.value = '';
u_password.value = '';
u_vlan.value = '2';
u_username.focus();
});
cancelUser.addEventListener('click', () => {
modal.style.display = 'none';
});
saveUser.addEventListener('click', async () => {
const username = u_username.value.trim();
const password = u_password.value;
const vlan = u_vlan.value || '2';
if (!username || !password) {
alert('Usuario y contraseña son obligatorios');
return;
}
try {
const r = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password, vlan })
});
const data = await r.json();
if (!data.ok) throw new Error(data.error || 'Error desconocido');
modal.style.display = 'none';
alert('Usuario guardado');
} catch (e) {
alert('Error al guardar: ' + e.message);
}
});
// SSE live events // SSE live events
function connectSSE() { function connectSSE() {
const es = new EventSource('/events'); const es = new EventSource('/events');
@@ -150,6 +209,12 @@ 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('status', (e) => {
try {
const s = JSON.parse(e.data);
radiusState.style.display = s.radius_reloading ? 'inline-block' : 'none';
} catch {}
});
es.addEventListener('clear', () => { loadHistory(); }); es.addEventListener('clear', () => { loadHistory(); });
} }

View File

@@ -5,9 +5,9 @@
"scripts": { "scripts": {
"dev": "docker compose up -d --build", "dev": "docker compose up -d --build",
"restart": "docker compose up -d --build --force-recreate", "restart": "docker compose up -d --build --force-recreate",
"hot": "docker compose up -d node",
"logs": "docker compose logs -f", "logs": "docker compose logs -f",
"down": "docker compose down", "down": "docker compose down",
"ps": "docker compose ps" "ps": "docker compose ps"
} }
} }