DNS work
authorThomas Walker Lynch <eknp9n@reasoningtechnology.com>
Mon, 15 Sep 2025 09:04:14 +0000 (02:04 -0700)
committerThomas Walker Lynch <eknp9n@reasoningtechnology.com>
Mon, 15 Sep 2025 09:04:14 +0000 (02:04 -0700)
developer/source/DNS/README.md [deleted file]
developer/source/DNS/README.org [new file with mode: 0644]
developer/source/DNS/deploy.py [deleted file]
developer/source/DNS/deploy_DNS.py [new file with mode: 0755]
developer/source/DNS/stage/etc/nftables.d/30-dnsredir.nft [deleted file]
developer/source/DNS/stage/etc/nftables.d/DNS-redirect.nft [new file with mode: 0644]
developer/source/DNS/stage/etc/systemd/system/DNS-redirect.service [new file with mode: 0644]
developer/source/DNS/stage/etc/systemd/system/unbound@.service
developer/source/DNS/stage/etc/unbound/unbound-US.conf
developer/source/DNS/stage/etc/unbound/unbound-x6.conf
developer/source/DNS/stage/usr/local/sbin/DNS_status.sh [new file with mode: 0755]

diff --git a/developer/source/DNS/README.md b/developer/source/DNS/README.md
deleted file mode 100644 (file)
index 19eeda5..0000000
+++ /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 (file)
index 0000000..615d155
--- /dev/null
@@ -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 (file)
index 33f07e0..0000000
+++ /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 (executable)
index 0000000..01519d9
--- /dev/null
@@ -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@<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/stage/etc/nftables.d/30-dnsredir.nft b/developer/source/DNS/stage/etc/nftables.d/30-dnsredir.nft
deleted file mode 100644 (file)
index 8ab5249..0000000
+++ /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 (file)
index 0000000..c044500
--- /dev/null
@@ -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 (file)
index 0000000..304b0d9
--- /dev/null
@@ -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
index 4fa31d8..ba2919b 100644 (file)
@@ -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
index 6a799f7..1995438 100644 (file)
@@ -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
index c34a068..ed49241 100644 (file)
@@ -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 (executable)
index 0000000..d4db58e
--- /dev/null
@@ -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