--- /dev/null
+#!/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())
+++ /dev/null
-#!/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())
--- /dev/null
+
+* 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
+
--- /dev/null
+
+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" }
+}
--- /dev/null
+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.
+ }
+}
+++ /dev/null
-#!/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
- }
-}
+++ /dev/null
-[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