#!/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.
+deploy.py — Deploy staged DNS bundle (Unbound per-subu + nft redirect)
+RT-v2025.09.15.4
+
+What it does
+ - Installs the staged tree under ./stage into /
+ - systemctl daemon-reload
+ - nft -f /etc/nftables.conf (relies on: include "/etc/nftables.d/*.nft")
+ - enable + restart unbound@<instance> for each instance (default: US x6)
+
+Assumptions
+ - This file lives next to install_staged_tree.py
+ - Stage contains:
+ stage/etc/nftables.d/10-block-IPv6.nft
+ stage/etc/nftables.d/20-SUBU-ports.nft
+ stage/etc/systemd/system/unbound@.service
+ stage/etc/unbound/unbound-US.conf (127.0.0.1@5301)
+ stage/etc/unbound/unbound-x6.conf (127.0.0.1@5302)
+ - /etc/nftables.conf has: include "/etc/nftables.d/*.nft"
+
+Exit codes
+ 0 = success, 2 = preflight/deploy error
"""
-
from __future__ import annotations
from pathlib import Path
-import argparse, importlib, os, sys, subprocess, shutil
+import argparse
+import importlib
+import os
+import subprocess
+import sys
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:
+def _preflight_errors() -> list[str]:
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
+ return errs
+
+def _install_stage(stage_root: Path) -> list[str]:
+ """
+ Call install_staged_tree.install_staged_tree(stage_root=..., dest_root=/, create_dirs=True)
+ and return its log lines.
+ """
+ sys.path.insert(0, str(ROOT))
try:
ist = importlib.import_module("install_staged_tree")
except Exception as e:
raise RuntimeError(f"failed to import install_staged_tree: {e}")
- # 2) Plan
+ # Expect signature: (stage_root, dest_root, create_dirs=False, skip_identical=True) -> (logs, ifaces)
+ try:
+ logs, _ifaces = ist.install_staged_tree(
+ stage_root=stage_root,
+ dest_root=Path("/"),
+ create_dirs=True,
+ skip_identical=True,
+ )
+ except TypeError as te:
+ # Fallback for older two-arg signature: install_staged_tree(stage_root, dest_root)
+ try:
+ logs, _ifaces = ist.install_staged_tree(stage_root, Path("/"))
+ except Exception as e2:
+ raise RuntimeError(f"install_staged_tree() call failed: {e2}") from te
+ return logs
+
+def deploy(instances: list[str]) -> list[str]:
+ logs: list[str] = []
+
+ # Plan
logs.append("Deploy DNS plan:")
logs.append(f" instances: {', '.join(instances)}")
logs.append(f" stage: {STAGE}")
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)
+ # Install staged files
+ install_logs = _install_stage(STAGE)
+ logs.extend(install_logs)
- # 6) Ensure nftables includes drop-ins
- _ensure_nft_include_line(logs)
+ # Reload systemd units (for unbound@.service changes)
+ _run(["systemctl", "daemon-reload"])
- # 7) Enable + reload nftables to pick up new rules
- _run(["systemctl", "enable", "--now", "nftables"])
- rc, _, _ = _run(["nft", "-c", "-f", str(NFT_CONF)])
+ # Apply nftables from the main config (which includes drop-ins)
+ rc, out, err = _run(["/usr/sbin/nft", "-f", "/etc/nftables.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
+ raise RuntimeError(f"nftables apply failed:\n{err or out}")
+
+ # Sanity: verify our tables are present
+ rc2, out2, err2 = _run(["/usr/sbin/nft", "list", "tables"])
+ if rc2 != 0:
+ raise RuntimeError(f"nftables list tables failed:\n{err2 or out2}")
+
+ required = {"inet NO-IPV6", "inet SUBU-DNS-REDIRECT", "inet SUBU-PORT-EGRESS"}
+ present = set()
+ for line in out2.splitlines():
+ parts = line.strip().split()
+ # lines look like: "table inet FOO"
+ if len(parts) == 3 and parts[0] == "table":
+ present.add(f"{parts[1]} {parts[2]}")
+ missing = required - present
+ if missing:
+ raise RuntimeError(f"nftables missing tables: {', '.join(sorted(missing))}")
+
+ # Enable + restart 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'}")
+ _run(["systemctl", "enable", unit])
+ _run(["systemctl", "restart", unit])
+ rcA, _, _ = _run(["systemctl", "is-active", unit])
+ logs.append(f"{unit}: {'active' if rcA == 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)")
+ 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_and_stage()
- except Exception as e:
- print(f"❌ deploy preflight found issue(s):\n - {e}", file=sys.stderr)
+ errs = _preflight_errors()
+ if errs:
+ print("❌ deploy preflight found issue(s):", file=sys.stderr)
+ for e in errs:
+ print(f" - {e}", file=sys.stderr)
return 2
try:
#!/usr/bin/env python3
"""
install_staged_tree.py
+RT-v2025.09.15.2
A dumb installer: copy staged files into the target root with backups and
deterministic permissions. No systemd stop/start, no daemon-reload.
-Given:
- - A staged tree (default: ./stage) containing any of:
- /usr/local/bin/apply_ip_state.sh
- /etc/wireguard/*.conf
- /etc/systemd/system/wg-quick@IFACE.service.d/*.conf
- /etc/iproute2/rt_tables
- - A destination root (default: /). Parent dirs may be created with --create-dirs.
-
-Does:
- - For each whitelisted staged file:
- * if a target already exists, copy it back into the stage as a timestamped backup
- * atomically replace target with staged version
- * set root:root ownership (best-effort) and explicit permissions
- - Prints a summary and suggests next steps (e.g., ./start_iface.py <ifaces>)
-
-Returns:
- - Exit 0 on success; non-zero on error
+- Extended whitelist to include DNS bundle assets:
+ * /etc/unbound/*.conf -> 0644
+ * /etc/nftables.d/*.nft -> 0644
+ * /usr/local/sbin/* -> 0500
+ * /etc/systemd/system/*.service -> 0644
+- Keeps existing WireGuard/iproute2 handling.
+- API unchanged:
+ install_staged_tree(stage_root: Path, dest_root: Path,
+ create_dirs=False, skip_identical=True)
+ -> returns (logs: list[str], detected_ifaces: list[str])
"""
from __future__ import annotations
from pathlib import Path
-from typing import Dict, Iterable, List, Optional, Sequence, Tuple
+from typing import List, Optional, Sequence, Tuple
import argparse
import datetime as dt
import hashlib
ROOT = Path(__file__).resolve().parent
DEFAULT_STAGE = ROOT / "stage"
-# Whitelisted install targets → mode
-# (These are *relative* to the stage root)
-MODE_RULES: List[Tuple[str, int]] = [
- ("usr/local/bin", 0o500), # files under here (scripts)
- ("etc/wireguard", 0o600), # *.conf
- ("etc/systemd/system", 0o644), # wg-quick@*.service.d/*.conf
- ("etc/iproute2", 0o644), # rt_tables
-]
-
def _sha256(path: Path) -> str:
h = hashlib.sha256()
with path.open("rb") as f:
shutil.copyfile(src, tmp)
os.chmod(tmp, mode)
try:
- os.chown(tmp, 0, 0) # best-effort; may fail if not root
+ os.chown(tmp, 0, 0) # best-effort
except PermissionError:
pass
os.replace(tmp, dst)
def _mode_for_rel(rel: Path) -> Optional[int]:
"""Choose a mode based on the relative path bucket."""
s = str(rel)
+
+ # Existing buckets
if s.startswith("usr/local/bin/"):
return 0o500
if s.startswith("etc/wireguard/") and rel.suffix == ".conf":
return 0o644
if s.startswith("etc/systemd/system/") and s.endswith(".conf"):
return 0o644
+
+ # NEW: DNS bundle buckets
+ if s.startswith("usr/local/sbin/"):
+ return 0o500
+ if s.startswith("etc/unbound/") and s.endswith(".conf"):
+ return 0o644
+ if s.startswith("etc/nftables.d/") and s.endswith(".nft"):
+ return 0o644
+ if s.startswith("etc/systemd/system/") and s.endswith(".service"):
+ return 0o644
+
return None
def _iter_stage_targets(stage_root: Path) -> List[Path]:
if p.is_file():
rels.append(p.relative_to(stage_root))
+ # NEW: /usr/local/sbin/*
+ sbin_dir = stage_root / "usr" / "local" / "sbin"
+ if sbin_dir.is_dir():
+ for p in sorted(sbin_dir.glob("*")):
+ if p.is_file():
+ rels.append(p.relative_to(stage_root))
+
# /etc/wireguard/*.conf
wg_dir = stage_root / "etc" / "wireguard"
if wg_dir.is_dir():
for p in sorted(sysd_dir.rglob("wg-quick@*.service.d/*.conf")):
rels.append(p.relative_to(stage_root))
+ # NEW: /etc/systemd/system/*.service
+ if sysd_dir.is_dir():
+ for p in sorted(sysd_dir.glob("*.service")):
+ if p.is_file():
+ rels.append(p.relative_to(stage_root))
+
# /etc/iproute2/rt_tables
rt = stage_root / "etc" / "iproute2" / "rt_tables"
if rt.is_file():
rels.append(rt.relative_to(stage_root))
+ # NEW: /etc/unbound/*.conf
+ ub_dir = stage_root / "etc" / "unbound"
+ if ub_dir.is_dir():
+ for p in sorted(ub_dir.glob("*.conf")):
+ rels.append(p.relative_to(stage_root))
+
+ # NEW: /etc/nftables.d/*.nft
+ nft_dir = stage_root / "etc" / "nftables.d"
+ if nft_dir.is_dir():
+ for p in sorted(nft_dir.glob("*.nft")):
+ rels.append(p.relative_to(stage_root))
+
return rels
def _discover_ifaces_from_stage(stage_root: Path) -> List[str]:
if sysd.is_dir():
for d in sysd.glob("wg-quick@*.service.d"):
name = d.name
- # name looks like: wg-quick@X.service.d
at = name.find("@")
dot = name.find(".service.d")
if at != -1 and dot != -1 and dot > at:
for line in logs:
print(line)
- # Summary + suggested next steps
print("\n=== Summary ===")
print(f"Installed {sum(1 for l in logs if l.startswith('install:'))} file(s).")
if ifaces:
lst = " ".join(ifaces)
print(f"Detected interfaces from stage: {lst}")
- print(f"\nNext steps:")
- print(f" # (optional) verify configs")
- print(f" sudo wg-quick strip /etc/wireguard/{ifaces[0]}.conf >/dev/null 2>&1 || true")
- print(f"\n # start interfaces")
+ print("\nNext steps:")
print(f" sudo ./start_iface.py {lst}")
else:
print("No interfaces detected in staged artifacts.")
print("\nNext steps:")
- print(" # start your interface(s)")
print(" sudo ./start_iface.py <iface> [more ifaces]")
return 0
except Exception as e:
+++ /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.
- }
-}