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}")
# ===================================================================