2026-06-01 22:12:11 -04:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
"""
|
|
|
|
|
maintenance.py -- Periodic maintenance tasks run by the routlin-maintenance systemd timer.
|
|
|
|
|
|
|
|
|
|
Tasks performed on each run:
|
2026-06-11 01:31:57 -04:00
|
|
|
1. DDNS update (delegates to ddns.py)
|
|
|
|
|
2. FreeRADIUS log rotation
|
|
|
|
|
3. ARP cache refresh
|
|
|
|
|
4. DNS metrics collection (delegates to metrics.py)
|
2026-06-01 22:12:11 -04:00
|
|
|
|
|
|
|
|
Usage:
|
2026-06-11 01:31:57 -04:00
|
|
|
python3 maintenance.py
|
2026-06-01 22:12:11 -04:00
|
|
|
"""
|
|
|
|
|
|
2026-06-03 03:23:19 -04:00
|
|
|
import ipaddress
|
2026-06-01 22:12:11 -04:00
|
|
|
import json
|
|
|
|
|
import re
|
2026-06-11 01:31:57 -04:00
|
|
|
import subprocess
|
2026-06-01 22:12:11 -04:00
|
|
|
import sys
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
2026-06-11 01:31:57 -04:00
|
|
|
SCRIPT_DIR = Path(__file__).parent
|
|
|
|
|
CONFIG_FILE = SCRIPT_DIR / "config.json"
|
|
|
|
|
DDNS_SCRIPT = SCRIPT_DIR / "ddns.py"
|
|
|
|
|
METRICS_SCRIPT = SCRIPT_DIR / "metrics.py"
|
|
|
|
|
RADIUS_LOG_FILE = Path("/var/log/freeradius/radius.log")
|
|
|
|
|
ARP_CACHE_FILE = Path("/var/lib/misc/arp-cache.json")
|
2026-06-01 22:12:11 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ===================================================================
|
2026-06-11 01:31:57 -04:00
|
|
|
# Config
|
2026-06-01 22:12:11 -04:00
|
|
|
# ===================================================================
|
|
|
|
|
|
|
|
|
|
def load_config():
|
|
|
|
|
if not CONFIG_FILE.exists():
|
|
|
|
|
print(f"ERROR: Config file not found: {CONFIG_FILE}", file=sys.stderr)
|
|
|
|
|
sys.exit(1)
|
|
|
|
|
with open(CONFIG_FILE) as f:
|
2026-06-11 01:31:57 -04:00
|
|
|
return json.load(f)
|
2026-06-01 22:12:11 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ===================================================================
|
2026-06-11 01:31:57 -04:00
|
|
|
# DDNS - delegate to ddns.py
|
2026-06-01 22:12:11 -04:00
|
|
|
# ===================================================================
|
|
|
|
|
|
2026-06-11 01:31:57 -04:00
|
|
|
def run_ddns():
|
|
|
|
|
subprocess.run([sys.executable, str(DDNS_SCRIPT), "--update"])
|
2026-06-01 22:12:11 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ===================================================================
|
|
|
|
|
# FreeRADIUS log rotation
|
|
|
|
|
# ===================================================================
|
|
|
|
|
|
2026-06-03 00:45:04 -04:00
|
|
|
def _clear_radius_log_dir(log_dir, reason):
|
|
|
|
|
try:
|
|
|
|
|
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 read {log_dir} (permission denied).")
|
|
|
|
|
except OSError as e:
|
|
|
|
|
print(f"WARNING: Error clearing FreeRADIUS log dir: {e}")
|
|
|
|
|
|
|
|
|
|
|
2026-06-01 22:12:11 -04:00
|
|
|
def rotate_radius_log(radius_cfg):
|
2026-06-03 00:45:04 -04:00
|
|
|
general = radius_cfg.get("general", {})
|
|
|
|
|
log_dir = RADIUS_LOG_FILE.parent
|
|
|
|
|
if not log_dir.exists():
|
2026-06-01 22:12:11 -04:00
|
|
|
return
|
2026-06-03 00:45:04 -04:00
|
|
|
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)
|
2026-06-01 22:12:11 -04:00
|
|
|
try:
|
2026-06-03 00:45:04 -04:00
|
|
|
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")
|
2026-06-01 22:12:11 -04:00
|
|
|
except PermissionError:
|
2026-06-03 00:45:04 -04:00
|
|
|
print(f"WARNING: Cannot read {log_dir} (permission denied).")
|
2026-06-01 22:12:11 -04:00
|
|
|
except OSError as e:
|
2026-06-03 00:45:04 -04:00
|
|
|
print(f"WARNING: Error checking FreeRADIUS log dir: {e}")
|
2026-06-01 22:12:11 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ===================================================================
|
2026-06-11 01:31:57 -04:00
|
|
|
# ARP cache
|
2026-06-01 22:12:11 -04:00
|
|
|
# ===================================================================
|
|
|
|
|
|
2026-06-03 03:20:52 -04:00
|
|
|
ARP_MAX_AGE_SECS = 4 * 3600
|
|
|
|
|
|
|
|
|
|
|
2026-06-03 03:23:19 -04:00
|
|
|
def refresh_arp_cache(cfg):
|
|
|
|
|
vlan_networks = []
|
2026-06-11 01:31:57 -04:00
|
|
|
for v in cfg.get('vlans', []):
|
2026-06-03 03:23:19 -04:00
|
|
|
subnet = v.get('subnet')
|
|
|
|
|
mask = v.get('subnet_mask')
|
|
|
|
|
if subnet and mask:
|
|
|
|
|
try:
|
|
|
|
|
vlan_networks.append(ipaddress.IPv4Network(f'{subnet}/{mask}', strict=False))
|
|
|
|
|
except ValueError:
|
|
|
|
|
pass
|
|
|
|
|
|
2026-06-03 02:57:59 -04:00
|
|
|
try:
|
2026-06-03 03:20:52 -04:00
|
|
|
result = subprocess.run(['ip', '-stats', 'neigh'], capture_output=True, text=True, timeout=5)
|
2026-06-11 01:31:57 -04:00
|
|
|
best = {}
|
2026-06-03 02:57:59 -04:00
|
|
|
for line in result.stdout.splitlines():
|
|
|
|
|
parts = line.split()
|
|
|
|
|
if 'lladdr' not in parts:
|
|
|
|
|
continue
|
2026-06-11 01:31:57 -04:00
|
|
|
if ':' in parts[0]:
|
2026-06-03 02:57:59 -04:00
|
|
|
continue
|
2026-06-03 03:23:19 -04:00
|
|
|
try:
|
|
|
|
|
addr = ipaddress.IPv4Address(parts[0])
|
|
|
|
|
if vlan_networks and not any(addr in n for n in vlan_networks):
|
|
|
|
|
continue
|
|
|
|
|
except ValueError:
|
|
|
|
|
continue
|
2026-06-03 03:03:38 -04:00
|
|
|
iface = parts[2] if len(parts) > 2 else ''
|
|
|
|
|
if iface.startswith('br-') or iface == 'docker0':
|
|
|
|
|
continue
|
2026-06-03 03:20:52 -04:00
|
|
|
state = parts[-1]
|
|
|
|
|
if state in ('FAILED', 'PERMANENT', 'NOARP', 'INCOMPLETE'):
|
|
|
|
|
continue
|
|
|
|
|
used_match = re.search(r'used\s+(\d+)/', line)
|
|
|
|
|
used_secs = int(used_match.group(1)) if used_match else 0
|
2026-06-03 03:34:35 -04:00
|
|
|
if state != 'REACHABLE' and used_secs > ARP_MAX_AGE_SECS:
|
2026-06-03 03:20:52 -04:00
|
|
|
continue
|
2026-06-03 02:57:59 -04:00
|
|
|
idx = parts.index('lladdr')
|
|
|
|
|
mac = parts[idx + 1].lower()
|
2026-06-03 03:20:52 -04:00
|
|
|
if mac not in best or used_secs < best[mac][0]:
|
2026-06-03 03:32:00 -04:00
|
|
|
best[mac] = (used_secs, {'ip': parts[0], 'state': 'REACHABLE'})
|
2026-06-03 03:20:52 -04:00
|
|
|
ARP_CACHE_FILE.write_text(json.dumps({m: e for m, (_, e) in best.items()}))
|
2026-06-03 02:57:59 -04:00
|
|
|
except Exception as exc:
|
|
|
|
|
print(f"WARNING: Could not refresh ARP cache: {exc}")
|
|
|
|
|
|
|
|
|
|
|
2026-06-11 01:31:57 -04:00
|
|
|
# ===================================================================
|
|
|
|
|
# Main
|
|
|
|
|
# ===================================================================
|
2026-06-01 22:12:11 -04:00
|
|
|
|
|
|
|
|
def main():
|
2026-06-11 01:31:57 -04:00
|
|
|
run_ddns()
|
|
|
|
|
cfg = load_config()
|
|
|
|
|
rotate_radius_log(cfg.get("radius", {}))
|
2026-06-03 03:23:19 -04:00
|
|
|
refresh_arp_cache(cfg)
|
2026-06-11 01:31:57 -04:00
|
|
|
subprocess.run([sys.executable, str(METRICS_SCRIPT), "--collect"])
|
2026-06-01 22:12:11 -04:00
|
|
|
|
2026-06-09 21:28:38 -04:00
|
|
|
|
2026-06-01 22:12:11 -04:00
|
|
|
if __name__ == "__main__":
|
|
|
|
|
main()
|