From 13670548649506c1fcca7b7512f470ea1f9dce01 Mon Sep 17 00:00:00 2001 From: Thomas Walker Lynch Date: Mon, 15 Sep 2025 02:04:14 -0700 Subject: [PATCH] DNS work --- developer/source/DNS/README.md | 70 ---------- developer/source/DNS/README.org | 91 +++++++++++++ developer/source/DNS/deploy.py | 55 -------- developer/source/DNS/deploy_DNS.py | 122 ++++++++++++++++++ .../DNS/stage/etc/nftables.d/30-dnsredir.nft | 14 -- .../DNS/stage/etc/nftables.d/DNS-redirect.nft | 20 +++ .../etc/systemd/system/DNS-redirect.service | 16 +++ .../stage/etc/systemd/system/unbound@.service | 13 +- .../DNS/stage/etc/unbound/unbound-US.conf | 38 ++---- .../DNS/stage/etc/unbound/unbound-x6.conf | 38 ++---- .../DNS/stage/usr/local/sbin/DNS_status.sh | 12 ++ 11 files changed, 283 insertions(+), 206 deletions(-) delete mode 100644 developer/source/DNS/README.md create mode 100644 developer/source/DNS/README.org delete mode 100644 developer/source/DNS/deploy.py create mode 100755 developer/source/DNS/deploy_DNS.py delete mode 100644 developer/source/DNS/stage/etc/nftables.d/30-dnsredir.nft create mode 100644 developer/source/DNS/stage/etc/nftables.d/DNS-redirect.nft create mode 100644 developer/source/DNS/stage/etc/systemd/system/DNS-redirect.service create mode 100755 developer/source/DNS/stage/usr/local/sbin/DNS_status.sh diff --git a/developer/source/DNS/README.md b/developer/source/DNS/README.md deleted file mode 100644 index 19eeda5..0000000 --- a/developer/source/DNS/README.md +++ /dev/null @@ -1,70 +0,0 @@ -# Unbound per-tunnel setup (US + x6) - -This bundle provides two Unbound instances that each egress via a specific -WireGuard tunnel, plus an nftables rule to steer DNS from specific UIDs to -the corresponding local stub resolver port. - -## Topology -- US instance - - listens: 127.0.0.1:5301 - - egress: 10.0.0.1 (WG US local address) - - intended UID: 2017 (e.g., user `Thomas-US`) -- x6 instance - - listens: 127.0.0.1:5302 - - egress: 10.8.0.2 (WG x6 local address) - - intended UID: 2018 (e.g., user `Thomas-x6`) - -Both instances bind ONLY on loopback (so they survive tunnel flaps) and set -`outgoing-interface` to the WG /32 address so queries exit via the tunnel. -IPv6 is disabled (consistent with your environment). - -## Install -Copy files: - - sudo cp stage/etc/unbound/unbound-US.conf /etc/unbound/ - sudo cp stage/etc/unbound/unbound-x6.conf /etc/unbound/ - sudo cp stage/etc/systemd/system/unbound@.service /etc/systemd/system/ - sudo mkdir -p /etc/nftables.d - sudo cp stage/etc/nftables.d/30-dnsredir.nft /etc/nftables.d/ - -Include the nft snippet and reload nftables: - - # add to /etc/nftables.conf (near end): - # include "/etc/nftables.d/30-dnsredir.nft" - sudo nft -f /etc/nftables.conf - -Systemd: - - sudo systemctl daemon-reload - sudo systemctl enable --now unbound@US unbound@x6 - -> The unit naturally waits for the matching tunnel: `After=wg-quick@%i.service`. - -## Optional (root hints + DNSSEC trust anchor) -Recommended once (before or after starting): - - sudo install -d -m 0755 /var/lib/unbound - sudo wget -O /var/lib/unbound/root.hints https://www.internic.net/domain/named.root - sudo unbound-anchor -a /var/lib/unbound/root.key - -The configs enable DNSSEC via `auto-trust-anchor-file`. - -## Test - - # US path - sudo -u Thomas-US dig @127.0.0.1 -p 5301 example.com +short - sudo -u Thomas-US curl -s ifconfig.co/country - - # x6 path - sudo -u Thomas-x6 dig @127.0.0.1 -p 5302 example.com +short - sudo -u Thomas-x6 curl -s ifconfig.co/country - - # Fail-closed example: stop a tunnel; queries from its UID should fail - sudo systemctl stop wg-quick@US - sudo -u Thomas-US dig example.com +short - -## Notes -- If resolv.conf is changed by NetworkManager or others, nftables redirection - still forces DNS to the right local stub (ports 5301/5302) per-UID. -- You can switch to forwarding instead of recursion by uncommenting the - `forward-zone` block in each config. diff --git a/developer/source/DNS/README.org b/developer/source/DNS/README.org new file mode 100644 index 0000000..615d155 --- /dev/null +++ b/developer/source/DNS/README.org @@ -0,0 +1,91 @@ +#+TITLE: DNS Bundle (Unbound + Per-subu Redirect) — RT-v2025.09.15.1 +#+AUTHOR: RT Toolkit +#+OPTIONS: toc:2 +#+STARTUP: show2levels + +* Overview +This bundle stages a *per-subu DNS* setup on the client: +- Two Unbound instances (templated via ~unbound@.service~): + - ~unbound@US~ listens on ~127.0.0.1:5301~, resolves *over the US tunnel* (outgoing interface = ~10.0.0.1~). + - ~unbound@x6~ listens on ~127.0.0.1:5302~, resolves *over the x6 tunnel* (outgoing interface = ~10.8.0.2~). +- nftables rules match the subu’s UID and *redirect TCP/UDP port 53* to the corresponding local Unbound port. +- A small deploy helper (~deploy_DNS.py~) installs the staged tree and enables services. + +* Why this design? +- When a subu (containerized user) does DNS, traffic is forced to the tunnel assigned to that subu. +- If a tunnel is down, DNS for that subu fails closed (no silent leak), while your ~local~ subu can still use ISP DNS. +- No changes to per-user resolv.conf are required: subu keep using ~nameserver 127.0.0.1~ (via redirect). + +* Layout +#+begin_example +DNS_bundle/ + README.org + deploy_DNS.py + stage/ + etc/ + nftables.d/ + DNS-redirect.nft + systemd/ + system/ + DNS-redirect.service + unbound@.service + unbound/ + unbound-US.conf + unbound-x6.conf + usr/ + local/ + sbin/ + DNS_status.sh +#+end_example + +* Assumptions / Customize +- Client WG local addresses (from your earlier setup): + - US: ~10.0.0.1/32~ + - x6: ~10.8.0.2/32~ +- Subu UIDs (adjust if different): + - US → UID ~2017~ + - x6 → UID ~2018~ +- If these differ on your box, edit: + - ~stage/etc/unbound/unbound-US.conf~ (~outgoing-interface~) + - ~stage/etc/unbound/unbound-x6.conf~ (~outgoing-interface~) + - ~stage/etc/nftables.d/DNS-redirect.nft~ (the ~meta skuid~ lines) + +* Deploy +1. Review staged files: + #+begin_src sh + tar tzf DNS_bundle.tgz | sed 's/^/ /' + #+end_src +2. Extract and run deploy (root): + #+begin_src sh + tar xzf DNS_bundle.tgz + cd DNS_bundle + sudo ./deploy_DNS.py --instances US x6 + #+end_src +3. Verify: + #+begin_src sh + systemctl status unbound@US unbound@x6 DNS-redirect + sudo nft list table inet NAT-DNS-REDIRECT + #+end_src + +* How it works +- nftables (~DNS-redirect.nft~) in ~inet~ *nat output* hook rewrites subu DNS to the local listener ports: + - US (UID 2017) → ~127.0.0.1:5301~ + - x6 (UID 2018) → ~127.0.0.1:5302~ +- Each Unbound instance binds to its port and *sources queries from the WG IP* using ~outgoing-interface~. +- Unit ordering ties each instance to its tunnel: ~After=~ and ~Requires=~ ~wg-quick@%i.service~. + +* Notes +- If a tunnel’s address is not present at Unbound start, the unit waits because of the dependency and restarts later. +- For DoT/DoH upstream, you can switch to ~forward-tls-upstream: yes~ with providers that support TLS on 853. +- The ~DNS_status.sh~ helper prints a quick status and the top of logs. + +* Rollback +#+begin_src sh +sudo systemctl disable --now unbound@US unbound@x6 DNS-redirect +sudo nft flush table inet NAT-DNS-REDIRECT || true +# Remove staged files if desired (be careful) +# sudo rm -f /etc/unbound/unbound-US.conf /etc/unbound/unbound-x6.conf +#+end_src + +* License +This bundle is provided “as-is”. Use at your own discretion. diff --git a/developer/source/DNS/deploy.py b/developer/source/DNS/deploy.py deleted file mode 100644 index 33f07e0..0000000 --- a/developer/source/DNS/deploy.py +++ /dev/null @@ -1,55 +0,0 @@ -#!/usr/bin/env python3 -""" -deploy_dns.py — installs staged DNS artifacts (Unbound, nftables snippet) -without starting/stopping services. Prints next-step commands. -""" - -from __future__ import annotations -from pathlib import Path -import os, sys - -def main(argv=None) -> int: - root = Path(__file__).resolve().parent - stage = root / "stage" - issues = [] - if os.geteuid() != 0: - issues.append("must be run as root (sudo)") - - for rel in [ - "etc/unbound/unbound-US.conf", - "etc/unbound/unbound-x6.conf", - "etc/systemd/system/unbound@.service", - "etc/nftables.d/30-dnsredir.nft", - ]: - if not (stage / rel).exists(): - issues.append(f"missing staged file: stage/{rel}") - - try: - import install_staged_tree as ist - except Exception as e: - issues.append(f"failed to import install_staged_tree: {e}") - - if issues: - print("❌ deploy preflight found issue(s):") - for i in issues: print(f" - {i}") - return 2 - - dest_root = Path("/") - staged = ist.install_staged_tree(stage_root=stage, dest_root=dest_root) - # Paths printed by install_staged_tree; keep our output short. - print("\nNext steps:") - print(" sudo systemctl daemon-reload") - print(' # ensure nft snippet included in /etc/nftables.conf:') - print(' # include "/etc/nftables.d/30-dnsredir.nft"') - print(" sudo nft -f /etc/nftables.conf") - print(" sudo install -d -m 0755 /var/lib/unbound") - print(" sudo unbound-anchor -a /var/lib/unbound/root.key") - print(" sudo systemctl enable --now unbound@US unbound@x6") - print("\nVerify:") - print(" sudo ss -ltnup '( sport = :5301 or sport = :5302 )'") - print(" sudo -u Thomas-US dig example.com +short") - print(" sudo -u Thomas-x6 dig example.com +short") - return 0 - -if __name__ == "__main__": - sys.exit(main()) diff --git a/developer/source/DNS/deploy_DNS.py b/developer/source/DNS/deploy_DNS.py new file mode 100755 index 0000000..01519d9 --- /dev/null +++ b/developer/source/DNS/deploy_DNS.py @@ -0,0 +1,122 @@ +#!/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/stage/etc/nftables.d/30-dnsredir.nft b/developer/source/DNS/stage/etc/nftables.d/30-dnsredir.nft deleted file mode 100644 index 8ab5249..0000000 --- a/developer/source/DNS/stage/etc/nftables.d/30-dnsredir.nft +++ /dev/null @@ -1,14 +0,0 @@ -# Redirect DNS traffic per-UID to local Unbound instances. -# US (uid 2017) -> 127.0.0.1:5301 -# x6 (uid 2018) -> 127.0.0.1:5302 -table inet nat { - chain output { - type nat hook output priority -100; - # US - meta skuid 2017 udp dport 53 redirect to :5301 - meta skuid 2017 tcp dport 53 redirect to :5301 - # x6 - meta skuid 2018 udp dport 53 redirect to :5302 - meta skuid 2018 tcp dport 53 redirect to :5302 - } -} diff --git a/developer/source/DNS/stage/etc/nftables.d/DNS-redirect.nft b/developer/source/DNS/stage/etc/nftables.d/DNS-redirect.nft new file mode 100644 index 0000000..c044500 --- /dev/null +++ b/developer/source/DNS/stage/etc/nftables.d/DNS-redirect.nft @@ -0,0 +1,20 @@ +#!/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 new file mode 100644 index 0000000..304b0d9 --- /dev/null +++ b/developer/source/DNS/stage/etc/systemd/system/DNS-redirect.service @@ -0,0 +1,16 @@ +[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 diff --git a/developer/source/DNS/stage/etc/systemd/system/unbound@.service b/developer/source/DNS/stage/etc/systemd/system/unbound@.service index 4fa31d8..ba2919b 100644 --- a/developer/source/DNS/stage/etc/systemd/system/unbound@.service +++ b/developer/source/DNS/stage/etc/systemd/system/unbound@.service @@ -1,20 +1,19 @@ [Unit] -Description=Unbound DNS (%i) -Documentation=man:unbound(8) +Description=Unbound DNS instance for %i (per-subu tunnel egress) After=network-online.target wg-quick@%i.service +Requires=wg-quick@%i.service Wants=network-online.target [Service] Type=simple ExecStart=/usr/sbin/unbound -d -p -c /etc/unbound/unbound-%i.conf +User=unbound +Group=unbound Restart=on-failure -# Lock down a bit -CapabilityBoundingSet=CAP_NET_BIND_SERVICE CAP_SETGID CAP_SETUID +RestartSec=2s AmbientCapabilities=CAP_NET_BIND_SERVICE +CapabilityBoundingSet=CAP_NET_BIND_SERVICE NoNewPrivileges=true -PrivateTmp=true -ProtectSystem=full -ProtectHome=true [Install] WantedBy=multi-user.target diff --git a/developer/source/DNS/stage/etc/unbound/unbound-US.conf b/developer/source/DNS/stage/etc/unbound/unbound-US.conf index 6a799f7..1995438 100644 --- a/developer/source/DNS/stage/etc/unbound/unbound-US.conf +++ b/developer/source/DNS/stage/etc/unbound/unbound-US.conf @@ -1,40 +1,18 @@ server: - verbosity: 1 username: "unbound" - directory: "/etc/unbound" chroot: "" - - do-ip6: no - do-udp: yes - do-tcp: yes - prefer-ip6: no - - # Listen only on loopback (US instance) + directory: "/etc/unbound" + do-daemonize: no interface: 127.0.0.1@5301 - access-control: 127.0.0.0/8 allow - - # Egress via US tunnel address (policy routing will carry it out the WG table) - outgoing-interface: 10.0.0.1 - - # Sensible hardening/cache hide-identity: yes hide-version: yes - harden-referral-path: yes + harden-glue: yes harden-dnssec-stripped: yes qname-minimisation: yes - aggressive-nsec: yes prefetch: yes - cache-min-ttl: 60 - cache-max-ttl: 86400 - - # DNSSEC TA (create with unbound-anchor) - auto-trust-anchor-file: "/var/lib/unbound/root.key" - # Optional root hints (download separately) - # root-hints: "/var/lib/unbound/root.hints" + outgoing-interface: 10.0.0.1 -# To use forwarding instead of full recursion, uncomment and edit: -# forward-zone: -# name: "." -# forward-tls-upstream: no -# forward-addr: 9.9.9.9 -# forward-addr: 1.1.1.1 +forward-zone: + name: "." + forward-addr: 1.1.1.1 + forward-addr: 1.0.0.1 diff --git a/developer/source/DNS/stage/etc/unbound/unbound-x6.conf b/developer/source/DNS/stage/etc/unbound/unbound-x6.conf index c34a068..ed49241 100644 --- a/developer/source/DNS/stage/etc/unbound/unbound-x6.conf +++ b/developer/source/DNS/stage/etc/unbound/unbound-x6.conf @@ -1,40 +1,18 @@ server: - verbosity: 1 username: "unbound" - directory: "/etc/unbound" chroot: "" - - do-ip6: no - do-udp: yes - do-tcp: yes - prefer-ip6: no - - # Listen only on loopback (x6 instance) + directory: "/etc/unbound" + do-daemonize: no interface: 127.0.0.1@5302 - access-control: 127.0.0.0/8 allow - - # Egress via x6 tunnel address (policy routing will carry it out the WG table) - outgoing-interface: 10.8.0.2 - - # Sensible hardening/cache hide-identity: yes hide-version: yes - harden-referral-path: yes + harden-glue: yes harden-dnssec-stripped: yes qname-minimisation: yes - aggressive-nsec: yes prefetch: yes - cache-min-ttl: 60 - cache-max-ttl: 86400 - - # DNSSEC TA (create with unbound-anchor) - auto-trust-anchor-file: "/var/lib/unbound/root.key" - # Optional root hints (download separately) - # root-hints: "/var/lib/unbound/root.hints" + outgoing-interface: 10.8.0.2 -# To use forwarding instead of full recursion, uncomment and edit: -# forward-zone: -# name: "." -# forward-tls-upstream: no -# forward-addr: 9.9.9.9 -# forward-addr: 1.1.1.1 +forward-zone: + name: "." + forward-addr: 1.1.1.1 + forward-addr: 1.0.0.1 diff --git a/developer/source/DNS/stage/usr/local/sbin/DNS_status.sh b/developer/source/DNS/stage/usr/local/sbin/DNS_status.sh new file mode 100755 index 0000000..d4db58e --- /dev/null +++ b/developer/source/DNS/stage/usr/local/sbin/DNS_status.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -euo pipefail +echo "== DNS status ==" +systemctl --no-pager --full status DNS-redirect unbound@US unbound@x6 || true +echo +echo "== nftables ==" +nft list table inet NAT-DNS-REDIRECT || true +echo +echo "== Unbound logs (last 50 lines each) ==" +journalctl -u unbound@US -n 50 --no-pager || true +echo +journalctl -u unbound@x6 -n 50 --no-pager || true -- 2.20.1