From: Thomas Walker Lynch Date: Mon, 15 Sep 2025 15:04:03 +0000 (-0700) Subject: subu port rules are in place X-Git-Url: https://git.reasoningtechnology.com/style/static/git-favicon.png?a=commitdiff_plain;h=9609078ad6b1a6336108cfedecbb25147084446d;p=subu subu port rules are in place --- diff --git a/developer/source/DNS/deploy.py b/developer/source/DNS/deploy.py new file mode 100755 index 0000000..dbc3fd4 --- /dev/null +++ b/developer/source/DNS/deploy.py @@ -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-.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. +""" + +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 index 01519d9..0000000 --- a/developer/source/DNS/deploy_DNS.py +++ /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@.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 index 0000000..68476a3 --- /dev/null +++ b/developer/source/DNS/doc_howto_install.org @@ -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 index 0000000..1316d40 --- /dev/null +++ b/developer/source/DNS/stage/etc/nftables.d/10-block-IPv6.nft @@ -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 index 0000000..369aaf2 --- /dev/null +++ b/developer/source/DNS/stage/etc/nftables.d/20-SUBU-ports.nft @@ -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: + 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 index c044500..0000000 --- a/developer/source/DNS/stage/etc/nftables.d/DNS-redirect.nft +++ /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 index 304b0d9..0000000 --- a/developer/source/DNS/stage/etc/systemd/system/DNS-redirect.service +++ /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