diff --git a/docker/routlin-dash/app/pages/radius/action.py b/docker/routlin-dash/app/pages/radius/action.py index da695bb..9e6ec88 100644 --- a/docker/routlin-dash/app/pages/radius/action.py +++ b/docker/routlin-dash/app/pages/radius/action.py @@ -1,16 +1,19 @@ import copy +import os 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 config_utils import CONFIGS_DIR, load_config, record_group, diff_fields +import validation as validate _PAGE = Path(__file__).parent.name bp = Blueprint(_PAGE, __name__) 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', } @@ -33,9 +36,8 @@ def regenerate(): 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: + if mac_format not in VALID_MAC_FORMATS: flash('Invalid MAC format.', 'error') return redirect(f'/{_PAGE}') if apply_to not in ('all', 'wireless'): @@ -43,10 +45,48 @@ def options_save(): 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 + before = copy.deepcopy(cfg.get('free_radius', {}).get('options', {})) + after = {'mac_format': mac_format, 'apply_to': apply_to} + cfg.setdefault('free_radius', {})['options'] = 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}') + + +@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') diff --git a/docker/routlin-dash/app/pages/radius/content.json b/docker/routlin-dash/app/pages/radius/content.json index 340e212..0d3b447 100644 --- a/docker/routlin-dash/app/pages/radius/content.json +++ b/docker/routlin-dash/app/pages/radius/content.json @@ -82,15 +82,6 @@ ], "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": [ @@ -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" + } + ] + } + ] + } + ] } ] } diff --git a/docker/routlin-dash/app/view_page.py b/docker/routlin-dash/app/view_page.py index 2f3ce49..2af4fbe 100644 --- a/docker/routlin-dash/app/view_page.py +++ b/docker/routlin-dash/app/view_page.py @@ -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 = ( + '
' + f'{left}{right}
' + ) + return ''.join(tail).strip(), summary + except FileNotFoundError: + return '(log file not found)', '' + except Exception: + return '(error reading log)', '' + def _ddns_log_tail(): 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() except OSError: 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 '' + _fr = cfg.get('free_radius', {}) + _fr_opts = _fr.get('options', {}) + _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_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_BLOCKLIST_COUNT'] = str(len(cfg.get('dns_blocking', {}).get('blocklists', []))) tokens['BLOCKLIST_STATS_HTML'] = _blocklist_stats_html(cfg) diff --git a/docker/routlin-dash/docker-compose.yml b/docker/routlin-dash/docker-compose.yml index 25112c2..697ce77 100644 --- a/docker/routlin-dash/docker-compose.yml +++ b/docker/routlin-dash/docker-compose.yml @@ -14,6 +14,7 @@ services: - /sys/devices:/sys/devices:ro - /etc/localtime:/etc/localtime:ro - /var/lib/misc:/var/lib/misc:ro + - /var/log/freeradius:/var/log/freeradius environment: - PYTHONPATH=/routlin_location - WEB_APP_DISPLAY_NAME=Routlin Dashboard diff --git a/routlin/config.json b/routlin/config.json index 8b5079d..bf26cc6 100644 --- a/routlin/config.json +++ b/routlin/config.json @@ -829,9 +829,14 @@ "vlan": "vpn" } ], - "radius_options": { - "mac_format": "aabbccddeeff", - "apply_to": "all", - "logging": false + "free_radius": { + "general": { + "logging": false, + "log_max_kb": 1024 + }, + "options": { + "mac_format": "aabbccddeeff", + "apply_to": "all" + } } -} \ No newline at end of file +} diff --git a/routlin/core.py b/routlin/core.py index 7af8d7b..5676380 100644 --- a/routlin/core.py +++ b/routlin/core.py @@ -1898,7 +1898,7 @@ def build_radius_users(data): Generate freeradius users file. 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. - 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( (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: 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') + fr_opts = data.get('free_radius', {}).get('options', {}) + mac_fmt = fr_opts.get('mac_format', 'aabbccddeeff') + apply_to = fr_opts.get('apply_to', 'all') lines = [ "# Generated by core.py -- do not edit manually.", @@ -1979,8 +1979,7 @@ def apply_radius(data): clients_content = build_radius_clients_conf(data, secret) users_content = build_radius_users(data) - opts = data.get('radius_options', {}) - logging = opts.get('logging', False) + logging = data.get('free_radius', {}).get('general', {}).get('logging', False) changed = _set_freeradius_log(logging) for path, content in [(RADIUS_CLIENTS_CONF, clients_content),