From 519e9e931a30dbe3e3cef19bf94cd04637e2768e Mon Sep 17 00:00:00 2001 From: Thomas Walker Lynch Date: Thu, 11 Sep 2025 02:22:09 -0700 Subject: [PATCH] adding network containment --- US/.Xauthority | 0 developer/Python/wg/.gitignore | 3 + developer/Python/wg/db/.gitignore | 4 + developer/Python/wg/db_bind_user_to_iface.py | 68 ++++ developer/Python/wg/db_checks.py | 84 ++++ developer/Python/wg/db_init_StanleyPark.py | 66 ++++ .../Python/wg/db_init_client_incommon.py | 69 ++++ developer/Python/wg/db_init_iface_US.py | 6 + developer/Python/wg/db_init_iface_x6.py | 6 + developer/Python/wg/db_init_server_US.py | 17 + .../Python/wg/db_init_server_incommon.py | 69 ++++ developer/Python/wg/db_init_server_x6.py | 16 + developer/Python/wg/db_schema.sql | 118 ++++++ developer/Python/wg/db_schema_load.sh | 23 ++ developer/Python/wg/db_wipe.sh | 71 ++++ developer/Python/wg/deprecated/.gitignore | 4 + developer/Python/wg/iface_down.py | 69 ++++ developer/Python/wg/iface_status.py | 131 +++++++ developer/Python/wg/iface_up.sh | 24 ++ developer/Python/wg/incommon.py | 39 ++ developer/Python/wg/inspect.sh | 11 + developer/Python/wg/inspect_1.py | 362 ++++++++++++++++++ developer/Python/wg/key/.gitignore | 4 + developer/Python/wg/key_client_generate.py | 63 +++ developer/Python/wg/key_server_set.py | 47 +++ developer/Python/wg/ls_iface.py | 89 +++++ developer/Python/wg/ls_key.py | 77 ++++ developer/Python/wg/ls_server.py | 91 +++++ developer/Python/wg/ls_server_setting.py | 137 +++++++ developer/Python/wg/ls_servers.sh | 7 + developer/Python/wg/ls_user.py | 55 +++ developer/Python/wg/manual_IP_terminology.org | 80 ++++ developer/Python/wg/manual_reference.org | 90 +++++ developer/Python/wg/manual_user.org | 104 +++++ developer/Python/wg/mothball/stage/.gitignore | 4 + .../wg/mothball/stage_IP_routes_script.py | 118 ++++++ .../wg/mothball/stage_IP_rules_script.py | 78 ++++ .../Python/wg/mothball/stage_StanleyPark.py | 139 +++++++ .../Python/wg/mothball/stage_UID_routes.py | 67 ++++ .../Python/wg/mothball/stage_list_clients.py | 92 +++++ .../Python/wg/mothball/stage_list_uid.py | 31 ++ .../Python/wg/mothball/stage_populate.py | 133 +++++++ .../wg/mothball/stage_preferred_server.py | 35 ++ developer/Python/wg/mothball/stage_wg_conf.py | 55 +++ .../wg/mothball/stage_wg_unit_IP_scripts.py | 36 ++ developer/Python/wg/mothball/stage_wipe.py | 97 +++++ developer/Python/wg/scratchpad/.gitignore | 4 + developer/Python/wg/stage_IP_routes_script.py | 105 +++++ developer/Python/wg/stage_IP_rules_script.py | 272 +++++++++++++ developer/Python/wg/stage_wg_conf.py | 81 ++++ .../wg/stage_wg_systemd_postup_ip_dropin.py | 81 ++++ developer/Python/wg/stage_wipe.py | 71 ++++ developer/Python/wg/wg_keys_incommon.py | 34 ++ 53 files changed, 3637 insertions(+) create mode 100644 US/.Xauthority create mode 100644 developer/Python/wg/.gitignore create mode 100644 developer/Python/wg/db/.gitignore create mode 100755 developer/Python/wg/db_bind_user_to_iface.py create mode 100755 developer/Python/wg/db_checks.py create mode 100755 developer/Python/wg/db_init_StanleyPark.py create mode 100644 developer/Python/wg/db_init_client_incommon.py create mode 100755 developer/Python/wg/db_init_iface_US.py create mode 100755 developer/Python/wg/db_init_iface_x6.py create mode 100755 developer/Python/wg/db_init_server_US.py create mode 100644 developer/Python/wg/db_init_server_incommon.py create mode 100755 developer/Python/wg/db_init_server_x6.py create mode 100644 developer/Python/wg/db_schema.sql create mode 100755 developer/Python/wg/db_schema_load.sh create mode 100755 developer/Python/wg/db_wipe.sh create mode 100644 developer/Python/wg/deprecated/.gitignore create mode 100755 developer/Python/wg/iface_down.py create mode 100755 developer/Python/wg/iface_status.py create mode 100755 developer/Python/wg/iface_up.sh create mode 100644 developer/Python/wg/incommon.py create mode 100755 developer/Python/wg/inspect.sh create mode 100755 developer/Python/wg/inspect_1.py create mode 100644 developer/Python/wg/key/.gitignore create mode 100755 developer/Python/wg/key_client_generate.py create mode 100755 developer/Python/wg/key_server_set.py create mode 100755 developer/Python/wg/ls_iface.py create mode 100755 developer/Python/wg/ls_key.py create mode 100755 developer/Python/wg/ls_server.py create mode 100755 developer/Python/wg/ls_server_setting.py create mode 100755 developer/Python/wg/ls_servers.sh create mode 100755 developer/Python/wg/ls_user.py create mode 100644 developer/Python/wg/manual_IP_terminology.org create mode 100644 developer/Python/wg/manual_reference.org create mode 100644 developer/Python/wg/manual_user.org create mode 100644 developer/Python/wg/mothball/stage/.gitignore create mode 100755 developer/Python/wg/mothball/stage_IP_routes_script.py create mode 100755 developer/Python/wg/mothball/stage_IP_rules_script.py create mode 100644 developer/Python/wg/mothball/stage_StanleyPark.py create mode 100755 developer/Python/wg/mothball/stage_UID_routes.py create mode 100755 developer/Python/wg/mothball/stage_list_clients.py create mode 100644 developer/Python/wg/mothball/stage_list_uid.py create mode 100644 developer/Python/wg/mothball/stage_populate.py create mode 100644 developer/Python/wg/mothball/stage_preferred_server.py create mode 100644 developer/Python/wg/mothball/stage_wg_conf.py create mode 100644 developer/Python/wg/mothball/stage_wg_unit_IP_scripts.py create mode 100755 developer/Python/wg/mothball/stage_wipe.py create mode 100644 developer/Python/wg/scratchpad/.gitignore create mode 100644 developer/Python/wg/stage_IP_routes_script.py create mode 100644 developer/Python/wg/stage_IP_rules_script.py create mode 100644 developer/Python/wg/stage_wg_conf.py create mode 100644 developer/Python/wg/stage_wg_systemd_postup_ip_dropin.py create mode 100644 developer/Python/wg/stage_wipe.py create mode 100644 developer/Python/wg/wg_keys_incommon.py diff --git a/US/.Xauthority b/US/.Xauthority new file mode 100644 index 0000000..e69de29 diff --git a/developer/Python/wg/.gitignore b/developer/Python/wg/.gitignore new file mode 100644 index 0000000..5c016c6 --- /dev/null +++ b/developer/Python/wg/.gitignore @@ -0,0 +1,3 @@ + +__pycache__ + diff --git a/developer/Python/wg/db/.gitignore b/developer/Python/wg/db/.gitignore new file mode 100644 index 0000000..53642ce --- /dev/null +++ b/developer/Python/wg/db/.gitignore @@ -0,0 +1,4 @@ + +* +!.gitignore + diff --git a/developer/Python/wg/db_bind_user_to_iface.py b/developer/Python/wg/db_bind_user_to_iface.py new file mode 100755 index 0000000..1ec4700 --- /dev/null +++ b/developer/Python/wg/db_bind_user_to_iface.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +# db_bind_user_to_iface.py — bind ONE linux user to ONE interface in the DB (no schema writes) +# Usage: ./db_bind_user_to_iface.py # e.g. ./db_bind_user_to_iface.py Thomas-x6 x6 + +from __future__ import annotations +import sys, sqlite3, pwd +from pathlib import Path +from typing import Optional +import incommon as ic # ROOT_DIR/DB_PATH, open_db() + +def system_uid_or_none(username: str) -> Optional[int]: + """Return the system UID for username, or None if the user doesn't exist locally.""" + try: + return pwd.getpwnam(username).pw_uid + except KeyError: + return None + +def bind_user_to_iface(conn: sqlite3.Connection, iface: str, username: str) -> str: + """ + Given (iface, username): + - Look up client.id by iface (table: client) + - Upsert into User(iface_id, username, uid) + - Update uid based on local /etc/passwd (None if user not found) + Returns a concise status string. + """ + row = conn.execute("SELECT id FROM Iface WHERE iface=? LIMIT 1;", (iface,)).fetchone() + if not row: + raise RuntimeError(f"Interface '{iface}' not found in client") + + iface_id = int(row[0]) + uid_val = system_uid_or_none(username) + + # Upsert binding + conn.execute(""" + INSERT INTO User (iface_id, username, uid, created_at, updated_at) + VALUES (?, ?, ?, strftime('%Y-%m-%dT%H:%M:%SZ','now'), strftime('%Y-%m-%dT%H:%M:%SZ','now')) + ON CONFLICT(iface_id, username) DO UPDATE SET + uid = excluded.uid, + updated_at = strftime('%Y-%m-%dT%H:%M:%SZ','now'); + """, (iface_id, username, uid_val)) + + if uid_val is None: + return f"bound {username} → {iface} (uid=NULL; user not present on this system)" + return f"bound {username} → {iface} (uid={uid_val})" + +def main(argv: list[str]) -> int: + if len(argv) != 2: + prog = Path(sys.argv[0]).name + print(f"Usage: {prog} ", file=sys.stderr) + return 2 + + username, iface = argv + try: + with ic.open_db() as conn: + msg = bind_user_to_iface(conn, iface, username) + conn.commit() + except FileNotFoundError as e: + print(f"❌ {e}", file=sys.stderr); return 1 + except sqlite3.Error as e: + print(f"❌ sqlite error: {e}", file=sys.stderr); return 1 + except RuntimeError as e: + print(f"❌ {e}", file=sys.stderr); return 1 + + print(f"✔ {msg}") + return 0 + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/developer/Python/wg/db_checks.py b/developer/Python/wg/db_checks.py new file mode 100755 index 0000000..ef172de --- /dev/null +++ b/developer/Python/wg/db_checks.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +# db_checks.py — quick audit for common misconfigurations + +from __future__ import annotations +import sys, sqlite3, ipaddress +import incommon as ic + +def audit(conn: sqlite3.Connection) -> int: + errs = 0 + + # 1) client present? + C = ic.rows(conn, """ + SELECT id, iface, local_address_cidr, rt_table_name_eff + FROM v_client_effective + ORDER BY iface; + """) + if not C: + print("WARN: no client present"); return 1 + + # 2) CIDR sanity + for cid, iface, cidr, rtname in C: + try: + ipaddress.IPv4Interface(cidr) + except Exception as e: + print(f"ERR: client {iface} has invalid CIDR {cidr}: {e}") + errs += 1 + + # 3) server exist and map to client + S = ic.rows(conn, """ + SELECT s.id, c.iface, s.name, s.public_key, s.endpoint_host, s.endpoint_port, s.allowed_ips + FROM server s + JOIN Iface c ON c.id = s.iface_id + ORDER BY c.iface, s.name; + """) + if not S: + print("WARN: no server present for any client") + + # 4) user bindings exist? (not required, but useful) + UB = ic.rows(conn, """ + SELECT c.iface, ub.username, ub.uid + FROM User ub + JOIN Iface c ON c.id = ub.iface_id + ORDER BY c.iface, ub.username; + """) + if not UB: + print("WARN: no User present") + + # 5) duplicate tunnel IPs across client (/32 equality) + tunnel_hosts = {} + for _, iface, cidr, _ in C: + try: + host = str(ipaddress.IPv4Interface(cidr).ip) + if host in tunnel_hosts and tunnel_hosts[host] != iface: + print(f"ERR: duplicate tunnel host {host} on {tunnel_hosts[host]} and {iface}") + errs += 1 + else: + tunnel_hosts[host] = iface + except Exception: + pass + + # 6) Server AllowedIPs hygiene: warn when 0.0.0.0/0 appears in server table + for sid, iface, sname, pub, host, port, allow in S: + if allow.strip() == "0.0.0.0/0": + # client-side full-tunnel is fine; server-side peer should use /32 entries + print(f"NOTE: server(name={sname}, client={iface}) has AllowedIPs=0.0.0.0/0 (client-side full-tunnel). Ensure server peer uses /32(s).") + + # 7) meta.subu_cidr present? + M = dict(ic.rows(conn, "SELECT key, value FROM meta;")) + if "subu_cidr" not in M: + print("WARN: meta.subu_cidr missing; default tooling may assume 10.0.0.0/24") + + print("OK: audit complete" if errs == 0 else f"FAIL: {errs} error(s)") + return 1 if errs else 0 + +def main(argv: list[str]) -> int: + try: + with ic.open_db() as conn: + return audit(conn) + except (sqlite3.Error, FileNotFoundError) as e: + print(f"❌ {e}", file=sys.stderr) + return 2 + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/developer/Python/wg/db_init_StanleyPark.py b/developer/Python/wg/db_init_StanleyPark.py new file mode 100755 index 0000000..fc47797 --- /dev/null +++ b/developer/Python/wg/db_init_StanleyPark.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +# db_init_StanleyPark.py — initialize the DB for the StanleyPark client + +from __future__ import annotations +import sys, subprocess, sqlite3 +from pathlib import Path +import incommon as ic + +# Use existing business functions (no duplication) +from db_init_iface_x6 import init_iface_x6 +from db_init_iface_US import init_iface_US +from db_init_server_x6 import init_server_x6 +from db_init_server_US import init_server_US +from db_bind_user_to_iface import bind_user_to_iface + +ROOT = Path(__file__).resolve().parent +DB = ic.DB_PATH + +def msg_wrapped_call(title: str, fn=None, *args, **kwargs): + """Print a before/after status line around calling `fn(*args, **kwargs)`. + Returns the function’s return value.""" + 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 _run_local(script: str, *argv: str): + subprocess.run([str(ROOT / script), *argv], check=True) + +def db_init_StanleyPark() -> int: + """ + Given the local SQLite DB at ic.DB_PATH, + it loads schema, upserts ifaces (x6, US), upserts server (x6, US), + binds users (Thomas-x6→x6, Thomas-US→US), generates missing keypairs, + commits, and prints public keys. Returns 0 on success (raises on failure). + """ + # 1) Schema + msg_wrapped_call("db_schema_load.sh", _run_local, "db_schema_load.sh") + + # 2) DB work in one connection/commit + with ic.open_db(DB) as conn: + msg_wrapped_call("db_init_iface_x6.py (init_iface_x6)", init_iface_x6, conn) + msg_wrapped_call("db_init_server_x6.py (init_server_x6)", init_server_x6, conn) + msg_wrapped_call("bind_user_to_iface: Thomas-x6 → x6", bind_user_to_iface, conn, "x6", "Thomas-x6") + + msg_wrapped_call("db_init_iface_US.py (init_iface_US)", init_iface_US, conn) + msg_wrapped_call("db_init_server_US.py (init_server_US)", init_server_US, conn) + msg_wrapped_call("bind_user_to_iface: Thomas-US → US", bind_user_to_iface, conn, "US", "Thomas-US") + + conn.commit() + print("✔ commit: database updated") + + return 0 + +def main(argv): + if argv: + print(f"Usage: {Path(sys.argv[0]).name}", file=sys.stderr) + return 2 + try: + return db_init_StanleyPark() + except (subprocess.CalledProcessError, sqlite3.Error, FileNotFoundError) as e: + print(f"❌ {e}", file=sys.stderr) + return 1 + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/developer/Python/wg/db_init_client_incommon.py b/developer/Python/wg/db_init_client_incommon.py new file mode 100644 index 0000000..cf7e6f5 --- /dev/null +++ b/developer/Python/wg/db_init_client_incommon.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +# Helpers to seed/update a row in client. + +from __future__ import annotations +import sqlite3 +from typing import Any, Optional, Dict +import incommon as ic # provides DB_PATH, open_db + +def upsert_client(conn: sqlite3.Connection, + *, + iface: str, + addr_cidr: str, + rt_table_name: Optional[str] = None, + rt_table_id: Optional[int] = None, + mtu: Optional[int] = None, + fwmark: Optional[int] = None, + dns_mode: Optional[str] = None, # 'none' or 'static' + dns_servers: Optional[str] = None, + autostart: Optional[int] = None, # 0 or 1 + bound_user: Optional[str] = None, + bound_uid: Optional[int] = None + ) -> str: + row = conn.execute( + """SELECT id, iface, rt_table_id, rt_table_name, local_address_cidr, + mtu, fwmark, dns_mode, dns_servers, autostart, + bound_user, bound_uid + FROM Iface WHERE iface=? LIMIT 1;""", + (iface,) + ).fetchone() + + defname = rt_table_name if rt_table_name is not None else iface + desired: Dict[str, Any] = {"iface": iface, "local_address_cidr": addr_cidr} + if rt_table_id is not None: desired["rt_table_id"] = rt_table_id + if rt_table_name is not None: desired["rt_table_name"] = rt_table_name + if mtu is not None: desired["mtu"] = mtu + if fwmark is not None: desired["fwmark"] = fwmark + if dns_mode is not None: desired["dns_mode"] = dns_mode + if dns_servers is not None: desired["dns_servers"] = dns_servers + if autostart is not None: desired["autostart"] = autostart + if bound_user is not None: desired["bound_user"] = bound_user + if bound_uid is not None: desired["bound_uid"] = bound_uid + + if row is None: + fields = ["iface","local_address_cidr","rt_table_name"] + vals = [iface, addr_cidr, defname] + for k in ("rt_table_id","mtu","fwmark","dns_mode","dns_servers","autostart","bound_user","bound_uid"): + if k in desired: fields.append(k); vals.append(desired[k]) + q = f"INSERT INTO Iface ({','.join(fields)}) VALUES ({','.join('?' for _ in vals)});" + cur = conn.execute(q, vals); conn.commit() + return f"seeded: client(iface={iface}) id={cur.lastrowid} addr={addr_cidr} rt={defname}" + else: + cid, _, rt_id, rt_name, cur_addr, cur_mtu, cur_fwm, cur_dns_mode, cur_dns_srv, cur_auto, cur_buser, cur_buid = row + current = { + "local_address_cidr": cur_addr, "rt_table_id": rt_id, "rt_table_name": rt_name, + "mtu": cur_mtu, "fwmark": cur_fwm, "dns_mode": cur_dns_mode, "dns_servers": cur_dns_srv, + "autostart": cur_auto, "bound_user": cur_buser, "bound_uid": cur_buid + } + changes: Dict[str, Any] = {} + for k, v in desired.items(): + if k == "iface": continue + if current.get(k) != v: changes[k] = v + if rt_name is None and "rt_table_name" not in changes: + changes["rt_table_name"] = defname + if not changes: + return f"ok: client(iface={iface}) unchanged id={cid} addr={cur_addr} rt={rt_name or defname}" + sets = ", ".join(f"{k}=?" for k in changes) + vals = list(changes.values()) + [iface] + conn.execute(f"UPDATE Iface SET {sets} WHERE iface=?;", vals); conn.commit() + return f"updated: client(iface={iface}) id={cid} " + " ".join(f"{k}={changes[k]}" for k in changes) diff --git a/developer/Python/wg/db_init_iface_US.py b/developer/Python/wg/db_init_iface_US.py new file mode 100755 index 0000000..42e8fe4 --- /dev/null +++ b/developer/Python/wg/db_init_iface_US.py @@ -0,0 +1,6 @@ +# db_init_iface_US.py +from db_init_client_incommon import upsert_client + +def init_iface_US(conn): + # iface US with dedicated table 'US' and a distinct host /32 + return upsert_client(conn, iface="US", addr_cidr="10.8.0.3/32", rt_table_name="US") diff --git a/developer/Python/wg/db_init_iface_x6.py b/developer/Python/wg/db_init_iface_x6.py new file mode 100755 index 0000000..d0dfdb1 --- /dev/null +++ b/developer/Python/wg/db_init_iface_x6.py @@ -0,0 +1,6 @@ +# db_init_iface_x6.py +from db_init_client_incommon import upsert_client + +def init_iface_x6(conn): + # iface x6 with dedicated table 'x6' and host /32 + return upsert_client(conn, iface="x6", addr_cidr="10.8.0.2/32", rt_table_name="x6") diff --git a/developer/Python/wg/db_init_server_US.py b/developer/Python/wg/db_init_server_US.py new file mode 100755 index 0000000..7d9a945 --- /dev/null +++ b/developer/Python/wg/db_init_server_US.py @@ -0,0 +1,17 @@ +# db_init_server_US.py +from db_init_server_incommon import upsert_server + +def init_server_US(conn): + # Endpoint from the historical config; adjust if needed + return upsert_server( + conn, + client_iface="US", + server_name="US", + server_public_key="h8ZYEEVMForvv9p5Wx+9+eZ87t692hTN7sks5Noedw8=", # placeholder from old wg0.conf snippet + endpoint_host="35.194.71.194", + endpoint_port=443, + allowed_ips="0.0.0.0/0", + keepalive_s=25, + route_allowed_ips=0, + priority=100, + ) diff --git a/developer/Python/wg/db_init_server_incommon.py b/developer/Python/wg/db_init_server_incommon.py new file mode 100644 index 0000000..18edb1f --- /dev/null +++ b/developer/Python/wg/db_init_server_incommon.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +# Helpers to upsert a row in server bound to a client iface. + +from __future__ import annotations +import sqlite3 +from typing import Optional, Any, Dict +import incommon as ic # provides open_db, get_client_id + +def upsert_server(conn: sqlite3.Connection, + *, + client_iface: str, + server_name: str, + server_public_key: str, + endpoint_host: str, + endpoint_port: int, + allowed_ips: str, + preshared_key: Optional[str] = None, + keepalive_s: Optional[int] = None, + route_allowed_ips: int = 0, + priority: int = 100) -> str: + cid = ic.get_client_id(conn, client_iface) + + row = conn.execute( + "SELECT id, public_key, preshared_key, endpoint_host, endpoint_port, allowed_ips, " + " keepalive_s, route_allowed_ips, priority " + "FROM server WHERE iface_id=? AND name=? LIMIT 1;", + (cid, server_name), + ).fetchone() + + desired = { + "public_key": server_public_key, + "preshared_key": preshared_key, + "endpoint_host": endpoint_host, + "endpoint_port": endpoint_port, + "allowed_ips": allowed_ips, + "keepalive_s": keepalive_s, + "route_allowed_ips": route_allowed_ips, + "priority": priority, + } + + if row is None: + q = ( + "INSERT INTO server (iface_id,name,public_key,preshared_key," + " endpoint_host,endpoint_port,allowed_ips,keepalive_s,route_allowed_ips,priority," + " created_at,updated_at) " + "VALUES (?,?,?,?,?,?,?,?,?,?, strftime('%Y-%m-%dT%H:%M:%SZ','now'), strftime('%Y-%m-%dT%H:%M:%SZ','now'));" + ) + params = (cid, server_name, desired["public_key"], desired["preshared_key"], + desired["endpoint_host"], desired["endpoint_port"], desired["allowed_ips"], + desired["keepalive_s"], desired["route_allowed_ips"], desired["priority"]) + cur = conn.execute(q, params); conn.commit() + return f"seeded: server(name={server_name}) client={client_iface} id={cur.lastrowid}" + else: + sid, pub, psk, host, port, allow, ka, route_ai, prio = row + current = { + "public_key": pub, "preshared_key": psk, "endpoint_host": host, "endpoint_port": port, + "allowed_ips": allow, "keepalive_s": ka, "route_allowed_ips": route_ai, "priority": prio + } + changes: Dict[str, Any] = {k: v for k, v in desired.items() if v != current.get(k)} + if not changes: + return f"ok: server(name={server_name}) client={client_iface} unchanged id={sid}" + sets = ", ".join(f"{k}=?" for k in changes) + params = list(changes.values()) + [cid, server_name] + conn.execute( + f"UPDATE server SET {sets}, updated_at=strftime('%Y-%m-%dT%H:%M:%SZ','now') " + "WHERE iface_id=? AND name=?;", params + ) + conn.commit() + return f"updated: server(name={server_name}) client={client_iface} id={sid} " + " ".join(f"{k}={changes[k]}" for k in changes) diff --git a/developer/Python/wg/db_init_server_x6.py b/developer/Python/wg/db_init_server_x6.py new file mode 100755 index 0000000..3377d91 --- /dev/null +++ b/developer/Python/wg/db_init_server_x6.py @@ -0,0 +1,16 @@ +# db_init_server_x6.py +from db_init_server_incommon import upsert_server + +def init_server_x6(conn): + return upsert_server( + conn, + client_iface="x6", + server_name="x6", + server_public_key="pcbDlC1ZVoBYaN83/zAsvIvhgw0iQOL1YZKX5hcAqno=", + endpoint_host="66.248.243.113", + endpoint_port=51820, + allowed_ips="0.0.0.0/0", + keepalive_s=25, + route_allowed_ips=0, + priority=100, + ) diff --git a/developer/Python/wg/db_schema.sql b/developer/Python/wg/db_schema.sql new file mode 100644 index 0000000..f7f93cd --- /dev/null +++ b/developer/Python/wg/db_schema.sql @@ -0,0 +1,118 @@ +PRAGMA foreign_keys = ON; +PRAGMA journal_mode = WAL; +PRAGMA user_version = 300; -- v3.00: singular, capitalized tables; private_key removed + +-- meta first (so later INSERTs succeed) +CREATE TABLE IF NOT EXISTS Meta ( + key TEXT PRIMARY KEY + ,value TEXT NOT NULL +); +INSERT OR REPLACE INTO Meta(key,value) VALUES ('schema','wg-client-v3.00-Ifaces'); +INSERT OR IGNORE INTO Meta(key,value) VALUES ('subu_cidr','10.0.0.0/24'); + +-- Iface, interface, device, netdevice, link — table of them +CREATE TABLE IF NOT EXISTS Iface ( + id INTEGER PRIMARY KEY + ,created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')) + ,updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')) + ,iface TEXT NOT NULL UNIQUE -- kernel interface name as shown by ip link (e.g., wg0, x6) + ,rt_table_id INTEGER -- e.g. 1002 + ,rt_table_name TEXT -- if NULL, default to iface (see view) + -- legacy caches (kept for compatibility; may be NULL) + ,bound_user TEXT + ,bound_uid INTEGER + ,local_address_cidr TEXT NOT NULL -- e.g. '10.8.0.2/32' + -- secrets: private key is NO LONGER stored in DB (lives under key/) + ,public_key TEXT CHECK (public_key IS NULL OR length(public_key) BETWEEN 43 AND 45) + ,mtu INTEGER + ,fwmark INTEGER + ,dns_mode TEXT NOT NULL DEFAULT 'none' CHECK (dns_mode IN ('none','static')) + ,dns_servers TEXT + ,autostart INTEGER NOT NULL DEFAULT 0 +); + +-- Server (one or more remote peers for an Iface) +CREATE TABLE IF NOT EXISTS Server ( + id INTEGER PRIMARY KEY + ,created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')) + ,updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')) + ,iface_id INTEGER NOT NULL REFERENCES Iface(id) ON DELETE CASCADE + ,name TEXT NOT NULL -- e.g. 'x6', 'US' + ,public_key TEXT NOT NULL CHECK (length(public_key) BETWEEN 43 AND 45) + ,preshared_key TEXT CHECK (preshared_key IS NULL OR length(preshared_key) BETWEEN 43 AND 45) + ,endpoint_host TEXT NOT NULL + ,endpoint_port INTEGER NOT NULL CHECK (endpoint_port BETWEEN 1 AND 65535) + ,allowed_ips TEXT NOT NULL -- typically '0.0.0.0/0' + ,keepalive_s INTEGER + ,route_allowed_ips INTEGER NOT NULL DEFAULT 1 + ,priority INTEGER NOT NULL DEFAULT 100 + ,UNIQUE(iface_id, name) +); + +-- Route (optional extra routes applied by post-up script) +CREATE TABLE IF NOT EXISTS Route ( + id INTEGER PRIMARY KEY + ,created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')) + ,updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')) + ,iface_id INTEGER NOT NULL REFERENCES Iface(id) ON DELETE CASCADE + ,cidr TEXT NOT NULL + ,via TEXT + ,table_name TEXT + ,metric INTEGER + ,on_up INTEGER NOT NULL DEFAULT 1 + ,on_down INTEGER NOT NULL DEFAULT 0 +); + +-- User (many linux users → one Iface) +-- each user is bound to an iface via an 'ip rule add uidrange ..' command +CREATE TABLE IF NOT EXISTS User ( + id INTEGER PRIMARY KEY + ,created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')) + ,updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')) + ,iface_id INTEGER NOT NULL REFERENCES Iface(id) ON DELETE CASCADE + ,username TEXT NOT NULL + ,uid INTEGER -- cached UID if resolved + ,UNIQUE(iface_id, username) +); + +-- Effective view (provides computed defaults like rt_table_name_eff) +CREATE VIEW IF NOT EXISTS v_iface_effective AS +SELECT + i.id + ,i.iface + ,COALESCE(i.rt_table_name, i.iface) AS rt_table_name_eff + ,i.local_address_cidr +FROM Iface i; + +-- mtime triggers +CREATE TRIGGER IF NOT EXISTS trg_iface_mtime +AFTER UPDATE ON Iface FOR EACH ROW +BEGIN + UPDATE Iface + SET updated_at=strftime('%Y-%m-%dT%H:%M:%SZ','now') + WHERE id=NEW.id; +END; + +CREATE TRIGGER IF NOT EXISTS trg_server_mtime +AFTER UPDATE ON Server FOR EACH ROW +BEGIN + UPDATE Server + SET updated_at=strftime('%Y-%m-%dT%H:%M:%SZ','now') + WHERE id=NEW.id; +END; + +CREATE TRIGGER IF NOT EXISTS trg_route_mtime +AFTER UPDATE ON Route FOR EACH ROW +BEGIN + UPDATE Route + SET updated_at=strftime('%Y-%m-%dT%H:%M:%SZ','now') + WHERE id=NEW.id; +END; + +CREATE TRIGGER IF NOT EXISTS trg_user_binding_mtime +AFTER UPDATE ON User FOR EACH ROW +BEGIN + UPDATE User + SET updated_at=strftime('%Y-%m-%dT%H:%M:%SZ','now') + WHERE id=NEW.id; +END; diff --git a/developer/Python/wg/db_schema_load.sh b/developer/Python/wg/db_schema_load.sh new file mode 100755 index 0000000..d4718bf --- /dev/null +++ b/developer/Python/wg/db_schema_load.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# db_init.sh — create/upgrade db/store by loading schema.sql (idempotent) + +set -euo pipefail +DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +DB="$DIR/db/store" +SCHEMA="$DIR/db_schema.sql" + +command -v sqlite3 >/dev/null || { echo "❌ sqlite3 not found"; exit 1; } +[[ -f "$SCHEMA" ]] || { echo "❌ schema file missing: $SCHEMA"; exit 1; } + +if [[ -f "$DB" ]]; then + ts="$(date -u +%Y%m%dT%H%M%SZ)" + cp -f -- "$DB" "$DB.bak-$ts" + echo "↩︎ Backed up existing DB to $DB.bak-$ts" +fi + +sqlite3 -cmd '.bail on' "$DB" < "$SCHEMA" + +ver="$(sqlite3 "$DB" 'PRAGMA user_version;')" +echo "✔ DB ready: $DB (user_version=$ver)" +echo " Tables:" +sqlite3 -noheader -list "$DB" "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name;" diff --git a/developer/Python/wg/db_wipe.sh b/developer/Python/wg/db_wipe.sh new file mode 100755 index 0000000..396c9dd --- /dev/null +++ b/developer/Python/wg/db_wipe.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +# Usage: db_wipe.sh [--force] [--dry-run] [--include-hidden] +# Job: Remove regular non-hidden files in ./db (e.g., store, store-wal, store-shm), keeping the directory. +# Safety: +# - Refuses to run if ./db does not exist or is not named exactly "db". +# - Prints a plan, then asks: "Are you sure? [y/N]" unless --force is used. +# - --dry-run prints what would be removed without deleting. +# - Hidden files (names starting with '.') are preserved by default (e.g., .gitignore). +# Notes: +# - Comments avoid possessive pronouns. + +set -euo pipefail + +SELF_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +DB_DIR="$SELF_DIR/db" + +FORCE=0 +DRYRUN=0 +INCLUDE_HIDDEN=0 + +while (($#)); do + case "$1" in + --force) FORCE=1 ;; + --dry-run) DRYRUN=1 ;; + --include-hidden) INCLUDE_HIDDEN=1 ;; + -h|--help) sed -n '2,30p' "$0"; exit 0 ;; + *) echo "unknown option: $1" >&2; exit 2 ;; + esac + shift || true +done + +# Guards +[[ -d "$DB_DIR" ]] || { echo "❌ not found: $DB_DIR"; exit 1; } +[[ "$(basename -- "$DB_DIR")" == "db" ]] || { echo "❌ expected directory named 'db', got: $(basename -- "$DB_DIR")"; exit 1; } + +# Build find expression +if (( INCLUDE_HIDDEN )); then + # include all regular files + mapfile -t TARGETS < <(find "$DB_DIR" -maxdepth 1 -type f -print | sort) +else + # exclude dotfiles (preserve .gitignore and other hidden files) + mapfile -t TARGETS < <(find "$DB_DIR" -maxdepth 1 -type f ! -name '.*' -print | sort) +fi + +if ((${#TARGETS[@]}==0)); then + echo "db_wipe: no matching files in: ${DB_DIR#$SELF_DIR/}" + exit 0 +fi + +echo "db_wipe: plan" +for f in "${TARGETS[@]}"; do + echo " delete: ${f#$SELF_DIR/}" +done + +if (( DRYRUN )); then + echo "db_wipe: dry-run; no changes made" + exit 0 +fi + +if (( ! FORCE )); then + printf "Are you sure? [y/N] " + read -r ans || true + case "${ans,,}" in y|yes) ;; *) echo "db_wipe: aborted"; exit 0 ;; esac +fi + +# Delete +for f in "${TARGETS[@]}"; do + rm -f -- "$f" +done + +echo "db_wipe: deleted ${#TARGETS[@]} file(s) from ${DB_DIR#$SELF_DIR/}" diff --git a/developer/Python/wg/deprecated/.gitignore b/developer/Python/wg/deprecated/.gitignore new file mode 100644 index 0000000..53642ce --- /dev/null +++ b/developer/Python/wg/deprecated/.gitignore @@ -0,0 +1,4 @@ + +* +!.gitignore + diff --git a/developer/Python/wg/iface_down.py b/developer/Python/wg/iface_down.py new file mode 100755 index 0000000..a1e6474 --- /dev/null +++ b/developer/Python/wg/iface_down.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +# iface_down.py — stop wg-quick@ and remove uid→rt rules + +from __future__ import annotations +import os, sys, sqlite3, subprocess +import incommon as ic # provides open_db() + +def sh(args: list[str], check: bool=False) -> subprocess.CompletedProcess: + return subprocess.run(args, text=True, capture_output=True, check=check) + +def get_rt_table_name(conn: sqlite3.Connection, iface: str) -> str: + row = conn.execute( + "SELECT rt_table_name_eff FROM v_client_effective WHERE iface=? LIMIT 1;", + (iface,) + ).fetchone() + if not row: + raise RuntimeError(f"Interface not found in DB: {iface}") + return str(row[0]) + +def get_bound_uids(conn: sqlite3.Connection, iface: str) -> list[int]: + rows = conn.execute( + """SELECT ub.uid + FROM User ub + JOIN Iface c ON c.id = ub.iface_id + WHERE c.iface=? AND ub.uid IS NOT NULL + ORDER BY ub.uid;""", + (iface,) + ).fetchall() + return [int(r[0]) for r in rows] + +def iface_down(iface: str) -> str: + if os.geteuid() != 0: + raise PermissionError("This script must be run as root.") + + # Stop interface (ignore failure) + sh(["systemctl", "stop", f"wg-quick@{iface}"]) + + # DB lookups + with ic.open_db() as conn: + table = get_rt_table_name(conn, iface) + uids = get_bound_uids(conn, iface) + + # Snapshot rules once for existence checks + rules = sh(["ip", "-4", "rule", "list"]).stdout + + removed = 0 + for uid in uids: + needle = f"uidrange {uid}-{uid} " + if needle in rules and f" lookup {table}" in rules: + # Try to delete; ignore failure to keep idempotence + sh(["ip", "-4", "rule", "del", "uidrange", f"{uid}-{uid}", "table", table]) + sh(["logger", f"iface_down: removed uid {uid} rule for table {table}"]) + removed += 1 + + return f"✅ {iface} stopped; removed {removed} uid rules from table {table}." + +def main(argv: list[str]) -> int: + if len(argv) != 1: + print(f"Usage: {os.path.basename(sys.argv[0])} ", file=sys.stderr) + return 2 + iface = argv[0] + try: + msg = iface_down(iface) + except (PermissionError, FileNotFoundError, sqlite3.Error, RuntimeError) as e: + print(f"❌ {e}", file=sys.stderr); return 1 + print(msg); return 0 + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/developer/Python/wg/iface_status.py b/developer/Python/wg/iface_status.py new file mode 100755 index 0000000..c0a12e9 --- /dev/null +++ b/developer/Python/wg/iface_status.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python3 +# iface_status.py — show unit/wg/route/uid-rule status for + +from __future__ import annotations +import os, sys, shutil, sqlite3, subprocess, time +from pathlib import Path +import incommon as ic # provides open_db() + +# --- small shell helpers ----------------------------------------------------- + +def sh(args: list[str]) -> subprocess.CompletedProcess: + """Run command; never raise; text mode; capture stdout/stderr.""" + return subprocess.run(args, text=True, capture_output=True) + +def which(cmd: str) -> bool: + return shutil.which(cmd) is not None + +def print_block(title: str, body: str | None = None) -> None: + print(f"=== {title} ===") + if body is not None and body != "": + print(body.rstrip()) + print() + +# --- DB helpers --------------------------------------------------------------- + +def get_rt_table_name(conn: sqlite3.Connection, iface: str) -> str: + row = conn.execute( + "SELECT rt_table_name_eff FROM v_client_effective WHERE iface=? LIMIT 1;", + (iface,) + ).fetchone() + if not row: + raise RuntimeError(f"Interface not found in DB: {iface}") + return str(row[0]) + +def get_bound_users(conn: sqlite3.Connection, iface: str) -> list[tuple[str, int | None]]: + rows = conn.execute( + """SELECT ub.username, ub.uid + FROM User ub + JOIN Iface c ON c.id = ub.iface_id + WHERE c.iface=? + ORDER BY ub.username;""", + (iface,) + ).fetchall() + return [(str(u), (None if v is None else int(v))) for (u, v) in rows] + +# --- core -------------------------------------------------------------------- + +def iface_status(iface: str) -> int: + # DB open + resolve table name early for helpful errors + with ic.open_db() as conn: + table = get_rt_table_name(conn, iface) + + # systemd status + en = sh(["systemctl", "is-enabled", f"wg-quick@{iface}"]) + ac = sh(["systemctl", "is-active", f"wg-quick@{iface}"]) + sys_body = "\n".join([ + (en.stdout.strip() if en.stdout.strip() else "").strip(), + (ac.stdout.strip() if ac.stdout.strip() else "").strip(), + ]).strip() + print_block(f"systemd: wg-quick@{iface}", sys_body) + + # wg presence + handshake age + wg_title = f"wg: {iface}" + if which("wg"): + if Path(f"/sys/class/net/{iface}").exists(): + lines: list[str] = ["(present)"] + # Try sudo-less handshake read; if not permitted, show hint + hs_try = sh(["sudo", "-n", "wg", "show", iface, "latest-handshakes"]) + if hs_try.returncode == 0 and hs_try.stdout.strip(): + # expected format: " " + epoch_part = hs_try.stdout.strip().split()[-1] + try: + hs = int(epoch_part) + if hs > 0: + age = int(time.time()) - hs + lines.append(f"latest-handshake: {age}s ago") + else: + lines.append("latest-handshake: none") + except ValueError: + lines.append("latest-handshake: unknown") + else: + prog = Path(sys.argv[0]).name or "iface_status.py" + lines.append(f"⚠ need sudo to read peers/handshake (try: sudo {prog} {iface})") + print_block(wg_title, "\n".join(lines)) + else: + print_block(wg_title, "(interface down or not present)") + else: + print_block(wg_title, "wg tool not found.") + + # route for table + rt = sh(["ip", "-4", "route", "show", "table", table]) + print_block(f"route: table {table}", rt.stdout if rt.stdout else "") + + # uid rules targeting table + rules = sh(["ip", "-4", "rule", "show"]).stdout.splitlines() + hits = [ln for ln in rules if f"lookup {table}" in ln] + print_block(f"uid rules → table {table}", "\n".join(hits) if hits else "(none)") + + # DB: bound users + with ic.open_db() as conn: + bound = get_bound_users(conn, iface) + + if not bound: + print_block(f"DB: bound users for {iface}", "(none)") + else: + # simple column render + header = ("username", "uid") + rows = [(u, ("" if v is None else str(v))) for (u, v) in bound] + w1 = max(len(header[0]), *(len(r[0]) for r in rows)) + w2 = max(len(header[1]), *(len(r[1]) for r in rows)) + body_lines = [f"{header[0]:<{w1}} {header[1]:<{w2}}", + f"{'-'*w1} {'-'*w2}"] + body_lines += [f"{u:<{w1}} {v:<{w2}}" for (u, v) in rows] + print_block(f"DB: bound users for {iface}", "\n".join(body_lines)) + + return 0 + +# --- cli --------------------------------------------------------------------- + +def main(argv: list[str]) -> int: + if len(argv) != 1: + print(f"Usage: {Path(sys.argv[0]).name} ", file=sys.stderr) + return 2 + try: + return iface_status(argv[0]) + except (sqlite3.Error, FileNotFoundError, RuntimeError) as e: + print(f"❌ {e}", file=sys.stderr) + return 1 + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/developer/Python/wg/iface_up.sh b/developer/Python/wg/iface_up.sh new file mode 100755 index 0000000..e5dbd0a --- /dev/null +++ b/developer/Python/wg/iface_up.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# iface_up.sh — enable/start wg-quick@ +set -euo pipefail + +(( $# == 1 )) || { echo "Usage: $0 "; exit 2; } +IFACE="$1" + +# Require root because systemd + net ops +if [[ $EUID -ne 0 ]]; then + echo "❌ This script must be run as root." >&2 + exit 1 +fi + +# Sanity: config must exist +[[ -r "/etc/wireguard/${IFACE}.conf" ]] || { + echo "❌ Missing: /etc/wireguard/${IFACE}.conf"; exit 1; } + +# Bring it up +systemctl enable --now "wg-quick@${IFACE}" + +# Quick confirmation +systemctl is-active --quiet "wg-quick@${IFACE}" \ + && echo "✅ ${IFACE} is active." \ + || { echo "⚠️ ${IFACE} failed to start."; exit 1; } diff --git a/developer/Python/wg/incommon.py b/developer/Python/wg/incommon.py new file mode 100644 index 0000000..a67a0aa --- /dev/null +++ b/developer/Python/wg/incommon.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +# Shared helpers (DB path + small SQLite utilities). No side effects on import. + +from __future__ import annotations +from pathlib import Path +import sqlite3 +from typing import Iterable, Sequence, Any, List, Tuple, Optional + +# Base paths +ROOT_DIR: Path = Path(__file__).resolve().parent +DB_PATH: Path = ROOT_DIR / "db" / "store" # default location + +def open_db(path: Optional[Path]=None) -> sqlite3.Connection: + p = path or DB_PATH + if not p.exists(): + raise FileNotFoundError(f"DB not found: {p}") + conn = sqlite3.connect(p.as_posix()) + # enforce FK; journal mode is set by schema, but enabling FK here is harmless and desired + conn.execute("PRAGMA foreign_keys = ON;") + return conn + +def rows(conn: sqlite3.Connection, sql: str, params: Sequence[Any]=()) -> List[tuple]: + cur = conn.execute(sql, tuple(params)) + out = cur.fetchall() + cur.close() + return out + +def get_client_id(conn: sqlite3.Connection, iface: str) -> int: + r = conn.execute("SELECT id FROM Iface WHERE iface=? LIMIT 1;", (iface,)).fetchone() + if not r: raise RuntimeError(f"client iface not found: {iface}") + return int(r[0]) + +# Tx helpers (optional but nice) +def begin_immediate(conn: sqlite3.Connection) -> None: + conn.execute("BEGIN IMMEDIATE;") + +def commit(conn: sqlite3.Connection) -> None: + conn.commit() + diff --git a/developer/Python/wg/inspect.sh b/developer/Python/wg/inspect.sh new file mode 100755 index 0000000..be2d5ef --- /dev/null +++ b/developer/Python/wg/inspect.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +# inspect.sh — prime sudo only if needed, then run inspect_1.py +set -euo pipefail +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" + +# If not primed, prompt via the tty (works inside Emacs shell without echoing) +if ! sudo -n true 2>/dev/null; then + sudo echo -n +fi + +sudo python3 "${SCRIPT_DIR}/inspect_1.py" "$@" diff --git a/developer/Python/wg/inspect_1.py b/developer/Python/wg/inspect_1.py new file mode 100755 index 0000000..e6a179a --- /dev/null +++ b/developer/Python/wg/inspect_1.py @@ -0,0 +1,362 @@ +#!/usr/bin/env python3 +# inspect.py — deep health: DB + systemd/drop-in + wg + route + uid rules + DNS plug + +from __future__ import annotations +import os, sys, re, time, shutil, sqlite3, subprocess +from pathlib import Path +from typing import List, Tuple, Optional +import incommon as ic # open_db() + +# ---------- small shell helpers ---------- + +def sh(args: List[str]) -> subprocess.CompletedProcess: + """Run command; never raise; text mode; capture stdout/stderr.""" + return subprocess.run(args, text=True, capture_output=True) + +def which(cmd: str) -> bool: + return shutil.which(cmd) is not None + +def print_block(title: str, body: str | None = None) -> None: + print(f"=== {title} ===") + if body: print(body.rstrip()) + print() + +def format_table(headers: List[str], rows: List[Tuple]) -> str: + cols = list(zip(*([headers] + [[str(c) for c in r] for r in rows]))) if rows else [headers] + widths = [max(len(x) for x in col) for col in cols] + line = lambda r: " ".join(f"{str(c):<{w}}" for c, w in zip(r, widths)) + out = [line(headers), line(tuple("-"*w for w in widths))] + for r in rows: out.append(line(tuple("" if c is None else str(c) for c in r))) + return "\n".join(out) + +# ---------- DB helpers ---------- + +def client_row(conn: sqlite3.Connection, iface: str): + return conn.execute(""" + SELECT c.iface, + v.rt_table_name_eff AS rt_table_name, + c.bound_user, c.bound_uid, + c.local_address_cidr, + substr(c.public_key,1,10)||'…' AS pub, + c.autostart, c.updated_at + FROM Iface c + JOIN v_client_effective v ON v.id=c.id + WHERE c.iface=? LIMIT 1; + """,(iface,)).fetchone() + +def server_rows(conn: sqlite3.Connection, iface: str) -> List[tuple]: + return conn.execute(""" + SELECT s.name, + s.endpoint_host || ':' || s.endpoint_port AS endpoint, + substr(s.public_key,1,10)||'…' AS pub, + s.allowed_ips, s.keepalive_s, s.priority + FROM server s + JOIN Iface c ON c.id=s.iface_id + WHERE c.iface=? + ORDER BY s.priority, s.name; + """,(iface,)).fetchall() + +def rtname_and_cidr(conn: sqlite3.Connection, iface: str) -> Tuple[str, str]: + row = conn.execute("SELECT rt_table_name_eff, local_address_cidr FROM v_client_effective WHERE iface=? LIMIT 1;",(iface,)).fetchone() + if not row: raise RuntimeError(f"Interface not found in DB: {iface}") + return str(row[0]), str(row[1]) + +def bound_uids(conn: sqlite3.Connection, iface: str) -> List[int]: + rows = conn.execute(""" + SELECT ub.uid + FROM User ub + JOIN Iface c ON c.id=ub.iface_id + WHERE c.iface=? AND ub.uid IS NOT NULL AND ub.uid!='' + ORDER BY ub.uid; + """,(iface,)).fetchall() + return [int(r[0]) for r in rows] + +def legacy_bound_uid(conn: sqlite3.Connection, iface: str) -> Optional[int]: + r = conn.execute("SELECT bound_uid FROM Iface WHERE iface=? AND bound_uid IS NOT NULL AND bound_uid!='';",(iface,)).fetchone() + return (int(r[0]) if r and r[0] is not None and str(r[0])!="" else None) + +def primary_server_ep_and_allowed(conn: sqlite3.Connection, iface: str) -> Tuple[str,str]: + ep = conn.execute(""" + SELECT s.endpoint_host||':'||s.endpoint_port + FROM server s JOIN Iface c ON c.id=s.iface_id + WHERE c.iface=? ORDER BY s.priority, s.name LIMIT 1; + """,(iface,)).fetchone() + allow = conn.execute(""" + SELECT s.allowed_ips + FROM server s JOIN Iface c ON c.id=s.iface_id + WHERE c.iface=? ORDER BY s.priority, s.name LIMIT 1; + """,(iface,)).fetchone() + return (str(ep[0]) if ep and ep[0] else ""), (str(allow[0]) if allow and allow[0] else "") + +# ---------- file checks ---------- + +def check_file(path: str, mode_oct: int, user: str, group: str) -> str: + p = Path(path) + if not p.exists(): return f"WARN: missing {path}" + try: + st = p.stat() + actual_mode = st.st_mode & 0o777 + import pwd, grp + u = pwd.getpwuid(st.st_uid).pw_name + g = grp.getgrgid(st.st_gid).gr_name + want = f"{oct(mode_oct)[2:]} {user} {group}" + got = f"{oct(actual_mode)[2:]} {u} {g}" + if actual_mode==mode_oct and u==user and g==group: + return f"OK: {path} ({got})" + else: + return f"WARN: {path} perms/owner {got} (expected {want})" + except Exception as e: + return f"WARN: {path} stat error: {e}" + +def rt_tables_has(table: str) -> bool: + try: + txt = Path("/etc/iproute2/rt_tables").read_text() + except Exception: + return False + pat = re.compile(rf"^\s*\d+\s+{re.escape(table)}\s*$", re.M) + return pat.search(txt) is not None + +# ---------- wg helpers ---------- + +def wg_present(iface: str) -> bool: + return Path(f"/sys/class/net/{iface}").exists() + +def wg_handshake_age_sec(iface: str) -> Optional[int]: + cp = sh(["sudo","-n","wg","show",iface,"latest-handshakes"]) + if cp.returncode != 0 or not cp.stdout.strip(): return None + try: + epoch = int(cp.stdout.split()[-1]) + if epoch<=0: return None + return int(time.time()) - epoch + except Exception: + return None + +def wg_endpoints_joined(iface: str) -> str: + cp = sh(["sudo","-n","wg","show",iface,"endpoints"]) + if cp.returncode != 0: return "" + vals = [] + for line in cp.stdout.splitlines(): + parts = line.split() + if len(parts)>=2: vals.append(parts[1]) + return "".join(vals) + +def wg_allowedips_csv(iface: str) -> str: + cp = sh(["sudo","-n","wg","show",iface,"allowed-ips"]) + if cp.returncode != 0: return "" + vals=[] + for line in cp.stdout.splitlines(): + parts = line.split() + if len(parts)>=2: vals.append(parts[1]) + return ",".join(vals) + +# ---------- redact helpers ---------- + +def redact_conf(text: str) -> str: + text = re.sub(r"^(PrivateKey\s*=\s*).+$", r"\1", text, flags=re.M) + text = re.sub(r"^(PresharedKey\s*=\s*).+$", r"\1", text, flags=re.M) + return text + +def sudo_cat(path: str) -> Optional[str]: + cp = sh(["sudo","-n","cat", path]) + if cp.returncode != 0: return None + return cp.stdout + +# ---------- main inspect ---------- + +def inspect_iface(iface: str) -> int: + # DB open + with ic.open_db() as conn: + crow = client_row(conn, iface) + if not crow: + print(f"❌ client row not found for iface={iface}", file=sys.stderr); return 1 + srv_rows = server_rows(conn, iface) + rtname, local_cidr = rtname_and_cidr(conn, iface) + local_ip = local_cidr.split("/",1)[0] + db_ep, db_allowed = primary_server_ep_and_allowed(conn, iface) + uids = bound_uids(conn, iface) + leg = legacy_bound_uid(conn, iface) + if leg is not None: uids.append(leg) + + # DB snapshot + print("=== DB: client '{}' ===".format(iface)) + headers = ["iface","rt_table_name","bound_user","bound_uid","local_address_cidr","pub","autostart","updated_at"] + print(format_table(headers, [crow])) + print() + print(f"--- server for '{iface}' ---") + if srv_rows: + print(format_table(["name","endpoint","pub","allowed_ips","keepalive_s","priority"], srv_rows)) + else: + print("(none)") + print() + + # systemd + drop-in + print(f"=== systemd: wg-quick@{iface} ===") + if which("systemctl"): + en = sh(["systemctl","is-enabled",f"wg-quick@{iface}"]).stdout.strip() + ac = sh(["systemctl","is-active", f"wg-quick@{iface}"]).stdout.strip() + if en: print(en) + if ac: print(ac) + drop_dir = f"/etc/systemd/system/wg-quick@{iface}.service.d" + # common filenames: legacy 'restart.conf' or new '10-postup-IP-scripts.conf' + candidates = [f"{drop_dir}/restart.conf", f"{drop_dir}/10-postup-IP-scripts.conf"] + print(f"-- drop-in expected: {candidates[0]}") + found = [p for p in candidates if Path(p).is_file()] + if found: + print("OK: drop-in file exists") + else: + print("WARN: drop-in file missing or unreadable") + dpaths = sh(["systemctl","show",f"wg-quick@{iface}","-p","DropInPaths","--value"]).stdout.strip() + if dpaths and any(p in dpaths for p in candidates): + print("OK: drop-in is loaded by systemd") + else: + print("WARN: drop-in not reported by systemd (need daemon-reload?)") + else: + print("(systemctl not available)") + print() + + # installed targets + print("=== installed targets ===") + print(check_file(f"/etc/wireguard/{iface}.conf", 0o600, "root", "root")) + # check both possible drop-in names + d1 = check_file(f"/etc/systemd/system/wg-quick@{iface}.service.d/restart.conf", 0o644, "root", "root") + d2 = check_file(f"/etc/systemd/system/wg-quick@{iface}.service.d/10-postup-IP-scripts.conf", 0o644, "root", "root") + # show OK if either exists + if d1.startswith("OK") or d2.startswith("OK"): + print(d1 if d1.startswith("OK") else d2) + else: + # print both warnings for clarity + print(d1); print(d2) + print(check_file("/usr/local/bin/IP_rule_add_UID.sh", 0o500, "root", "root")) + print(check_file(f"/usr/local/bin/route_init_{iface}.sh", 0o500, "root", "root")) + print("OK: rt_tables entry for '{}' present".format(rtname) if rt_tables_has(rtname) + else f"WARN: rt_tables entry for '{rtname}' missing") + print() + + # wg + addr + print(f"=== wg + addr: {iface} ===") + present = wg_present(iface) + print("(present)" if present else "(interface down or not present)") + if present: + has_ip = sh(["ip","-4","addr","show","dev",iface]).stdout.find(f" {local_ip}/")>=0 + print(f"OK: {iface} has {local_ip}" if has_ip else f"WARN: {iface} missing {local_ip}") + if which("wg"): + age = wg_handshake_age_sec(iface) + if age is None: + print("latest-handshake: none") + else: + print(f"latest-handshake: {age}s ago") + if age>600: print("WARN: handshake is stale (>600s)") + # endpoint and allowed-ips comparison (requires sudo) + wg_ep = wg_endpoints_joined(iface) + if db_ep: + if wg_ep == db_ep: + print(f"OK: endpoint matches DB ({wg_ep})") + else: + print(f"WARN: endpoint mismatch (wg={wg_ep or 'n/a'} db={db_ep})") + wg_allowed = wg_allowedips_csv(iface) + if db_allowed: + if wg_allowed == db_allowed: + print(f"OK: allowed-ips match DB ({wg_allowed})") + else: + print(f"WARN: allowed-ips mismatch (wg={wg_allowed or 'n/a'} db={db_allowed})") + else: + prog = Path(sys.argv[0]).name + print(f"⚠ need sudo for handshake/peer checks (try: sudo {prog} {iface})") + print() + + # route table checks + print(f"=== route: table {rtname} ===") + rt = sh(["ip","-4","route","show","table",rtname]).stdout + print(rt or "") + def_ok = any(re.match(rf"^default\s+dev\s+{re.escape(iface)}\b", ln) for ln in rt.splitlines()) + bh_ok = any(re.match(r"^blackhole\s+default\b", ln) for ln in rt.splitlines()) + print("OK: default -> {}".format(iface) if def_ok else f"WARN: default route not on {iface}") + print("OK: blackhole guard present" if bh_ok else "WARN: blackhole guard missing") + print() + + # uid rules + print(f"=== ip rules for bound UIDs → table {rtname} ===") + rules_txt = sh(["ip","-4","rule","show"]).stdout + if uids: + for u in uids: + if re.search(rf"uidrange {u}-{u}.*lookup {re.escape(rtname)}", rules_txt): + print(f"OK: uid {u} -> table {rtname}") + else: + print(f"WARN: missing rule for uid {u} -> table {rtname}") + else: + print("(no bound UIDs recorded)") + print() + print(f"=== ip rule lines targeting '{rtname}' (all) ===") + hit_lines = [ln for ln in rules_txt.splitlines() if f"lookup {rtname}" in ln] + print("\n".join(hit_lines) if hit_lines else "(none)") + print() + + # DNS leak plug: iptables redirects + print("=== iptables nat OUTPUT DNS redirect (→ 127.0.0.1:53) ===") + if which("iptables"): + nat = sh(["iptables","-t","nat","-S","OUTPUT"]).stdout + r_udp = re.search(r"-A OUTPUT.*-p udp .* --dport 53 .* REDIRECT .*to-ports 53", nat or "") + r_tcp = re.search(r"-A OUTPUT.*-p tcp .* --dport 53 .* REDIRECT .*to-ports 53", nat or "") + print(r_udp.group(0) if r_udp else "WARN: no UDP:53 redirect") + print(r_tcp.group(0) if r_tcp else "WARN: no TCP:53 redirect") + else: + print("(iptables not available)") + print() + + # on-disk configs (redacted) + conf = f"/etc/wireguard/{iface}.conf" + drop_restart = f"/etc/systemd/system/wg-quick@{iface}.service.d/restart.conf" + drop_postup = f"/etc/systemd/system/wg-quick@{iface}.service.d/10-postup-IP-scripts.conf" + + print(f"=== file: {conf} (redacted) ===") + txt = sudo_cat(conf) + if txt is None: + print("(missing or unreadable; need sudo to view)") + else: + print(redact_conf(txt)) + print() + + pick_drop = drop_restart if Path(drop_restart).exists() else drop_postup + print(f"=== file: {pick_drop} (hooks) ===") + txt = sudo_cat(pick_drop) + if txt is None: + print("(missing or unreadable; need sudo to view)") + else: + # Show only interesting service lines if present + lines = [ln for ln in txt.splitlines() + if ln.startswith(("ExecStart","Restart","RestartSec","ExecStartPre","ExecStartPost"))] + print("\n".join(lines) if lines else txt) + print() + + # summary verdict + print("=== summary ===") + ok = True + ok &= def_ok + ok &= bh_ok + if uids: + for u in uids: + if not re.search(rf"uidrange {u}-{u}.*lookup {re.escape(rtname)}", rules_txt): ok = False + ok &= rt_tables_has(rtname) + ok &= Path(f"/etc/wireguard/{iface}.conf").exists() + ok &= (Path(drop_restart).exists() or Path(drop_postup).exists()) + ok &= wg_present(iface) + if db_ep and which("wg"): + # If wg is present and sudo works, compare endpoint; otherwise skip + wg_ep = wg_endpoints_joined(iface) + if wg_ep and wg_ep != db_ep: ok = False + print("✅ Looks consistent for '{}'.".format(iface) if ok else "⚠️ Something is off — check WARN lines above.") + return 0 if ok else 1 + +# ---------- cli ---------- + +def main(argv: List[str]) -> int: + if len(argv)!=1: + print(f"Usage: {Path(sys.argv[0]).name} ", file=sys.stderr) + return 2 + try: + return inspect_iface(argv[0]) + except (sqlite3.Error, FileNotFoundError, RuntimeError) as e: + print(f"❌ {e}", file=sys.stderr); return 1 + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/developer/Python/wg/key/.gitignore b/developer/Python/wg/key/.gitignore new file mode 100644 index 0000000..53642ce --- /dev/null +++ b/developer/Python/wg/key/.gitignore @@ -0,0 +1,4 @@ + +* +!.gitignore + diff --git a/developer/Python/wg/key_client_generate.py b/developer/Python/wg/key_client_generate.py new file mode 100755 index 0000000..96df023 --- /dev/null +++ b/developer/Python/wg/key_client_generate.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +# key_client_generate.py — generate a machine-wide WG keypair +# Usage: ./key_client_generate.py +# - Writes private key to: key/ +# - Updates ALL client.public_key in local DB (no private key stored in DB) + +from __future__ import annotations +import sys, shutil, subprocess, sqlite3, os +from pathlib import Path +import incommon as ic # ROOT_DIR, DB_PATH, open_db() + +def generate_keypair() -> tuple[str, str]: + if not shutil.which("wg"): + raise RuntimeError("wg not found; install wireguard-tools") + priv = subprocess.run(["wg","genkey"], check=True, text=True, capture_output=True).stdout.strip() + pub = subprocess.run(["wg","pubkey"], check=True, input=priv.encode(), capture_output=True).stdout.decode().strip() + # quick sanity + if not (43 <= len(pub) <= 45): + raise RuntimeError(f"generated public key length looks wrong ({len(pub)})") + return priv, pub + +def write_private_key(machine: str, private_key: str) -> Path: + key_dir = ic.ROOT_DIR / "key" + key_dir.mkdir(parents=True, exist_ok=True) + out_path = key_dir / machine + if out_path.exists(): + raise FileExistsError(f"refusing to overwrite existing private key file: {out_path}") + with open(out_path, "w", encoding="utf-8") as f: + f.write(private_key + "\n") + os.chmod(out_path, 0o600) + return out_path + +def update_client_public_keys(pub: str) -> int: + if not ic.DB_PATH.exists(): + raise FileNotFoundError(f"DB not found: {ic.DB_PATH}") + with ic.open_db() as conn: + cur = conn.execute( + "UPDATE Iface " + " SET public_key=?, updated_at=strftime('%Y-%m-%dT%H:%M:%SZ','now');", + (pub,) + ) + conn.commit() + return cur.rowcount or 0 + +def main(argv: list[str]) -> int: + if len(argv) != 1: + print(f"Usage: {Path(sys.argv[0]).name} ", file=sys.stderr) + return 2 + machine = argv[0] + try: + priv, pub = generate_keypair() + out_path = write_private_key(machine, priv) + n = update_client_public_keys(pub) + print(f"wrote: {out_path.relative_to(ic.ROOT_DIR)} (600)") + print(f"updated client.public_key for {n} row(s)") + print(f"public_key: {pub}") + return 0 + except (RuntimeError, FileExistsError, FileNotFoundError, sqlite3.Error, subprocess.CalledProcessError) as e: + print(f"❌ {e}", file=sys.stderr) + return 1 + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/developer/Python/wg/key_server_set.py b/developer/Python/wg/key_server_set.py new file mode 100755 index 0000000..f53022e --- /dev/null +++ b/developer/Python/wg/key_server_set.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +# key_server_set.py — set a server's public key by nickname +# Usage: ./key_server_set.py + +from __future__ import annotations +import sys, sqlite3 +from pathlib import Path +import incommon as ic # DB_PATH, open_db() + +def valid_pub(pub: str) -> bool: + # wg public keys are base64-like and typically 44 chars; allow 43–45 as used elsewhere + return isinstance(pub, str) and (43 <= len(pub.strip()) <= 45) + +def set_server_pubkey(server_name: str, pubkey: str) -> int: + if not ic.DB_PATH.exists(): + raise FileNotFoundError(f"DB not found: {ic.DB_PATH}") + with ic.open_db() as conn: + cur = conn.execute( + "UPDATE server " + " SET public_key=?, updated_at=strftime('%Y-%m-%dT%H:%M:%SZ','now') " + " WHERE name=?;", + (pubkey.strip(), server_name) + ) + conn.commit() + return cur.rowcount or 0 + +def main(argv: list[str]) -> int: + if len(argv) != 2: + print(f"Usage: {Path(sys.argv[0]).name} ", file=sys.stderr) + return 2 + name, pub = argv + if not valid_pub(pub): + print(f"❌ public_key length looks wrong ({len(pub)})", file=sys.stderr) + return 1 + try: + n = set_server_pubkey(name, pub) + if n == 0: + print(f"⚠️ no matching server rows for name='{name}'") + else: + print(f"updated server.public_key for {n} row(s) where name='{name}'") + return 0 + except (sqlite3.Error, FileNotFoundError) as e: + print(f"❌ {e}", file=sys.stderr) + return 1 + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/developer/Python/wg/ls_iface.py b/developer/Python/wg/ls_iface.py new file mode 100755 index 0000000..e9454f0 --- /dev/null +++ b/developer/Python/wg/ls_iface.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +""" +ls_client.py — list client from the DB + +Default output: interface names, one per line. + +Options: + -i, --iface IFACE Filter to a single interface (exact match) + -l, --long Show a table with iface, rt_table_name, rt_table_id, addr, autostart, updated_at + -h, --help Show usage +""" + +from __future__ import annotations +import sys +import argparse +import sqlite3 +from typing import List, Tuple +import incommon as ic # DB_PATH, open_db() + +def parse_args(argv: List[str]) -> argparse.Namespace: + ap = argparse.ArgumentParser(add_help=False, prog="ls_client.py", description="List client from the DB") + ap.add_argument("-i","--iface", help="Filter by interface (exact match)") + ap.add_argument("-l","--long", action="store_true", help="Long table output") + ap.add_argument("-h","--help", action="help", help="Show this help and exit") + return ap.parse_args(argv) + +def fmt_table(headers: List[str], rows: List[Tuple]) -> str: + if not rows: return "" + # normalize to strings; keep empty for None + rows = [[("" if c is None else str(c)) for c in r] for r in rows] + cols = list(zip(*([headers] + rows))) + widths = [max(len(x) for x in col) for col in cols] + line = lambda r: " ".join(f"{str(c):<{w}}" for c, w in zip(r, widths)) + out = [line(headers), line(tuple("-"*w for w in widths))] + out += [line(r) for r in rows] + return "\n".join(out) + +def list_names(conn: sqlite3.Connection, iface: str | None) -> int: + if iface: + rows = conn.execute("SELECT iface FROM Iface WHERE iface=? ORDER BY iface;", (iface,)).fetchall() + else: + rows = conn.execute("SELECT iface FROM Iface ORDER BY iface;").fetchall() + for (name,) in rows: + print(name) + return 0 + +def list_long(conn: sqlite3.Connection, iface: str | None) -> int: + if iface: + rows = conn.execute(""" + SELECT c.iface, + v.rt_table_name_eff AS rt_table_name, + COALESCE(c.rt_table_id,'') AS rt_table_id, + c.local_address_cidr, + c.autostart, + c.updated_at + FROM Iface c + JOIN v_client_effective v ON v.id = c.id + WHERE c.iface = ? + ORDER BY c.iface; + """, (iface,)).fetchall() + else: + rows = conn.execute(""" + SELECT c.iface, + v.rt_table_name_eff AS rt_table_name, + COALESCE(c.rt_table_id,'') AS rt_table_id, + c.local_address_cidr, + c.autostart, + c.updated_at + FROM Iface c + JOIN v_client_effective v ON v.id = c.id + ORDER BY c.iface; + """).fetchall() + + hdr = ["iface","rt_table_name","rt_table_id","addr","autostart","updated_at"] + txt = fmt_table(hdr, rows) + if txt: print(txt) + return 0 + +def main(argv: List[str]) -> int: + args = parse_args(argv) + try: + with ic.open_db() as conn: + return list_long(conn, args.iface) if args.long else list_names(conn, args.iface) + except (sqlite3.Error, FileNotFoundError) as e: + print(f"❌ {e}", file=sys.stderr) + return 2 + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/developer/Python/wg/ls_key.py b/developer/Python/wg/ls_key.py new file mode 100755 index 0000000..c616372 --- /dev/null +++ b/developer/Python/wg/ls_key.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +# ls_keys.py — list WireGuard public keys only +# Usage: +# ./ls_keys.py # all client/server +# ./ls_keys.py -i x6 # only iface x6 + +from __future__ import annotations +import sys, argparse, sqlite3 +from pathlib import Path +from typing import List, Tuple +import incommon as ic # DB_PATH, open_db() + +def format_table(headers: List[str], rows: List[Tuple]) -> str: + if not rows: + return "(none)" + cols = list(zip(*([headers] + [[("" if c is None else str(c)) for c in r] for r in rows]))) + widths = [max(len(x) for x in col) for col in cols] + def line(r): return " ".join(f"{str(c):<{w}}" for c, w in zip(r, widths)) + out = [line(headers), line(tuple("-"*w for w in widths))] + for r in rows: out.append(line(r)) + return "\n".join(out) + +def list_client_keys(conn: sqlite3.Connection, iface: str | None, banner=False) -> str: + if banner: + print("\n=== Public keys generated locally by client, probably by using `db_update_client_key`===") + rows = conn.execute( + "SELECT iface, public_key AS client_public_key " + "FROM Iface " + + ("WHERE iface=? " if iface else "") + + "ORDER BY iface;", + ((iface,) if iface else tuple()), + ).fetchall() + return format_table(["iface","client_public_key"], rows) + +def list_server_keys(conn: sqlite3.Connection, iface: str | None ,banner=False) -> str: + if banner: + print("\n=== Public keys imported from remote server, probably edited into db_init_server_.py ===") + rows = conn.execute( + "SELECT c.iface AS client, s.name AS server, s.public_key AS server_public_key " + "FROM server s JOIN Iface c ON c.id = s.iface_id " + + ("WHERE c.iface=? " if iface else "") + + "ORDER BY c.iface, s.name;", + ((iface,) if iface else tuple()), + ).fetchall() + return format_table(["client","server","server_public_key"], rows) + +def client_pub_for_iface(conn: sqlite3.Connection, iface: str) -> str | None: + r = conn.execute("SELECT public_key FROM Iface WHERE iface=? LIMIT 1;", (iface,)).fetchone() + return (r[0] if r and r[0] else None) + +def main(argv: List[str]) -> int: + ap = argparse.ArgumentParser(description="List WireGuard public keys from the local DB.") + ap.add_argument("-i","--iface", help="filter for one iface (e.g., x6)") + args = ap.parse_args(argv) + + try: + # Ensure DB exists + if not ic.DB_PATH.exists(): + print(f"❌ DB not found: {ic.DB_PATH}", file=sys.stderr) + return 1 + with ic.open_db() as conn: + print(list_client_keys(conn, args.iface, banner=True)) + print() + print(list_server_keys(conn, args.iface, banner=True)) + if args.iface: + cpub = client_pub_for_iface(conn, args.iface) + if cpub: + print() + print("# Copy to server peer config if needed:") + print(f'CLIENT_PUB="{cpub}"') + return 0 + except (sqlite3.Error, FileNotFoundError) as e: + print(f"❌ {e}", file=sys.stderr) + return 1 + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/developer/Python/wg/ls_server.py b/developer/Python/wg/ls_server.py new file mode 100755 index 0000000..e1ee92d --- /dev/null +++ b/developer/Python/wg/ls_server.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +""" +ls_server.py — list server from the DB + +Default output: server names, one per line. + +Options: + -i, --iface IFACE Filter to a single client interface (e.g., x6, US) + -l, --long Show a table with client, name, endpoint, allowed_ips, priority + -h, --help Show usage +""" + +from __future__ import annotations +import sys +import sqlite3 +import argparse +from typing import List, Tuple +import incommon as ic # DB_PATH, open_db() + +def parse_args(argv: List[str]) -> argparse.Namespace: + ap = argparse.ArgumentParser(add_help=False, prog="ls_server.py", description="List server from the DB") + ap.add_argument("-i","--iface", help="Filter by client interface") + ap.add_argument("-l","--long", action="store_true", help="Long table output") + ap.add_argument("-h","--help", action="help", help="Show this help and exit") + return ap.parse_args(argv) + +def fmt_table(headers: List[str], rows: List[Tuple]) -> str: + if not rows: return "" + cols = list(zip(*([headers] + [[("" if c is None else str(c)) for c in r] for r in rows]))) + widths = [max(len(x) for x in col) for col in cols] + line = lambda r: " ".join(f"{str(c):<{w}}" for c, w in zip(r, widths)) + out = [line(headers), line(tuple("-"*w for w in widths))] + for r in rows: out.append(line(r)) + return "\n".join(out) + +def list_names(conn: sqlite3.Connection, iface: str | None) -> int: + if iface: + rows = conn.execute(""" + SELECT s.name + FROM server s + JOIN Iface c ON c.id = s.iface_id + WHERE c.iface = ? + ORDER BY s.name + """, (iface,)).fetchall() + else: + rows = conn.execute("SELECT name FROM server ORDER BY name").fetchall() + for (name,) in rows: + print(name) + return 0 + +def list_long(conn: sqlite3.Connection, iface: str | None) -> int: + if iface: + rows = conn.execute(""" + SELECT c.iface, + s.name, + s.endpoint_host || ':' || CAST(s.endpoint_port AS TEXT) AS endpoint, + s.allowed_ips, + s.priority + FROM server s + JOIN Iface c ON c.id = s.iface_id + WHERE c.iface = ? + ORDER BY c.iface, s.priority, s.name + """, (iface,)).fetchall() + else: + rows = conn.execute(""" + SELECT c.iface, + s.name, + s.endpoint_host || ':' || CAST(s.endpoint_port AS TEXT) AS endpoint, + s.allowed_ips, + s.priority + FROM server s + JOIN Iface c ON c.id = s.iface_id + ORDER BY c.iface, s.priority, s.name + """).fetchall() + + hdr = ["client","name","endpoint","allowed_ips","priority"] + txt = fmt_table(hdr, rows) + if txt: print(txt) + return 0 + +def main(argv: List[str]) -> int: + args = parse_args(argv) + try: + with ic.open_db() as conn: + return list_long(conn, args.iface) if args.long else list_names(conn, args.iface) + except (sqlite3.Error, FileNotFoundError) as e: + print(f"❌ {e}", file=sys.stderr) + return 2 + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/developer/Python/wg/ls_server_setting.py b/developer/Python/wg/ls_server_setting.py new file mode 100755 index 0000000..594cd70 --- /dev/null +++ b/developer/Python/wg/ls_server_setting.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +""" +ls_server_settings.py — print server-side WireGuard [Peer] stanzas from the DB + +Purpose: + Emit configuration that belongs in a *server* wg conf (e.g., /etc/wireguard/wg0.conf). + One [Peer] block per (client, server) row. + +What is printed (per block): + - PublicKey = client's public key (from client.public_key) + - AllowedIPs = client's tunnel address(es) as seen by the server (from client.local_address_cidr) + (Use /32 per client. If multiple /32 per client are later added, enumerate them.) + - PresharedKey = server.preshared_key (only if present) + +Notes: + - Endpoint is NOT set on the server for client peers (client usually dials the server). + - PersistentKeepalive is generally set on the client; server may omit it. + +Usage: + ./ls_server_settings.py # all client and their server entries + ./ls_server_settings.py x6 us # only for these client ifaces + ./ls_server_settings.py --server x6 # filter by server.name +""" + +from __future__ import annotations +import sys, sqlite3 +from typing import Iterable, List, Optional, Sequence, Tuple +from pathlib import Path + +# local helper import is optional; only used to locate db path if present +try: + import incommon as ic + DB_PATH = ic.DB_PATH +except Exception: + DB_PATH = Path(__file__).resolve().parent / "db" / "store" + +def die(msg: str, code: int = 1) -> None: + print(f"❌ {msg}", file=sys.stderr); sys.exit(code) + +def open_db(path: Path) -> sqlite3.Connection: + if not path.exists(): die(f"DB not found: {path}") + return sqlite3.connect(path.as_posix()) + +def parse_args(argv: Sequence[str]) -> Tuple[List[str], Optional[str]]: + ifaces: List[str] = [] + server_filter: Optional[str] = None + it = iter(argv) + for a in it: + if a == "--server": + try: server_filter = next(it) + except StopIteration: die("--server requires a value") + else: + ifaces.append(a) + return ifaces, server_filter + +def rows(conn: sqlite3.Connection, q: str, params: Iterable = ()) -> List[tuple]: + cur = conn.execute(q, tuple(params)) + out = cur.fetchall() + cur.close() + return out + +def collect(conn: sqlite3.Connection, ifaces: List[str], server_filter: Optional[str]) -> List[dict]: + where = [] + args: List = [] + if ifaces: + ph = ",".join("?" for _ in ifaces) + where.append(f"c.iface IN ({ph})") + args.extend(ifaces) + if server_filter: + where.append("s.name = ?") + args.append(server_filter) + w = ("WHERE " + " AND ".join(where)) if where else "" + q = f""" + SELECT c.id, c.iface, c.public_key, c.local_address_cidr, + s.name, s.preshared_key, s.endpoint_host, s.endpoint_port + FROM Iface c + LEFT JOIN server s ON s.iface_id = c.id + {w} + ORDER BY s.name, c.iface, s.priority ASC, s.id ASC; + """ + R = rows(conn, q, args) + out: List[dict] = [] + for cid, iface, cpub, cidr, sname, psk, host, port in R: + out.append({ + "iface_id": cid, + "iface": iface or "", + "client_pub": cpub or "", + "client_cidr": cidr or "", + "server_name": sname or "(unassigned)", + "server_host": host or "", + "server_port": port or None, + "psk": psk or None, + }) + return out + +def print_header() -> None: + print("# === Server-side WireGuard peer stanzas ===") + print("# Place each [Peer] block into the server's wg conf (e.g., /etc/wireguard/wg0.conf).") + print("# Endpoint is not set for client peers on the server.") + print("# AllowedIPs must be /32 per client address; enumerate multiple /32 if a client uses several.") + print() + +def print_blocks(items: List[dict]) -> None: + if not items: + print("# (no rows matched)"); return + print_header() + # group by server_name for readability + cur_group = None + for r in items: + grp = r["server_name"] + if grp != cur_group: + cur_group = grp + ep = f" ({r['server_host']}:{r['server_port']})" if r["server_host"] and r["server_port"] else "" + print(f"## Server: {grp}{ep}") + # stanza + print("[Peer]") + print(f"# client iface={r['iface']} tunnel={r['client_cidr']}") + print(f"PublicKey = {r['client_pub']}") + # AllowedIPs: prefer the exact CIDR stored for the client (typically /32) + print(f"AllowedIPs = {r['client_cidr']}") + if r["psk"]: + print(f"PresharedKey = {r['psk']}") + print() + # end + +def main(argv: Sequence[str]) -> int: + ifaces, server_filter = parse_args(argv) + try: + with open_db(DB_PATH) as conn: + items = collect(conn, ifaces, server_filter) + except sqlite3.Error as e: + die(f"sqlite error: {e}") + print_blocks(items) + return 0 + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/developer/Python/wg/ls_servers.sh b/developer/Python/wg/ls_servers.sh new file mode 100755 index 0000000..5d4f4ef --- /dev/null +++ b/developer/Python/wg/ls_servers.sh @@ -0,0 +1,7 @@ + +# ls_server.sh +#!/usr/bin/env bash +set -euo pipefail +DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +DB="$DIR/db/store" +sqlite3 -noheader -batch "$DB" "SELECT name FROM server ORDER BY name;" diff --git a/developer/Python/wg/ls_user.py b/developer/Python/wg/ls_user.py new file mode 100755 index 0000000..90c0ef2 --- /dev/null +++ b/developer/Python/wg/ls_user.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +""" +ls_users.py — print " " from DB (names only) + +- Validates required tables exist (client, User) +- No side effects; read-only +""" + +from __future__ import annotations +import sys +import sqlite3 +import incommon as ic # DB_PATH, open_db() + +HELP = """Usage: ls_users.py +Prints one line per user binding as: " ". +""" + +def tables_ok(conn: sqlite3.Connection) -> bool: + row = conn.execute( + """ + SELECT + (SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='client'), + (SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='User') + """ + ).fetchone() + return row == (1, 1) + +def list_users(conn: sqlite3.Connection) -> None: + cur = conn.execute( + """ + SELECT ub.username, c.iface + FROM User ub + JOIN Iface c ON c.id = ub.iface_id + ORDER BY c.iface, ub.username + """ + ) + for username, iface in cur.fetchall(): + print(f"{username} {iface}") + +def main(argv: list[str]) -> int: + if argv and argv[0] in ("-h", "--help"): + print(HELP.strip()); return 0 + try: + with ic.open_db() as conn: + if not tables_ok(conn): + print("❌ Missing tables (client/User). Initialize the database first.", file=sys.stderr) + return 1 + list_users(conn) + return 0 + except (sqlite3.Error, FileNotFoundError) as e: + print(f"❌ {e}", file=sys.stderr) + return 2 + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/developer/Python/wg/manual_IP_terminology.org b/developer/Python/wg/manual_IP_terminology.org new file mode 100644 index 0000000..cc87c81 --- /dev/null +++ b/developer/Python/wg/manual_IP_terminology.org @@ -0,0 +1,80 @@ +#+TITLE: Interface vs Link vs Netdevice: a cynical field guide +#+AUTHOR: Thomas & Nerith (session) +#+LANGUAGE: en +#+OPTIONS: toc:2 num:t + +* TL;DR +In Linux networking (and in this doc), /interface/, /link/, and /netdevice/ can all refer to the same kernel object, e.g., wg0, x6, eth0. This conflation of terms came about because different tribes named the same thing differently. + +* What these words actually refer to +- interface: common admin usage for referring to said kernel network object. +- link: iproute2's vocabulary for said kernel network object (as in the command: `ip link show ` which gives information about said kernel network object). +- netdevice: the kernel's term (struct net_device under the hood) + +* Where the words come from +- Kernel engineers: /netdevice/ is the internal type that packets touch. +- iproute2 authors: named their subcommands by subsystem; the L2-ish one is /link/. Hence ip link, ip addr, ip route, ip rule, ip neigh. +- Humans: kept saying /interface/ because that was the word from ifconfig days and textbooks. + +* Cynic's guide to commands (map the terrain) +- ip link show x6 → show properties of interface x6 (state, mtu, type, flags); not L3 addresses or routes (here /link/ == /interface/) +- ip addr add A dev x6 → attach IP address A as a property of interface x6; this alone does not force source choice or egress path (here /dev/ = /interface/) +- ip route add dev x6 → write a route entry: map destination → interface x6 (here /dev/ = /interface/) +- ip rule add ... → write a policy rule that selects which routing table to consult +- ip neigh ... → view/manage the neighbor cache (ARP/ND) per interface; maps L3 neighbor → L2 address; not routing + + +* Device + +In computing, a /device/ is a piece of hardware. This includes printers, disk drives, memory cards, NIC cards, etc. An emulated device is software that is written to do the same thing as an actual device. This is sometimes done when compatibility with an old device is needed, but the old device is not available. A virtual device is software that is written to do the same thing as an imagined device. This is sometimes done to make available features that no physical device provides. A virtual device can also be state that is kept to support multiplexing a real device among many users, while giving each user the appearance of having sole ownership of said device. It is also common to call a device emulator a virtual device. + +In unix operating systems special files are used for interfacing to devices. Such an interface is often called a /device file/, which inevitably gets shortened to /device/. + +In networking, the kernel keeps state data for a device, and software drivers for shipping data to and from a device used for networking. Such software objects are often called /network devices/. The interface to the kernel used for talking to devices inevitably gets called a /device/. + +The terms, /physical device/, /device file/, and /netdevice/ are used to distinguish among the various possible meanings of /device/. We observe that generally terminology suffers due to a desire to flatten and thus simplify the discussion of the communication abstraction stack. + +* Interface + +An /interface/ is a shared surface between two systems. A user interface is the shared surface between a user and a system. E.g. the dashboard of a car is a user interface to the car. + +In software programming, an interface is a set of data and routines used for communication between software systems. For example, an API is a application programming interface. + +The OS provides named interfaces for communicating with network devices. Within the context of network programming, The literature will refer to such an interface as the /device/, /link/, or /interface/, the latter being the only term fitting the wider scope conventional definition. + +* Link + +A /link/ is a pathway that connects two systems. With an interface, there is no link, as the systems touch. A link has two interfaces, one on each end. Hence it was inevitable that a link interface would be called a /link/. And if the link connects to a device, then that link interface itself gets called a /device/. + +In iproute2 /link/ means the local endpoint object. Do not assume a remote counterpart exists just because you saw the word /link/. + +* WireGuard mini-map +We will use this terminology: + +- We will consider that WireGuard is conceptually a virtual device. +- There can be many interfaces to said WireGuard device, taking names like wg0 or x6. Each has a keypair, a listen port, and a set of peers. +- Config tools: "wg" (CLI, not a daemon), "wg-quick" (oneshot helper per interface). +- Reality check: + - ip link show type wireguard → lists all WG interfaces + - ip -d link show x6 → detailed information about the x6 interface + - wg show x6 → peer/crypto state for the x6 interface + +* Sanity tests you can run +#+begin_src sh +# list all WireGuard interfaces +ip link show type wireguard + +# detailed view of one interface +ip -d link show x6 + +# see handshake and byte counters +wg show x6 + +# show L3 addresses bound to an interface +ip addr show dev x6 + +# show routes in a named table (if you use policy routing) +ip route show table x6 +#+end_src + + diff --git a/developer/Python/wg/manual_reference.org b/developer/Python/wg/manual_reference.org new file mode 100644 index 0000000..6b0b894 --- /dev/null +++ b/developer/Python/wg/manual_reference.org @@ -0,0 +1,90 @@ +#+title: WireGuard Client — Reference +#+author: Thomas / Aerenis +#+startup: showall + +* Directory layout (wg/) +- =schema.sql= :: SQLite schema for clients/servers/routes/meta (keys stored in DB). +- =wg_client.db= :: SQLite DB (created by =db_init.sh=). +- =db_init.sh= :: Creates/initializes DB from =schema.sql= (user-space). +- =client_create_keys.sh= :: Creates a fresh client keypair for an =iface= and stores into DB. +- =config_client_StanleyPark.sh= :: Upserts the StanleyPark client row (iface, addr, mtu, dns_mode, autostart, etc.). +- =config_server_x6.sh= :: Upserts the remote server (“x6”) row linked to the client. +- =bind_user.sh= :: Binds a Linux username (and resolves UID) to a client interface in DB. +- =ls_clients.sh= :: Lists interface names only (one per line). +- =ls_servers.sh= :: Lists server names (optionally grouped by client). +- =ls_users.sh= :: Lists = = pairs. +- =inspect.sh= :: Shows effective config from DB and current system state for a given iface. +- =IP_rule_add_UID.sh= :: Helper installed to =/usr/local/bin= (adds =ip rule uidrange= entries idempotently). +- =stage_generate.sh= :: Builds staged artifacts from DB: + - =stage/wireguard/.conf= + - =stage/systemd/wg-quick@.d/restart.conf= + - =stage/usr/local/bin/routes_init_.sh= + - copies =IP_rule_add_UID.sh= into stage for install + - Offers to clean stage first; supports =--clean=, =--no-clean=, =--dry-clean= +- =stage_install.sh= :: Copies staged files into: + - =/etc/wireguard/.conf= + - =/etc/systemd/system/wg-quick@.d/restart.conf= + - =/usr/local/bin/routes_init_.sh= + - =/usr/local/bin/IP_rule_add_UID.sh= + - Reloads systemd daemon and prints next steps. +- =stage_clean.sh= :: Empties =./stage= safely (with confirmation). +- =routes_init_x6.sh= :: (Legacy) Example per-iface route script; superseded by staged =routes_init_.sh= +- =deprecated/= :: Old scripts retained for reference. +- =stage/= :: Generated artifacts awaiting installation. +- =scratchpad/= :: (Optional) Temporary workspace for ad-hoc edits before installation. + +* Schema (summary) +- =clients= + - =iface= (TEXT UNIQUE): bare interface name (e.g., ‘x6’) + - =rt_table_id= (INTEGER): e.g., 1002 + - =rt_table_name= (TEXT): defaults to iface if NULL (used by route scripts and =ip rule=) + - =bound_user= (TEXT), =bound_uid= (INTEGER): Linux user + UID that should egress via this iface + - =local_address_cidr=, =private_key=, =public_key=, =mtu=, =fwmark= + - =dns_mode= (‘none’ or ‘static’), =dns_servers= (if static) + - =autostart= (0/1) +- =servers= + - Linked by =client_id= → =clients.id= + - =name= (‘x6’), =public_key=, optional =preshared_key= + - =endpoint_host=, =endpoint_port=, =allowed_ips=, =keepalive_s= + - =route_allowed_ips= (0/1): when 0, =Table= is set to =off= in wg conf and routing is handled by our scripts + - =priority= (lower preferred) — first by priority then id is staged +- =routes= + - Linked by =client_id= + - =cidr=, optional =via=, optional =table_name= (else use client rt name), optional =metric= + - =on_up= (1/0), =on_down= (1/0) — generator emits only =on_up= routes in =routes_init_.sh= +- =meta= + - =schema= key describing current schema version/string + +* Generated files (stage/) +- wireguard/.conf :: + - =[Interface]= :: Address, PrivateKey, optional MTU/FwMark/DNS, optional =Table= off + - =[Peer]= :: Server public key, optional PSK, Endpoint, AllowedIPs, optional PersistentKeepalive +- systemd/wg-quick@.d/restart.conf :: + - Restart policy; force fresh link; =ExecStartPost= hooks: + - routes init script + - =IP_rule_add_UID.sh = (if bound) + - logger line +- usr/local/bin/routes_init_.sh :: + - Installs default route to device in =rt_table_name= and a blackhole default guard + - Adds any DB =routes= with =on_up=1 + +* Operational Notes +- =iface= names are bare (not prefixed with =wg_=). Systemd unit is =wg-quick@.service=. +- Unbound rides the tunnel; leave WireGuard DNS unset (=dns_mode=none=) unless you want static DNS in the conf. +- Copy-based install preserves an audit trail in =./stage=. Clean explicitly when desired. + +* Security +- The DB contains *private keys*. Restrict permissions: + #+begin_src bash + chmod 600 wg_client.db + #+end_src +- Back up =wg_client.db= securely. + +* Troubleshooting +- If unit fails to start: =journalctl -u wg-quick@ -b= +- Handshake age / peer state: =wg show= +- Routing: =ip rule=, =ip route show table = +- Regenerate & reinstall on mismatch: + #+begin_src bash + ./stage_generate.sh --clean && sudo ./stage_install.sh && sudo systemctl restart wg-quick@ + #+end_src diff --git a/developer/Python/wg/manual_user.org b/developer/Python/wg/manual_user.org new file mode 100644 index 0000000..bef4b37 --- /dev/null +++ b/developer/Python/wg/manual_user.org @@ -0,0 +1,104 @@ +#+title: WireGuard Client — Admin User Guide +#+author: Thomas / Aerenis +#+startup: showall + +* Overview +Authoritative state lives here: +- ~/executable/setup/Debian12_client/wg/ +- Keys + config live in *SQLite* (./db/store). +- You *stage* generated files in ./stage/, then *install* as root. +- Interface names are *bare* (e.g., =x6=, =US=). Unit: =wg-quick@=; config: =/etc/wireguard/.conf=. +- Unbound is used for DNS; typically =dns_mode= is =none= (no =DNS= line in WG conf). +- Staging dirs are not auto-cleaned; each of =db/=, =stage/=, =scratchpad/= contains a =.gitignore= that ignores everything except itself. + +* Typical Workflow (example: x6) +1) Initialize DB +#+begin_src bash +./db_init.sh +#+end_src + +2) Create/Update *client* record for this host (inserts the =x6= row) +#+begin_src bash +./config_client_StanleyPark.sh +#+end_src + +3) Create/rotate *client keys* (writes keys into DB for =x6=) +#+begin_src bash +./client_create_keys.sh x6 +#+end_src + +4) Configure the *remote server* record (x6) +#+begin_src bash +./config_server_x6.sh +#+end_src + +5) Bind Linux user(s) to interface (traffic steering via uid rules) +#+begin_src bash +./user_to_iface.sh Thomas-x6 x6 +# or bulk: +./user_all_to_iface.sh +#+end_src +Verify: +#+begin_src bash +./ls_users.sh +#+end_src + +6) Generate staged files (will offer to clean ./stage first) +#+begin_src bash +./stage_generate.sh +#+end_src +Review contents of =./stage= (WG conf, systemd drop-in, route script). + +7) Install (as root) — copies staged files into the system +#+begin_src bash +sudo ./stage_install.sh +#+end_src + +8) Enable & start the interface +#+begin_src bash +sudo systemctl enable wg-quick@x6 +sudo systemctl start wg-quick@x6 +#+end_src + +9) Inspect / validate +#+begin_src bash +./inspect.sh x6 +ip rule | grep x6 +ip route show table x6 +wg show +#+end_src + +* Key Rotation (client) +- Update keys in DB and redeploy: +#+begin_src bash +./client_create_keys.sh x6 +./stage_generate.sh --clean +sudo ./stage_install.sh +sudo systemctl restart wg-quick@x6 +#+end_src +- Then update the *server’s* peer public key accordingly. + +* Listing helpers +- Interfaces: +#+begin_src bash +./ls_clients.sh # prints: x6, US, ... +#+end_src +- Servers (per client): +#+begin_src bash +./ls_servers.sh # prints server names per client +#+end_src +- User bindings: +#+begin_src bash +./ls_users.sh # prints: +#+end_src + +* Notes +- =./stage= is not auto-cleaned. Use: +#+begin_src bash +./stage_clean.sh +#+end_src +- Protect your DB (contains private keys): +#+begin_src bash +chmod 700 db +chmod 600 db/store +#+end_src diff --git a/developer/Python/wg/mothball/stage/.gitignore b/developer/Python/wg/mothball/stage/.gitignore new file mode 100644 index 0000000..53642ce --- /dev/null +++ b/developer/Python/wg/mothball/stage/.gitignore @@ -0,0 +1,4 @@ + +* +!.gitignore + diff --git a/developer/Python/wg/mothball/stage_IP_routes_script.py b/developer/Python/wg/mothball/stage_IP_routes_script.py new file mode 100755 index 0000000..d1ec126 --- /dev/null +++ b/developer/Python/wg/mothball/stage_IP_routes_script.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +# stage_IP_route_script.py — emit /usr/local/bin/route_init_.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 +# Output: stage/usr/local/bin/route_init_.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} ", 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:])) diff --git a/developer/Python/wg/mothball/stage_IP_rules_script.py b/developer/Python/wg/mothball/stage_IP_rules_script.py new file mode 100755 index 0000000..7fae716 --- /dev/null +++ b/developer/Python/wg/mothball/stage_IP_rules_script.py @@ -0,0 +1,78 @@ +#!/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 → and per-UID rules +- Appends a final PROHIBIT for subu_cidr to enforce hard containment +- Writes: stage/usr/local/bin/ (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:])) diff --git a/developer/Python/wg/mothball/stage_StanleyPark.py b/developer/Python/wg/mothball/stage_StanleyPark.py new file mode 100644 index 0000000..c374029 --- /dev/null +++ b/developer/Python/wg/mothball/stage_StanleyPark.py @@ -0,0 +1,139 @@ +#!/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:])) diff --git a/developer/Python/wg/mothball/stage_UID_routes.py b/developer/Python/wg/mothball/stage_UID_routes.py new file mode 100755 index 0000000..7dfeb31 --- /dev/null +++ b/developer/Python/wg/mothball/stage_UID_routes.py @@ -0,0 +1,67 @@ +#!/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:])) diff --git a/developer/Python/wg/mothball/stage_list_clients.py b/developer/Python/wg/mothball/stage_list_clients.py new file mode 100755 index 0000000..a36657a --- /dev/null +++ b/developer/Python/wg/mothball/stage_list_clients.py @@ -0,0 +1,92 @@ +#!/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:])) diff --git a/developer/Python/wg/mothball/stage_list_uid.py b/developer/Python/wg/mothball/stage_list_uid.py new file mode 100644 index 0000000..5acf312 --- /dev/null +++ b/developer/Python/wg/mothball/stage_list_uid.py @@ -0,0 +1,31 @@ +#!/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} ", file=sys.stderr) + return 2 + return list_uid(int(argv[0])) + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/developer/Python/wg/mothball/stage_populate.py b/developer/Python/wg/mothball/stage_populate.py new file mode 100644 index 0000000..bcb803a --- /dev/null +++ b/developer/Python/wg/mothball/stage_populate.py @@ -0,0 +1,133 @@ +#!/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:])) diff --git a/developer/Python/wg/mothball/stage_preferred_server.py b/developer/Python/wg/mothball/stage_preferred_server.py new file mode 100644 index 0000000..8e39d4d --- /dev/null +++ b/developer/Python/wg/mothball/stage_preferred_server.py @@ -0,0 +1,35 @@ +#!/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} ", 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:])) diff --git a/developer/Python/wg/mothball/stage_wg_conf.py b/developer/Python/wg/mothball/stage_wg_conf.py new file mode 100644 index 0000000..a395db2 --- /dev/null +++ b/developer/Python/wg/mothball/stage_wg_conf.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +# stage_wg_conf.py — write stage/wireguard/.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:])) diff --git a/developer/Python/wg/mothball/stage_wg_unit_IP_scripts.py b/developer/Python/wg/mothball/stage_wg_unit_IP_scripts.py new file mode 100644 index 0000000..cef5abb --- /dev/null +++ b/developer/Python/wg/mothball/stage_wg_unit_IP_scripts.py @@ -0,0 +1,36 @@ +#!/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} ", 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:])) diff --git a/developer/Python/wg/mothball/stage_wipe.py b/developer/Python/wg/mothball/stage_wipe.py new file mode 100755 index 0000000..161ab79 --- /dev/null +++ b/developer/Python/wg/mothball/stage_wipe.py @@ -0,0 +1,97 @@ +#!/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:])) diff --git a/developer/Python/wg/scratchpad/.gitignore b/developer/Python/wg/scratchpad/.gitignore new file mode 100644 index 0000000..53642ce --- /dev/null +++ b/developer/Python/wg/scratchpad/.gitignore @@ -0,0 +1,4 @@ + +* +!.gitignore + diff --git a/developer/Python/wg/stage_IP_routes_script.py b/developer/Python/wg/stage_IP_routes_script.py new file mode 100644 index 0000000..d00189f --- /dev/null +++ b/developer/Python/wg/stage_IP_routes_script.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +# stage_IP_routes_script.py — emit /usr/local/bin/routes_init_.sh + +from __future__ import annotations +import sqlite3, sys +from pathlib import Path +import incommon as ic # open_db, rows (works with DB_PATH) + +ROOT = Path(__file__).resolve().parent +STAGE_ROOT = ROOT / "stage" + +def _sq(s: str) -> str: + return "'" + s.replace("'", "'\"'\"'") + "'" + +def stage_ip_routes_script(iface: str) -> Path: + """ + Given an iface name, queries DB for its table name and preferred server, + and writes a runtime script that: + 1) sets default + blackhole default in the iface's route table + 2) pins the server endpoint /32 via the current GW/NIC (metric 5) + 3) applies extra Route rows (on_up=1) + Returns the path written. + """ + with ic.open_db() as conn: + row = conn.execute( + "SELECT id, rt_table_name_eff FROM v_iface_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]) + + 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 = (srow[0] if srow and srow[0] else "") + ep_port = (srow[1] if srow and srow[1] else "") + + 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,)) + + out = STAGE_ROOT / "usr" / "local" / "bin" / f"routes_init_{iface}.sh" + out.parent.mkdir(parents=True, exist_ok=True) + + lines = [ + "#!/usr/bin/env bash", + "set -euo pipefail", + f"table={_sq(rtname)}", + f"dev={_sq(iface)}", + f"endpoint_host={_sq(str(ep_host))}", + f"endpoint_port={_sq(str(ep_port))}", + "", + "# 1) Default in dedicated table", + 'ip -4 route replace default dev "$dev" table "$table"', + 'ip -4 route replace blackhole default metric 32767 table "$table"', + "", + "# 2) Keep peer endpoint reachable outside the tunnel", + 'ep_ip=$(getent ahostsv4 "$endpoint_host" | awk \'NR==1{print $1}\')', + 'if [[ -n "$ep_ip" ]]; then', + ' gw=$(ip -4 route get "$ep_ip" | awk \'/ via /{print $3; exit}\')', + ' nic=$(ip -4 route get "$ep_ip" | awk \'/ dev /{for(i=1;i<=NF;i++) if ($i=="dev"){print $(i+1); exit}}\')', + ' if [[ -n "$gw" && -n "$nic" ]]; then', + ' ip -4 route replace "${ep_ip}/32" via "$gw" dev "$nic" metric 5', + ' fi', + 'fi', + "", + "# 3) Extra routes 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.write_text("\n".join(lines) + "\n") + out.chmod(0o500) + return out + +def main(argv): + if len(argv) != 1: + print(f"Usage: {Path(sys.argv[0]).name} ", file=sys.stderr); return 2 + try: + p = stage_ip_routes_script(argv[0]) + except (sqlite3.Error, FileNotFoundError, RuntimeError) as e: + print(f"❌ {e}", file=sys.stderr); return 1 + print(f"staged: {p.relative_to(ROOT)}") + return 0 + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/developer/Python/wg/stage_IP_rules_script.py b/developer/Python/wg/stage_IP_rules_script.py new file mode 100644 index 0000000..eaa25b1 --- /dev/null +++ b/developer/Python/wg/stage_IP_rules_script.py @@ -0,0 +1,272 @@ +#!/usr/bin/env python3 +""" +stage_IP_rules_script.py — synthesize & stage an ip-rule script (scoped to given users) +and per-interface systemd drop-ins that invoke it after wg-quick@IFACE up. + +Inputs (CLI): + stage_IP_rules_script.py [ ...] + - Looks up users in DB, finds their iface bindings & UIDs, and emits: + 1) stage/usr/local/bin/set_subu_IP_rules.sh + 2) stage/etc/systemd/wg-quick@IFACE.service.d/10-postup-IP-rules.conf (per iface) + +Notes: + - Script lines are idempotent via a small helper (grep before ip rule add). + - We include per-iface source-CIDR rule and per-user UID rules, plus a SUBU containment rule. + - Only the ifaces required by the provided users are staged. +""" + +from __future__ import annotations +import ipaddress +import sqlite3 +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, Iterable, List, Optional, Sequence, Tuple + +import incommon as ic # expected to provide: open_db(), rows() + +ROOT = Path(__file__).resolve().parent +STAGE_ROOT = ROOT / "stage" +OUTPUT_SCRIPT_NAME = "set_subu_IP_rules.sh" + + +# ---------- Data models ---------- + +@dataclass +class IfaceInfo: + iface_id: int + iface_name: str + rt_table_name_eff: str + local_address_cidr: str # e.g., '10.8.0.2/32' + + +@dataclass +class UserBinding: + username: str + uid: int + iface: IfaceInfo + + +# ---------- DB access ---------- + +def load_user_bindings(conn: sqlite3.Connection, usernames: Sequence[str]) -> List[UserBinding]: + """ + Return one UserBinding per (username, iface) row for the given usernames. + Requires uid to be non-null; raises if unknown usernames or missing UIDs. + """ + if not usernames: + return [] + + # Fetch bindings joined with effective iface info + placeholders = ",".join("?" for _ in usernames) + sql = f""" + SELECT + ub.username, + ub.uid, + i.id AS iface_id, + i.iface AS iface_name, + v.rt_table_name_eff, + v.local_address_cidr + FROM User ub + JOIN Iface i ON i.id = ub.iface_id + JOIN v_iface_effective v ON v.id = i.id + WHERE ub.username IN ({placeholders}) + ORDER BY ub.username, i.iface; + """ + rows = conn.execute(sql, tuple(usernames)).fetchall() + + found_usernames = {r[0] for r in rows} + missing = [u for u in usernames if u not in found_usernames] + if missing: + raise RuntimeError(f"user(s) not found in User: {', '.join(missing)}") + + bindings: List[UserBinding] = [] + for (username, uid, iface_id, iface_name, rtname, cidr) in rows: + if uid is None or str(uid) == "": + raise RuntimeError(f"user '{username}' has no cached UID in DB (User.uid is NULL)") + bindings.append( + UserBinding( + username=str(username), + uid=int(uid), + iface=IfaceInfo( + iface_id=int(iface_id), + iface_name=str(iface_name), + rt_table_name_eff=str(rtname), + local_address_cidr=str(cidr), + ), + ) + ) + return bindings + + +def load_subu_cidr(conn: sqlite3.Connection, default: str = "10.0.0.0/24") -> str: + row = conn.execute("SELECT value FROM Meta WHERE key='subu_cidr' LIMIT 1;").fetchone() + return str(row[0]) if row and row[0] else default + + +# ---------- Validation ---------- + +def assert_unique_hosts_in_subu(selected_ifaces: Iterable[IfaceInfo], subu_cidr: str) -> None: + """ + Ensure there are no duplicate host IPs within the SUBU network among the selected ifaces. + Only checks addresses that fall inside subu_cidr. + """ + net = ipaddress.IPv4Network(subu_cidr, strict=False) + seen: Dict[str, str] = {} + for info in selected_ifaces: + ip = str(ipaddress.IPv4Interface(info.local_address_cidr).ip) + if ipaddress.IPv4Address(ip) in net: + if ip in seen and seen[ip] != info.iface_name: + raise RuntimeError(f"duplicate SUBU IP {ip} on {seen[ip]} and {info.iface_name}") + seen[ip] = info.iface_name + + +# ---------- Script synthesis ---------- + +def synthesize_ip_rule_script(bindings: List[UserBinding], subu_cidr: str) -> List[str]: + """ + Build the shell script lines implementing: + - per-iface source-based rule: from lookup
+ - per-user UID rule: uidrange U-U lookup
+ - SUBU containment rule: from prohibit + The helper add_IP_rule_if_not_exists makes rules idempotent. + """ + # Group users by iface + by_iface: Dict[int, Dict[str, object]] = {} + for b in bindings: + key = b.iface.iface_id + entry = by_iface.setdefault( + key, + { + "iface": b.iface.iface_name, + "rtname": b.iface.rt_table_name_eff, + "addr": b.iface.local_address_cidr, + "uids": set(), # type: ignore[dict-item] + }, + ) + entry["uids"].add(b.uid) # type: ignore[index] + + lines: List[str] = [ + "#!/usr/bin/env bash", + "# Enforce IPv4 rules for selected users; 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; }', + "", + ] + + # Emit per-iface blocks + for _, data in sorted(by_iface.items(), key=lambda kv: kv[1]["iface"]): # type: ignore[index] + iface = str(data["iface"]) + table = str(data["rtname"]) + src_cidr = str(data["addr"]) + uids = sorted(int(u) for u in data["uids"]) # type: ignore[index] + + lines.append(f"# iface={iface} table={table} src={src_cidr}") + # source-based rule + lines.append( + f'add_IP_rule_if_not_exists "from {src_cidr} lookup {table}" from "{src_cidr}" lookup "{table}" pref 17000' + ) + # uid-based rules + for u in uids: + lines.append( + f'add_IP_rule_if_not_exists "uidrange {u}-{u} lookup {table}" uidrange "{u}-{u}" lookup "{table}" pref 17010' + ) + lines.append("") + + # SUBU containment (keeps traffic within the SUBU prefix from escaping unintended paths) + lines += [ + "# hard containment for SUBU address space", + f'add_IP_rule_if_not_exists "from {subu_cidr} prohibit" from "{subu_cidr}" prohibit pref 18050', + "", + ] + return lines + + +def stage_ip_rule_script(lines: List[str], stage_root: Optional[Path] = None) -> Path: + sr = stage_root or STAGE_ROOT + out = sr / "usr" / "local" / "bin" / OUTPUT_SCRIPT_NAME + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text("\n".join(lines)) + out.chmod(0o500) + return out + + +# ---------- systemd drop-in synthesis ---------- + +def stage_wg_systemd_postup_ip_dropin(iface_name: str, script_path_in_unit: str, stage_root: Optional[Path] = None) -> Path: + """ + Write a per-interface systemd override so wg-quick@IFACE runs the rules script on 'up'. + """ + sr = stage_root or STAGE_ROOT + dropdir = sr / "etc" / "systemd" / f"wg-quick@{iface_name}.service.d" + dropdir.mkdir(parents=True, exist_ok=True) + path = dropdir / "10-postup-IP-rules.conf" + content = f"""[Service] +# Ensure our ip rules are applied after wg-quick brings {iface_name} up +ExecStartPost=+{script_path_in_unit} +ExecStartPost=+/usr/bin/logger 'wg-quick@{iface_name} up: ip rules applied' +""" + path.write_text(content) + return path + + +# ---------- Orchestration ---------- + +def stage_for_users(usernames: Sequence[str], stage_root: Optional[Path] = None) -> Tuple[Path, Dict[str, Path], str]: + """ + High-level: resolve users → bindings, validate, synthesize script, stage drop-ins (only needed ifaces). + Returns (script_path, {iface: dropin_path}, summary) + """ + if not usernames: + raise RuntimeError("no usernames provided") + + with ic.open_db() as conn: + bindings = load_user_bindings(conn, usernames) + # Derive iface set from bindings + iface_infos = {b.iface.iface_id: b.iface for b in bindings}.values() + subu = load_subu_cidr(conn, "10.0.0.0/24") + assert_unique_hosts_in_subu(iface_infos, subu) + + # Synthesize & stage the rules script + script_lines = synthesize_ip_rule_script(bindings, subu) + script_path = stage_ip_rule_script(script_lines, stage_root=stage_root) + + # Stage systemd drop-ins for just the ifaces we touched + dropins: Dict[str, Path] = {} + for iface in sorted({b.iface.iface_name for b in bindings}): + dropins[iface] = stage_wg_systemd_postup_ip_dropin( + iface_name=iface, + script_path_in_unit=f"/usr/local/bin/{OUTPUT_SCRIPT_NAME}", + stage_root=stage_root, + ) + + # Build a compact summary + per_iface = ",".join( + f"{iface}:{','.join(str(b.uid) for b in sorted({ub.uid for ub in bindings if ub.iface.iface_name==iface})) or '-'}" + for iface in sorted({b.iface.iface_name for b in bindings}) + ) + summary = f"users={len(set(b.username for b in bindings))}, ifaces={len(dropins)} ({per_iface}), rules_script={script_path.relative_to(stage_root or STAGE_ROOT)}" + return script_path, dropins, summary + + +# ---------- CLI ---------- + +def _print_usage_and_exit() -> None: + prog = Path(sys.argv[0]).name + print(f"Usage: {prog} [ ...]", file=sys.stderr) + sys.exit(2) + + +if __name__ == "__main__": + if len(sys.argv) < 2: + _print_usage_and_exit() + try: + script_path, dropins, summary = stage_for_users(sys.argv[1:]) + except Exception as e: + print(f"error: {e}", file=sys.stderr) + sys.exit(1) + print(f"staged: stage/{script_path.relative_to(STAGE_ROOT)}") + for iface, p in dropins.items(): + print(f"staged: stage/{p.relative_to(STAGE_ROOT)} (iface {iface})") + print(summary) diff --git a/developer/Python/wg/stage_wg_conf.py b/developer/Python/wg/stage_wg_conf.py new file mode 100644 index 0000000..4662d91 --- /dev/null +++ b/developer/Python/wg/stage_wg_conf.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +# stage_wg_conf.py — write stage/wireguard/.conf (Table=off) + +from __future__ import annotations +from pathlib import Path +from typing import Optional, Union + +ROOT = Path(__file__).resolve().parent +STAGE_ROOT = ROOT / "stage" + +def write_wg_conf( + out_path: Path, + *, + addr: str, + private_key: str, + mtu: Optional[Union[int, str]] = None, + fwmark: Optional[Union[int, str]] = None, + dns_mode: str = "none", + dns_servers: Optional[str] = None, + peer_pub: str, + psk: Optional[str], + host: str, + port: Union[int, str], + allowed: str, + keepalive: Optional[Union[int, str]] = None, +) -> Path: + """ + Given WG interface params + peer params, writes a config with Table=off. + Returns the written path. + """ + out_path.parent.mkdir(parents=True, exist_ok=True) + lines = [] + lines.append("[Interface]") + lines.append(f"Address = {addr}") + lines.append(f"PrivateKey = {private_key}") + if mtu not in (None, "", 0): lines.append(f"MTU = {mtu}") + if fwmark not in (None, "", 0): lines.append(f"FwMark = {fwmark}") + if dns_mode == "static" and dns_servers: + lines.append(f"DNS = {dns_servers}") + # policy routing handled by our scripts, not wg-quick + lines.append("Table = off") + lines.append("") + lines.append("[Peer]") + lines.append(f"PublicKey = {peer_pub}") + if psk: lines.append(f"PresharedKey = {psk}") + lines.append(f"Endpoint = {host}:{port}") + lines.append(f"AllowedIPs = {allowed}") + if keepalive not in (None, "", 0): lines.append(f"PersistentKeepalive = {keepalive}") + out_path.write_text("\n".join(lines) + "\n") + out_path.chmod(0o400) + return out_path + +# Optional CLI wrapper +if __name__ == "__main__": + import sys, argparse + ap = argparse.ArgumentParser() + ap.add_argument("out") + ap.add_argument("--addr", required=True) + ap.add_argument("--priv", required=True) + ap.add_argument("--mtu") + ap.add_argument("--fwmark") + ap.add_argument("--dns-mode", default="none") + ap.add_argument("--dns-servers") + ap.add_argument("--peer-pub", required=True) + ap.add_argument("--psk") + ap.add_argument("--host", required=True) + ap.add_argument("--port", required=True) + ap.add_argument("--allowed", required=True) + ap.add_argument("--keepalive") + args = ap.parse_args() + p = write_wg_conf( + Path(args.out), + addr=args.addr, + private_key=args.priv, + mtu=args.mtu, fwmark=args.fwmark, + dns_mode=args.dns_mode, dns_servers=args.dns_servers, + peer_pub=args.peer_pub, psk=args.psk, + host=args.host, port=args.port, allowed=args.allowed, + keepalive=args.keepalive, + ) + print(f"staged: {p}") diff --git a/developer/Python/wg/stage_wg_systemd_postup_ip_dropin.py b/developer/Python/wg/stage_wg_systemd_postup_ip_dropin.py new file mode 100644 index 0000000..8c94222 --- /dev/null +++ b/developer/Python/wg/stage_wg_systemd_postup_ip_dropin.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +# stage_wg_systemd_postup_ip_dropin.py — stage a systemd unit override ("dropin") +# for wg-quick@IFACE to run IP policy + route scripts after the interface comes up. + +from __future__ import annotations +from pathlib import Path +from typing import Optional +import argparse +import re + +ROOT = Path(__file__).resolve().parent +STAGE_ROOT = ROOT / "stage" + +_IFACE_WELLFORMED_RE = re.compile(r"^[A-Za-z0-9_.-]+$") + +def wellformed_iface_name_guard(iface: str) + if not iface or not _IFACE_WELLFORMED_RE.match(iface): + raise ValueError(f"Invalid iface '{iface}': allowed chars are A–Z, a–z, 0–9, _ . -") + +def stage_wg_systemd_postup_ip_dropin(iface: str ,*) -> Path: + """ + Create and stage a systemd droppin for setting wg rules and routes + - ExecStartPre: delete a stale dev 'iface' before wg-quick runs. + - ExecStartPost: set the tunnel ip rules and routes + /usr/local/bin/wg_IP_rules.sh + - Emit a log line via logger. + + Returns: Path to the staged drop-in file. + """ + wellformed_iface_name_guard(iface) + sr = stage_root or STAGE_ROOT + dropin_dir = sr / "etc" / "systemd" / f"wg-quick@{iface}.service.d" + dropin_dir.mkdir(parents=True, exist_ok=True) + dropin_conf_path = dropin_dir / "10-postup-IP-scripts.conf" + + restart_lines = "Restart=on-failure\nRestartSec=5" if restart_on_failure else "" + pre_line = f"ExecStartPre=-/usr/sbin/ip link delete {iface}" if delete_iface_pre else "" + policy_line = ( + "ExecStartPost=+/usr/local/bin/set_subu_IP_rules.sh" + if use_global_policy_script + else f"ExecStartPost=+/usr/local/bin/policy_init_{iface}.sh" + ) + + content = f"""[Service] +{restart_lines} +{pre_line} +{policy_line} +ExecStartPost=+/usr/local/bin/routes_init_{iface}.sh +ExecStartPost=+/usr/bin/logger 'wg-quick@{iface} up: rules+routes applied' +""" + # Remove blank lines if some options are disabled + content = "\n".join(ln for ln in content.splitlines() if ln.strip()) + "\n" + dropin_conf_path.write_text(content) + return dropin_conf_path + +# ----- CLI ----- +if __name__ == "__main__": + p = argparse.ArgumentParser( + description="Stage a systemd drop-in to run IP policy+routes after wg-quick@IFACE up" + ) + p.add_argument("iface", help="WireGuard interface name (e.g., x6)") + p.add_argument("--per-iface-policy", action="store_true", + help="Use /usr/local/bin/policy_init_.sh instead of global set_subu_IP_rules.sh") + p.add_argument("--stage-root", type=Path, default=None, + help="Custom staging root (default: ./stage next to this script)") + p.add_argument("--no-delete-pre", action="store_true", + help="Do not delete a stale iface before wg-quick runs") + p.add_argument("--no-restart", action="store_true", + help="Do not set Restart=on-failure/RestartSec=5") + args = p.parse_args() + + out = stage_wg_systemd_postup_ip_dropin( + args.iface, + use_global_policy_script=not args.per_iface_policy, + stage_root=args.stage_root, + delete_iface_pre=not args.no_delete_pre, + restart_on_failure=not args.no_restart, + ) + print(f"staged: {out.relative_to(ROOT)}") + + diff --git a/developer/Python/wg/stage_wipe.py b/developer/Python/wg/stage_wipe.py new file mode 100644 index 0000000..9270e13 --- /dev/null +++ b/developer/Python/wg/stage_wipe.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +# stage_wipe.py — safely wipe ./stage (keeps hidden files unless --hard) + +from __future__ import annotations +import argparse, shutil, sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent +STAGE_ROOT = ROOT / "stage" + +def wipe_stage(*, yes: bool=False, dry_run: bool=False, hard: bool=False) -> int: + """Given flags, deletes staged output. Keeps dotfiles unless hard=True.""" + st = STAGE_ROOT + if not st.exists(): + print(f"Nothing to wipe: {st} does not exist.") + return 0 + + # safety: only operate on ./stage relative to this repo folder + if st.resolve() != (ROOT / "stage").resolve(): + print(f"Refusing: unsafe STAGE path: {st}", file=sys.stderr) + return 1 + + # quick stats + try: + count = sum(1 for _ in st.rglob("*")) + except Exception: + count = 0 + + if dry_run: + print(f"DRY RUN — would wipe: {st} (items: {count})") + for p in sorted(st.iterdir()): + print(f" {p.name}") + return 0 + + if not yes: + try: + ans = input(f"Permanently delete contents of {st}? [y/N] ").strip() + except EOFError: + ans = "" + if ans.lower() not in ("y","yes"): + print("Aborted.") + return 0 + + if hard: + shutil.rmtree(st, ignore_errors=True) + print(f"Removed stage dir: {st}") + else: + # remove non-hidden entries only; keep dotfiles (e.g. .gitignore) + for p in st.iterdir(): + if p.name.startswith("."): + continue # preserve hidden entries + try: + if p.is_dir(): + shutil.rmtree(p, ignore_errors=True) + else: + p.unlink(missing_ok=True) + except Exception: + pass + print(f"Cleared contents of: {st} (hidden files preserved)") + return 0 + +def main(argv): + ap = argparse.ArgumentParser() + 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") + ap.add_argument("--hard", action="store_true", help="remove the stage dir itself") + args = ap.parse_args(argv) + return wipe_stage(yes=args.yes, dry_run=args.dry_run, hard=args.hard) + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/developer/Python/wg/wg_keys_incommon.py b/developer/Python/wg/wg_keys_incommon.py new file mode 100644 index 0000000..1578899 --- /dev/null +++ b/developer/Python/wg/wg_keys_incommon.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +# wg_keys_incommon.py — predicates + actuators for WG keypairs + +from __future__ import annotations +import shutil, subprocess, sqlite3 + +def wellformed_client_keypair(conn: sqlite3.Connection, iface: str) -> bool: + """Predicate: True iff client IFACE has a syntactically valid WG keypair.""" + row = conn.execute( + "SELECT private_key, public_key FROM Iface WHERE iface=? LIMIT 1;", (iface,) + ).fetchone() + if not row: return False + priv, pub = (row[0] or ""), (row[1] or "") + return (43 <= len(priv.strip()) <= 45) and (43 <= len(pub.strip()) <= 45) + +def generate_client_keypair_if_missing(conn: sqlite3.Connection, iface: str) -> bool: + """ + Actuator: if IFACE lacks a well-formed keypair, generate one with `wg`, + store it in the DB, and return True. Return False if nothing changed. + """ + if wellformed_client_keypair(conn, iface): + return False + if not shutil.which("wg"): + raise RuntimeError("wg not found; cannot generate keys") + gen = subprocess.run(["wg","genkey"], capture_output=True, text=True, check=True) + priv = gen.stdout.strip() + pubp = subprocess.run(["wg","pubkey"], input=priv.encode(), capture_output=True, check=True) + pub = pubp.stdout.decode().strip() + conn.execute( + "UPDATE Iface SET private_key=?, public_key=?, " + "updated_at=strftime('%Y-%m-%dT%H:%M:%SZ','now') WHERE iface=?", + (priv, pub, iface), + ) + return True -- 2.20.1