diff --git a/docker-compose.yml b/docker-compose.yml index e157fc8..22250f1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,11 +12,14 @@ services: - RADIUS_HOST=freeradius - RADIUS_AUTH_PORT=1812 - RADIUS_SECRET=${RADIUS_SHARED_SECRET:-testing123} + - PGHOST=postgres + - PGPORT=5432 + - PGDATABASE=radius + - PGUSER=radius + - PGPASSWORD=radius 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 @@ -24,27 +27,43 @@ services: image: freeradius/freeradius-server:3.2.2 depends_on: - node + - postgres ports: - "1812:1812/udp" - "1813:1813/udp" environment: - - REST_ENDPOINT=http://node:3000 - RADIUS_CLIENTS_CIDR=${RADIUS_CLIENTS_CIDR:-0.0.0.0/0} - RADIUS_SHARED_SECRET=${RADIUS_SHARED_SECRET:-testing123} volumes: + - ./freeradius/mods-available/sql:/etc/freeradius/mods-available/sql:ro + - ./freeradius/mods-available/sql:/etc/freeradius/mods-enabled/sql: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_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/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"] networks: - radius_net + postgres: + image: postgres:16-alpine + environment: + - POSTGRES_DB=radius + - POSTGRES_USER=radius + - POSTGRES_PASSWORD=radius + volumes: + - postgres_data:/var/lib/postgresql/data + - ./postgres/init:/docker-entrypoint-initdb.d:ro + ports: + - "5432:5432" + networks: + - radius_net + networks: radius_net: driver: bridge + +volumes: + postgres_data: diff --git a/freeradius/Dockerfile b/freeradius/Dockerfile new file mode 100644 index 0000000..68cc122 --- /dev/null +++ b/freeradius/Dockerfile @@ -0,0 +1,9 @@ +FROM freeradius/freeradius-server:3.2.2 + +USER root +RUN apt-get update \ + && apt-get install -y --no-install-recommends freeradius-postgresql \ + && rm -rf /var/lib/apt/lists/* + +# Default command preserved by base image + diff --git a/freeradius/mods-available/sql b/freeradius/mods-available/sql new file mode 100644 index 0000000..29ea369 --- /dev/null +++ b/freeradius/mods-available/sql @@ -0,0 +1,28 @@ +sql { + driver = "rlm_sql_postgresql" + dialect = "postgresql" + + server = "postgres" + port = 5432 + login = "radius" + password = "radius" + radius_db = "radius" + + pool { + start = 2 + min = 1 + max = 5 + spare = 1 + uses = 0 + lifetime = 0 + cleanup_interval = 30 + } + + # Leave default queries location + # queries = ${modconfdir}/sql/main/${dialect}/queries.conf + + read_clients = no + + # We only use per-user tables; disable group processing to avoid extra schema + read_groups = no +} diff --git a/freeradius/mods-config/files/authorize b/freeradius/mods-config/files/authorize index 00c35d9..cef1eae 100644 --- a/freeradius/mods-config/files/authorize +++ b/freeradius/mods-config/files/authorize @@ -1,8 +1,8 @@ # Managed by Node dashboard; do not edit manually -user1 Cleartext-Password := "contra1" - Tunnel-Type = VLAN, - Tunnel-Medium-Type = IEEE-802, - Tunnel-Private-Group-Id = "2" +# user1 Cleartext-Password := "contra1" +# Tunnel-Type = VLAN, +# Tunnel-Medium-Type = IEEE-802, +# Tunnel-Private-Group-Id = "2" user2 Cleartext-Password := "contra2" Tunnel-Type = VLAN, @@ -14,10 +14,10 @@ prueba2 Cleartext-Password := "contra2" Tunnel-Medium-Type = IEEE-802, Tunnel-Private-Group-Id = "2" -dario Cleartext-Password := "contra1" - Tunnel-Type = VLAN, - Tunnel-Medium-Type = IEEE-802, - 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, diff --git a/freeradius/sites-enabled/default b/freeradius/sites-enabled/default index ad6a8e7..1680b6b 100644 --- a/freeradius/sites-enabled/default +++ b/freeradius/sites-enabled/default @@ -17,8 +17,8 @@ server default { eap return } - # MAC-Auth / Portal: Llama a la API REST para decidir y añadir atributos - rest + # Cargar atributos desde SQL (VLAN/bw, etc.) + sql # Laboratorio: aceptar todo en flujos no EAP update control { Auth-Type := Accept @@ -35,12 +35,14 @@ server default { } accounting { + # Enviar eventos de accounting al dashboard (solo logging) rest ok } post-auth { - # Obtener atributos de VLAN/otros desde el API + # Log de eventos post-auth al dashboard (no modifica la respuesta) rest.post-auth + # Para EAP, los atributos del túnel interno se copian (use_tunneled_reply = yes) } } diff --git a/freeradius/sites-enabled/inner-tunnel b/freeradius/sites-enabled/inner-tunnel index 57377d7..814c936 100644 --- a/freeradius/sites-enabled/inner-tunnel +++ b/freeradius/sites-enabled/inner-tunnel @@ -6,9 +6,21 @@ server inner-tunnel { } authorize { - # Obtener credenciales del usuario desde el API (debe devolver Cleartext-Password) - rest_inner - # Fallback/local: también consultar backend 'files' (user1/user2) + # Cargar credenciales/atributos del usuario desde SQL + sql + # En caso de que el módulo SQL no haya poblado Cleartext-Password, obténlo vía xlat + update control { + Cleartext-Password := "%{sql:SELECT value FROM radcheck WHERE username='%{User-Name}' AND attribute='Cleartext-Password' ORDER BY id DESC LIMIT 1}" + } + # Cargar atributos de respuesta desde SQL (VLAN y ancho de banda) para PEAP (se copian al outer) + update reply { + Tunnel-Type := "%{sql:SELECT value FROM radreply WHERE username='%{User-Name}' AND attribute='Tunnel-Type' ORDER BY id DESC LIMIT 1}" + Tunnel-Medium-Type := "%{sql:SELECT value FROM radreply WHERE username='%{User-Name}' AND attribute='Tunnel-Medium-Type' ORDER BY id DESC LIMIT 1}" + Tunnel-Private-Group-Id := "%{sql:SELECT value FROM radreply WHERE username='%{User-Name}' AND attribute='Tunnel-Private-Group-Id' ORDER BY id DESC LIMIT 1}" + WISPr-Bandwidth-Max-Down := "%{sql:SELECT value FROM radreply WHERE username='%{User-Name}' AND attribute='WISPr-Bandwidth-Max-Down' ORDER BY id DESC LIMIT 1}" + WISPr-Bandwidth-Max-Up := "%{sql:SELECT value FROM radreply WHERE username='%{User-Name}' AND attribute='WISPr-Bandwidth-Max-Up' ORDER BY id DESC LIMIT 1}" + } + # Fallback/local: también consultar backend 'files' files # Procesar EAP (PEAP) y MS-CHAPv2 eap @@ -23,6 +35,6 @@ server inner-tunnel { } post-auth { - # Nada aquí; el outer post-auth añadirá VLAN + # Nada: los atributos se copian fuera si use_tunneled_reply = yes } } diff --git a/node-api/index.js b/node-api/index.js index 4610c4f..ccbe2f0 100644 --- a/node-api/index.js +++ b/node-api/index.js @@ -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 }); }); diff --git a/node-api/package.json b/node-api/package.json index d4a9673..6d624e0 100644 --- a/node-api/package.json +++ b/node-api/package.json @@ -7,6 +7,7 @@ "dependencies": { "express": "^4.19.2", "morgan": "^1.10.0", - "radius": "^1.1.0" + "radius": "^1.1.0", + "pg": "^8.12.0" } } diff --git a/postgres/init/01-schema.sql b/postgres/init/01-schema.sql new file mode 100644 index 0000000..1d2872e --- /dev/null +++ b/postgres/init/01-schema.sql @@ -0,0 +1,21 @@ +-- Minimal FreeRADIUS schema for users via rlm_sql (PostgreSQL) + +CREATE TABLE IF NOT EXISTS radcheck ( + id SERIAL PRIMARY KEY, + username VARCHAR(64) NOT NULL DEFAULT '', + attribute VARCHAR(64) NOT NULL DEFAULT '', + op CHAR(2) NOT NULL DEFAULT ':=', + value VARCHAR(253) NOT NULL DEFAULT '' +); + +CREATE TABLE IF NOT EXISTS radreply ( + id SERIAL PRIMARY KEY, + username VARCHAR(64) NOT NULL DEFAULT '', + attribute VARCHAR(64) NOT NULL DEFAULT '', + op CHAR(2) NOT NULL DEFAULT ':=', + value VARCHAR(253) NOT NULL DEFAULT '' +); + +CREATE INDEX IF NOT EXISTS radcheck_user ON radcheck (username); +CREATE INDEX IF NOT EXISTS radreply_user ON radreply (username); +