migrado de rlm_rest a rlm_sql_postgress y rlm_sql

This commit is contained in:
2025-09-26 12:54:28 -06:00
parent 9f35550e58
commit 142b683357
9 changed files with 229 additions and 178 deletions

View File

@@ -3,9 +3,10 @@ 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';
import pkgPg from 'pg';
const { Pool } = pkgPg;
const app = express();
app.use(express.json());
@@ -23,8 +24,6 @@ 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';
// Requests store + SSE clients
const requests = [];
@@ -47,98 +46,98 @@ function broadcastStatus(payload) {
}
}
const AUTH_FILE = process.env.AUTH_FILE || '/shared/authorize';
// 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 });
function parseUsersFromText(text) {
const users = [];
const lines = text.split(/\r?\n/);
let i = 0;
while (i < lines.length) {
const line = lines[i];
const m = line.match(/^\s*(#?)\s*([^\s#]+)\s+Cleartext-Password\s*:=\s*"([^"]+)"/);
if (m) {
const disabled = m[1] === '#';
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;
}
i++;
}
return users;
}
async function readUsersFromFile() {
// SQL helpers: users in radcheck/radreply
async function readUsersFromDb() {
const client = await pool.connect();
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(() => {});
} 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);
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 {
setTimeout(() => {
radiusReloading = false;
broadcastStatus({ radius_reloading: false });
}, 1500);
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)],
];
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')",
[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();
}
}
@@ -206,97 +205,57 @@ app.post('/accounting', (req, res) => {
});
// Authorize inner-tunnel (EAP): devolver Cleartext-Password para el usuario
app.post('/authorize-inner', (req, res) => {
console.log('--- RADIUS Authorize (inner) ---');
console.log(JSON.stringify(req.body, null, 2));
app.post('/authorize-inner', async (req, res) => res.status(410).json({}));
const attrs = normalizeAttributes(req.body);
const username = (attrs['User-Name'] || '').toString();
const list = await readUsersFromFile();
const entry = list.find(u => u.username === username && !u.disabled);
const password = entry?.password;
if (!password) {
// 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: 'authorize-inner',
type: 'post-auth',
attrs,
decision: 'notfound',
});
// No devolvemos nada -> FreeRADIUS seguirá su flujo y probablemente rechace
return res.status(200).json({});
} catch (e) {
console.error('post-auth log error:', e);
}
pushRequest({
id: Date.now() + ':' + Math.random().toString(16).slice(2),
ts: new Date().toISOString(),
type: 'authorize-inner',
attrs,
decision: 'provide-password',
});
return res.status(200).json({
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 list = await readUsersFromFile();
const entry = list.find(u => u.username === username && !u.disabled);
const vlan = entry?.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) }
]
});
return res.status(200).json({});
});
// Users API
app.get('/api/users', async (req, res) => {
const items = await readUsersFromFile();
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 items = await readUsersFromFile();
const idx = items.findIndex(u => u.username === String(username));
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);
await upsertUserToDb(user);
res.json({ ok: true });
});
app.patch('/api/users/:username', async (req, res) => {
const uname = req.params.username;
const uname = String(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);
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);
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);
const uname = String(req.params.username);
await deleteUserFromDb(uname);
res.json({ ok: true });
});