intermediate, still working on DNS
authorThomas Walker Lynch <eknp9n@reasoningtechnology.com>
Tue, 16 Sep 2025 09:55:46 +0000 (02:55 -0700)
committerThomas Walker Lynch <eknp9n@reasoningtechnology.com>
Tue, 16 Sep 2025 09:55:46 +0000 (02:55 -0700)
developer/source/DNS/.gitignore [new file with mode: 0644]
developer/source/DNS/deploy.py
developer/source/DNS/install_staged_tree.py
developer/source/DNS/stage/etc/nftables.d/10-block-IPv6.nft [deleted file]
developer/source/DNS/stage/etc/nftables.d/20-SUBU-ports.nft [deleted file]

diff --git a/developer/source/DNS/.gitignore b/developer/source/DNS/.gitignore
new file mode 100644 (file)
index 0000000..8ddaf79
--- /dev/null
@@ -0,0 +1,6 @@
+
+__pycache__
+stage/
+deprecated/
+
+
index dbc3fd4..5f12397 100755 (executable)
 #!/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-<inst>.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@<instance>.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@<instance> 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:
index e1225d5..7c1786f 100755 (executable)
@@ -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 <ifaces>)
-
-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 <iface> [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 (file)
index 1316d40..0000000
+++ /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 (file)
index 369aaf2..0000000
+++ /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:<port>
-    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.
-  }
-}