Development

This commit is contained in:
Matthew Grotke 2026-05-25 01:04:47 -04:00
parent a4652866c3
commit 27eaea3d73
19 changed files with 602 additions and 427 deletions

View file

@ -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