diff --git a/docker/routlin-dash/app/pages/dnsblocking/content.json b/docker/routlin-dash/app/pages/dnsblocking/content.json index e891835..2abe7a3 100644 --- a/docker/routlin-dash/app/pages/dnsblocking/content.json +++ b/docker/routlin-dash/app/pages/dnsblocking/content.json @@ -207,6 +207,18 @@ "value": "%GENERAL_LOG_ERRORS_ONLY%", "hint": "Only write error-level messages to the log." }, + { + "type": "hr" + }, + { + "type": "pre_block", + "text": "%DNS_LOG_TAIL%", + "scroll_to_bottom": true + }, + { + "type": "raw_html", + "html": "%DNS_LOG_SUMMARY%" + }, { "type": "button_row", "items": [ diff --git a/docker/routlin-dash/app/pages/dnsblocking/view.py b/docker/routlin-dash/app/pages/dnsblocking/view.py index 941b4fa..2d437bb 100644 --- a/docker/routlin-dash/app/pages/dnsblocking/view.py +++ b/docker/routlin-dash/app/pages/dnsblocking/view.py @@ -1,9 +1,38 @@ import json import os from datetime import datetime, timezone -from config_utils import collect_layout_tokens, load_datasource, fmt_bytes, relative_time, BLOCKLISTS_DIR +from config_utils import collect_layout_tokens, load_datasource, fmt_bytes, relative_time, BLOCKLISTS_DIR, CONFIGS_DIR from factory import e, load_json, build_table, table_token_key, iter_table_items, PAGES_DIR +DNS_LOG_FILE = f'{CONFIGS_DIR}/dns-blocklists.log' +DNS_LOG_MAX = 50 + + +def _dnsblocking_log_tail(cfg): + try: + log_max_kb = cfg.get('dns_blocking', {}).get('general', {}).get('log_max_kb', 1024) + size_kb = os.path.getsize(DNS_LOG_FILE) / 1024 + with open(DNS_LOG_FILE) as f: + lines = f.readlines() + if not lines: + return '(log is empty)', '' + total = len(lines) + tail = lines[-DNS_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 blocklist_stats_html(cfg): rows = '' @@ -50,6 +79,7 @@ def collect_tokens(cfg): tokens['GENERAL_LOG_ERRORS_ONLY'] = 'true' if dns_blk_gen.get('log_errors_only') else 'false' tokens['GENERAL_DAILY_EXECUTE_TIME'] = str(dns_blk_gen.get('daily_execute_time_24hr_local', '-')) tokens['BLOCKLIST_STATS_HTML'] = blocklist_stats_html(cfg) + tokens['DNS_LOG_TAIL'], tokens['DNS_LOG_SUMMARY'] = _dnsblocking_log_tail(cfg) tokens['BLOCKLIST_FORMAT_OPTIONS'] = json.dumps([ {'value': 'hosts', 'label': 'hosts (hosts file format)'}, {'value': 'dnsmasq', 'label': 'dnsmasq (local=/ syntax)'}, diff --git a/docker/routlin-dash/app/pages/radius/action.py b/docker/routlin-dash/app/pages/radius/action.py index 4b1d6e6..4894eaf 100644 --- a/docker/routlin-dash/app/pages/radius/action.py +++ b/docker/routlin-dash/app/pages/radius/action.py @@ -1,5 +1,8 @@ import copy +import gzip +import io import os +import re from pathlib import Path from flask import Blueprint, request, redirect, flash, send_file, abort, jsonify from auth import require_level @@ -73,23 +76,52 @@ def logging_save(): 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): + log_dir = os.path.dirname(RADIUS_LOG_FILE) + chunks = [] + + # Collect radius.log.N.gz files, sorted oldest-first (highest N first) + gz_files = [] + for name in os.listdir(log_dir) if os.path.isdir(log_dir) else []: + m = re.fullmatch(r'radius\.log\.(\d+)\.gz', name) + if m: + gz_files.append((int(m.group(1)), os.path.join(log_dir, name))) + for _, path in sorted(gz_files, reverse=True): + try: + with gzip.open(path, 'rb') as f: + chunks.append(f.read()) + except OSError: + pass + + # radius.log.1 (plain, older than current) + rotated = RADIUS_LOG_FILE + '.1' + if os.path.isfile(rotated): + try: + with open(rotated, 'rb') as f: + chunks.append(f.read()) + except OSError: + pass + + # radius.log (current) + if os.path.isfile(RADIUS_LOG_FILE): + try: + with open(RADIUS_LOG_FILE, 'rb') as f: + chunks.append(f.read()) + except OSError: + pass + + if not chunks: abort(404) - return send_file(RADIUS_LOG_FILE, as_attachment=True, download_name='radius.log', mimetype='text/plain') + + data = b''.join(chunks) + return send_file( + io.BytesIO(data), + as_attachment=True, + download_name='radius.log', + mimetype='text/plain', + ) @bp.route('/api/radius/log-tail', methods=['GET']) @@ -98,20 +130,43 @@ def api_log_tail(): try: cfg = load_config() log_max_kb = cfg.get('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() + + current = [] + try: + with open(RADIUS_LOG_FILE) as f: + current = f.readlines() + except FileNotFoundError: + pass + + prev = [] + if len(current) < 50: + try: + with open(RADIUS_LOG_FILE + '.1') as f: + prev = f.readlines() + except FileNotFoundError: + pass + + need = max(0, 50 - len(current)) + lines = (prev[-need:] if need and prev else []) + current + if not lines: - return jsonify({'log': '(log is empty)', 'summary': ''}) - total = len(lines) - tail = lines[-50:] - 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)' + return jsonify({'log': '(log is empty)', 'left': '', 'right': ''}) + + log_dir = os.path.dirname(RADIUS_LOG_FILE) + try: + size_kb = sum( + os.path.getsize(os.path.join(log_dir, f)) + for f in os.listdir(log_dir) + if os.path.isfile(os.path.join(log_dir, f)) + ) / 1024 + except OSError: + size_kb = 0.0 + + tail = lines[-50:] + pct = min(100, round(size_kb / log_max_kb * 100)) if log_max_kb else 0 + note = ' (includes rotated log)' if (prev and need) else '' + left = f'Showing {len(tail)} lines{note}' + right = f'Total log size: {size_kb:.1f} KB ({pct}% of max)' return jsonify({'log': ''.join(tail).strip(), 'left': left, 'right': right}) - except FileNotFoundError: - return jsonify({'log': '(log file not found)', 'left': '', 'right': ''}) except Exception: return jsonify({'log': '(error reading log)', 'left': '', 'right': ''}) diff --git a/docker/routlin-dash/app/pages/radius/content.json b/docker/routlin-dash/app/pages/radius/content.json index 1abb899..35d4bae 100644 --- a/docker/routlin-dash/app/pages/radius/content.json +++ b/docker/routlin-dash/app/pages/radius/content.json @@ -114,7 +114,7 @@ "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." + "hint": "Enables auth and auth_accept/auth_reject in radiusd.conf." }, { "type": "hr" @@ -130,17 +130,11 @@ }, { "type": "button_row", - "justify": "space-between", "items": [ { "type": "button_ghost", "action": "/action/radius/logging_download", "text": "Download Log" - }, - { - "type": "button_danger", - "formaction": "/action/radius/logging_clear", - "text": "Clear Log" } ] }, diff --git a/docker/routlin-dash/app/pages/radius/view.py b/docker/routlin-dash/app/pages/radius/view.py index 8b1b882..7075971 100644 --- a/docker/routlin-dash/app/pages/radius/view.py +++ b/docker/routlin-dash/app/pages/radius/view.py @@ -8,25 +8,48 @@ RADIUS_LOG_FILE = '/var/log/freeradius/radius.log' def radius_log_tail(cfg): try: log_max_kb = cfg.get('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() + + current = [] + try: + with open(RADIUS_LOG_FILE) as f: + current = f.readlines() + except FileNotFoundError: + pass + + prev = [] + if len(current) < RADIUS_LOG_MAX: + try: + with open(RADIUS_LOG_FILE + '.1') as f: + prev = f.readlines() + except FileNotFoundError: + pass + + need = max(0, RADIUS_LOG_MAX - len(current)) + lines = (prev[-need:] if need and prev else []) + current + 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)' + + log_dir = os.path.dirname(RADIUS_LOG_FILE) + try: + size_kb = sum( + os.path.getsize(os.path.join(log_dir, f)) + for f in os.listdir(log_dir) + if os.path.isfile(os.path.join(log_dir, f)) + ) / 1024 + except OSError: + size_kb = 0.0 + + tail = lines[-RADIUS_LOG_MAX:] + pct = min(100, round(size_kb / log_max_kb * 100)) if log_max_kb else 0 + note = ' (includes rotated log)' if (prev and need) else '' + left = f'Showing {len(tail)} lines{note}' + right = f'Total log 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)', '' diff --git a/routlin/maintenance.py b/routlin/maintenance.py index 04bfde4..41d9cb2 100644 --- a/routlin/maintenance.py +++ b/routlin/maintenance.py @@ -488,20 +488,46 @@ def process_provider(provider, current_ip, force=False): # FreeRADIUS log rotation # =================================================================== -def rotate_radius_log(radius_cfg): - """Truncate the FreeRADIUS log if it exceeds radius.general.log_max_kb.""" - max_kb = radius_cfg.get("general", {}).get("log_max_kb", 1024) - max_bytes = int(max_kb * 1024) - if not RADIUS_LOG_FILE.exists(): - return +def _clear_radius_log_dir(log_dir, reason): + """Delete all files in log_dir and print reason.""" try: - if RADIUS_LOG_FILE.stat().st_size > max_bytes: - RADIUS_LOG_FILE.write_text("") - print(f"FreeRADIUS log cleared (exceeded {max_kb} KB).") + files = [p for p in log_dir.iterdir() if p.is_file()] + if not files: + return + for p in files: + try: + p.unlink() + except PermissionError: + print(f"WARNING: Cannot delete {p} (permission denied).") + except OSError as e: + print(f"WARNING: Error deleting {p}: {e}") + print(f"FreeRADIUS logs cleared ({reason}).") except PermissionError: - print(f"WARNING: Cannot write to {RADIUS_LOG_FILE} (permission denied).") + print(f"WARNING: Cannot read {log_dir} (permission denied).") except OSError as e: - print(f"WARNING: Error checking FreeRADIUS log: {e}") + print(f"WARNING: Error clearing FreeRADIUS log dir: {e}") + + +def rotate_radius_log(radius_cfg): + """Clear the FreeRADIUS log dir if logging is disabled or total size exceeds log_max_kb.""" + general = radius_cfg.get("general", {}) + log_dir = RADIUS_LOG_FILE.parent + if not log_dir.exists(): + return + if not general.get("logging", False): + _clear_radius_log_dir(log_dir, "logging disabled") + return + max_kb = general.get("log_max_kb", 1024) + max_bytes = int(max_kb * 1024) + try: + files = [p for p in log_dir.iterdir() if p.is_file()] + total = sum(p.stat().st_size for p in files) + if total > max_bytes: + _clear_radius_log_dir(log_dir, f"total {total // 1024} KB exceeded {max_kb} KB") + except PermissionError: + print(f"WARNING: Cannot read {log_dir} (permission denied).") + except OSError as e: + print(f"WARNING: Error checking FreeRADIUS log dir: {e}") # ===================================================================