subu port rules are in place
authorThomas Walker Lynch <eknp9n@reasoningtechnology.com>
Mon, 15 Sep 2025 15:04:03 +0000 (08:04 -0700)
committerThomas Walker Lynch <eknp9n@reasoningtechnology.com>
Mon, 15 Sep 2025 15:04:03 +0000 (08:04 -0700)
developer/source/DNS/deploy.py [new file with mode: 0755]
developer/source/DNS/deploy_DNS.py [deleted file]
developer/source/DNS/doc_howto_install.org [new file with mode: 0644]
developer/source/DNS/stage/etc/nftables.d/10-block-IPv6.nft [new file with mode: 0644]
developer/source/DNS/stage/etc/nftables.d/20-SUBU-ports.nft [new file with mode: 0644]
developer/source/DNS/stage/etc/nftables.d/DNS-redirect.nft [deleted file]
developer/source/DNS/stage/etc/systemd/system/DNS-redirect.service [deleted file]

diff --git a/developer/source/DNS/deploy.py b/developer/source/DNS/deploy.py
new file mode 100755 (executable)
index 0000000..dbc3fd4
--- /dev/null
@@ -0,0 +1,175 @@
+#!/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.
+"""
+
+from __future__ import annotations
+from pathlib import Path
+import argparse, importlib, os, sys, subprocess, shutil
+
+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:
+  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
+  try:
+    ist = importlib.import_module("install_staged_tree")
+  except Exception as e:
+    raise RuntimeError(f"failed to import install_staged_tree: {e}")
+
+  # 2) Plan
+  logs.append("Deploy DNS plan:")
+  logs.append(f"  instances: {', '.join(instances)}")
+  logs.append(f"  stage: {STAGE}")
+  logs.append(f"  root:  /")
+  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)
+
+  # 6) Ensure nftables includes drop-ins
+  _ensure_nft_include_line(logs)
+
+  # 7) Enable + reload nftables to pick up new rules
+  _run(["systemctl", "enable", "--now", "nftables"])
+  rc, _, _ = _run(["nft", "-c", "-f", str(NFT_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
+  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'}")
+
+  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)")
+  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)
+    return 2
+
+  try:
+    logs = deploy(args.instances)
+    print("\n".join(logs))
+    return 0
+  except Exception as e:
+    print(f"❌ deploy failed: {e}", file=sys.stderr)
+    return 2
+
+if __name__ == "__main__":
+  sys.exit(main())
diff --git a/developer/source/DNS/deploy_DNS.py b/developer/source/DNS/deploy_DNS.py
deleted file mode 100755 (executable)
index 01519d9..0000000
+++ /dev/null
@@ -1,122 +0,0 @@
-#!/usr/bin/env python3
-"""
-deploy_DNS.py — Deploy staged DNS bundle (Unbound per-subu + nft redirect)
-RT-v2025.09.15.1
-
-Given:
-  - A staged tree under ./stage with:
-      * etc/unbound/unbound-{US,x6}.conf
-      * etc/systemd/system/unbound@.service
-      * etc/systemd/system/DNS-redirect.service
-      * etc/nftables.d/DNS-redirect.nft
-  - install_staged_tree.py available in PYTHONPATH or alongside this script.
-  - Instance names provided via --instances (default: US x6).
-
-Does:
-  - Validates root, prints a plan with short 'stage:/' paths.
-  - Installs staged files into / (preserving backups) via install_staged_tree.install_staged_tree().
-  - systemctl daemon-reload
-  - Enables & starts: DNS-redirect.service, unbound@<instance>.service for each instance.
-
-Returns:
-  - Exit 0 on success, 2 on errors.
-"""
-
-from __future__ import annotations
-from pathlib import Path
-import argparse, importlib, os, sys, subprocess
-
-ROOT = Path(__file__).resolve().parent
-STAGE = ROOT / "stage"
-
-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() -> None:
-  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 deploy(instances: list[str]) -> list[str]:
-  logs: list[str] = []
-  # Import installer
-  try:
-    ist = importlib.import_module("install_staged_tree")
-  except Exception as e:
-    raise RuntimeError(f"failed to import install_staged_tree: {e}")
-
-  # Plan
-  logs.append("Deploy DNS plan:")
-  logs.append(f"  instances: {', '.join(instances)}")
-  logs.append(f"  stage: {STAGE}")
-  logs.append(f"  root:  /")
-  logs.append("")
-  logs.append("Installing staged artifacts…")
-
-  # Install
-  paths = ist.install_staged_tree(STAGE, dry_run=False)  # expects function signature from your project
-  for item in paths:
-    try:
-      action, src, dst = item
-    except Exception:
-      logs.append(str(item))
-      continue
-    if action == "backup":
-      logs.append(f"backup: {dst} -> {src}")
-    elif action == "install":
-      logs.append(f"install: stage:/{Path(src).relative_to(STAGE)} -> {dst}")
-    elif action == "identical":
-      logs.append(f"identical: skip stage:/{Path(src).relative_to(STAGE)}")
-    else:
-      logs.append(f"{action}: {src} -> {dst}")
-
-  # Reload and (enable|start) units
-  _run(["systemctl","daemon-reload"])
-
-  # DNS redirect service
-  _run(["systemctl","enable","--now","DNS-redirect.service"])
-  rc, out, err = _run(["systemctl","is-active","DNS-redirect.service"])
-  logs.append(f"DNS-redirect.service: {'active' if rc==0 else 'inactive'}")
-
-  # Unbound instances
-  for inst in instances:
-    unit = f"unbound@{inst}.service"
-    _run(["systemctl","enable","--now", unit])
-    rc, out, err = _run(["systemctl","is-active", unit])
-    logs.append(f"{unit}: {'active' if rc==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).")
-  ap.add_argument("--instances", nargs="+", default=["US","x6"], help="Unbound instances to enable (default: US x6)")
-  args = ap.parse_args(argv)
-
-  try:
-    _require_root()
-  except Exception as e:
-    print(f"❌ deploy preflight found issue(s):\n  - {e}", file=sys.stderr)
-    return 2
-
-  try:
-    logs = deploy(args.instances)
-    print("\n".join(logs))
-    return 0
-  except Exception as e:
-    print(f"❌ deploy failed: {e}", file=sys.stderr)
-    return 2
-
-if __name__ == "__main__":
-  sys.exit(main())
diff --git a/developer/source/DNS/doc_howto_install.org b/developer/source/DNS/doc_howto_install.org
new file mode 100644 (file)
index 0000000..68476a3
--- /dev/null
@@ -0,0 +1,32 @@
+
+* 1. modify stage files
+
+  The stage/ directory  holds bespoke configuration files for host StanleyPark's configuration.
+
+  Copy/Modify the sraged files for your site.
+
+  Work on the stage is done in user space.  The program `sudo install_staged_tree.py` copies the files on the stage into the root file system, `/', or optionally to another specified directory target. However, normally one will run `deploy.py` to do the install and to make the systemctl calls to restart services.
+
+* 2. edit /etc/nftables.conf
+
+  requires root priv
+
+  Strange, but Debian 12 nftables does not automatically include the scripts in its drop-in directory, so ..
+
+  add this at the bottom of /etc/nftables.conf
+
+      flush ruleset
+      include "/etc/nftables.d/*.nft"
+
+* 3. run `deploy.py`
+
+  requires root priv
+
+* 4. check
+
+  requires root priv
+
+  nft list ruleset | sed -n '/SUBU-/,/}/p'
+  systemctl status nftables
+  ss -ltnup 'sport = :5301' 'sport = :5302'   # your Unbound listeners
+
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
new file mode 100644 (file)
index 0000000..1316d40
--- /dev/null
@@ -0,0 +1,6 @@
+
+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
new file mode 100644 (file)
index 0000000..369aaf2
--- /dev/null
@@ -0,0 +1,52 @@
+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.
+  }
+}
diff --git a/developer/source/DNS/stage/etc/nftables.d/DNS-redirect.nft b/developer/source/DNS/stage/etc/nftables.d/DNS-redirect.nft
deleted file mode 100644 (file)
index c044500..0000000
+++ /dev/null
@@ -1,20 +0,0 @@
-#!/usr/sbin/nft -f
-# DNS redirect for subu UIDs -> local Unbound instance ports
-# - US (UID 2017) -> 127.0.0.1:5301
-# - x6 (UID 2018) -> 127.0.0.1:5302
-# Adjust UIDs and ports as needed.
-
-flush table inet NAT-DNS-REDIRECT
-table inet NAT-DNS-REDIRECT {
-  chain output {
-    type nat hook output priority -100; policy accept;
-
-    # UDP DNS
-    meta skuid 2017 udp dport 53 redirect to :5301
-    meta skuid 2018 udp dport 53 redirect to :5302
-
-    # TCP DNS
-    meta skuid 2017 tcp dport 53 redirect to :5301
-    meta skuid 2018 tcp dport 53 redirect to :5302
-  }
-}
diff --git a/developer/source/DNS/stage/etc/systemd/system/DNS-redirect.service b/developer/source/DNS/stage/etc/systemd/system/DNS-redirect.service
deleted file mode 100644 (file)
index 304b0d9..0000000
+++ /dev/null
@@ -1,16 +0,0 @@
-[Unit]
-Description=DNS Redirect (nftables) — redirect per-subu DNS to local Unbound ports
-After=network-online.target
-Wants=network-online.target
-
-[Service]
-Type=oneshot
-RemainAfterExit=yes
-ExecStart=/usr/sbin/nft -f /etc/nftables.d/DNS-redirect.nft
-ExecReload=/usr/sbin/nft -f /etc/nftables.d/DNS-redirect.nft
-ExecStop=/usr/sbin/nft flush table inet NAT-DNS-REDIRECT
-User=root
-Group=root
-
-[Install]
-WantedBy=multi-user.target