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