sistema de usuarios completo
This commit is contained in:
@@ -13,3 +13,13 @@ prueba2 Cleartext-Password := "contra2"
|
|||||||
Tunnel-Type = VLAN,
|
Tunnel-Type = VLAN,
|
||||||
Tunnel-Medium-Type = IEEE-802,
|
Tunnel-Medium-Type = IEEE-802,
|
||||||
Tunnel-Private-Group-Id = "2"
|
Tunnel-Private-Group-Id = "2"
|
||||||
|
|
||||||
|
dario Cleartext-Password := "contra1"
|
||||||
|
Tunnel-Type = VLAN,
|
||||||
|
Tunnel-Medium-Type = IEEE-802,
|
||||||
|
Tunnel-Private-Group-Id = "2"
|
||||||
|
|
||||||
|
margie Cleartext-Password := "bonita"
|
||||||
|
Tunnel-Type = VLAN,
|
||||||
|
Tunnel-Medium-Type = IEEE-802,
|
||||||
|
Tunnel-Private-Group-Id = "2"
|
||||||
|
|||||||
@@ -26,13 +26,8 @@ const RADIUS_SECRET = process.env.RADIUS_SECRET || process.env.RADIUS_SHARED_SEC
|
|||||||
const DOCKER_SOCK = process.env.DOCKER_SOCK || '/var/run/docker.sock';
|
const DOCKER_SOCK = process.env.DOCKER_SOCK || '/var/run/docker.sock';
|
||||||
const FREERADIUS_CONTAINER = process.env.FREERADIUS_CONTAINER || 'radiusnucleo-freeradius-1';
|
const FREERADIUS_CONTAINER = process.env.FREERADIUS_CONTAINER || 'radiusnucleo-freeradius-1';
|
||||||
|
|
||||||
// In-memory request store + SSE clients
|
// Requests 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;
|
let radiusReloading = false;
|
||||||
|
|
||||||
@@ -54,19 +49,66 @@ function broadcastStatus(payload) {
|
|||||||
|
|
||||||
const AUTH_FILE = process.env.AUTH_FILE || '/shared/authorize';
|
const AUTH_FILE = process.env.AUTH_FILE || '/shared/authorize';
|
||||||
|
|
||||||
async function persistUsersToFreeradius() {
|
function parseUsersFromText(text) {
|
||||||
try {
|
const users = [];
|
||||||
const header = '# Managed by Node dashboard; do not edit manually\n';
|
const lines = text.split(/\r?\n/);
|
||||||
const blocks = [];
|
let i = 0;
|
||||||
for (const [username, { password, vlan }] of users.entries()) {
|
while (i < lines.length) {
|
||||||
const v = String(vlan || VLAN_ID);
|
const line = lines[i];
|
||||||
blocks.push(`${username} Cleartext-Password := "${password}"
|
const m = line.match(/^\s*(#?)\s*([^\s#]+)\s+Cleartext-Password\s*:=\s*"([^"]+)"/);
|
||||||
Tunnel-Type = VLAN,
|
if (m) {
|
||||||
Tunnel-Medium-Type = IEEE-802,
|
const disabled = m[1] === '#';
|
||||||
Tunnel-Private-Group-Id = "${v}"\n`);
|
const username = m[2];
|
||||||
|
const password = m[3];
|
||||||
|
let vlan = undefined;
|
||||||
|
i++;
|
||||||
|
while (i < lines.length && lines[i].trim() !== '') {
|
||||||
|
const l = lines[i].replace(/^\s*#\s*/, '');
|
||||||
|
const mv = l.match(/Tunnel-Private-Group-Id\s*=\s*"?(\d+)"?/i);
|
||||||
|
if (mv) vlan = mv[1];
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
users.push({ username, password, vlan: vlan || VLAN_ID, disabled });
|
||||||
|
while (i < lines.length && lines[i].trim() === '') i++;
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
await fs.writeFile(AUTH_FILE, header + blocks.join('\n'));
|
i++;
|
||||||
// Trigger FreeRADIUS reload via Docker API (HUP)
|
}
|
||||||
|
return users;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readUsersFromFile() {
|
||||||
|
try {
|
||||||
|
const data = await fs.readFile(AUTH_FILE, 'utf8').catch(() => '');
|
||||||
|
return parseUsersFromText(data);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeUsersToFile(users) {
|
||||||
|
const header = '# Managed by Node dashboard; do not edit manually\n';
|
||||||
|
const chunks = [];
|
||||||
|
for (const u of users) {
|
||||||
|
const lines = [
|
||||||
|
`${u.username} Cleartext-Password := "${u.password}"`,
|
||||||
|
` Tunnel-Type = VLAN,`,
|
||||||
|
` Tunnel-Medium-Type = IEEE-802,`,
|
||||||
|
` Tunnel-Private-Group-Id = "${String(u.vlan || VLAN_ID)}"`,
|
||||||
|
''
|
||||||
|
];
|
||||||
|
if (u.disabled) {
|
||||||
|
chunks.push(lines.map(l => l ? `# ${l}` : '').join('\n'));
|
||||||
|
} else {
|
||||||
|
chunks.push(lines.join('\n'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await fs.writeFile(AUTH_FILE, header + chunks.join('\n'));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function persistUsersToFreeradius(users) {
|
||||||
|
try {
|
||||||
|
await writeUsersToFile(users);
|
||||||
triggerRadiusReload().catch(() => {});
|
triggerRadiusReload().catch(() => {});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to persist users to FreeRADIUS files:', e);
|
console.error('Failed to persist users to FreeRADIUS files:', e);
|
||||||
@@ -170,7 +212,8 @@ app.post('/authorize-inner', (req, res) => {
|
|||||||
|
|
||||||
const attrs = normalizeAttributes(req.body);
|
const attrs = normalizeAttributes(req.body);
|
||||||
const username = (attrs['User-Name'] || '').toString();
|
const username = (attrs['User-Name'] || '').toString();
|
||||||
const entry = users.get(username);
|
const list = await readUsersFromFile();
|
||||||
|
const entry = list.find(u => u.username === username && !u.disabled);
|
||||||
const password = entry?.password;
|
const password = entry?.password;
|
||||||
|
|
||||||
if (!password) {
|
if (!password) {
|
||||||
@@ -204,7 +247,9 @@ app.post('/authorize-inner', (req, res) => {
|
|||||||
app.post('/post-auth', (req, res) => {
|
app.post('/post-auth', (req, res) => {
|
||||||
const attrs = normalizeAttributes(req.body);
|
const attrs = normalizeAttributes(req.body);
|
||||||
const username = (attrs['User-Name'] || '').toString();
|
const username = (attrs['User-Name'] || '').toString();
|
||||||
const vlan = users.get(username)?.vlan || VLAN_ID;
|
const list = await readUsersFromFile();
|
||||||
|
const entry = list.find(u => u.username === username && !u.disabled);
|
||||||
|
const vlan = entry?.vlan || VLAN_ID;
|
||||||
return res.status(200).json({
|
return res.status(200).json({
|
||||||
reply: [
|
reply: [
|
||||||
{ name: 'Tunnel-Type', value: 'VLAN' },
|
{ name: 'Tunnel-Type', value: 'VLAN' },
|
||||||
@@ -217,17 +262,41 @@ app.post('/post-auth', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Users API
|
// Users API
|
||||||
app.get('/api/users', (req, res) => {
|
app.get('/api/users', async (req, res) => {
|
||||||
const items = Array.from(users.entries()).map(([username, { password, vlan }]) => ({ username, password, vlan }));
|
const items = await readUsersFromFile();
|
||||||
res.json({ items });
|
res.json({ items });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/users', (req, res) => {
|
app.post('/api/users', async (req, res) => {
|
||||||
const { username, password, vlan } = req.body || {};
|
const { username, password, vlan, disabled } = req.body || {};
|
||||||
if (!username || !password) return res.status(400).json({ ok: false, error: 'username and password required' });
|
if (!username || !password) return res.status(400).json({ ok: false, error: 'username and password required' });
|
||||||
const vlanStr = vlan ? String(vlan) : VLAN_ID;
|
const items = await readUsersFromFile();
|
||||||
users.set(String(username), { password: String(password), vlan: vlanStr });
|
const idx = items.findIndex(u => u.username === String(username));
|
||||||
persistUsersToFreeradius().then(() => console.log('Users synced to FreeRADIUS files'));
|
const user = { username: String(username), password: String(password), vlan: String(vlan || VLAN_ID), disabled: !!disabled };
|
||||||
|
if (idx >= 0) items[idx] = user; else items.push(user);
|
||||||
|
await persistUsersToFreeradius(items);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch('/api/users/:username', async (req, res) => {
|
||||||
|
const uname = req.params.username;
|
||||||
|
const { password, vlan, disabled } = req.body || {};
|
||||||
|
const items = await readUsersFromFile();
|
||||||
|
const idx = items.findIndex(u => u.username === uname);
|
||||||
|
if (idx < 0) return res.status(404).json({ ok: false, error: 'not_found' });
|
||||||
|
if (password !== undefined) items[idx].password = String(password);
|
||||||
|
if (vlan !== undefined) items[idx].vlan = String(vlan);
|
||||||
|
if (disabled !== undefined) items[idx].disabled = !!disabled;
|
||||||
|
await persistUsersToFreeradius(items);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/users/:username', async (req, res) => {
|
||||||
|
const uname = req.params.username;
|
||||||
|
const items = await readUsersFromFile();
|
||||||
|
const next = items.filter(u => u.username !== uname);
|
||||||
|
if (next.length === items.length) return res.status(404).json({ ok: false, error: 'not_found' });
|
||||||
|
await persistUsersToFreeradius(next);
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,10 @@
|
|||||||
<button id="exportCsv">Exportar CSV</button>
|
<button id="exportCsv">Exportar CSV</button>
|
||||||
<button id="addUser">Añadir usuario</button>
|
<button id="addUser">Añadir usuario</button>
|
||||||
</div>
|
</div>
|
||||||
|
<h3>Usuarios</h3>
|
||||||
|
<div id="users"></div>
|
||||||
|
<hr />
|
||||||
|
<h3>Eventos</h3>
|
||||||
<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 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%;">
|
<div style="background:#fff; color:#222; padding:16px; border-radius:8px; min-width:300px; max-width:90%;">
|
||||||
@@ -60,6 +64,7 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
const list = document.getElementById('list');
|
const list = document.getElementById('list');
|
||||||
|
const usersDiv = document.getElementById('users');
|
||||||
const statusEl = document.getElementById('status');
|
const statusEl = document.getElementById('status');
|
||||||
const countEl = document.getElementById('count');
|
const countEl = document.getElementById('count');
|
||||||
const btnRefresh = document.getElementById('refresh');
|
const btnRefresh = document.getElementById('refresh');
|
||||||
@@ -110,6 +115,63 @@ async function loadHistory() {
|
|||||||
history.forEach(renderItem);
|
history.forEach(renderItem);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadUsers() {
|
||||||
|
const r = await fetch('/api/users');
|
||||||
|
const data = await r.json();
|
||||||
|
const users = data.items || [];
|
||||||
|
usersDiv.innerHTML = '';
|
||||||
|
const table = document.createElement('table');
|
||||||
|
table.style.width = '100%';
|
||||||
|
table.cellPadding = 6;
|
||||||
|
table.innerHTML = `<tr><th>Usuario</th><th>VLAN</th><th>Estado</th><th>Acciones</th></tr>`;
|
||||||
|
users.forEach(u => {
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
tr.innerHTML = `
|
||||||
|
<td>${u.username}</td>
|
||||||
|
<td>${u.vlan}</td>
|
||||||
|
<td>${u.disabled ? 'Desactivado' : 'Activo'}</td>
|
||||||
|
<td>
|
||||||
|
<button data-edit="${u.username}">Editar</button>
|
||||||
|
<button data-toggle="${u.username}">${u.disabled ? 'Activar' : 'Desactivar'}</button>
|
||||||
|
<button data-del="${u.username}">Eliminar</button>
|
||||||
|
</td>`;
|
||||||
|
table.appendChild(tr);
|
||||||
|
});
|
||||||
|
usersDiv.appendChild(table);
|
||||||
|
usersDiv.querySelectorAll('button[data-edit]').forEach(btn => btn.addEventListener('click', () => editUser(btn.getAttribute('data-edit'))));
|
||||||
|
usersDiv.querySelectorAll('button[data-toggle]').forEach(btn => btn.addEventListener('click', () => toggleUser(btn.getAttribute('data-toggle'))));
|
||||||
|
usersDiv.querySelectorAll('button[data-del]').forEach(btn => btn.addEventListener('click', () => deleteUser(btn.getAttribute('data-del'))));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function editUser(username) {
|
||||||
|
const r = await fetch('/api/users');
|
||||||
|
const data = await r.json();
|
||||||
|
const u = (data.items || []).find(x => x.username === username);
|
||||||
|
if (!u) return alert('Usuario no encontrado');
|
||||||
|
modal.style.display = 'flex';
|
||||||
|
u_username.value = u.username;
|
||||||
|
u_password.value = u.password || '';
|
||||||
|
u_vlan.value = u.vlan || '2';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleUser(username) {
|
||||||
|
const r = await fetch('/api/users');
|
||||||
|
const data = await r.json();
|
||||||
|
const u = (data.items || []).find(x => x.username === username);
|
||||||
|
if (!u) return alert('Usuario no encontrado');
|
||||||
|
await fetch(`/api/users/${encodeURIComponent(username)}`, {
|
||||||
|
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ disabled: !u.disabled })
|
||||||
|
});
|
||||||
|
await loadUsers();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteUser(username) {
|
||||||
|
if (!confirm(`¿Eliminar usuario ${username}?`)) return;
|
||||||
|
await fetch(`/api/users/${encodeURIComponent(username)}`, { method: 'DELETE' });
|
||||||
|
await loadUsers();
|
||||||
|
}
|
||||||
|
|
||||||
btnRefresh.addEventListener('click', loadHistory);
|
btnRefresh.addEventListener('click', loadHistory);
|
||||||
|
|
||||||
btnSelfTest.addEventListener('click', async () => {
|
btnSelfTest.addEventListener('click', async () => {
|
||||||
@@ -196,6 +258,7 @@ saveUser.addEventListener('click', async () => {
|
|||||||
if (!data.ok) throw new Error(data.error || 'Error desconocido');
|
if (!data.ok) throw new Error(data.error || 'Error desconocido');
|
||||||
modal.style.display = 'none';
|
modal.style.display = 'none';
|
||||||
alert('Usuario guardado');
|
alert('Usuario guardado');
|
||||||
|
await loadUsers();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert('Error al guardar: ' + e.message);
|
alert('Error al guardar: ' + e.message);
|
||||||
}
|
}
|
||||||
@@ -218,7 +281,7 @@ function connectSSE() {
|
|||||||
es.addEventListener('clear', () => { loadHistory(); });
|
es.addEventListener('clear', () => { loadHistory(); });
|
||||||
}
|
}
|
||||||
|
|
||||||
loadHistory().then(connectSSE);
|
Promise.all([loadUsers(), loadHistory()]).then(connectSSE);
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user