+++ /dev/null
-sudo ./install_staged_tree.py
-sudo systemctl daemon-reload
-# enable nft snippet (once):
-sudo sed -i '1{/table inet filter {/!{h;s/.*/include "\/etc\/nftables.d\/30-dnsredir.nft"/;H;x}}' /etc/nftables.conf || true
-sudo nft -f /etc/nftables.conf
-
-# bring up Unbound instances (they wait for wg links):
-sudo systemctl enable --now unbound@US unbound@x6
--- /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
+*
+!/.gitignore
+++ /dev/null
-
-*
-!.gitignore
-
+++ /dev/null
-#!/usr/bin/env python3
-# stage_IP_route_script.py — emit /usr/local/bin/route_init_<iface>.sh from DB
-# Purpose at runtime of the emitted script:
-# 1) Ensure default + blackhole default in the dedicated route table.
-# 2) Pin the peer endpoint (/32 via GW on NIC, metric 5) outside the tunnel so the handshake cannot vanish.
-# 3) Apply any extra route from the route table (on_up=1).
-#
-# Usage: stage_IP_route_script.py <iface>
-# Output: stage/usr/local/bin/route_init_<iface>.sh (chmod 500)
-# Idempotence (runtime): uses `ip -4 route replace`
-# Failure modes (runtime): if DNS resolution fails, step (2) is skipped; (1) and (3) still apply.
-
-from __future__ import annotations
-import sys, sqlite3
-from pathlib import Path
-import incommon as ic # open_db(), rows()
-
-def _bash_single_quote(s: str) -> str:
- # Safe single-quoted literal for bash
- return "'" + s.replace("'", "'\"'\"'") + "'"
-
-def stage_ip_route_script(iface: str) -> Path:
- # Resolve DB data
- with ic.open_db() as conn:
- row = conn.execute(
- "SELECT id, rt_table_name_eff FROM v_client_effective WHERE iface=? LIMIT 1;",
- (iface,)
- ).fetchone()
- if not row:
- raise RuntimeError(f"iface not found in DB: {iface}")
- iface_id, rtname = int(row[0]), str(row[1])
-
- # Preferred server: lowest priority, then lowest id
- srow = conn.execute(
- """
- SELECT s.endpoint_host, s.endpoint_port
- FROM server s
- JOIN Iface c ON c.id=s.iface_id
- WHERE c.id=?
- ORDER BY s.priority ASC, s.id ASC
- LIMIT 1;
- """,
- (iface_id,)
- ).fetchone()
- ep_host = str(srow[0]) if srow and srow[0] else ""
- ep_port = str(srow[1]) if srow and srow[1] else ""
-
- # Extra route for on_up
- extra = ic.rows(conn, """
- SELECT cidr, COALESCE(via,''), COALESCE(table_name,''), COALESCE(metric,'')
- FROM route
- WHERE iface_id=? AND on_up=1
- ORDER BY id;
- """, (iface_id,))
-
- # Paths
- out_path = Path(__file__).resolve().parent / "stage" / "usr" / "local" / "bin" / f"route_init_{iface}.sh"
- out_path.parent.mkdir(parents=True, exist_ok=True)
-
- # Emit script
- lines: list[str] = []
- lines.append("#!/usr/bin/env bash")
- lines.append("set -euo pipefail")
- lines.append(f"table={_bash_single_quote(rtname)}")
- lines.append(f"dev={_bash_single_quote(iface)}")
- lines.append(f"endpoint_host={_bash_single_quote(ep_host)}")
- lines.append(f"endpoint_port={_bash_single_quote(ep_port)}")
- lines.append("")
- lines.append("# 1) Default in dedicated table")
- lines.append('ip -4 route replace default dev "$dev" table "$table"')
- lines.append('ip -4 route replace blackhole default metric 32767 table "$table"')
- lines.append("")
- lines.append("# 2) Keep peer endpoint reachable outside the tunnel")
- lines.append('ep_ip=$(getent ahostsv4 "$endpoint_host" | awk \'NR==1{print $1}\')')
- lines.append('if [[ -n "$ep_ip" ]]; then')
- lines.append(' gw=$(ip -4 route get "$ep_ip" | awk \'/ via /{print $3; exit}\')')
- lines.append(' nic=$(ip -4 route get "$ep_ip" | awk \'/ dev /{for(i=1;i<=NF;i++) if ($i=="dev"){print $(i+1); exit}}\')')
- lines.append(' if [[ -n "$gw" && -n "$nic" ]]; then')
- lines.append(' ip -4 route replace "${ep_ip}/32" via "$gw" dev "$nic" metric 5')
- lines.append(' fi')
- lines.append('fi')
- lines.append("")
- lines.append("# 3) Extra route from DB")
- for cidr, via, tbl, met in extra:
- cidr = str(cidr)
- via = str(via or "")
- tbl = str(tbl or rtname)
- met = str(met or "")
- cmd = ["ip -4 route replace", cidr]
- if via: cmd += ["via", via]
- cmd += ['table', f'"{tbl}"']
- if met: cmd += ['metric', met]
- lines.append(" ".join(cmd))
-
- out_path.write_text("\n".join(lines) + "\n")
- out_path.chmod(0o500)
-
- return out_path
-
-def main(argv: list[str]) -> int:
- if len(argv) != 1:
- print(f"Usage: {Path(sys.argv[0]).name} <iface>", file=sys.stderr)
- return 2
- iface = argv[0]
- try:
- out = stage_ip_route_script(iface)
- except (sqlite3.Error, FileNotFoundError, RuntimeError) as e:
- print(f"❌ {e}", file=sys.stderr); return 1
- # Print relative-to-CWD as requested style: 'stage/...'
- try:
- rel = out.relative_to(Path.cwd())
- print(f"staged: {rel}")
- except ValueError:
- print(f"staged: {out}")
- return 0
-
-if __name__ == "__main__":
- sys.exit(main(sys.argv[1:]))
+++ /dev/null
-#!/usr/bin/env python3
-"""
-stage_IP_rules.py — stage a runtime script to enforce IPv4 rules for all subu
-
-- Reads subu_cidr from DB.meta
-- For each client: adds FROM <src_cidr> → <table> and per-UID rules
-- Appends a final PROHIBIT for subu_cidr to enforce hard containment
-- Writes: stage/usr/local/bin/<OUTPUT_SCRIPT_NAME> (no args at runtime)
-"""
-
-from __future__ import annotations
-import sys
-from pathlib import Path
-from typing import Optional, Sequence, Dict, List
-import incommon as ic
-
-OUTPUT_SCRIPT_NAME = "set_subu_IP_rules.sh"
-
-def stage_set_subu_ip_rules(ifaces: Optional[Sequence[str]] = None) -> tuple[Path, str]:
- with ic.open_db() as conn: # ← no path arg
- client = ic.fetch_client(conn, ifaces) # expects id, iface, rtname, addr from v_client_effective
- if not client: raise RuntimeError("no client selected")
- subu = ic.subu_cidr(conn, "10.0.0.0/24")
- ic.validate_unique_hosts(client, subu)
- uid_map: Dict[int, List[int]] = {int(c["id"]): ic.collect_uids(conn, int(c["id"])) for c in client}
-
- out = ic.STAGE_ROOT / "usr" / "local" / "bin" / OUTPUT_SCRIPT_NAME
-
- lines: List[str] = []
-
- lines += [
- "#!/usr/bin/env bash",
- "# Enforce IPv4 rules for all subu; idempotent per rule.",
- "set -euo pipefail",
- "",
- 'add_IP_rule_if_not_exists(){ local search_phrase=$1; shift; if ! ip -4 rule list | grep -F -q -- "$search_phrase"; then ip -4 rule add "$@"; fi; }',
- ""
- ]
-
- for c in client:
- table = c["rtname"]; src_cidr = c["addr"]; cid = int(c["id"])
- lines += [f"# client: iface={c['iface']} table={table} src={src_cidr} id={cid}"]
- lines += [f'add_IP_rule_if_not_exists "from {src_cidr} lookup {table}" from "{src_cidr}" lookup "{table}" pref 17000']
- for u in uid_map[cid]:
- lines += [f'add_IP_rule_if_not_exists "from {src_cidr} lookup {table}" from "{src_cidr}" lookup "{table}" pref 17000']
- lines += [""]
-
- lines += [
- "# hard containment for subu space",
- f'add_IP_rule_if_not_exists "from {subu} prohibit" from "{subu}" prohibit pref 18050',
- ""
- ]
-
- ic.write_exec_quiet(out, "\n".join(lines))
-
- per_iface = ", ".join(
- f"{c['iface']}:[{','.join(str(u) for u in uid_map[int(c['id'])]) or '-'}]"
- for c in client
- )
- total_uid_rules = sum(len(uid_map[int(c["id"])]) for c in client)
- summary = f"client={len(client)}, uid_rules={total_uid_rules} ({per_iface})"
- return out, summary
-
-def main(argv: Sequence[str]) -> int:
- ifaces = list(argv) if argv else None
- try:
- path, summary = stage_set_subu_ip_rules(ifaces)
- except Exception as e:
- print(f"❌ {e}", file=sys.stderr); return 1
- try:
- rel = "stage/" + path.relative_to(ic.STAGE_ROOT).as_posix()
- except Exception:
- rel = path.as_posix().replace(ic.ROOT.as_posix() + "/", "")
- print(f"staged: {rel} — {summary}")
- return 0
-
-if __name__ == "__main__":
- sys.exit(main(sys.argv[1:]))
+++ /dev/null
-#!/usr/bin/env python3
-# stage_StanleyPark.py — stage artifacts for this client machine only
-# Chooses just the ifaces we run here (x6, US) and reuses existing business funcs.
-
-from __future__ import annotations
-import sys, sqlite3, shutil
-from pathlib import Path
-import incommon as ic
-
-# Reuse business modules (no logic duplication)
-import stage_clean as stclean
-import stage_wg_conf as stconf
-import stage_preferred_server as stpref
-import stage_IP_route_script as striproute
-import stage_IP_rules_script as striprules
-import stage_wg_unit_IP_scripts as stdrop
-
-# Ifaces for THIS machine (adjust if needed)
-IFACES = ["x6", "US"]
-
-def msg_wrapped_call(title: str, fn=None, *args, **kwargs):
- print(f"→ {title}", flush=True)
- res = fn(*args, **kwargs) if fn else None
- print(f"✔ {title}" + (f": {res}" if res not in (None, "") else ""), flush=True)
- return res
-
-def fetch_client_by_iface(conn: sqlite3.Connection, iface: str) -> dict | None:
- conn.row_factory = sqlite3.Row
- # Prefer the effective-view; fall back if missing
- try:
- r = conn.execute("""
- SELECT c.id, c.iface, v.rt_table_name_eff AS rtname,
- COALESCE(c.rt_table_id,'') AS rtid,
- c.local_address_cidr AS addr,
- c.private_key AS priv,
- COALESCE(c.mtu,'') AS mtu,
- COALESCE(c.fwmark,'') AS fwmark,
- c.dns_mode AS dns_mode,
- COALESCE(c.dns_servers,'') AS dns_servers,
- c.autostart AS autostart
- FROM Iface c
- JOIN v_client_effective v ON v.id=c.id
- WHERE c.iface=? LIMIT 1;
- """,(iface,)).fetchone()
- except sqlite3.Error:
- r = conn.execute("""
- SELECT id, iface, COALESCE(rt_table_name,iface) AS rtname,
- COALESCE(rt_table_id,'') AS rtid,
- local_address_cidr AS addr,
- private_key AS priv,
- COALESCE(mtu,'') AS mtu,
- COALESCE(fwmark,'') AS fwmark,
- dns_mode AS dns_mode,
- COALESCE(dns_servers,'') AS dns_servers,
- autostart AS autostart
- FROM Iface WHERE iface=? LIMIT 1;
- """,(iface,)).fetchone()
- return (dict(r) if r else None)
-
-def stage_for_ifaces(ifaces: list[str], clean_mode: str | None) -> int:
- # 0) Clean stage dir
- if clean_mode == "--clean":
- msg_wrapped_call("stage clean (--yes)", stclean.clean, yes=True, dry_run=False, hard=False)
- elif clean_mode == "--no-clean":
- Path(stclean.stage_root()).mkdir(parents=True, exist_ok=True)
- else:
- msg_wrapped_call("stage clean (interactive)", stclean.clean, yes=False, dry_run=False, hard=False)
-
- root = Path(__file__).resolve().parent
- stage_root = root / "stage"
- (stage_root / "wireguard").mkdir(parents=True, exist_ok=True)
- (stage_root / "systemd").mkdir(parents=True, exist_ok=True)
- (stage_root / "usr" / "local" / "bin").mkdir(parents=True, exist_ok=True)
-
- # Optional helper carry-over (kept same behavior)
- ip_rule_add = root / "IP_rule_add_UID.sh"
- if ip_rule_add.exists():
- dst = stage_root / "usr" / "local" / "bin" / "IP_rule_add_UID.sh"
- shutil.copy2(ip_rule_add, dst); dst.chmod(0o500)
- print(f"staged: {dst.relative_to(root)}")
-
- # 1) Global policy script — limit to selected ifaces (so rules are scoped)
- msg_wrapped_call(f"stage global set_subu_IP_rules.sh for {ifaces}",
- striprules.stage_set_subu_ip_rules, ifaces)
-
- # 2) Per-iface artifacts
- with ic.open_db() as conn:
- for iface in ifaces:
- c = fetch_client_by_iface(conn, iface)
- if not c:
- print(f"⚠️ iface '{iface}' not in DB; skipping"); continue
-
- cid = int(c["id"])
- addr = str(c["addr"])
- priv = str(c["priv"])
- mtu = str(c["mtu"])
- fw = str(c["fwmark"])
- dns_m = str(c["dns_mode"])
- dns_s = str(c["dns_servers"])
-
- srow = stpref.preferred_server_row(cid)
- if not srow:
- print(f"⚠️ No server for client '{iface}' (id={cid}). Skipping.")
- continue
- (s_name, s_pub, s_psk, s_host, s_port, s_allow, s_ka, s_route) = srow
-
- # WG conf
- conf_out = stage_root / "wireguard" / f"{iface}.conf"
- msg_wrapped_call(f"wg conf for {iface}",
- stconf.write_wg_conf, conf_out, addr, priv, mtu, fw, dns_m, dns_s,
- s_pub, s_psk, s_host, str(s_port), s_allow, str(s_ka or "")
- )
-
- # Per-iface route script
- msg_wrapped_call(f"route_init for {iface}", striproute.stage_ip_route_script, iface)
-
- # Systemd override referencing global rules + per-iface route
- msg_wrapped_call(f"wg-quick override for {iface}", stdrop.stage_dropin, iface)
-
- print(f"✔ Staged: {iface}")
-
- print(f"✅ Stage generation complete in: {stage_root}")
- return 0
-
-def main(argv: list[str]) -> int:
- clean_mode = None
- if argv and argv[0] in ("--clean","--no-clean"):
- clean_mode = argv[0]
- argv = argv[1:]
- if argv:
- print(f"Usage: {Path(sys.argv[0]).name} [--clean|--no-clean]", file=sys.stderr)
- return 2
- try:
- return stage_for_ifaces(IFACES, clean_mode)
- except (sqlite3.Error, FileNotFoundError, RuntimeError) as e:
- print(f"❌ {e}", file=sys.stderr); return 1
-
-if __name__ == "__main__":
- sys.exit(main(sys.argv[1:]))
+++ /dev/null
-#!/usr/bin/env python3
-# stage_UID_route.py — emit /usr/local/bin/set_subu_UID_route.sh from DB
-
-from __future__ import annotations
-import sys, sqlite3, ipaddress
-from pathlib import Path
-import incommon as ic
-
-OUT = Path(__file__).resolve().parent / "stage" / "usr" / "local" / "bin" / "set_subu_UID_route.sh"
-
-def main(argv: list[str]) -> int:
- try:
- with ic.open_db() as conn:
- rows = ic.rows(conn, """
- SELECT c.iface, c.rt_table_name_eff AS rtname, c.local_address_cidr,
- ub.uid
- FROM Iface c
- LEFT JOIN user_binding ub ON ub.iface_id=c.id
- ORDER BY c.iface, ub.uid;
- """)
-
- meta = dict(ic.rows(conn, "SELECT key, value FROM meta;"))
- subu_cidr = meta.get("subu_cidr", "10.0.0.0/24")
- except (sqlite3.Error, FileNotFoundError) as e:
- print(f"❌ {e}", file=sys.stderr); return 1
-
- OUT.parent.mkdir(parents=True, exist_ok=True)
-
- lines = []
- lines.append("#!/usr/bin/env bash")
- lines.append("# Set per-UID policy routing; idempotent.")
- lines.append("set -euo pipefail")
- lines.append('ensure(){ local n=\"$1\"; shift; if ! ip -4 rule list | grep -F -q -- \"$n\"; then ip -4 rule add \"$@\"; fi; }')
- lines.append('ensureroute(){ local tbl=\"$1\"; shift; ip route replace \"$@\" table \"$tbl\"; }')
- lines.append("")
-
- seen = set()
- for iface, rtname, cidr, uid in rows:
- if not iface: continue
- try: src_ip = str(ipaddress.IPv4Interface(cidr).ip)
- except: continue
- # table name per UID (avoid rt_tables entries by using numeric if you prefer)
- if uid is None: continue
- tname = f"{rtname}_u{uid}"
- key = (iface, uid)
- if key in seen: continue
- seen.add(key)
-
- # route: default via iface with pinned src
- lines.append(f"# uid {uid} on {iface} → src {src_ip} via table {tname}")
- lines.append(f'ensureroute "{tname}" default dev {iface} src {src_ip}')
- lines.append(f'ensure "uidrange {uid}-{uid} lookup {tname}" uidrange "{uid}-{uid}" lookup "{tname}" pref 17010')
- # symmetry guard for already-sourced packets
- lines.append(f'ensure "from {src_ip}/32 lookup {tname}" from "{src_ip}/32" lookup "{tname}" pref 17000')
- lines.append("")
-
- # global hard containment for subu space
- lines.append(f'# hard containment for subu space {subu_cidr}')
- lines.append(f'ensure "from {subu_cidr} prohibit" from "{subu_cidr}" prohibit pref 18050')
- content = "\n".join(lines) + "\n"
- OUT.write_text(content)
- OUT.chmod(0o500)
- print(f"staged: {OUT.relative_to(Path(__file__).resolve().parent)}")
- return 0
-
-if __name__ == "__main__":
- sys.exit(main(sys.argv[1:]))
+++ /dev/null
-#!/usr/bin/env python3
-# stage_list_client.py — emit one line per client with fields needed for staging
-# Output format (pipe-separated, no header):
-# id|iface|rt_table_name|rt_table_id|addr|priv|mtu|fwmark|dns_mode|dns_servers|autostart
-
-from __future__ import annotations
-import sys, sqlite3
-from pathlib import Path
-
-def rows(conn: sqlite3.Connection, sql: str, params: tuple = ()) -> list[sqlite3.Row]:
- conn.row_factory = sqlite3.Row
- cur = conn.execute(sql, params)
- return cur.fetchall()
-
-def list_client(db_path: Path) -> int:
- try:
- conn = sqlite3.connect(str(db_path))
- except sqlite3.Error as e:
- print(f"❌ sqlite open failed: {e}", file=sys.stderr)
- return 1
-
- try:
- # Prefer the view (effective rt_table_name); fall back to COALESCE if view missing.
- try_sql = """
- SELECT c.id,
- c.iface,
- v.rt_table_name_eff AS rt_table_name,
- COALESCE(c.rt_table_id, '') AS rt_table_id,
- c.local_address_cidr AS addr,
- c.private_key AS priv,
- COALESCE(c.mtu, '') AS mtu,
- COALESCE(c.fwmark, '') AS fwmark,
- c.dns_mode,
- COALESCE(c.dns_servers, '') AS dns_servers,
- c.autostart
- FROM Iface c
- JOIN v_client_effective v ON v.id = c.id
- ORDER BY c.id;
- """
- try:
- R = rows(conn, try_sql)
- except sqlite3.Error:
- # Fallback without the view
- fallback_sql = """
- SELECT id,
- iface,
- COALESCE(rt_table_name, iface) AS rt_table_name,
- COALESCE(rt_table_id, '') AS rt_table_id,
- local_address_cidr AS addr,
- private_key AS priv,
- COALESCE(mtu, '') AS mtu,
- COALESCE(fwmark, '') AS fwmark,
- dns_mode,
- COALESCE(dns_servers, '') AS dns_servers,
- autostart
- FROM Iface
- ORDER BY id;
- """
- R = rows(conn, fallback_sql)
-
- for r in R:
- fields = [
- r["id"],
- r["iface"],
- r["rt_table_name"],
- r["rt_table_id"],
- r["addr"],
- r["priv"],
- r["mtu"],
- r["fwmark"],
- r["dns_mode"],
- r["dns_servers"],
- r["autostart"],
- ]
- print("|".join("" if v is None else str(v) for v in fields))
- return 0
- finally:
- conn.close()
-
-def main(argv: list[str]) -> int:
- if len(argv) != 1:
- prog = Path(sys.argv[0]).name
- print(f"Usage: {prog} /path/to/db", file=sys.stderr)
- return 2
- db_path = Path(argv[0])
- if not db_path.exists():
- print(f"❌ DB not found: {db_path}", file=sys.stderr)
- return 1
- return list_client(db_path)
-
-if __name__ == "__main__":
- sys.exit(main(sys.argv[1:]))
+++ /dev/null
-#!/usr/bin/env python3
-# stage_list_uid.py — print Uid (one per line) bound to a iface_id
-
-from __future__ import annotations
-import sys, sqlite3
-from pathlib import Path
-import incommon as ic
-
-def list_uid(iface_id: int) -> int:
- try:
- with ic.open_db() as conn:
- rows = conn.execute("""
- SELECT ub.uid
- FROM user_binding ub
- WHERE ub.iface_id=? AND ub.uid IS NOT NULL AND ub.uid!=''
- ORDER BY ub.uid;
- """,(iface_id,)).fetchall()
- except (sqlite3.Error, FileNotFoundError) as e:
- print(f"❌ {e}", file=sys.stderr); return 1
- for (uid,) in rows:
- print(uid)
- return 0
-
-def main(argv):
- if len(argv)!=1:
- print(f"Usage: {Path(sys.argv[0]).name} <iface_id>", file=sys.stderr)
- return 2
- return list_uid(int(argv[0]))
-
-if __name__ == "__main__":
- sys.exit(main(sys.argv[1:]))
+++ /dev/null
-#!/usr/bin/env python3
-# stage_populate.py — orchestrate stage generation (no business logic here)
-
-from __future__ import annotations
-import sys, sqlite3, shutil
-from pathlib import Path
-import incommon as ic
-
-# imports of our freshly Pythonized helpers
-import stage_clean as stclean
-import stage_wg_conf as stconf
-import stage_preferred_server as stpref
-import stage_list_uids as stuids
-import stage_IP_route_script as striproute
-import stage_IP_rules_script as striprules
-import stage_wg_unit_IP_scripts as stdrop
-
-def msg_wrapped_call(title: str, fn=None, *args, **kwargs):
- print(f"→ {title}", flush=True)
- res = fn(*args, **kwargs) if fn else None
- print(f"✔ {title}" + (f": {res}" if res not in (None, "") else ""), flush=True)
- return res
-
-def list_client(conn: sqlite3.Connection) -> list[sqlite3.Row]:
- conn.row_factory = sqlite3.Row
- try:
- sql = """
- SELECT c.id, c.iface, v.rt_table_name_eff AS rtname,
- COALESCE(c.rt_table_id,'') AS rtid,
- c.local_address_cidr AS addr,
- c.private_key AS priv,
- COALESCE(c.mtu,'') AS mtu,
- COALESCE(c.fwmark,'') AS fwmark,
- c.dns_mode AS dns_mode,
- COALESCE(c.dns_servers,'') AS dns_servers,
- c.autostart AS autostart
- FROM Iface c
- JOIN v_client_effective v ON v.id=c.id
- ORDER BY c.id;
- """
- return list(conn.execute(sql))
- except sqlite3.Error:
- # fallback if view missing
- sql = """
- SELECT id, iface, COALESCE(rt_table_name,iface) AS rtname,
- COALESCE(rt_table_id,'') AS rtid,
- local_address_cidr AS addr,
- private_key AS priv,
- COALESCE(mtu,'') AS mtu,
- COALESCE(fwmark,'') AS fwmark,
- dns_mode, COALESCE(dns_servers,'') AS dns_servers,
- autostart
- FROM Iface ORDER BY id;
- """
- return list(conn.execute(sql))
-
-def stage_populate(clean_mode: str | None) -> int:
- # 0) clean stage
- if clean_mode == "--clean":
- msg_wrapped_call("stage clean (--yes)", stclean.clean, yes=True, dry_run=False, hard=False)
- elif clean_mode == "--no-clean":
- Path(stclean.stage_root()).mkdir(parents=True, exist_ok=True)
- else:
- # interactive prompt like original
- msg_wrapped_call("stage clean (interactive)", stclean.clean, yes=False, dry_run=False, hard=False)
-
- # base dirs
- root = Path(__file__).resolve().parent
- stage_root = root / "stage"
- (stage_root / "wireguard").mkdir(parents=True, exist_ok=True)
- (stage_root / "systemd").mkdir(parents=True, exist_ok=True)
- (stage_root / "usr" / "local" / "bin").mkdir(parents=True, exist_ok=True)
-
- # 1) optional helper copy
- ip_rule_add = root / "IP_rule_add_UID.sh"
- if ip_rule_add.exists():
- dst = stage_root / "usr" / "local" / "bin" / "IP_rule_add_UID.sh"
- shutil.copy2(ip_rule_add, dst)
- dst.chmod(0o500)
- print(f"staged: {dst.relative_to(root)}")
-
- # 2) stage global policy script once (replaces per-iface policy_init_*.sh)
- msg_wrapped_call("stage global set_subu_IP_rules.sh", striprules.stage_set_subu_ip_rules)
-
- # 3) per-client staging
- with ic.open_db() as conn:
- for r in list_client(conn):
- cid = int(r["id"]); iface = str(r["iface"])
- rt = str(r["rtname"])
- addr = str(r["addr"]); priv = str(r["priv"])
- mtu = str(r["mtu"]); fw = str(r["fwmark"])
- dns_m = str(r["dns_mode"]); dns_s = str(r["dns_servers"])
-
- # 3a) preferred server
- srow = stpref.preferred_server_row(cid)
- if not srow:
- print(f"⚠️ No server for client '{iface}' (id={cid}). Skipping.")
- continue
- (s_name, s_pub, s_psk, s_host, s_port, s_allow, s_ka, s_route) = srow
-
- # 3b) WG conf
- conf_out = stage_root / "wireguard" / f"{iface}.conf"
- msg_wrapped_call(f"wg conf for {iface}",
- stconf.write_wg_conf, conf_out, addr, priv, mtu, fw, dns_m, dns_s,
- s_pub, s_psk, s_host, str(s_port), s_allow, str(s_ka or "")
- )
-
- # 3c) route init script
- msg_wrapped_call(f"route_init for {iface}", striproute.stage_ip_route_script, iface)
-
- # 3d) systemd override referencing global rules + per-iface route
- msg_wrapped_call(f"wg-quick override for {iface}", stdrop.stage_dropin, iface)
-
- print(f"✔ Staged: {iface}")
-
- print(f"✅ Stage generation complete in: {stage_root}")
- return 0
-
-def main(argv):
- clean_mode = None
- if argv:
- if argv[0] in ("--clean","--no-clean"):
- clean_mode = argv[0]
- argv = argv[1:]
- if argv:
- print(f"Usage: {Path(sys.argv[0]).name} [--clean|--no-clean]", file=sys.stderr); return 2
- try:
- return stage_populate(clean_mode)
- except (sqlite3.Error, FileNotFoundError, RuntimeError) as e:
- print(f"❌ {e}", file=sys.stderr); return 1
-
-if __name__ == "__main__":
- sys.exit(main(sys.argv[1:]))
+++ /dev/null
-#!/usr/bin/env python3
-# stage_preferred_server.py — emit the preferred server row for a iface_id
-# Output: name|peer_pub|psk|endpoint_host|endpoint_port|allowed_ips|keepalive|route_allowed_ips
-
-from __future__ import annotations
-import sys, sqlite3
-from pathlib import Path
-import incommon as ic
-
-def preferred_server_row(iface_id: int) -> tuple | None:
- with ic.open_db() as conn:
- r = conn.execute("""
- SELECT name, public_key, COALESCE(preshared_key,''),
- endpoint_host, endpoint_port, allowed_ips,
- COALESCE(keepalive_s,''), route_allowed_ips
- FROM server
- WHERE iface_id=?
- ORDER BY priority ASC, id ASC
- LIMIT 1;
- """,(iface_id,)).fetchone()
- return tuple(r) if r else None
-
-def main(argv):
- if len(argv)!=1:
- print(f"Usage: {Path(sys.argv[0]).name} <iface_id>", file=sys.stderr)
- return 2
- row = preferred_server_row(int(argv[0]))
- if not row:
- # empty stdout on "no server" just like the shell version
- return 0
- print("|".join("" if v is None else str(v) for v in row))
- return 0
-
-if __name__ == "__main__":
- sys.exit(main(sys.argv[1:]))
+++ /dev/null
-#!/usr/bin/env python3
-# stage_wg_conf.py — write stage/wireguard/<iface>.conf
-
-from __future__ import annotations
-import sys, argparse
-from pathlib import Path
-
-def write_wg_conf(out: Path, addr: str, priv: str, mtu: str, fwmark: str,
- dns_mode: str, dns_servers: str,
- peer_pub: str, psk: str, host: str, port: str,
- allowed: str, keepalive: str) -> Path:
- out.parent.mkdir(parents=True, exist_ok=True)
- lines = [
- "[Interface]",
- f"Address = {addr}",
- f"PrivateKey = {priv}",
- ]
- if mtu: lines.append(f"MTU = {mtu}")
- if fwmark: lines.append(f"FwMark = {fwmark}")
- if dns_mode == "static" and dns_servers:
- lines.append(f"DNS = {dns_servers}")
- lines.append("Table = off") # policy routing handled outside wg-quick
- lines += [
- "",
- "[Peer]",
- f"PublicKey = {peer_pub}",
- ]
- if psk: lines.append(f"PresharedKey = {psk}")
- lines += [
- f"Endpoint = {host}:{port}",
- f"AllowedIPs = {allowed}",
- ]
- if keepalive: lines.append(f"PersistentKeepalive = {keepalive}")
-
- out.write_text("\n".join(lines) + "\n")
- out.chmod(0o400)
- return out
-
-def main(argv):
- ap = argparse.ArgumentParser()
- ap.add_argument("out"); ap.add_argument("addr"); ap.add_argument("priv")
- ap.add_argument("mtu"); ap.add_argument("fwmark")
- ap.add_argument("dns_mode"); ap.add_argument("dns_servers")
- ap.add_argument("peer_pub"); ap.add_argument("psk")
- ap.add_argument("host"); ap.add_argument("port")
- ap.add_argument("allowed"); ap.add_argument("keepalive")
- args = ap.parse_args(argv)
- out = write_wg_conf(Path(args.out), args.addr, args.priv, args.mtu, args.fwmark,
- args.dns_mode, args.dns_servers, args.peer_pub, args.psk,
- args.host, args.port, args.allowed, args.keepalive)
- print(f"staged: {out}")
- return 0
-
-if __name__ == "__main__":
- sys.exit(main(sys.argv[1:]))
+++ /dev/null
-#!/usr/bin/env python3
-# stage_wg_unit_IP_scripts.py — write systemd unit override for wg-quick@IFACE
-
-from __future__ import annotations
-import sys
-from pathlib import Path
-
-def stage_dropin(iface: str) -> Path:
- root = Path(__file__).resolve().parent
- stage_root = root / "stage"
- dropin_dir = stage_root / "etc" / "systemd" / f"wg-quick@{iface}.service.d"
- dropin_dir.mkdir(parents=True, exist_ok=True)
- conf = dropin_dir / "10-postup-IP-scripts.conf"
- conf.write_text(
- "[Service]\n"
- "Restart=on-failure\n"
- "RestartSec=5\n"
- f"ExecStartPre=-/usr/sbin/ip link delete {iface}\n"
- f"ExecStartPost=+/usr/local/bin/set_subu_IP_rules.sh\n"
- f"ExecStartPost=+/usr/local/bin/route_init_{iface}.sh\n"
- f"ExecStartPost=+/usr/bin/logger 'wg-quick@{iface} up: rules+route applied'\n"
- )
- return conf
-
-def main(argv):
- if len(argv)!=1:
- print(f"Usage: {Path(sys.argv[0]).name} <iface>", file=sys.stderr); return 2
- p = stage_dropin(argv[0])
- # print a "stage/..." relative path for consistency
- root = Path(__file__).resolve().parent
- rel = p.as_posix().replace(root.as_posix() + "/", "")
- print(f"staged: {rel}")
- return 0
-
-if __name__ == "__main__":
- sys.exit(main(sys.argv[1:]))
+++ /dev/null
-#!/usr/bin/env python3
-# stage_wipe.py — safely wipe ./stage contents (keeps hidden files by default)
-# Usage:
-# ./stage_wipe.py [--yes] [--dry-run] [--hard]
-#
-# Notes:
-# - Default (no --hard): removes ONLY non-hidden entries in ./stage, keeps dotfiles like .gitignore.
-# - --hard: removes the stage directory itself (this will remove hidden files as well), then recreates it.
-
-from __future__ import annotations
-import argparse, shutil, sys, subprocess
-from pathlib import Path
-
-def stage_root() -> Path:
- return Path(__file__).resolve().parent / "stage"
-
-def human_count_and_size(p: Path) -> tuple[int, str]:
- try:
- count = sum(1 for _ in p.rglob("*"))
- except Exception:
- count = 0
- try:
- cp = subprocess.run(["du", "-sh", p.as_posix()], text=True, capture_output=True)
- size = cp.stdout.split()[0] if cp.returncode == 0 and cp.stdout else "?"
- except Exception:
- size = "?"
- return count, size
-
-def wipe(yes: bool, dry_run: bool, hard: bool) -> int:
- st = stage_root()
- if not st.exists():
- print(f"Nothing to wipe: {st} does not exist.")
- return 0
-
- # Path safety guard
- safe_root = Path(__file__).resolve().parent / "stage"
- if st.resolve() != safe_root.resolve():
- print(f"Refusing: STAGE path looks unsafe: {st}", file=sys.stderr)
- return 1
-
- count, size = human_count_and_size(st)
-
- if dry_run:
- if hard:
- print(f"DRY RUN — would remove the entire directory: {st} (items: {count}, size: ~{size})")
- else:
- print(f"DRY RUN — would remove NON-HIDDEN contents of: {st} (items: {count}, size: ~{size})")
- for p in sorted(st.iterdir()):
- if not p.name.startswith('.'):
- print(" " + p.as_posix())
- return 0
-
- if not yes:
- prompt = f"Permanently delete {'ALL of ' if hard else 'non-hidden entries in '}{st} (items: {count}, size: ~{size})? [y/N] "
- try:
- ans = input(prompt).strip()
- except EOFError:
- ans = ""
- if ans.lower() not in ("y", "yes"):
- print("Aborted.")
- return 0
-
- if hard:
- # Remove entire stage directory (hidden files included), then recreate it
- try:
- shutil.rmtree(st)
- print(f"Removed stage dir: {st}")
- except Exception as e:
- print(f"WARN: rmtree failed: {e}", file=sys.stderr)
- st.mkdir(parents=True, exist_ok=True)
- else:
- # Remove only non-hidden entries; keep dotfiles like .gitignore
- for p in list(st.iterdir()):
- if p.name.startswith('.'):
- continue # preserve hidden files/dirs
- try:
- if p.is_dir():
- shutil.rmtree(p)
- else:
- p.unlink(missing_ok=True)
- except Exception as e:
- print(f"WARN: failed to remove {p}: {e}", file=sys.stderr)
- print(f"Cleared non-hidden contents of: {st}")
-
- print("✅ Done.")
- return 0
-
-def main(argv):
- ap = argparse.ArgumentParser(description="Wipe the stage directory (keeps hidden files unless --hard).")
- ap.add_argument("--yes", action="store_true", help="do not prompt")
- ap.add_argument("--dry-run", action="store_true", help="show what would be removed, then exit")
- ap.add_argument("--hard", action="store_true", help="remove the stage dir itself (also removes hidden files)")
- args = ap.parse_args(argv)
- return wipe(args.yes, args.dry_run, args.hard)
-
-if __name__ == "__main__":
- sys.exit(main(sys.argv[1:]))