agregado de nuevos usuarios listo
This commit is contained in:
@@ -12,6 +12,11 @@ services:
|
||||
- RADIUS_HOST=freeradius
|
||||
- RADIUS_AUTH_PORT=1812
|
||||
- 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:
|
||||
- radius_net
|
||||
|
||||
@@ -29,8 +34,11 @@ services:
|
||||
volumes:
|
||||
- ./freeradius/mods-available/rest:/etc/freeradius/mods-available/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
|
||||
# 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/clients.conf:/etc/freeradius/clients.conf:ro
|
||||
command: ["-X"]
|
||||
|
||||
@@ -25,4 +25,11 @@ rest {
|
||||
method = "post"
|
||||
body = "json"
|
||||
}
|
||||
|
||||
# Post-auth: obtener atributos de respuesta (VLAN, etc.)
|
||||
post-auth {
|
||||
uri = "http://node:3000/post-auth"
|
||||
method = "post"
|
||||
body = "json"
|
||||
}
|
||||
}
|
||||
|
||||
11
freeradius/mods-available/rest_inner
Normal file
11
freeradius/mods-available/rest_inner
Normal 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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,15 @@
|
||||
# Managed by Node dashboard; do not edit manually
|
||||
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"
|
||||
|
||||
@@ -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 {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,28 +40,7 @@ server default {
|
||||
}
|
||||
|
||||
post-auth {
|
||||
# Asignación de VLAN dinámica por usuario
|
||||
if (&User-Name == "user1") {
|
||||
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"
|
||||
}
|
||||
}
|
||||
# Obtener atributos de VLAN/otros desde el API
|
||||
rest.post-auth
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,16 +6,16 @@ server inner-tunnel {
|
||||
}
|
||||
|
||||
authorize {
|
||||
# Primero obtenemos credenciales del usuario desde el API
|
||||
rest.authorize_inner_tunnel
|
||||
# Luego dejamos que EAP procese (PEAP/MSCHAPv2)
|
||||
# Obtener credenciales del usuario desde el API (debe devolver Cleartext-Password)
|
||||
rest_inner
|
||||
# Fallback/local: también consultar backend 'files' (user1/user2)
|
||||
files
|
||||
# Procesar EAP (PEAP) y MS-CHAPv2
|
||||
eap
|
||||
# mschap puede establecer Auth-Type si procede
|
||||
mschap
|
||||
}
|
||||
|
||||
authenticate {
|
||||
# Autenticación EAP (PEAP/MSCHAPv2)
|
||||
eap
|
||||
Auth-Type MS-CHAP {
|
||||
mschap
|
||||
@@ -23,7 +23,6 @@ server inner-tunnel {
|
||||
}
|
||||
|
||||
post-auth {
|
||||
# Aquí podríamos añadir lógica adicional de auditoría si se desea
|
||||
# No agregamos atributos de reply aquí; se añadirán en el outer post-auth
|
||||
# Nada aquí; el outer post-auth añadirá VLAN
|
||||
}
|
||||
}
|
||||
|
||||
29
freeradius/startup.sh
Executable file
29
freeradius/startup.sh
Executable 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"
|
||||
@@ -1,7 +1,9 @@
|
||||
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';
|
||||
|
||||
@@ -21,10 +23,18 @@ 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';
|
||||
|
||||
// In-memory request store + SSE clients
|
||||
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();
|
||||
let radiusReloading = false;
|
||||
|
||||
function pushRequest(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
|
||||
function buildAcceptPayload(extra = {}) {
|
||||
return {
|
||||
@@ -105,12 +169,9 @@ app.post('/authorize-inner', (req, res) => {
|
||||
console.log(JSON.stringify(req.body, null, 2));
|
||||
|
||||
const attrs = normalizeAttributes(req.body);
|
||||
const users = {
|
||||
'user1': 'contra1',
|
||||
'user2': 'contra2',
|
||||
};
|
||||
const username = (attrs['User-Name'] || '').toString();
|
||||
const password = users[username];
|
||||
const entry = users.get(username);
|
||||
const password = entry?.password;
|
||||
|
||||
if (!password) {
|
||||
pushRequest({
|
||||
@@ -133,12 +194,43 @@ app.post('/authorize-inner', (req, res) => {
|
||||
});
|
||||
|
||||
return res.status(200).json({
|
||||
control: {
|
||||
'Cleartext-Password': password,
|
||||
},
|
||||
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 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
|
||||
app.get('/api/requests', (req, res) => {
|
||||
res.json({ items: requests.slice(-MAX_REQUESTS) });
|
||||
@@ -196,6 +288,9 @@ app.get('/events', (req, res) => {
|
||||
// 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));
|
||||
});
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
<span class="chip" id="status">Conectando…</span>
|
||||
<span class="chip" id="count">0 eventos</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 class="toolbar">
|
||||
<button id="refresh">Recargar historial</button>
|
||||
@@ -38,8 +39,23 @@
|
||||
<button id="clear">Limpiar</button>
|
||||
<button id="copy">Copiar</button>
|
||||
<button id="exportCsv">Exportar CSV</button>
|
||||
<button id="addUser">Añadir usuario</button>
|
||||
</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>
|
||||
|
||||
<script>
|
||||
@@ -50,10 +66,18 @@ const btnRefresh = document.getElementById('refresh');
|
||||
const btnSelfTest = document.getElementById('selfTest');
|
||||
const selfTestStatus = document.getElementById('selfTestStatus');
|
||||
const autoScroll = document.getElementById('autoScroll');
|
||||
const radiusState = document.getElementById('radiusState');
|
||||
let total = 0;
|
||||
const btnClear = document.getElementById('clear');
|
||||
const btnCopy = document.getElementById('copy');
|
||||
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 = [];
|
||||
|
||||
function renderItem(ev) {
|
||||
@@ -142,6 +166,41 @@ btnExportCsv.addEventListener('click', () => {
|
||||
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
|
||||
function connectSSE() {
|
||||
const es = new EventSource('/events');
|
||||
@@ -150,6 +209,12 @@ function connectSSE() {
|
||||
es.addEventListener('message', (e) => {
|
||||
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(); });
|
||||
}
|
||||
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
"scripts": {
|
||||
"dev": "docker compose up -d --build",
|
||||
"restart": "docker compose up -d --build --force-recreate",
|
||||
"hot": "docker compose up -d node",
|
||||
"logs": "docker compose logs -f",
|
||||
"down": "docker compose down",
|
||||
"ps": "docker compose ps"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user