Development

This commit is contained in:
Matthew Grotke 2026-06-01 13:29:07 -04:00
parent 4f5f2a8071
commit 0d096a0d99
6 changed files with 175 additions and 33 deletions

View file

@ -1,16 +1,19 @@
import copy import copy
import os
from pathlib import Path from pathlib import Path
from flask import Blueprint, request, redirect, flash from flask import Blueprint, request, redirect, flash, send_file, abort
from auth import require_level from auth import require_level
from config_utils import CONFIGS_DIR, load_config, record_group, diff_fields from config_utils import CONFIGS_DIR, load_config, record_group, diff_fields
import validation as validate
_PAGE = Path(__file__).parent.name _PAGE = Path(__file__).parent.name
bp = Blueprint(_PAGE, __name__) bp = Blueprint(_PAGE, __name__)
RADIUS_SECRET_FILE = Path(CONFIGS_DIR) / '.radius-secret' RADIUS_SECRET_FILE = Path(CONFIGS_DIR) / '.radius-secret'
RADIUS_LOG_FILE = '/var/log/freeradius/radius.log'
_VALID_MAC_FORMATS = { 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',
'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',
} }
@ -33,9 +36,8 @@ def regenerate():
def options_save(): def options_save():
mac_format = request.form.get('mac_format', 'aabbccddeeff') mac_format = request.form.get('mac_format', 'aabbccddeeff')
apply_to = request.form.get('apply_to', 'all') apply_to = request.form.get('apply_to', 'all')
logging = 'logging' in request.form
if mac_format not in _VALID_MAC_FORMATS: if mac_format not in VALID_MAC_FORMATS:
flash('Invalid MAC format.', 'error') flash('Invalid MAC format.', 'error')
return redirect(f'/{_PAGE}') return redirect(f'/{_PAGE}')
if apply_to not in ('all', 'wireless'): if apply_to not in ('all', 'wireless'):
@ -43,10 +45,48 @@ def options_save():
return redirect(f'/{_PAGE}') return redirect(f'/{_PAGE}')
cfg = load_config() cfg = load_config()
before = copy.deepcopy(cfg.get('radius_options', {})) before = copy.deepcopy(cfg.get('free_radius', {}).get('options', {}))
after = {'mac_format': mac_format, 'apply_to': apply_to, 'logging': logging} after = {'mac_format': mac_format, 'apply_to': apply_to}
cfg['radius_options'] = after cfg.setdefault('free_radius', {})['options'] = after
changes = diff_fields(before, after) changes = diff_fields(before, after)
flash(record_group(cfg, 'radius_options', 'setting', 'radius_options', changes, 'core apply'), 'success') flash(record_group(cfg, 'free_radius.options', 'setting', 'free_radius', changes, 'core apply'), 'success')
return redirect(f'/{_PAGE}') return redirect(f'/{_PAGE}')
@bp.route('/action/radius/logging_save', methods=['POST'])
@require_level('administrator')
def logging_save():
log_max_kb = validate.int_range(request.form.get('log_max_kb', '').strip(), 64, None)
if log_max_kb is None:
flash('Max Log Size must be a number >= 64.', 'error')
return redirect(f'/{_PAGE}')
logging = 'logging' in request.form
cfg = load_config()
before = copy.deepcopy(cfg.get('free_radius', {}).get('general', {}))
after = {'logging': logging, 'log_max_kb': log_max_kb}
cfg.setdefault('free_radius', {})['general'] = after
changes = diff_fields(before, after)
flash(record_group(cfg, 'free_radius.general', 'setting', 'free_radius', changes, 'core apply'), 'success')
return redirect(f'/{_PAGE}')
@bp.route('/action/radius/logging_clear', methods=['POST'])
@require_level('administrator')
def logging_clear():
try:
open(RADIUS_LOG_FILE, 'w').close()
flash('RADIUS log cleared.', 'success')
except Exception as ex:
flash(f'Could not clear log: {ex}', 'error')
return redirect(f'/{_PAGE}')
@bp.route('/action/radius/logging_download', methods=['GET'])
@require_level('administrator')
def logging_download():
if not os.path.isfile(RADIUS_LOG_FILE):
abort(404)
return send_file(RADIUS_LOG_FILE, as_attachment=True, download_name='radius.log', mimetype='text/plain')

View file

@ -82,15 +82,6 @@
], ],
"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."
}, },
{
"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", "type": "button_row",
"items": [ "items": [
@ -105,6 +96,79 @@
] ]
} }
] ]
},
{
"type": "card",
"label": "Logging",
"client_requirement": "client_is_administrator+",
"items": [
{
"type": "pre_block",
"text": "%RADIUS_LOG_TAIL%",
"scroll_to_bottom": true
},
{
"type": "raw_html",
"html": "%RADIUS_LOG_SUMMARY%"
},
{
"type": "button_row",
"justify": "space-between",
"items": [
{
"type": "button_ghost",
"action": "/action/radius/logging_download",
"text": "Download Log"
},
{
"type": "button_danger",
"action": "/action/radius/logging_clear",
"method": "post",
"text": "Clear Log"
}
]
},
{
"type": "hr"
},
{
"type": "form",
"action": "/action/radius/logging_save",
"method": "post",
"items": [
{
"type": "field",
"label": "Enable Auth Logging",
"name": "logging",
"input_type": "checkbox",
"checkbox_label": "Log auth requests",
"value": "%RADIUS_LOGGING%",
"hint": "Enables auth and auth_accept/auth_reject in radiusd.conf. High volume on busy networks - enable for debugging only."
},
{
"type": "field",
"label": "Max Log Size (KB)",
"name": "log_max_kb",
"input_type": "number",
"layout": "inline",
"value": "%RADIUS_GEN_LOG_MAX_KB%",
"min": "64",
"hint": "Log display will be truncated to this size."
},
{
"type": "button_row",
"items": [
{
"type": "button_primary",
"action": "/action/radius/logging_save",
"method": "post",
"text": "Save"
}
]
}
]
}
]
} }
] ]
} }

View file

@ -502,7 +502,36 @@ def _blocklist_stats_html(cfg):
) )
DDNS_LOG_MAX = 50 DDNS_LOG_MAX = 50
RADIUS_LOG_MAX = 50
RADIUS_LOG_FILE = '/var/log/freeradius/radius.log'
def _radius_log_tail():
try:
cfg = load_config()
log_max_kb = cfg.get('free_radius', {}).get('general', {}).get('log_max_kb', 1024)
size_kb = os.path.getsize(RADIUS_LOG_FILE) / 1024
with open(RADIUS_LOG_FILE) as f:
lines = f.readlines()
if not lines:
return '(log is empty)', ''
total = len(lines)
tail = lines[-RADIUS_LOG_MAX:]
shown = len(tail)
hidden = total - shown
pct = min(100, round(size_kb / log_max_kb * 100)) if log_max_kb else 0
left = f'Showing {shown} of {total} lines ({hidden} not shown)' if hidden > 0 else f'Showing {shown} of {total} lines'
right = f'Log file size: {size_kb:.1f} KB ({pct}% of max)'
summary = (
'<div class="text-muted" style="display:flex;justify-content:space-between;margin-top:0.5em;">'
f'<span>{left}</span><span>{right}</span></div>'
)
return ''.join(tail).strip(), summary
except FileNotFoundError:
return '(log file not found)', ''
except Exception:
return '(error reading log)', ''
def _ddns_log_tail(): def _ddns_log_tail():
log_path = f'{CONFIGS_DIR}/ddns.log' log_path = f'{CONFIGS_DIR}/ddns.log'
@ -819,10 +848,14 @@ 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', {}) _fr = cfg.get('free_radius', {})
tokens['RADIUS_MAC_FORMAT'] = _radius_opts.get('mac_format', 'aabbccddeeff') _fr_opts = _fr.get('options', {})
tokens['RADIUS_APPLY_TO'] = _radius_opts.get('apply_to', 'all') _fr_gen = _fr.get('general', {})
tokens['RADIUS_LOGGING'] = 'true' if _radius_opts.get('logging', False) else '' tokens['RADIUS_MAC_FORMAT'] = _fr_opts.get('mac_format', 'aabbccddeeff')
tokens['RADIUS_APPLY_TO'] = _fr_opts.get('apply_to', 'all')
tokens['RADIUS_LOGGING'] = 'true' if _fr_gen.get('logging', False) else ''
tokens['RADIUS_GEN_LOG_MAX_KB'] = str(_fr_gen.get('log_max_kb', 1024))
tokens['RADIUS_LOG_TAIL'], tokens['RADIUS_LOG_SUMMARY'] = _radius_log_tail()
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

@ -14,6 +14,7 @@ services:
- /sys/devices:/sys/devices:ro - /sys/devices:/sys/devices:ro
- /etc/localtime:/etc/localtime:ro - /etc/localtime:/etc/localtime:ro
- /var/lib/misc:/var/lib/misc:ro - /var/lib/misc:/var/lib/misc:ro
- /var/log/freeradius:/var/log/freeradius
environment: environment:
- PYTHONPATH=/routlin_location - PYTHONPATH=/routlin_location
- WEB_APP_DISPLAY_NAME=Routlin Dashboard - WEB_APP_DISPLAY_NAME=Routlin Dashboard

View file

@ -829,9 +829,14 @@
"vlan": "vpn" "vlan": "vpn"
} }
], ],
"radius_options": { "free_radius": {
"mac_format": "aabbccddeeff", "general": {
"apply_to": "all", "logging": false,
"logging": false "log_max_kb": 1024
},
"options": {
"mac_format": "aabbccddeeff",
"apply_to": "all"
}
} }
} }

View file

@ -1898,7 +1898,7 @@ 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.
MAC format and DEFAULT rule scope are read from radius_options in config. MAC format and DEFAULT rule scope are read from free_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
@ -1906,9 +1906,9 @@ 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', {}) fr_opts = data.get('free_radius', {}).get('options', {})
mac_fmt = opts.get('mac_format', 'aabbccddeeff') mac_fmt = fr_opts.get('mac_format', 'aabbccddeeff')
apply_to = opts.get('apply_to', 'all') apply_to = fr_opts.get('apply_to', 'all')
lines = [ lines = [
"# Generated by core.py -- do not edit manually.", "# Generated by core.py -- do not edit manually.",
@ -1979,8 +1979,7 @@ 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)
opts = data.get('radius_options', {}) logging = data.get('free_radius', {}).get('general', {}).get('logging', False)
logging = opts.get('logging', False)
changed = _set_freeradius_log(logging) changed = _set_freeradius_log(logging)
for path, content in [(RADIUS_CLIENTS_CONF, clients_content), for path, content in [(RADIUS_CLIENTS_CONF, clients_content),