diff --git a/docker/routlin-dash/app/pages/radius/action.py b/docker/routlin-dash/app/pages/radius/action.py index 4894eaf..feaeb11 100644 --- a/docker/routlin-dash/app/pages/radius/action.py +++ b/docker/routlin-dash/app/pages/radius/action.py @@ -1,6 +1,7 @@ import copy import gzip import io +import ipaddress import os import re from pathlib import Path @@ -39,17 +40,26 @@ def regenerate(): def options_save(): mac_format = request.form.get('mac_format', 'aabbccddeeff') apply_to = request.form.get('apply_to', 'all') + ap_ips_raw = request.form.get('ap_ips', '') if mac_format not in VALID_MAC_FORMATS: flash('Invalid MAC format.', 'error') return redirect(f'/{_PAGE}') - if apply_to not in ('all', 'wireless'): + if apply_to not in ('all', 'wireless', 'huntgroup'): flash('Invalid apply_to value.', 'error') return redirect(f'/{_PAGE}') + ap_ips = [line.strip() for line in ap_ips_raw.splitlines() if line.strip()] + for ip in ap_ips: + try: + ipaddress.IPv4Address(ip) + except ValueError: + flash(f'Invalid IP address: {ip}', 'error') + return redirect(f'/{_PAGE}') + cfg = load_config() before = copy.deepcopy(cfg.get('radius', {}).get('options', {})) - after = {'mac_format': mac_format, 'apply_to': apply_to} + after = {'mac_format': mac_format, 'apply_to': apply_to, 'ap_ips': ap_ips} cfg.setdefault('radius', {})['options'] = after changes = diff_fields(before, after) @@ -57,6 +67,42 @@ def options_save(): return redirect(f'/{_PAGE}') +@bp.route('/action/radius/default_vlan_save', methods=['POST']) +@require_level('administrator') +def default_vlan_save(): + chosen = request.form.get('default_vlan', '').strip() + cfg = load_config() + vlans = cfg.get('vlans', []) + + if chosen and not any(v['name'] == chosen for v in vlans): + flash('Invalid VLAN selection.', 'error') + return redirect(f'/{_PAGE}') + + old_name = next((v['name'] for v in vlans if v.get('radius_default') is True), '') + for v in vlans: + v['radius_default'] = (v['name'] == chosen) if chosen else False + + changes = diff_fields({'radius_default': old_name}, {'radius_default': chosen}) + flash(record_group(cfg, 'radius', 'fallback_vlan', chosen or 'none', changes, 'core apply'), 'success') + return redirect(f'/{_PAGE}') + + +@bp.route('/action/radius/eap_save', methods=['POST']) +@require_level('administrator') +def eap_save(): + allow_weak_eap = 'allow_weak_eap' in request.form + tunneled_reply = 'tunneled_reply' in request.form + + cfg = load_config() + before = copy.deepcopy(cfg.get('radius', {}).get('eap', {})) + after = {'allow_weak_eap': allow_weak_eap, 'tunneled_reply': tunneled_reply} + cfg.setdefault('radius', {})['eap'] = after + + changes = diff_fields(before, after) + flash(record_group(cfg, 'radius.eap', 'setting', 'radius', changes, 'core apply'), 'success') + return redirect(f'/{_PAGE}') + + @bp.route('/action/radius/logging_save', methods=['POST']) @require_level('administrator') def logging_save(): diff --git a/docker/routlin-dash/app/pages/radius/content.json b/docker/routlin-dash/app/pages/radius/content.json index 140978e..d8fab69 100644 --- a/docker/routlin-dash/app/pages/radius/content.json +++ b/docker/routlin-dash/app/pages/radius/content.json @@ -77,10 +77,21 @@ "input_type": "select", "value": "%RADIUS_APPLY_TO%", "options": [ - {"value": "all", "label": "All clients"}, - {"value": "wireless", "label": "Wireless clients only (NAS-Port-Type = Wireless-802.11)"} + {"value": "all", "label": "All clients"}, + {"value": "wireless", "label": "Wireless clients only (NAS-Port-Type = Wireless-802.11)"}, + {"value": "huntgroup", "label": "Wireless clients only (AP huntgroup by IP)"} ], - "hint": "Scoping to wireless only prevents the DEFAULT rule from assigning a VLAN to unknown wired switch ports." + "hint": "Scoping to wireless only prevents the DEFAULT rule from assigning a VLAN to unknown wired switch ports. Huntgroup is more precise but requires AP IPs below." + }, + { + "type": "field", + "label": "Access Point IPs", + "name": "ap_ips", + "input_type": "textarea", + "value": "%RADIUS_AP_IPS%", + "rows": 4, + "hint": "One IP address per line. Used when Apply DEFAULT Rule To is set to huntgroup.", + "placeholder": "192.168.1.10\n192.168.1.11" }, { "type": "button_row", @@ -97,6 +108,87 @@ } ] }, + { + "type": "card", + "label": "Fallback VLAN", + "client_requirement": "client_is_administrator+", + "items": [ + { + "type": "p", + "text": "Unknown or unregistered devices are assigned to this VLAN. For wired switch ports, also set the fallback network in the switch configuration." + }, + { + "type": "form", + "action": "/action/radius/default_vlan_save", + "method": "post", + "items": [ + { + "type": "field", + "label": "Fallback VLAN", + "name": "default_vlan", + "input_type": "select", + "value": "%RADIUS_DEFAULT_VLAN%", + "options": "%RADIUS_DEFAULT_VLAN_OPTIONS%", + "hint": "Devices not in the RADIUS user list will be placed on this VLAN." + }, + { + "type": "button_row", + "items": [ + { + "type": "button_primary", + "text": "Save" + } + ] + } + ] + } + ] + }, + { + "type": "card", + "label": "EAP Settings", + "client_requirement": "client_is_administrator+", + "items": [ + { + "type": "p", + "text": "These settings are required for MAC-based 802.1X authentication on managed switches." + }, + { + "type": "form", + "action": "/action/radius/eap_save", + "method": "post", + "items": [ + { + "type": "field", + "label": "", + "name": "allow_weak_eap", + "input_type": "checkbox", + "checkbox_label": "Allow weak EAP types", + "value": "%RADIUS_ALLOW_WEAK_EAP%", + "hint": "Enables EAP-MD5. Required for switch port MAC-based 802.1X authentication." + }, + { + "type": "field", + "label": "", + "name": "tunneled_reply", + "input_type": "checkbox", + "checkbox_label": "Use tunneled reply (EAP-TTLS / EAP-PEAP)", + "value": "%RADIUS_TUNNELED_REPLY%", + "hint": "Sets use_tunneled_reply = yes in EAP-TTLS and EAP-PEAP modules. Required for switch MAC authentication." + }, + { + "type": "button_row", + "items": [ + { + "type": "button_primary", + "text": "Save" + } + ] + } + ] + } + ] + }, { "type": "card", "label": "Logging", diff --git a/docker/routlin-dash/app/pages/radius/view.py b/docker/routlin-dash/app/pages/radius/view.py index 52791fa..c7604ba 100644 --- a/docker/routlin-dash/app/pages/radius/view.py +++ b/docker/routlin-dash/app/pages/radius/view.py @@ -1,3 +1,4 @@ +import json import os from config_utils import collect_layout_tokens, CONFIGS_DIR @@ -65,8 +66,24 @@ def collect_tokens(cfg): fr_gen = fr.get('general', {}) tokens['RADIUS_MAC_FORMAT'] = fr_opts.get('mac_format', 'aabbccddeeff') tokens['RADIUS_APPLY_TO'] = fr_opts.get('apply_to', 'all') + tokens['RADIUS_AP_IPS'] = '\n'.join(fr_opts.get('ap_ips', [])) tokens['RADIUS_LOGGING'] = 'true' if fr_gen.get('logging', False) else '' tokens['RADIUS_LOGGING_HINT'] = 'Unchecking will clear logs.' if fr_gen.get('logging', False) else '' tokens['RADIUS_GEN_LOG_MAX_KB'] = str(fr_gen.get('log_max_kb', 1024)) + + fr_eap = fr.get('eap', {}) + tokens['RADIUS_ALLOW_WEAK_EAP'] = 'true' if fr_eap.get('allow_weak_eap', False) else '' + tokens['RADIUS_TUNNELED_REPLY'] = 'true' if fr_eap.get('tunneled_reply', False) else '' + + vlans = cfg.get('vlans', []) + default_vlan = next((v['name'] for v in vlans if v.get('radius_default') is True), '') + vlan_options = [{'value': '', 'label': 'None (reject unknown devices)'}] + vlan_options += [ + {'value': v['name'], 'label': f"{v['name']} (VLAN {v.get('vlan_id', '?')})"} + for v in vlans + ] + tokens['RADIUS_DEFAULT_VLAN'] = default_vlan + tokens['RADIUS_DEFAULT_VLAN_OPTIONS'] = json.dumps(vlan_options) + tokens['RADIUS_LOG_TAIL'], tokens['RADIUS_LOG_SUMMARY'] = radius_log_tail(cfg) return tokens diff --git a/routlin/config.json b/routlin/config.json index 45512de..64f3259 100644 --- a/routlin/config.json +++ b/routlin/config.json @@ -836,7 +836,12 @@ }, "options": { "mac_format": "aabbccddeeff", - "apply_to": "all" + "apply_to": "all", + "ap_ips": [] + }, + "eap": { + "allow_weak_eap": false, + "tunneled_reply": false } } } diff --git a/routlin/core.py b/routlin/core.py index bfda7cf..e861e06 100644 --- a/routlin/core.py +++ b/routlin/core.py @@ -1830,7 +1830,10 @@ RADIUS_SECRET_FILE = SCRIPT_DIR / ".radius-secret" 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") +RADIUS_EAP_FILE = Path("/etc/freeradius/3.0/mods-available/eap") +RADIUS_HUNTGROUPS = Path("/etc/freeradius/3.0/huntgroups") RADIUS_LOG_FILE = Path("/var/log/freeradius/radius.log") +RADIUS_HUNTGROUP_NAME = "routlin-aps" def radius_clients(data): """Return list of (reservation, vlan) tuples where radius_client is True.""" @@ -1854,6 +1857,8 @@ def ensure_radius_secret(): RADIUS_SECRET_FILE.write_text(secret + "\n") RADIUS_SECRET_FILE.chmod(0o644) print(f"Generated RADIUS shared secret: {RADIUS_SECRET_FILE}") + print(f" ACTION REQUIRED: enter this shared secret into your managed switch's RADIUS configuration:") + print(f" {secret}") return secret def build_radius_clients_conf(data, secret): @@ -1941,12 +1946,14 @@ def build_radius_users(data): "", ] - default_id = default_vlan.get('vlan_id') - default_check = ( - "DEFAULT NAS-Port-Type = Wireless-802.11, Auth-Type := Accept" - if apply_to == 'wireless' - else "DEFAULT Auth-Type := Accept" - ) + default_id = default_vlan.get('vlan_id') + ap_ips = fr_opts.get('ap_ips', []) + if apply_to == 'wireless': + default_check = "DEFAULT NAS-Port-Type = Wireless-802.11, Auth-Type := Accept" + elif apply_to == 'huntgroup' and ap_ips: + default_check = f'DEFAULT Huntgroup-Name == "{RADIUS_HUNTGROUP_NAME}", Auth-Type := Accept' + else: + default_check = "DEFAULT Auth-Type := Accept" lines += [ f"# Default -- unknown MACs land on VLAN {default_id} ({default_vlan['name']})", default_check, @@ -1976,6 +1983,50 @@ def _set_freeradius_log(enabled): return True +def _write_huntgroups(data): + opts = data.get('radius', {}).get('options', {}) + apply_to = opts.get('apply_to', 'all') + ap_ips = opts.get('ap_ips', []) + if apply_to != 'huntgroup' or not ap_ips: + return False + lines = [ + "# Generated by core.py -- do not edit manually.", + "# Edit config.json and re-run: sudo python3 core.py --apply", + "", + ] + for ip in ap_ips: + lines.append(f"{RADIUS_HUNTGROUP_NAME} NAS-IP-Address == {ip}") + content = "\n".join(lines) + "\n" + existing = RADIUS_HUNTGROUPS.read_text() if RADIUS_HUNTGROUPS.exists() else None + if existing == content: + return False + RADIUS_HUNTGROUPS.write_text(content) + print(f"Written: {RADIUS_HUNTGROUPS}") + return True + + +def _set_freeradius_eap(data): + if not RADIUS_EAP_FILE.exists(): + return False + eap_cfg = data.get('radius', {}).get('eap', {}) + tunneled_reply = eap_cfg.get('tunneled_reply', False) + allow_weak_eap = eap_cfg.get('allow_weak_eap', False) + import re + content = RADIUS_EAP_FILE.read_text() + updated = content + tr_val = 'yes' if tunneled_reply else 'no' + updated = re.sub(r'(?m)^(\s*use_tunneled_reply\s*=\s*)(yes|no)', rf'\g<1>{tr_val}', updated) + if allow_weak_eap: + updated = re.sub(r'(?m)^(\s*)#(\s*md5\s*\{)', r'\1\2', updated) + else: + updated = re.sub(r'(?m)^(\s*)(md5\s*\{)', r'\1#\2', updated) + if updated == content: + return False + RADIUS_EAP_FILE.write_text(updated) + print(f"EAP: tunneled_reply={tr_val}, allow_weak_eap={allow_weak_eap}") + return True + + def apply_radius(data): """Write FreeRADIUS config files and restart the service.""" secret = ensure_radius_secret() @@ -1985,7 +2036,9 @@ def apply_radius(data): logging = data.get('radius', {}).get('general', {}).get('logging', False) - changed = _set_freeradius_log(logging) + changed = _set_freeradius_log(logging) + changed |= _write_huntgroups(data) + changed |= _set_freeradius_eap(data) for path, content in [(RADIUS_CLIENTS_CONF, clients_content), (RADIUS_USERS_FILE, users_content)]: existing = path.read_text() if path.exists() else None