Development
This commit is contained in:
parent
a4652866c3
commit
27eaea3d73
19 changed files with 602 additions and 427 deletions
258
routlin/dns-blocklists.py
Normal file
258
routlin/dns-blocklists.py
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
#!/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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue