From: Thomas Walker Lynch Date: Tue, 16 Sep 2025 09:55:46 +0000 (-0700) Subject: intermediate, still working on DNS X-Git-Url: https://git.reasoningtechnology.com/style/static/gitweb.js?a=commitdiff_plain;h=650a935d4ea62ea187152c739935eda626af97af;p=subu intermediate, still working on DNS --- diff --git a/developer/source/DNS/.gitignore b/developer/source/DNS/.gitignore new file mode 100644 index 0000000..8ddaf79 --- /dev/null +++ b/developer/source/DNS/.gitignore @@ -0,0 +1,6 @@ + +__pycache__ +stage/ +deprecated/ + + diff --git a/developer/source/DNS/deploy.py b/developer/source/DNS/deploy.py index dbc3fd4..5f12397 100755 --- a/developer/source/DNS/deploy.py +++ b/developer/source/DNS/deploy.py @@ -1,104 +1,81 @@ #!/usr/bin/env python3 """ -deploy_DNS.py — Deploy staged DNS bundle (Unbound per-subu + nft redirect/egress) -RT-v2025.09.15.2 - -Model: - - nftables is the single authority for firewall/NAT. - - /etc/nftables.conf includes all /etc/nftables.d/*.nft drop-ins. - - Unbound runs as instances (unbound@US, unbound@x6), each bound to 127.0.0.1:53xx. - - No separate DNS-redirect.service unit. - -Given: - - stage/ contains: - * etc/unbound/unbound-.conf (e.g. unbound-US.conf, unbound-x6.conf) - * etc/systemd/system/unbound@.service (template, if you ship one) - * etc/nftables.d/20-SUBU-ports.nft (your combined redirect + egress rules) - - install_staged_tree.py importable (same dir or PYTHONPATH). - - Instances list via --instances (default: US x6). - -Does: - - Validates root and presence of stage/. - - Installs staged tree with backups (via install_staged_tree.install_staged_tree()). - - Ensures /etc/nftables.conf has a single include line for /etc/nftables.d/*.nft - (adds it if missing; keeps any existing content, does NOT add `flush ruleset` here). - - Disables/removes any legacy DNS-redirect.service if present. - - Enables and reloads nftables.service. - - Enables/starts unbound@.service for each instance. - - Prints concise logs with stage:/… relative paths. - -Exit: - - 0 on success; 2 on preflight/deploy errors. +deploy.py — Deploy staged DNS bundle (Unbound per-subu + nft redirect) +RT-v2025.09.15.4 + +What it does + - Installs the staged tree under ./stage into / + - systemctl daemon-reload + - nft -f /etc/nftables.conf (relies on: include "/etc/nftables.d/*.nft") + - enable + restart unbound@ for each instance (default: US x6) + +Assumptions + - This file lives next to install_staged_tree.py + - Stage contains: + stage/etc/nftables.d/10-block-IPv6.nft + stage/etc/nftables.d/20-SUBU-ports.nft + stage/etc/systemd/system/unbound@.service + stage/etc/unbound/unbound-US.conf (127.0.0.1@5301) + stage/etc/unbound/unbound-x6.conf (127.0.0.1@5302) + - /etc/nftables.conf has: include "/etc/nftables.d/*.nft" + +Exit codes + 0 = success, 2 = preflight/deploy error """ - from __future__ import annotations from pathlib import Path -import argparse, importlib, os, sys, subprocess, shutil +import argparse +import importlib +import os +import subprocess +import sys ROOT = Path(__file__).resolve().parent STAGE = ROOT / "stage" -NFT_CONF = Path("/etc/nftables.conf") -NFT_INCLUDE_LINE = 'include "/etc/nftables.d/*.nft"' - -def _short(p: Path) -> str: - try: - return "stage:/" + str(p.relative_to(STAGE)).replace("\\", "/") - except Exception: - return str(p) def _run(cmd: list[str]) -> tuple[int, str, str]: cp = subprocess.run(cmd, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) return (cp.returncode, cp.stdout.strip(), cp.stderr.strip()) -def _require_root_and_stage() -> None: +def _preflight_errors() -> list[str]: errs = [] if os.geteuid() != 0: errs.append("must be run as root (sudo)") if not STAGE.exists(): errs.append(f"stage dir missing: {STAGE}") - if errs: - raise RuntimeError("; ".join(errs)) - -def _ensure_nft_include_line(logs: list[str]) -> None: - # Make sure /etc/nftables.conf includes our drop-ins exactly once. - # We do NOT force 'flush ruleset' here—your .nft files should be self-contained. - if not NFT_CONF.exists(): - raise RuntimeError(f"{NFT_CONF} not found (install nftables or create a minimal config)") - - text = NFT_CONF.read_text() - if NFT_INCLUDE_LINE in text: - logs.append(f"nftables: include already present in {NFT_CONF}") - return - - # Append the include at the end with a preceding newline if needed. - sep = "" if text.endswith("\n") else "\n" - NFT_CONF.write_text(text + f"{sep}{NFT_INCLUDE_LINE}\n") - logs.append(f"nftables: appended include to {NFT_CONF}") - -def _retire_legacy_unit(unit: str, logs: list[str]) -> None: - # Best-effort: disable and remove old per-feature unit if present. - rc, _, _ = _run(["systemctl", "is-enabled", unit]) - if rc == 0: - _run(["systemctl", "disable", "--now", unit]) - logs.append(f"retired: {unit} (disabled + stopped)") - unit_path = Path("/etc/systemd/system") / unit - if unit_path.exists(): - try: - unit_path.unlink() - logs.append(f"removed: {unit_path}") - except Exception as e: - logs.append(f"warn: could not remove {unit_path}: {e}") - -def deploy(instances: list[str]) -> list[str]: - logs: list[str] = [] - - # 1) Import installer + return errs + +def _install_stage(stage_root: Path) -> list[str]: + """ + Call install_staged_tree.install_staged_tree(stage_root=..., dest_root=/, create_dirs=True) + and return its log lines. + """ + sys.path.insert(0, str(ROOT)) try: ist = importlib.import_module("install_staged_tree") except Exception as e: raise RuntimeError(f"failed to import install_staged_tree: {e}") - # 2) Plan + # Expect signature: (stage_root, dest_root, create_dirs=False, skip_identical=True) -> (logs, ifaces) + try: + logs, _ifaces = ist.install_staged_tree( + stage_root=stage_root, + dest_root=Path("/"), + create_dirs=True, + skip_identical=True, + ) + except TypeError as te: + # Fallback for older two-arg signature: install_staged_tree(stage_root, dest_root) + try: + logs, _ifaces = ist.install_staged_tree(stage_root, Path("/")) + except Exception as e2: + raise RuntimeError(f"install_staged_tree() call failed: {e2}") from te + return logs + +def deploy(instances: list[str]) -> list[str]: + logs: list[str] = [] + + # Plan logs.append("Deploy DNS plan:") logs.append(f" instances: {', '.join(instances)}") logs.append(f" stage: {STAGE}") @@ -106,61 +83,57 @@ def deploy(instances: list[str]) -> list[str]: logs.append("") logs.append("Installing staged artifacts…") - # 3) Install staged tree - results = ist.install_staged_tree(STAGE, dry_run=False) - for item in results: - try: - action, backup_or_src, dst = item - except Exception: - logs.append(str(item)) - continue - if action == "backup": - logs.append(f"backup: {dst} -> {backup_or_src}") - elif action == "install": - logs.append(f"install: {_short(Path(backup_or_src))} -> {dst}") - elif action == "identical": - logs.append(f"identical: skip {_short(Path(backup_or_src))}") - else: - logs.append(f"{action}: {backup_or_src} -> {dst}") - - # 4) Systemd reload (units may have been installed) - _run(["systemctl", "daemon-reload"]) - - # 5) Retire any old per-feature unit - _retire_legacy_unit("DNS-redirect.service", logs) + # Install staged files + install_logs = _install_stage(STAGE) + logs.extend(install_logs) - # 6) Ensure nftables includes drop-ins - _ensure_nft_include_line(logs) + # Reload systemd units (for unbound@.service changes) + _run(["systemctl", "daemon-reload"]) - # 7) Enable + reload nftables to pick up new rules - _run(["systemctl", "enable", "--now", "nftables"]) - rc, _, _ = _run(["nft", "-c", "-f", str(NFT_CONF)]) + # Apply nftables from the main config (which includes drop-ins) + rc, out, err = _run(["/usr/sbin/nft", "-f", "/etc/nftables.conf"]) if rc != 0: - raise RuntimeError(f"nftables config check failed for {NFT_CONF}") - _run(["systemctl", "reload", "nftables"]) - rc, out, _ = _run(["systemctl", "is-active", "nftables"]) - logs.append(f"nftables.service: {'active' if rc==0 else 'inactive'}") - - # 8) Unbound instances + raise RuntimeError(f"nftables apply failed:\n{err or out}") + + # Sanity: verify our tables are present + rc2, out2, err2 = _run(["/usr/sbin/nft", "list", "tables"]) + if rc2 != 0: + raise RuntimeError(f"nftables list tables failed:\n{err2 or out2}") + + required = {"inet NO-IPV6", "inet SUBU-DNS-REDIRECT", "inet SUBU-PORT-EGRESS"} + present = set() + for line in out2.splitlines(): + parts = line.strip().split() + # lines look like: "table inet FOO" + if len(parts) == 3 and parts[0] == "table": + present.add(f"{parts[1]} {parts[2]}") + missing = required - present + if missing: + raise RuntimeError(f"nftables missing tables: {', '.join(sorted(missing))}") + + # Enable + restart unbound instances for inst in instances: unit = f"unbound@{inst}.service" - _run(["systemctl", "enable", "--now", unit]) - rc, _, _ = _run(["systemctl", "is-active", unit]) - logs.append(f"{unit}: {'active' if rc==0 else 'inactive'}") + _run(["systemctl", "enable", unit]) + _run(["systemctl", "restart", unit]) + rcA, _, _ = _run(["systemctl", "is-active", unit]) + logs.append(f"{unit}: {'active' if rcA == 0 else 'inactive'}") logs.append("") logs.append("✓ DNS deploy complete.") return logs def main(argv=None) -> int: - ap = argparse.ArgumentParser(description="Deploy staged DNS (Unbound per-subu + nft redirect/egress).") - ap.add_argument("--instances", nargs="+", default=["US", "x6"], help="Unbound instances to enable (default: US x6)") + ap = argparse.ArgumentParser(description="Deploy staged DNS (Unbound per-subu + nft redirect).") + ap.add_argument("--instances", nargs="+", default=["US", "x6"], + help="Unbound instances to enable (default: US x6)") args = ap.parse_args(argv) - try: - _require_root_and_stage() - except Exception as e: - print(f"❌ deploy preflight found issue(s):\n - {e}", file=sys.stderr) + errs = _preflight_errors() + if errs: + print("❌ deploy preflight found issue(s):", file=sys.stderr) + for e in errs: + print(f" - {e}", file=sys.stderr) return 2 try: diff --git a/developer/source/DNS/install_staged_tree.py b/developer/source/DNS/install_staged_tree.py index e1225d5..7c1786f 100755 --- a/developer/source/DNS/install_staged_tree.py +++ b/developer/source/DNS/install_staged_tree.py @@ -1,32 +1,26 @@ #!/usr/bin/env python3 """ install_staged_tree.py +RT-v2025.09.15.2 A dumb installer: copy staged files into the target root with backups and deterministic permissions. No systemd stop/start, no daemon-reload. -Given: - - A staged tree (default: ./stage) containing any of: - /usr/local/bin/apply_ip_state.sh - /etc/wireguard/*.conf - /etc/systemd/system/wg-quick@IFACE.service.d/*.conf - /etc/iproute2/rt_tables - - A destination root (default: /). Parent dirs may be created with --create-dirs. - -Does: - - For each whitelisted staged file: - * if a target already exists, copy it back into the stage as a timestamped backup - * atomically replace target with staged version - * set root:root ownership (best-effort) and explicit permissions - - Prints a summary and suggests next steps (e.g., ./start_iface.py ) - -Returns: - - Exit 0 on success; non-zero on error +- Extended whitelist to include DNS bundle assets: + * /etc/unbound/*.conf -> 0644 + * /etc/nftables.d/*.nft -> 0644 + * /usr/local/sbin/* -> 0500 + * /etc/systemd/system/*.service -> 0644 +- Keeps existing WireGuard/iproute2 handling. +- API unchanged: + install_staged_tree(stage_root: Path, dest_root: Path, + create_dirs=False, skip_identical=True) + -> returns (logs: list[str], detected_ifaces: list[str]) """ from __future__ import annotations from pathlib import Path -from typing import Dict, Iterable, List, Optional, Sequence, Tuple +from typing import List, Optional, Sequence, Tuple import argparse import datetime as dt import hashlib @@ -37,15 +31,6 @@ import sys ROOT = Path(__file__).resolve().parent DEFAULT_STAGE = ROOT / "stage" -# Whitelisted install targets → mode -# (These are *relative* to the stage root) -MODE_RULES: List[Tuple[str, int]] = [ - ("usr/local/bin", 0o500), # files under here (scripts) - ("etc/wireguard", 0o600), # *.conf - ("etc/systemd/system", 0o644), # wg-quick@*.service.d/*.conf - ("etc/iproute2", 0o644), # rt_tables -] - def _sha256(path: Path) -> str: h = hashlib.sha256() with path.open("rb") as f: @@ -77,7 +62,7 @@ def _atomic_install(src: Path, dst: Path, mode: int) -> None: shutil.copyfile(src, tmp) os.chmod(tmp, mode) try: - os.chown(tmp, 0, 0) # best-effort; may fail if not root + os.chown(tmp, 0, 0) # best-effort except PermissionError: pass os.replace(tmp, dst) @@ -85,6 +70,8 @@ def _atomic_install(src: Path, dst: Path, mode: int) -> None: def _mode_for_rel(rel: Path) -> Optional[int]: """Choose a mode based on the relative path bucket.""" s = str(rel) + + # Existing buckets if s.startswith("usr/local/bin/"): return 0o500 if s.startswith("etc/wireguard/") and rel.suffix == ".conf": @@ -93,6 +80,17 @@ def _mode_for_rel(rel: Path) -> Optional[int]: return 0o644 if s.startswith("etc/systemd/system/") and s.endswith(".conf"): return 0o644 + + # NEW: DNS bundle buckets + if s.startswith("usr/local/sbin/"): + return 0o500 + if s.startswith("etc/unbound/") and s.endswith(".conf"): + return 0o644 + if s.startswith("etc/nftables.d/") and s.endswith(".nft"): + return 0o644 + if s.startswith("etc/systemd/system/") and s.endswith(".service"): + return 0o644 + return None def _iter_stage_targets(stage_root: Path) -> List[Path]: @@ -106,6 +104,13 @@ def _iter_stage_targets(stage_root: Path) -> List[Path]: if p.is_file(): rels.append(p.relative_to(stage_root)) + # NEW: /usr/local/sbin/* + sbin_dir = stage_root / "usr" / "local" / "sbin" + if sbin_dir.is_dir(): + for p in sorted(sbin_dir.glob("*")): + if p.is_file(): + rels.append(p.relative_to(stage_root)) + # /etc/wireguard/*.conf wg_dir = stage_root / "etc" / "wireguard" if wg_dir.is_dir(): @@ -118,11 +123,29 @@ def _iter_stage_targets(stage_root: Path) -> List[Path]: for p in sorted(sysd_dir.rglob("wg-quick@*.service.d/*.conf")): rels.append(p.relative_to(stage_root)) + # NEW: /etc/systemd/system/*.service + if sysd_dir.is_dir(): + for p in sorted(sysd_dir.glob("*.service")): + if p.is_file(): + rels.append(p.relative_to(stage_root)) + # /etc/iproute2/rt_tables rt = stage_root / "etc" / "iproute2" / "rt_tables" if rt.is_file(): rels.append(rt.relative_to(stage_root)) + # NEW: /etc/unbound/*.conf + ub_dir = stage_root / "etc" / "unbound" + if ub_dir.is_dir(): + for p in sorted(ub_dir.glob("*.conf")): + rels.append(p.relative_to(stage_root)) + + # NEW: /etc/nftables.d/*.nft + nft_dir = stage_root / "etc" / "nftables.d" + if nft_dir.is_dir(): + for p in sorted(nft_dir.glob("*.nft")): + rels.append(p.relative_to(stage_root)) + return rels def _discover_ifaces_from_stage(stage_root: Path) -> List[str]: @@ -140,7 +163,6 @@ def _discover_ifaces_from_stage(stage_root: Path) -> List[str]: if sysd.is_dir(): for d in sysd.glob("wg-quick@*.service.d"): name = d.name - # name looks like: wg-quick@X.service.d at = name.find("@") dot = name.find(".service.d") if at != -1 and dot != -1 and dot > at: @@ -220,21 +242,16 @@ def main(argv: Optional[Sequence[str]] = None) -> int: for line in logs: print(line) - # Summary + suggested next steps print("\n=== Summary ===") print(f"Installed {sum(1 for l in logs if l.startswith('install:'))} file(s).") if ifaces: lst = " ".join(ifaces) print(f"Detected interfaces from stage: {lst}") - print(f"\nNext steps:") - print(f" # (optional) verify configs") - print(f" sudo wg-quick strip /etc/wireguard/{ifaces[0]}.conf >/dev/null 2>&1 || true") - print(f"\n # start interfaces") + print("\nNext steps:") print(f" sudo ./start_iface.py {lst}") else: print("No interfaces detected in staged artifacts.") print("\nNext steps:") - print(" # start your interface(s)") print(" sudo ./start_iface.py [more ifaces]") return 0 except Exception as e: diff --git a/developer/source/DNS/stage/etc/nftables.d/10-block-IPv6.nft b/developer/source/DNS/stage/etc/nftables.d/10-block-IPv6.nft deleted file mode 100644 index 1316d40..0000000 --- a/developer/source/DNS/stage/etc/nftables.d/10-block-IPv6.nft +++ /dev/null @@ -1,6 +0,0 @@ - -table inet BLOCK-IPv6 { - chain input { type filter hook input priority raw; policy accept; meta nfproto ipv6 drop counter comment "drop all IPv6 inbound" } - chain output { type filter hook output priority raw; policy accept; meta nfproto ipv6 drop counter comment "drop all IPv6 outbound" } - chain forward { type filter hook forward priority raw; policy accept; meta nfproto ipv6 drop counter comment "drop all IPv6 forward" } -} diff --git a/developer/source/DNS/stage/etc/nftables.d/20-SUBU-ports.nft b/developer/source/DNS/stage/etc/nftables.d/20-SUBU-ports.nft deleted file mode 100644 index 369aaf2..0000000 --- a/developer/source/DNS/stage/etc/nftables.d/20-SUBU-ports.nft +++ /dev/null @@ -1,52 +0,0 @@ -table inet SUBU-DNS-REDIRECT { - chain output { - type nat hook output priority -100; policy accept; - - # Redirect all DNS from the two subu UIDs to local Unbound instances - # This catches “to any dst:53” and loops it to 127.0.0.1: - meta skuid 2017 udp dport 53 redirect to :5301 counter comment "US → 127.0.0.1:5301" - meta skuid 2018 udp dport 53 redirect to :5302 counter comment "x6 → 127.0.0.1:5302" - meta skuid 2017 tcp dport 53 redirect to :5301 counter comment "US → 127.0.0.1:5301" - meta skuid 2018 tcp dport 53 redirect to :5302 counter comment "x6 → 127.0.0.1:5302" - } -} - -table inet SUBU-PORT-EGRESS { - chain output { - type filter hook output priority 0; policy accept; - - # Always allow loopback - iifname "lo" accept - - # No IPv6 for subu for now - meta skuid {2017,2018} meta nfproto ipv6 drop counter comment "no IPv6 for subu" - - ######## x6 (UID 2018) ######## - # Block specific exfil channels even if via x6 (put BEFORE accept) - meta skuid 2018 tcp dport {25,465,587} drop counter comment "block SMTP/Submission" - meta skuid 2018 udp dport {3478,5349,19302-19309} drop counter comment "block STUN/TURN" - meta skuid 2018 tcp dport 853 drop counter comment "block DoT (TCP/853)" - - # (Optional) allow ICMP echo out via x6 for ping/traceroute/PMTU - meta skuid 2018 oifname "x6" ip protocol icmp icmp type echo-request accept - - # Enforce interface binding for all remaining traffic - meta skuid 2018 oifname "x6" accept - meta skuid 2018 oifname != "x6" drop counter comment "x6 must use wg x6" - - ######## US (UID 2017) ######## - meta skuid 2017 tcp dport {25,465,587} drop counter comment "block SMTP/Submission" - meta skuid 2017 udp dport {3478,5349,19302-19309} drop counter comment "block STUN/TURN" - meta skuid 2017 tcp dport 853 drop counter comment "block DoT (TCP/853)" - - # (Optional) ICMP via US - meta skuid 2017 oifname "US" ip protocol icmp icmp type echo-request accept - - meta skuid 2017 oifname "US" accept - meta skuid 2017 oifname != "US" drop counter comment "US must use wg US" - - # NOTE: DoH (HTTPS/443) and DoQ (QUIC/UDP/443 or 784) are indistinguishable - # from normal web traffic by port alone. Catching them requires a TLS/HTTP proxy, - # SNI/ALPN-aware DPI, or blocking known DoH endpoints via IP lists. - } -}