From 1a473296b7dd4ef4e71e62e70f66479c9c85a8b8 Mon Sep 17 00:00:00 2001 From: Matthew Grotke Date: Sun, 7 Jun 2026 23:49:42 -0400 Subject: [PATCH] Development --- docker/routlin-dash/app/main.py | 15 ++++++ routlin/mod_radius.py | 81 +++++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+) diff --git a/docker/routlin-dash/app/main.py b/docker/routlin-dash/app/main.py index 42ea4e5..62835ea 100644 --- a/docker/routlin-dash/app/main.py +++ b/docker/routlin-dash/app/main.py @@ -174,5 +174,20 @@ def _seed_initial_account(): _seed_initial_account() + +def _write_credentials_key(): + key = settings.get_credentials_key() + if not key: + return + path = f'{config_utils.CONFIGS_DIR}/.credentials-key' + try: + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + os.write(fd, key + b'\n') + os.close(fd) + except OSError as exc: + print(f'[main] WARNING: Could not write .credentials-key: {exc}', file=sys.stderr) + +_write_credentials_key() + if __name__ == "__main__": app.run(host="0.0.0.0", port=25327) diff --git a/routlin/mod_radius.py b/routlin/mod_radius.py index b4d2ee8..61e8f0f 100644 --- a/routlin/mod_radius.py +++ b/routlin/mod_radius.py @@ -7,11 +7,15 @@ module config. """ import re +import sqlite3 import subprocess from pathlib import Path +from cryptography.fernet import Fernet import mod_shared as shared RADIUS_SECRET_FILE = shared.SCRIPT_DIR / ".radius-secret" +CREDENTIALS_KEY_FILE = shared.SCRIPT_DIR / ".credentials-key" +CREDENTIALS_DB_FILE = shared.SCRIPT_DIR / ".client-credentials" RADIUS_CLIENTS_CONF = Path("/etc/freeradius/3.0/clients.conf") RADIUS_USERS_FILE = Path("/etc/freeradius/3.0/users") RADIUS_CONF_FILE = Path("/etc/freeradius/3.0/radiusd.conf") @@ -21,6 +25,44 @@ RADIUS_LOG_FILE = Path("/var/log/freeradius/radius.log") RADIUS_HUNTGROUP_NAME = "routlin-aps" +# digest_type values (mirrors clientcredentials/action.py) +DIGEST_CYPHERTEXT_FERNET = 0 +USER_TYPE_SUPPLICANT = 1 + + +# =================================================================== +# Credential helpers +# =================================================================== + +def _load_fernet(): + if not CREDENTIALS_KEY_FILE.exists(): + print(f"WARNING: {CREDENTIALS_KEY_FILE} not found - cannot decrypt supplicant passwords") + return None + try: + return Fernet(CREDENTIALS_KEY_FILE.read_text().strip().encode()) + except Exception as exc: + print(f"WARNING: Could not load credentials key: {exc}") + return None + + +def _load_supplicant_credentials(): + if not CREDENTIALS_DB_FILE.exists(): + return [] + try: + conn = sqlite3.connect(str(CREDENTIALS_DB_FILE)) + conn.row_factory = sqlite3.Row + rows = conn.execute( + "SELECT username, password, digest_type, vlan FROM credentials" + " WHERE user_type=? AND enabled=1", + (USER_TYPE_SUPPLICANT,) + ).fetchall() + conn.close() + return [dict(r) for r in rows] + except Exception as exc: + print(f"WARNING: Could not read credentials DB: {exc}") + return [] + + # =================================================================== # Data helpers # =================================================================== @@ -153,6 +195,45 @@ def build_radius_users(data): "", ] + if auth_mode in ('eap_password', 'eap_certificate'): + creds = _load_supplicant_credentials() + fernet = _load_fernet() if auth_mode == 'eap_password' else None + for cred in creds: + vlan = vlan_by_name.get(cred['vlan']) + if not vlan: + continue + vlan_id = vlan.get('vlan_id') + username = cred['username'] + if auth_mode == 'eap_password': + if fernet is None: + print(f"WARNING: Skipping '{username}' - credentials key unavailable") + continue + if cred['digest_type'] != DIGEST_CYPHERTEXT_FERNET: + print(f"WARNING: Skipping '{username}' - unexpected digest_type {cred['digest_type']}") + continue + try: + plaintext = fernet.decrypt(cred['password'].encode()).decode() + except Exception: + print(f"WARNING: Skipping '{username}' - decryption failed") + continue + lines += [ + f"# {username} -> VLAN {vlan_id} ({vlan['name']})", + f"{username} Cleartext-Password := \"{plaintext}\"", + f" Tunnel-Type = VLAN,", + f" Tunnel-Medium-Type = IEEE-802,", + f" Tunnel-Private-Group-Id = \"{vlan_id}\"", + "", + ] + else: # eap_certificate - cert verified by TLS stack, entry provides VLAN reply attrs + lines += [ + f"# {username} -> VLAN {vlan_id} ({vlan['name']})", + f"{username} Auth-Type := EAP", + f" Tunnel-Type = VLAN,", + f" Tunnel-Medium-Type = IEEE-802,", + f" Tunnel-Private-Group-Id = \"{vlan_id}\"", + "", + ] + default_id = default_vlan.get('vlan_id') ap_ips = fr_opts.get('ap_ips', []) if apply_to == 'wireless':