Development

This commit is contained in:
Matthew Grotke 2026-06-01 12:58:06 -04:00
parent 6f23a57220
commit 4f5f2a8071
5 changed files with 157 additions and 9 deletions

View file

@ -1,7 +1,8 @@
import copy
from pathlib import Path from pathlib import Path
from flask import Blueprint, redirect, flash from flask import Blueprint, request, redirect, flash
from auth import require_level from auth import require_level
from config_utils import CONFIGS_DIR from config_utils import CONFIGS_DIR, load_config, record_group, diff_fields
_PAGE = Path(__file__).parent.name _PAGE = Path(__file__).parent.name
@ -9,6 +10,11 @@ bp = Blueprint(_PAGE, __name__)
RADIUS_SECRET_FILE = Path(CONFIGS_DIR) / '.radius-secret' RADIUS_SECRET_FILE = Path(CONFIGS_DIR) / '.radius-secret'
_VALID_MAC_FORMATS = {
'aabbccddeeff', 'aa-bb-cc-dd-ee-ff', 'aa:bb:cc:dd:ee:ff',
'AABBCCDDEEFF', 'AA-BB-CC-DD-EE-FF', 'AA:BB:CC:DD:EE:FF',
}
@bp.route('/action/radius/regenerate', methods=['POST']) @bp.route('/action/radius/regenerate', methods=['POST'])
@require_level('administrator') @require_level('administrator')
@ -20,3 +26,27 @@ def regenerate():
return redirect(f'/{_PAGE}') return redirect(f'/{_PAGE}')
flash('Secret deleted. A new secret will be generated when the pending command is applied.', 'success') flash('Secret deleted. A new secret will be generated when the pending command is applied.', 'success')
return redirect(f'/{_PAGE}') return redirect(f'/{_PAGE}')
@bp.route('/action/radius/options_save', methods=['POST'])
@require_level('administrator')
def options_save():
mac_format = request.form.get('mac_format', 'aabbccddeeff')
apply_to = request.form.get('apply_to', 'all')
logging = 'logging' in request.form
if mac_format not in _VALID_MAC_FORMATS:
flash('Invalid MAC format.', 'error')
return redirect(f'/{_PAGE}')
if apply_to not in ('all', 'wireless'):
flash('Invalid apply_to value.', 'error')
return redirect(f'/{_PAGE}')
cfg = load_config()
before = copy.deepcopy(cfg.get('radius_options', {}))
after = {'mac_format': mac_format, 'apply_to': apply_to, 'logging': logging}
cfg['radius_options'] = after
changes = diff_fields(before, after)
flash(record_group(cfg, 'radius_options', 'setting', 'radius_options', changes, 'core apply'), 'success')
return redirect(f'/{_PAGE}')

View file

@ -43,6 +43,68 @@
] ]
} }
] ]
},
{
"type": "card",
"label": "Options",
"client_requirement": "client_is_administrator+",
"items": [
{
"type": "form",
"action": "/action/radius/options_save",
"method": "post",
"items": [
{
"type": "field",
"label": "MAC Address Format",
"name": "mac_format",
"input_type": "select",
"value": "%RADIUS_MAC_FORMAT%",
"options": [
{"value": "aabbccddeeff", "label": "aabbccddeeff"},
{"value": "aa-bb-cc-dd-ee-ff", "label": "aa-bb-cc-dd-ee-ff"},
{"value": "aa:bb:cc:dd:ee:ff", "label": "aa:bb:cc:dd:ee:ff"},
{"value": "AABBCCDDEEFF", "label": "AABBCCDDEEFF"},
{"value": "AA-BB-CC-DD-EE-FF", "label": "AA-BB-CC-DD-EE-FF"},
{"value": "AA:BB:CC:DD:EE:FF", "label": "AA:BB:CC:DD:EE:FF"}
],
"hint": "Format used in the FreeRADIUS users file. Must match your AP/controller's expected format."
},
{
"type": "field",
"label": "Apply DEFAULT Rule To",
"name": "apply_to",
"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)"}
],
"hint": "Scoping to wireless only prevents the DEFAULT rule from assigning a VLAN to unknown wired switch ports."
},
{
"type": "field",
"label": "Auth Logging",
"name": "logging",
"input_type": "checkbox",
"checkbox_label": "Log auth requests",
"value": "%RADIUS_LOGGING%",
"hint": "Enables auth logging in radiusd.conf (auth, auth_accept, auth_reject). High volume on busy networks."
},
{
"type": "button_row",
"items": [
{
"type": "button_primary",
"action": "/action/radius/options_save",
"method": "post",
"text": "Save"
}
]
}
]
}
]
} }
] ]
} }

View file

@ -819,6 +819,10 @@ def collect_tokens():
tokens['RADIUS_SECRET'] = open(f'{CONFIGS_DIR}/.radius-secret').read().strip() tokens['RADIUS_SECRET'] = open(f'{CONFIGS_DIR}/.radius-secret').read().strip()
except OSError: except OSError:
tokens['RADIUS_SECRET'] = '(Generation is pending - visit Actions to apply generation command)' tokens['RADIUS_SECRET'] = '(Generation is pending - visit Actions to apply generation command)'
_radius_opts = cfg.get('radius_options', {})
tokens['RADIUS_MAC_FORMAT'] = _radius_opts.get('mac_format', 'aabbccddeeff')
tokens['RADIUS_APPLY_TO'] = _radius_opts.get('apply_to', 'all')
tokens['RADIUS_LOGGING'] = 'true' if _radius_opts.get('logging', False) else ''
tokens['STAT_BANNED_IP_COUNT'] = str(sum(1 for b in cfg.get('banned_ips', []) if b.get('enabled', True))) tokens['STAT_BANNED_IP_COUNT'] = str(sum(1 for b in cfg.get('banned_ips', []) if b.get('enabled', True)))
tokens['STAT_BLOCKLIST_COUNT'] = str(len(cfg.get('dns_blocking', {}).get('blocklists', []))) tokens['STAT_BLOCKLIST_COUNT'] = str(len(cfg.get('dns_blocking', {}).get('blocklists', [])))
tokens['BLOCKLIST_STATS_HTML'] = _blocklist_stats_html(cfg) tokens['BLOCKLIST_STATS_HTML'] = _blocklist_stats_html(cfg)

View file

@ -828,5 +828,10 @@
"redirect_to": "192.168.40.1", "redirect_to": "192.168.40.1",
"vlan": "vpn" "vlan": "vpn"
} }
] ],
"radius_options": {
"mac_format": "aabbccddeeff",
"apply_to": "all",
"logging": false
}
} }

View file

@ -1825,6 +1825,8 @@ def remove_nat_service():
RADIUS_SECRET_FILE = SCRIPT_DIR / ".radius-secret" RADIUS_SECRET_FILE = SCRIPT_DIR / ".radius-secret"
RADIUS_CLIENTS_CONF = Path("/etc/freeradius/3.0/clients.conf") RADIUS_CLIENTS_CONF = Path("/etc/freeradius/3.0/clients.conf")
RADIUS_USERS_FILE = Path("/etc/freeradius/3.0/users") RADIUS_USERS_FILE = Path("/etc/freeradius/3.0/users")
RADIUS_CONF_FILE = Path("/etc/freeradius/3.0/radiusd.conf")
RADIUS_LOG_FILE = Path("/var/log/freeradius/radius.log")
def radius_clients(data): def radius_clients(data):
"""Return list of (reservation, vlan) tuples where radius_client is True.""" """Return list of (reservation, vlan) tuples where radius_client is True."""
@ -1877,12 +1879,26 @@ def build_radius_clients_conf(data, secret):
] ]
return "\n".join(lines) return "\n".join(lines)
def _fmt_mac(raw, fmt):
c = raw.replace(':', '').replace('-', '').lower()
pairs = [c[i:i+2] for i in range(0, 12, 2)]
upper = fmt[0].isupper()
if fmt in ('aabbccddeeff', 'AABBCCDDEEFF'):
sep = ''
elif fmt in ('aa-bb-cc-dd-ee-ff', 'AA-BB-CC-DD-EE-FF'):
sep = '-'
else:
sep = ':'
joined = sep.join(pairs)
return joined.upper() if upper else joined
def build_radius_users(data): def build_radius_users(data):
""" """
Generate freeradius users file. Generate freeradius users file.
Each MAC reservation across all VLANs gets an entry mapping it to its VLAN ID. Each MAC reservation across all VLANs gets an entry mapping it to its VLAN ID.
Unknown MACs fall through to DEFAULT which returns the radius_default VLAN. Unknown MACs fall through to DEFAULT which returns the radius_default VLAN.
MACs are formatted without colons (FreeRADIUS MAB format). MAC format and DEFAULT rule scope are read from radius_options in config.
""" """
default_vlan = next( default_vlan = next(
(v for v in data["vlans"] if v.get("radius_default") is True), None (v for v in data["vlans"] if v.get("radius_default") is True), None
@ -1890,6 +1906,10 @@ def build_radius_users(data):
if default_vlan is None: if default_vlan is None:
die("No VLAN has radius_default: true. Cannot generate RADIUS users file.") die("No VLAN has radius_default: true. Cannot generate RADIUS users file.")
opts = data.get('radius_options', {})
mac_fmt = opts.get('mac_format', 'aabbccddeeff')
apply_to = opts.get('apply_to', 'all')
lines = [ lines = [
"# Generated by core.py -- do not edit manually.", "# Generated by core.py -- do not edit manually.",
"# Edit config.json and re-run: sudo python3 core.py --apply", "# Edit config.json and re-run: sudo python3 core.py --apply",
@ -1900,12 +1920,13 @@ def build_radius_users(data):
for r in data.get("dhcp_reservations", []): for r in data.get("dhcp_reservations", []):
if r.get("enabled") is not True: if r.get("enabled") is not True:
continue continue
mac = r.get("mac", "").replace(":", "").lower() raw_mac = r.get("mac", "")
if not mac: if not raw_mac:
continue continue
vlan = vlan_by_name.get(r.get("vlan", "")) vlan = vlan_by_name.get(r.get("vlan", ""))
if not vlan: if not vlan:
continue continue
mac = _fmt_mac(raw_mac, mac_fmt)
vlan_id = vlan.get('vlan_id') vlan_id = vlan.get('vlan_id')
lines += [ lines += [
f"# {r['description']} -> VLAN {vlan_id} ({vlan['name']})", f"# {r['description']} -> VLAN {vlan_id} ({vlan['name']})",
@ -1916,10 +1937,15 @@ def build_radius_users(data):
"", "",
] ]
default_id = default_vlan.get('vlan_id') 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"
)
lines += [ lines += [
f"# Default -- unknown MACs land on VLAN {default_id} ({default_vlan['name']})", f"# Default -- unknown MACs land on VLAN {default_id} ({default_vlan['name']})",
"DEFAULT Auth-Type := Accept", default_check,
f" Tunnel-Type = VLAN,", f" Tunnel-Type = VLAN,",
f" Tunnel-Medium-Type = IEEE-802,", f" Tunnel-Medium-Type = IEEE-802,",
f" Tunnel-Private-Group-Id = \"{default_id}\"", f" Tunnel-Private-Group-Id = \"{default_id}\"",
@ -1928,6 +1954,24 @@ def build_radius_users(data):
return "\n".join(lines) return "\n".join(lines)
def _set_freeradius_log(enabled):
"""Enable or disable auth logging lines in radiusd.conf."""
if not RADIUS_CONF_FILE.exists():
return False
import re
value = 'yes' if enabled else 'no'
content = RADIUS_CONF_FILE.read_text()
updated = re.sub(r'(?m)^(\s*auth\s*=\s*)(yes|no)', rf'\g<1>{value}', content)
updated = re.sub(r'(?m)^(\s*auth_accept\s*=\s*)(yes|no)', rf'\g<1>{value}', updated)
updated = re.sub(r'(?m)^(\s*auth_reject\s*=\s*)(yes|no)', rf'\g<1>{value}', updated)
if updated == content:
print(f"radiusd.conf: auth logging already {'enabled' if enabled else 'disabled'}.")
return False
RADIUS_CONF_FILE.write_text(updated)
print(f"radiusd.conf: auth logging {'enabled' if enabled else 'disabled'}.")
return True
def apply_radius(data): def apply_radius(data):
"""Write FreeRADIUS config files and restart the service.""" """Write FreeRADIUS config files and restart the service."""
secret = ensure_radius_secret() secret = ensure_radius_secret()
@ -1935,7 +1979,10 @@ def apply_radius(data):
clients_content = build_radius_clients_conf(data, secret) clients_content = build_radius_clients_conf(data, secret)
users_content = build_radius_users(data) users_content = build_radius_users(data)
changed = False opts = data.get('radius_options', {})
logging = opts.get('logging', False)
changed = _set_freeradius_log(logging)
for path, content in [(RADIUS_CLIENTS_CONF, clients_content), for path, content in [(RADIUS_CLIENTS_CONF, clients_content),
(RADIUS_USERS_FILE, users_content)]: (RADIUS_USERS_FILE, users_content)]:
existing = path.read_text() if path.exists() else None existing = path.read_text() if path.exists() else None