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 = ( + '