Development
This commit is contained in:
parent
a4652866c3
commit
27eaea3d73
19 changed files with 602 additions and 427 deletions
217
routlin/core.py
217
routlin/core.py
|
|
@ -74,7 +74,6 @@ Validation:
|
|||
|
||||
Usage:
|
||||
sudo python3 core.py --apply Apply config fast: restart running services only
|
||||
sudo python3 core.py --update-blocklists Refresh blocklists and apply (used by timer)
|
||||
sudo python3 core.py --status Show service and timer status
|
||||
sudo python3 core.py --view-configs Show active per-VLAN dnsmasq config files
|
||||
sudo python3 core.py --view-leases Show active DHCP leases
|
||||
|
|
@ -94,8 +93,6 @@ import re
|
|||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
import argparse
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
|
@ -128,11 +125,11 @@ DASHB_TIMER_FILE = SYSTEMD_DIR / f"{DASHB_TIMER_NAME}.timer"
|
|||
DASHB_TIMER_SVC_FILE = SYSTEMD_DIR / f"{DASHB_TIMER_NAME}.service"
|
||||
DASHB_TIMER_INTERVAL_SEC = 60
|
||||
DASHB_QUEUE_FILE = SCRIPT_DIR / ".dashboard-queue"
|
||||
STATUS_TIMER_NAME = f"{PRODUCT_NAME}-status-check"
|
||||
STATUS_TIMER_FILE = SYSTEMD_DIR / f"{STATUS_TIMER_NAME}.timer"
|
||||
STATUS_TIMER_SVC_FILE = SYSTEMD_DIR / f"{STATUS_TIMER_NAME}.service"
|
||||
STATUS_TIMER_INTERVAL_SEC = 300
|
||||
STATUS_FILE = SCRIPT_DIR / ".status"
|
||||
HEALTH_TIMER_NAME = f"{PRODUCT_NAME}-health-check"
|
||||
HEALTH_TIMER_FILE = SYSTEMD_DIR / f"{HEALTH_TIMER_NAME}.timer"
|
||||
HEALTH_TIMER_SVC_FILE = SYSTEMD_DIR / f"{HEALTH_TIMER_NAME}.service"
|
||||
HEALTH_TIMER_INTERVAL_SEC = 300
|
||||
HEALTH_FILE = SCRIPT_DIR / ".health"
|
||||
DASHB_DONE_FILE = SCRIPT_DIR / ".dashboard-done"
|
||||
DASHB_LAST_RUN_FILE = SCRIPT_DIR / ".dashboard-last-run"
|
||||
DASHB_LOCK_FILE = SCRIPT_DIR / ".dashboard-lock"
|
||||
|
|
@ -463,132 +460,6 @@ def blocklists_available(data):
|
|||
combos.add(combo_hash(names))
|
||||
return any(merged_path(h).exists() for h in combos)
|
||||
|
||||
def parse_dnsmasq_format(content):
|
||||
domains = set()
|
||||
for ln in content.splitlines():
|
||||
ln = ln.strip()
|
||||
if not ln or ln.startswith("#"):
|
||||
continue
|
||||
if ln.startswith("local=/"):
|
||||
domain = ln.removeprefix("local=/").rstrip("/")
|
||||
if domain:
|
||||
domains.add(domain)
|
||||
elif ln.startswith("address=/"):
|
||||
parts = ln.removeprefix("address=/").split("/")
|
||||
if parts:
|
||||
domains.add(parts[0])
|
||||
return domains
|
||||
|
||||
def parse_hosts_format(content):
|
||||
domains = set()
|
||||
for ln in content.splitlines():
|
||||
ln = ln.strip()
|
||||
if not ln or ln.startswith("#"):
|
||||
continue
|
||||
parts = ln.split()
|
||||
if len(parts) >= 2:
|
||||
domains.add(parts[1])
|
||||
return domains
|
||||
|
||||
def parse_blocklist(content, fmt):
|
||||
if fmt == "dnsmasq":
|
||||
return parse_dnsmasq_format(content)
|
||||
return parse_hosts_format(content)
|
||||
|
||||
def build_merged_conf(domains, bl_names):
|
||||
"""Build a merged dnsmasq conf blocking all domains and their subdomains."""
|
||||
lines = [
|
||||
"# Generated by core.py -- do not edit manually.",
|
||||
f"# Blocklist combination: {', '.join(sorted(bl_names))}",
|
||||
f"# Merged: {len(domains):,} unique domains.",
|
||||
"#",
|
||||
"# Blocks domain and all subdomains via local=/domain/ syntax.",
|
||||
"",
|
||||
]
|
||||
for domain in sorted(domains):
|
||||
lines.append(f"local=/{domain}/")
|
||||
return "\n".join(lines)
|
||||
|
||||
def download_all_blocklists(data):
|
||||
"""
|
||||
Download every blocklist referenced by at least one VLAN.
|
||||
Returns dict: name -> (content_str, entry) or (None, entry) on failure.
|
||||
"""
|
||||
bl_library = {bl["name"]: bl for bl in data.get("blocklists", [])}
|
||||
needed = set()
|
||||
for vlan in data["vlans"]:
|
||||
needed.update(vlan.get("use_blocklists", []))
|
||||
|
||||
results = {}
|
||||
for name in needed:
|
||||
entry = bl_library[name]
|
||||
url = entry["url"]
|
||||
try:
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "dns-dhcp.py/1.0"})
|
||||
with urllib.request.urlopen(req, timeout=30) as r:
|
||||
content = r.read().decode("utf-8", errors="ignore")
|
||||
log.info(f"Downloaded: {entry['description']} ({len(content):,} bytes)")
|
||||
results[name] = (content, entry)
|
||||
except Exception as e:
|
||||
log.error(f"Failed to download '{entry['description']}' from {url}: {e}")
|
||||
results[name] = (None, entry)
|
||||
return results
|
||||
|
||||
def update_blocklists(data):
|
||||
"""
|
||||
Download all referenced blocklists, build per-combo merged files,
|
||||
and clean up stale merged files. Returns active hashes set.
|
||||
"""
|
||||
BLOCKLIST_DIR.mkdir(exist_ok=True)
|
||||
|
||||
log.info("Downloading blocklists...")
|
||||
downloaded = download_all_blocklists(data)
|
||||
|
||||
# Parse domains per blocklist name; save raw files
|
||||
domains_by_name = {}
|
||||
for name, (content, entry) in downloaded.items():
|
||||
if content is None:
|
||||
log.error(f"Blocklist '{name}' failed to download -- it will be skipped.")
|
||||
domains_by_name[name] = set()
|
||||
else:
|
||||
(BLOCKLIST_DIR / entry["save_as"]).write_text(content)
|
||||
domains = parse_blocklist(content, entry.get("format", "dnsmasq"))
|
||||
log.info(f"Parsed {len(domains):,} domains from '{name}'")
|
||||
domains_by_name[name] = domains
|
||||
|
||||
# Build one merged file per unique combo
|
||||
active_hashes = set()
|
||||
combos = {}
|
||||
for vlan in data["vlans"]:
|
||||
names = frozenset(vlan.get("use_blocklists", []))
|
||||
if names:
|
||||
h = combo_hash(names)
|
||||
combos[h] = names
|
||||
|
||||
for h, names in combos.items():
|
||||
combo_domains = set()
|
||||
for name in names:
|
||||
combo_domains.update(domains_by_name.get(name, set()))
|
||||
|
||||
merged = build_merged_conf(combo_domains, names)
|
||||
merged_path(h).write_text(merged)
|
||||
active_hashes.add(h)
|
||||
log.info(
|
||||
f"Merged [{h}] ({', '.join(sorted(names))}): "
|
||||
f"{len(combo_domains):,} unique domains."
|
||||
)
|
||||
|
||||
# Remove stale merged files (hashes no longer in active combos)
|
||||
for f in BLOCKLIST_DIR.glob("merged-*.conf"):
|
||||
h = f.stem.removeprefix("merged-")
|
||||
if h not in active_hashes:
|
||||
f.unlink()
|
||||
log.info(f"Removed stale merged file: {f.name}")
|
||||
|
||||
# Return True if all blocklists downloaded successfully
|
||||
any_failed = any(content is None for content, _ in downloaded.values())
|
||||
return not any_failed
|
||||
|
||||
# ===================================================================
|
||||
# Build per-VLAN dnsmasq config
|
||||
# ===================================================================
|
||||
|
|
@ -608,7 +479,7 @@ def _wan_has_ipv6(iface):
|
|||
def build_vlan_dnsmasq_conf(vlan, data, iface):
|
||||
"""Generate the complete dnsmasq config for one VLAN instance."""
|
||||
dns_cfg = data.get("upstream_dns", {})
|
||||
general = data.get("general", {})
|
||||
general = data.get("network_interfaces", {})
|
||||
overrides = [o for o in data.get("host_overrides", []) if o.get("enabled") is True]
|
||||
name = vlan["name"]
|
||||
d = vlan.get("dhcp_information", {})
|
||||
|
|
@ -714,7 +585,7 @@ def build_vlan_dnsmasq_conf(vlan, data, iface):
|
|||
line("no-resolv")
|
||||
if dns_cfg.get("strict_order"):
|
||||
line("strict-order")
|
||||
wan = data["general"]["wan_interface"]
|
||||
wan = data["network_interfaces"]["wan_interface"]
|
||||
wan_has_ipv6 = _wan_has_ipv6(wan)
|
||||
for srv in dns_cfg.get("upstream_servers", []):
|
||||
if ":" in srv and not wan_has_ipv6:
|
||||
|
|
@ -737,7 +608,7 @@ def build_vlan_dnsmasq_conf(vlan, data, iface):
|
|||
line(f"conf-file={bl_file}")
|
||||
line()
|
||||
elif bl_names:
|
||||
line("# Blocklist not yet downloaded -- run --update-blocklists to fetch")
|
||||
line("# Blocklist not yet downloaded -- run: sudo python3 dns-blocklists.py")
|
||||
line()
|
||||
|
||||
return "\n".join(L)
|
||||
|
|
@ -1201,10 +1072,9 @@ def parse_time_to_calendar(time_str):
|
|||
return f"*-*-* {hh.zfill(2)}:{mm.zfill(2)}:00"
|
||||
|
||||
def install_timer(data):
|
||||
general = data.get("general", {})
|
||||
general = data.get("dns_blocking", {}).get("general", {})
|
||||
execute_time = general.get("daily_execute_time_24hr_local", "02:30")
|
||||
on_calendar = parse_time_to_calendar(execute_time)
|
||||
script_path = Path(__file__).resolve()
|
||||
|
||||
timer_content = "\n".join([
|
||||
"# Generated by core.py -- do not edit manually.",
|
||||
|
|
@ -1221,17 +1091,18 @@ def install_timer(data):
|
|||
"",
|
||||
])
|
||||
|
||||
blocklist_script = SCRIPT_DIR / "dns-blocklists.py"
|
||||
service_content = "\n".join([
|
||||
"# Generated by core.py -- do not edit manually.",
|
||||
"",
|
||||
"[Unit]",
|
||||
"Description=core.py daily blocklist refresh",
|
||||
"Description=Daily blocklist refresh",
|
||||
"After=network-online.target",
|
||||
"Wants=network-online.target",
|
||||
"",
|
||||
"[Service]",
|
||||
"Type=oneshot",
|
||||
f"ExecStart=/usr/bin/python3 {script_path} --update-blocklists",
|
||||
f"ExecStart=/usr/bin/python3 {blocklist_script}",
|
||||
"",
|
||||
])
|
||||
|
||||
|
|
@ -1534,7 +1405,7 @@ def banned_ip_sets(data):
|
|||
# ===================================================================
|
||||
|
||||
def build_nft_config(data, dry_run=False):
|
||||
wan = data["general"]["wan_interface"]
|
||||
wan = data["network_interfaces"]["wan_interface"]
|
||||
# Exclude WG VLANs whose interface is not up -- nft rejects rules that
|
||||
# reference non-existent interfaces, which would leave no firewall at all.
|
||||
vlans = [v for v in data["vlans"]
|
||||
|
|
@ -2232,8 +2103,8 @@ def disable_avahi():
|
|||
|
||||
|
||||
def show_status(data):
|
||||
import status as _status
|
||||
_status.print_table(_status.run_and_write(data))
|
||||
import health as _health
|
||||
_health.print_table(_health.run_and_write(data))
|
||||
|
||||
def show_configs(data):
|
||||
for vlan in data["vlans"]:
|
||||
|
|
@ -2550,9 +2421,9 @@ def show_metrics(data):
|
|||
def stop_instances(data):
|
||||
"""Remove timers and stop all per-VLAN instances (config files preserved)."""
|
||||
_remove_timers(
|
||||
names=[BLIST_TIMER_NAME, DASHB_TIMER_NAME, STATUS_TIMER_NAME, DDNS_TIMER_NAME],
|
||||
timer_files=[BLIST_TIMER_FILE, DASHB_TIMER_FILE, STATUS_TIMER_FILE, DDNS_TIMER_FILE],
|
||||
svc_files=[BLIST_TIMER_SVC_FILE, DASHB_TIMER_SVC_FILE, STATUS_TIMER_SVC_FILE, DDNS_TIMER_SVC_FILE],
|
||||
names=[BLIST_TIMER_NAME, DASHB_TIMER_NAME, HEALTH_TIMER_NAME, DDNS_TIMER_NAME],
|
||||
timer_files=[BLIST_TIMER_FILE, DASHB_TIMER_FILE, HEALTH_TIMER_FILE, DDNS_TIMER_FILE],
|
||||
svc_files=[BLIST_TIMER_SVC_FILE, DASHB_TIMER_SVC_FILE, HEALTH_TIMER_SVC_FILE, DDNS_TIMER_SVC_FILE],
|
||||
daemon_reload=True,
|
||||
)
|
||||
print()
|
||||
|
|
@ -2746,7 +2617,7 @@ def _dry_run_conflicting_services(data):
|
|||
|
||||
def _dry_run_blocklists(data):
|
||||
print("Blocklists (dry-run) ================================================")
|
||||
for entry in data.get("blocklists", []):
|
||||
for entry in data.get("dns_blocking", {}).get("blocklists", []):
|
||||
print(f" Would download: {entry['description']}")
|
||||
print(f" URL: {entry['url']}")
|
||||
seen = {}
|
||||
|
|
@ -2763,7 +2634,7 @@ def _dry_run_blocklists(data):
|
|||
|
||||
def _dry_run_timer(data):
|
||||
print("Timer (dry-run) =====================================================")
|
||||
general = data.get("general", {})
|
||||
general = data.get("dns_blocking", {}).get("general", {})
|
||||
execute_time = general.get("daily_execute_time_24hr_local", "02:30")
|
||||
for path, label in [(BLIST_TIMER_FILE, "timer unit"), (BLIST_TIMER_SVC_FILE, "service unit")]:
|
||||
action = "update" if path.exists() else "create and enable"
|
||||
|
|
@ -3133,7 +3004,7 @@ def cmd_apply(data, dry_run=False):
|
|||
print("dnsmasq instances ===================================================")
|
||||
if not blocklists_available(data):
|
||||
print(" NOTE: No merged blocklist files found -- blocklist rules will be absent.")
|
||||
print(" Run --update-blocklists to download and merge blocklists.")
|
||||
print(" Run: sudo python3 dns-blocklists.py")
|
||||
apply_dnsmasq_instances(data, start_if_needed=True)
|
||||
print()
|
||||
|
||||
|
|
@ -3147,12 +3018,12 @@ def cmd_apply(data, dry_run=False):
|
|||
|
||||
print("Interval timers =====================================================")
|
||||
# build parallel lists; dashboard timer only installed when queue file exists
|
||||
t_names = [STATUS_TIMER_NAME]
|
||||
t_files = [STATUS_TIMER_FILE]
|
||||
s_files = [STATUS_TIMER_SVC_FILE]
|
||||
t_names = [HEALTH_TIMER_NAME]
|
||||
t_files = [HEALTH_TIMER_FILE]
|
||||
s_files = [HEALTH_TIMER_SVC_FILE]
|
||||
t_descs = ["Router status health check"]
|
||||
t_execs = [f"/usr/bin/python3 {SCRIPT_DIR / 'status.py'}"]
|
||||
t_intervals = [STATUS_TIMER_INTERVAL_SEC]
|
||||
t_execs = [f"/usr/bin/python3 {SCRIPT_DIR / 'health.py'}"]
|
||||
t_intervals = [HEALTH_TIMER_INTERVAL_SEC]
|
||||
if DASHB_QUEUE_FILE.exists():
|
||||
t_names += [DASHB_TIMER_NAME]
|
||||
t_files += [DASHB_TIMER_FILE]
|
||||
|
|
@ -3204,24 +3075,8 @@ def cmd_apply(data, dry_run=False):
|
|||
|
||||
print("Done.")
|
||||
|
||||
import status as _status
|
||||
_status.print_table(_status.run_and_write(data))
|
||||
|
||||
|
||||
def cmd_update_blocklists(data):
|
||||
"""--update-blocklists: download and merge blocklists. On success, call
|
||||
cmd_apply to reload dnsmasq instances with the new blocklists.
|
||||
"""
|
||||
check_root()
|
||||
print("Updating blocklists =================================================")
|
||||
success = update_blocklists(data)
|
||||
print()
|
||||
if success:
|
||||
print("Applying updated configs ============================================")
|
||||
cmd_apply(data)
|
||||
else:
|
||||
print("WARNING: Blocklist update had errors -- skipping --apply.")
|
||||
print(" Existing merged files (if any) are unchanged.")
|
||||
import health as _health
|
||||
_health.print_table(_health.run_and_write(data))
|
||||
|
||||
|
||||
def main():
|
||||
|
|
@ -3231,7 +3086,6 @@ def main():
|
|||
epilog=(
|
||||
"examples:\n"
|
||||
" sudo python3 core.py --apply Apply full config (idempotent, safe to re-run)\n"
|
||||
" sudo python3 core.py --update-blocklists Refresh blocklists and apply\n"
|
||||
" sudo python3 core.py --status Show service and timer status\n"
|
||||
" sudo python3 core.py --view-configs Show active per-VLAN dnsmasq config files\n"
|
||||
" sudo python3 core.py --view-leases Show active DHCP leases\n"
|
||||
|
|
@ -3245,9 +3099,8 @@ def main():
|
|||
" sudo python3 core.py --disable --dry-run\n"
|
||||
)
|
||||
)
|
||||
parser.add_argument("--apply", action="store_true", help="Apply full config: services, networkd, dnsmasq, nftables, timer, boot service")
|
||||
parser.add_argument("--update-blocklists", action="store_true", help="Refresh blocklists and apply (used by timer)")
|
||||
parser.add_argument("--dry-run", action="store_true", help="Preview all actions without making changes (combine with --apply or --disable)")
|
||||
parser.add_argument("--apply", action="store_true", help="Apply full config: services, networkd, dnsmasq, nftables, timer, boot service")
|
||||
parser.add_argument("--dry-run", action="store_true", help="Preview all actions without making changes (combine with --apply or --disable)")
|
||||
parser.add_argument("--status", action="store_true", help="Show service and timer status")
|
||||
parser.add_argument("--view-configs", action="store_true", help="Show active per-VLAN dnsmasq config files")
|
||||
parser.add_argument("--view-leases", action="store_true", help="Show active DHCP leases")
|
||||
|
|
@ -3260,9 +3113,7 @@ def main():
|
|||
|
||||
args = parser.parse_args()
|
||||
|
||||
update_blocklists_flag = getattr(args, "update_blocklists", False)
|
||||
|
||||
if not any([args.apply, update_blocklists_flag,
|
||||
if not any([args.apply,
|
||||
args.dry_run, args.status, args.view_configs, args.view_leases,
|
||||
args.view_rules, args.disable, args.view_metrics,
|
||||
args.reset_leases]):
|
||||
|
|
@ -3281,7 +3132,7 @@ def main():
|
|||
print(f" - {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
general = data.get("general", {})
|
||||
general = data.get("dns_blocking", {}).get("general", {})
|
||||
setup_logging(
|
||||
general.get("log_max_kb", 1024),
|
||||
general.get("log_errors_only", False)
|
||||
|
|
@ -3318,10 +3169,6 @@ def main():
|
|||
cmd_disable(data, dry_run=args.dry_run)
|
||||
return
|
||||
|
||||
if update_blocklists_flag:
|
||||
cmd_update_blocklists(data)
|
||||
return
|
||||
|
||||
if args.apply:
|
||||
cmd_apply(data, dry_run=args.dry_run)
|
||||
return
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue