#!/usr/bin/env python3 """ dns-blocklists.py -- Download and merge DNS blocklists defined in core.json. Reads the blocklists library from core.json, downloads every blocklist referenced by at least one VLAN, merges them into per-combo conf files (one per unique combination of blocklist names), then sends SIGHUP to each running dnsmasq instance so it reloads its config without restarting. Usage: sudo python3 dns-blocklists.py """ import hashlib import json import logging import os import subprocess import sys import urllib.request import urllib.error from pathlib import Path PRODUCT_NAME = "routlin" SCRIPT_DIR = Path(__file__).parent CONFIG_FILE = SCRIPT_DIR / "core.json" BLOCKLIST_DIR = SCRIPT_DIR / "blocklists" LOG_FILE = SCRIPT_DIR / "dns-blocklists.log" log = None def _chown_to_script_dir_owner(path): try: stat = SCRIPT_DIR.stat() os.chown(path, stat.st_uid, stat.st_gid) except Exception: pass def setup_logging(max_kb, errors_only): global log try: if LOG_FILE.exists() and LOG_FILE.stat().st_size > max_kb * 1024: LOG_FILE.write_text("") if not LOG_FILE.exists(): LOG_FILE.touch() _chown_to_script_dir_owner(LOG_FILE) file_handler = logging.FileHandler(LOG_FILE) except PermissionError: print(f"WARNING: Cannot write to {LOG_FILE} (permission denied). " f"Run with sudo or fix ownership: sudo chown $USER {LOG_FILE}") file_handler = None level = logging.ERROR if errors_only else logging.INFO handlers = [logging.StreamHandler(sys.stdout)] if file_handler: handlers.insert(0, file_handler) logging.basicConfig( level=level, format="%(asctime)s %(levelname)-8s %(message)s", datefmt="%Y-%m-%d %H:%M:%S", handlers=handlers, ) log = logging.getLogger("dns-blocklists") def die(msg): print(f"ERROR: {msg}", file=sys.stderr) sys.exit(1) def check_root(): if os.geteuid() != 0: die("This script must be run as root (sudo).") def load_config(): if not CONFIG_FILE.exists(): die(f"Config file not found: {CONFIG_FILE}") with open(CONFIG_FILE) as f: data = json.load(f) if not data.get("vlans"): die("No vlans defined in core.json.") return data def combo_hash(names): key = ",".join(sorted(names)) return hashlib.sha256(key.encode()).hexdigest()[:8] def merged_path(h): return BLOCKLIST_DIR / f"merged-{h}.conf" 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): lines = [ "# Generated by dns-blocklists.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): bl_library = {bl["name"]: bl for bl in data.get("dns_blocking", {}).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-blocklists.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): BLOCKLIST_DIR.mkdir(exist_ok=True) log.info("Downloading blocklists...") downloaded = download_all_blocklists(data) 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 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." ) 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}") any_failed = any(content is None for content, _ in downloaded.values()) return not any_failed def reload_dnsmasq_instances(): """Send SIGHUP to every active dnsmasq-routlin-* instance so it reloads its conf-file inclusions without restarting. No DNS or DHCP interruption.""" result = subprocess.run( ["systemctl", "list-units", "--state=active", "--no-legend", "--plain", f"dnsmasq-{PRODUCT_NAME}-*.service"], capture_output=True, text=True, ) units = [line.split()[0] for line in result.stdout.splitlines() if line.strip()] if not units: print(" No active dnsmasq instances found.") return for unit in units: r = subprocess.run(["systemctl", "kill", "--signal=SIGHUP", unit], capture_output=True, text=True) if r.returncode == 0: print(f" Reloaded: {unit}") else: print(f" WARNING: Failed to reload {unit}: {r.stderr.strip()}") def main(): check_root() data = load_config() general = data.get("dns_blocking", {}).get("general", {}) setup_logging( general.get("log_max_kb", 1024), general.get("log_errors_only", False), ) print("Updating blocklists =================================================") success = update_blocklists(data) print() if success: print("Reloading dnsmasq instances =========================================") reload_dnsmasq_instances() else: print("WARNING: Blocklist update had errors -- skipping reload.") print(" Existing merged files (if any) are unchanged.") if __name__ == "__main__": main()