From: Thomas Walker Lynch Date: Sat, 13 Sep 2025 14:33:09 +0000 (-0700) Subject: directory reorg X-Git-Url: https://git.reasoningtechnology.com/style/static/gitweb.css?a=commitdiff_plain;h=7b22593dbf93069a56823b540578a0e044027524;p=subu directory reorg --- diff --git a/developer/Python/wg/.gitignore b/developer/Python/wg/.gitignore deleted file mode 100644 index 5c016c6..0000000 --- a/developer/Python/wg/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ - -__pycache__ - diff --git a/developer/Python/wg/db/.gitignore b/developer/Python/wg/db/.gitignore deleted file mode 100644 index 53642ce..0000000 --- a/developer/Python/wg/db/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ - -* -!.gitignore - diff --git a/developer/Python/wg/db_bind_user_to_iface.py b/developer/Python/wg/db_bind_user_to_iface.py deleted file mode 100755 index 1ec4700..0000000 --- a/developer/Python/wg/db_bind_user_to_iface.py +++ /dev/null @@ -1,68 +0,0 @@ -#!/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 deleted file mode 100755 index ef172de..0000000 --- a/developer/Python/wg/db_checks.py +++ /dev/null @@ -1,84 +0,0 @@ -#!/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 deleted file mode 100755 index 2965415..0000000 --- a/developer/Python/wg/db_init_StanleyPark.py +++ /dev/null @@ -1,78 +0,0 @@ -#!/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 -from db_init_ip_table_registration import assign_missing_rt_table_ids -from db_init_ip_iface_addr_assign import reconcile_kernel_and_db_ipv4_addresses - -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") - - msg_wrapped_call( - "db_init_ip_table_registration" - ,lambda: assign_missing_rt_table_ids(conn ,low=20000 ,high=29999 ,dry_run=False) - ) - - msg_wrapped_call( - "db_init_ip_iface_addr_assign" - ,lambda: reconcile_kernel_and_db_ipv4_addresses(conn ,pool_cidr="10.0.0.0/16" ,assign_prefix=32 ,reserve_first=0 ,dry_run=False) - ) - - 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_iface.py b/developer/Python/wg/db_init_iface.py deleted file mode 100644 index 1f9443e..0000000 --- a/developer/Python/wg/db_init_iface.py +++ /dev/null @@ -1,72 +0,0 @@ -#!/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 - -# Normally don't set the addr_cidr, the system will automically -# assign a free address, or reuse one that is already set. - -def upsert_client(conn: sqlite3.Connection, - *, - iface: str, - addr_cidr: Optional[str] = None, - 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 deleted file mode 100755 index bf03c95..0000000 --- a/developer/Python/wg/db_init_iface_US.py +++ /dev/null @@ -1,6 +0,0 @@ -# db_init_iface_US.py -from db_init_iface 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", rt_table_name="US") diff --git a/developer/Python/wg/db_init_iface_x6.py b/developer/Python/wg/db_init_iface_x6.py deleted file mode 100755 index 82eb5fe..0000000 --- a/developer/Python/wg/db_init_iface_x6.py +++ /dev/null @@ -1,6 +0,0 @@ -# db_init_iface_x6.py -from db_init_iface import upsert_client - -def init_iface_x6(conn): - # iface x6 with dedicated table 'x6' and host /32 - return upsert_client(conn, iface="x6", rt_table_name="x6") diff --git a/developer/Python/wg/db_init_ip_iface_addr_assign.py b/developer/Python/wg/db_init_ip_iface_addr_assign.py deleted file mode 100755 index 561635e..0000000 --- a/developer/Python/wg/db_init_ip_iface_addr_assign.py +++ /dev/null @@ -1,224 +0,0 @@ -#!/usr/bin/env python3 -""" -db_init_ip_iface_addr_assign.py - -Business API: - reconcile_kernel_and_db_ipv4_addresses(conn ,pool_cidr="10.0.0.0/16" ,assign_prefix=32 ,reserve_first=0 ,dry_run=False) - -> (updated_count ,notes) -""" - -from __future__ import annotations -import argparse -import ipaddress -import json -import sqlite3 -import subprocess -from typing import Dict ,Iterable ,List ,Optional ,Sequence ,Tuple - -import incommon as ic - - -def fetch_ifaces(conn: sqlite3.Connection) -> List[Tuple[int ,str ,Optional[str]]]: - sql = """ - SELECT id, - iface, - NULLIF(TRIM(local_address_cidr),'') AS local_address_cidr - FROM Iface - ORDER BY id; - """ - cur = conn.execute(sql) - rows = cur.fetchall() - return [ - (int(r[0]) ,str(r[1]) ,(str(r[2]) if r[2] is not None else None)) - for r in rows - ] - - -def update_iface_addresses(conn: sqlite3.Connection ,updates: Dict[int ,str]) -> int: - if not updates: - return 0 - with conn: - for iface_id ,cidr in updates.items(): - conn.execute("UPDATE Iface SET local_address_cidr=? WHERE id=?" ,(cidr ,iface_id)) - return len(updates) - - -def kernel_ipv4_cidr_for(iface: str) -> Optional[str]: - try: - cp = subprocess.run( - ["ip","-j","addr","show","dev",iface] - ,check=False - ,capture_output=True - ,text=True - ) - except Exception: - return None - if cp.returncode != 0 or not cp.stdout.strip(): - return None - try: - data = json.loads(cp.stdout) - except json.JSONDecodeError: - return None - if not isinstance(data ,list) or not data: - return None - addr_info = data[0].get("addr_info") or [] - for a in addr_info: - if a.get("family") == "inet" and a.get("scope") == "global": - local = a.get("local"); plen = a.get("prefixlen") - if local and isinstance(plen ,int): - return f"{local}/{plen}" - for a in addr_info: - if a.get("family") == "inet": - local = a.get("local"); plen = a.get("prefixlen") - if local and isinstance(plen ,int): - return f"{local}/{plen}" - return None - - -def kernel_ipv4_map(ifaces: Sequence[str]) -> Dict[str ,Optional[str]]: - return {name: kernel_ipv4_cidr_for(name) for name in ifaces} - - -def _host_ip_from_cidr(cidr: str): - try: - ipi = ipaddress.ip_interface(cidr) - except ValueError: - return None - if isinstance(ipi.ip ,ipaddress.IPv4Address): - return ipaddress.IPv4Address(int(ipi.ip)) - return None - - -def _collect_used_hosts_from(cidrs: Iterable[str] ,pool: ipaddress.IPv4Network) -> List[ipaddress.IPv4Address]: - used: List[ipaddress.IPv4Address] = [] - for c in cidrs: - hip = _host_ip_from_cidr(c) - if hip is not None and hip in pool: - used.append(hip) - return used - - -def _first_free_hosts( - count: int - ,used_hosts: Iterable[ipaddress.IPv4Address] - ,pool: ipaddress.IPv4Network - ,reserve_first: int = 0 -) -> List[ipaddress.IPv4Address]: - used_set = {int(h) for h in used_hosts} - result: List[ipaddress.IPv4Address] = [] - start = int(pool.network_address) + 1 + max(0 ,reserve_first) - end = int(pool.broadcast_address) - 1 - for val in range(start ,end+1): - if val not in used_set: - result.append(ipaddress.IPv4Address(val)) - if len(result) >= count: - break - if len(result) < count: - raise RuntimeError(f"address pool exhausted in {pool} (needed {count} more)") - return result - - -def plan_address_updates( - rows: Sequence[Tuple[int ,str ,Optional[str]]] - ,pool_cidr: str - ,assign_prefix: int - ,reserve_first: int - ,kmap: Dict[str ,Optional[str]] -) -> Tuple[Dict[int ,str] ,List[str]]: - notes: List[str] = [] - pool = ipaddress.IPv4Network(pool_cidr ,strict=False) - if pool.version != 4: - raise ValueError("only IPv4 pools supported") - - kernel_present = [c for c in kmap.values() if c] - db_present = [c for (_i ,_n ,c) in rows if c] - used_hosts = ( - _collect_used_hosts_from(kernel_present ,pool) - + _collect_used_hosts_from(db_present ,pool) - ) - - alloc_targets: List[Tuple[int ,str]] = [] - updates: Dict[int ,str] = {} - - for iface_id ,iface_name ,db_cidr in rows: - k_cidr = kmap.get(iface_name) - - if k_cidr: - if db_cidr != k_cidr: - updates[iface_id] = k_cidr - if db_cidr: - notes.append(f"sync: iface '{iface_name}' DB {db_cidr} -> kernel {k_cidr}") - else: - notes.append(f"sync: iface '{iface_name}' set from kernel {k_cidr}") - continue - - if db_cidr: - notes.append(f"note: iface '{iface_name}' has DB {db_cidr} but no kernel IPv4") - continue - - alloc_targets.append((iface_id ,iface_name)) - - if alloc_targets: - free = _first_free_hosts(len(alloc_targets) ,used_hosts ,pool ,reserve_first=reserve_first) - for idx ,(iface_id ,iface_name) in enumerate(alloc_targets): - cidr = f"{free[idx]}/{assign_prefix}" - updates[iface_id] = cidr - notes.append(f"assign: iface '{iface_name}' -> {cidr} (from pool {pool_cidr})") - - return (updates ,notes) - - -def reconcile_kernel_and_db_ipv4_addresses( - conn: sqlite3.Connection - ,pool_cidr: str = "10.0.0.0/16" - ,assign_prefix: int = 32 - ,reserve_first: int = 0 - ,dry_run: bool = False -) -> Tuple[int ,List[str]]: - rows = fetch_ifaces(conn) - iface_names = [n for (_i ,n ,_c) in rows] - kmap = kernel_ipv4_map(iface_names) - - updates ,notes = plan_address_updates( - rows - ,pool_cidr - ,assign_prefix - ,reserve_first - ,kmap - ) - if not updates: - return (0 ,notes or ["noop: nothing to change"]) - if dry_run: - return (0 ,notes) - - updated = update_iface_addresses(conn ,updates) - return (updated ,notes) - - -# --- thin CLI --- - -def main(argv=None) -> int: - ap = argparse.ArgumentParser() - ap.add_argument("--pool" ,type=str ,default="10.0.0.0/16") - ap.add_argument("--assign-prefix" ,type=int ,default=32) - ap.add_argument("--reserve-first" ,type=int ,default=0) - ap.add_argument("--dry-run" ,action="store_true") - args = ap.parse_args(argv) - with ic.open_db() as conn: - updated ,notes = reconcile_kernel_and_db_ipv4_addresses( - conn - ,pool_cidr=args.pool - ,assign_prefix=args.assign_prefix - ,reserve_first=args.reserve_first - ,dry_run=args.dry_run - ) - if notes: - print("\n".join(notes)) - if not args.dry_run: - print(f"updated rows: {updated}") - return 0 - - -if __name__ == "__main__": - import sys - sys.exit(main()) diff --git a/developer/Python/wg/db_init_ip_table_registration.py b/developer/Python/wg/db_init_ip_table_registration.py deleted file mode 100755 index 8436a2d..0000000 --- a/developer/Python/wg/db_init_ip_table_registration.py +++ /dev/null @@ -1,159 +0,0 @@ -#!/usr/bin/env python3 -""" -db_init_ip_table_registration.py - -Business API: - assign_missing_rt_table_ids(conn ,low=20000 ,high=29999 ,dry_run=False) - -> (updated_count ,planned_map ,notes) - -Policy: -- Effective table name per iface is COALESCE(rt_table_name ,iface). -- If that name exists in /etc/iproute2/rt_tables, reuse its number. -- Else allocate first free number in [low ,high]. -- Writes DB only. Does NOT write rt_tables. -""" - -from __future__ import annotations -import argparse -import sqlite3 -from pathlib import Path -from typing import Dict ,Iterable ,List ,Optional ,Sequence ,Tuple - -import incommon as ic # for CLI path only - -RT_TABLES_PATH = Path("/etc/iproute2/rt_tables") - - -def parse_rt_tables(path: Path) -> Tuple[List[str] ,Dict[str ,int] ,Dict[int ,str]]: - text = path.read_text() if path.exists() else "" - lines = text.splitlines() - name_to_num: Dict[str ,int] = {} - num_to_name: Dict[int ,str] = {} - for ln in lines: - s = ln.strip() - if not s or s.startswith("#"): - continue - parts = s.split() - if len(parts) >= 2 and parts[0].isdigit(): - n = int(parts[0]); name = parts[1] - if name not in name_to_num and n not in num_to_name: - name_to_num[name] = n - num_to_name[n] = name - return (lines ,name_to_num ,num_to_name) - - -def first_free_id(used: Iterable[int] ,low: int ,high: int) -> int: - used_set = set(u for u in used if low <= u <= high) - for n in range(low ,high+1): - if n not in used_set: - return n - raise RuntimeError(f"no free routing-table IDs in [{low},{high}]") - - -def fetch_effective_ifaces(conn: sqlite3.Connection) -> List[Tuple[int ,str ,Optional[int]]]: - sql = """ - SELECT i.id, - COALESCE(i.rt_table_name, i.iface) AS eff_name, - i.rt_table_id - FROM Iface i - ORDER BY i.id; - """ - cur = conn.execute(sql) - rows = cur.fetchall() - return [ - (int(r[0]) ,str(r[1]) ,(int(r[2]) if r[2] is not None else None)) - for r in rows - ] - - -def update_rt_ids(conn: sqlite3.Connection ,updates: Dict[int ,int]) -> int: - if not updates: - return 0 - with conn: - for iface_id ,rt_id in updates.items(): - conn.execute("UPDATE Iface SET rt_table_id=? WHERE id=?" ,(rt_id ,iface_id)) - return len(updates) - - -def plan_rt_id_assignments( - ifaces: Sequence[Tuple[int ,str ,Optional[int]]] - ,name_to_num_sys: Dict[str ,int] - ,existing_ids_in_db: Iterable[int] - ,low: int - ,high: int -) -> Dict[int ,int]: - used_numbers = set(int(x) for x in existing_ids_in_db) | set(name_to_num_sys.values()) - planned: Dict[int ,int] = {} - - names_seen: Dict[str ,int] = {} - for iface_id ,eff_name ,_ in ifaces: - if eff_name in names_seen and names_seen[eff_name] != iface_id: - raise RuntimeError( - f"duplicate effective table name in DB: '{eff_name}' used by Iface.id {names_seen[eff_name]} and {iface_id}" - ) - names_seen[eff_name] = iface_id - - for iface_id ,eff_name ,current_id in ifaces: - if current_id is not None: - used_numbers.add(int(current_id)) - continue - if eff_name in name_to_num_sys: - rt_id = int(name_to_num_sys[eff_name]) - else: - rt_id = first_free_id(used_numbers ,low ,high) - planned[iface_id] = rt_id - used_numbers.add(rt_id) - - return planned - - -def assign_missing_rt_table_ids( - conn: sqlite3.Connection - ,low: int = 20000 - ,high: int = 29999 - ,dry_run: bool = False -) -> Tuple[int ,Dict[int ,int] ,List[str]]: - _ ,name_to_num_sys ,_ = parse_rt_tables(RT_TABLES_PATH) - notes: List[str] = [] - - rows = fetch_effective_ifaces(conn) - existing_ids = [r[2] for r in rows if r[2] is not None] - planned = plan_rt_id_assignments(rows ,name_to_num_sys ,existing_ids ,low ,high) - - if not planned: - return (0 ,{} ,["noop: all Iface.rt_table_id already set"]) - - for iface_id ,eff_name ,current in rows: - if iface_id in planned: - notes.append(f"Iface.id={iface_id} name='{eff_name}' rt_table_id: {current} -> {planned[iface_id]}") - - if dry_run: - return (0 ,planned ,notes) - - updated = update_rt_ids(conn ,planned) - return (updated ,planned ,notes) - - -# --- thin CLI --- - -def main(argv=None) -> int: - ap = argparse.ArgumentParser() - ap.add_argument("--low" ,type=int ,default=20000) - ap.add_argument("--high" ,type=int ,default=29999) - ap.add_argument("--dry-run" ,action="store_true") - args = ap.parse_args(argv) - if args.low < 0 or args.high < args.low: - print(f"error: invalid range [{args.low},{args.high}]") - return 2 - with ic.open_db() as conn: - updated ,_planned ,notes = assign_missing_rt_table_ids(conn ,low=args.low ,high=args.high ,dry_run=args.dry_run) - if notes: - print("\n".join(notes)) - if not args.dry_run: - print(f"updated rows: {updated}") - return 0 - - -if __name__ == "__main__": - import sys - sys.exit(main()) diff --git a/developer/Python/wg/db_init_server_US.py b/developer/Python/wg/db_init_server_US.py deleted file mode 100755 index d8cfcd0..0000000 --- a/developer/Python/wg/db_init_server_US.py +++ /dev/null @@ -1,17 +0,0 @@ -# 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=", - 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 deleted file mode 100644 index 18edb1f..0000000 --- a/developer/Python/wg/db_init_server_incommon.py +++ /dev/null @@ -1,69 +0,0 @@ -#!/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 deleted file mode 100755 index 3377d91..0000000 --- a/developer/Python/wg/db_init_server_x6.py +++ /dev/null @@ -1,16 +0,0 @@ -# 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 deleted file mode 100644 index cf9cdb0..0000000 --- a/developer/Python/wg/db_schema.sql +++ /dev/null @@ -1,118 +0,0 @@ -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, unused - ,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 -- 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 deleted file mode 100755 index d4718bf..0000000 --- a/developer/Python/wg/db_schema_load.sh +++ /dev/null @@ -1,23 +0,0 @@ -#!/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 deleted file mode 100755 index 396c9dd..0000000 --- a/developer/Python/wg/db_wipe.sh +++ /dev/null @@ -1,71 +0,0 @@ -#!/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 deleted file mode 100644 index 53642ce..0000000 --- a/developer/Python/wg/deprecated/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ - -* -!.gitignore - diff --git a/developer/Python/wg/doc_IP_terminaology.org b/developer/Python/wg/doc_IP_terminaology.org deleted file mode 100644 index 8f6587b..0000000 --- a/developer/Python/wg/doc_IP_terminaology.org +++ /dev/null @@ -1,98 +0,0 @@ -#+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/. - -* Machine Peers - -- Client - -In these documents, the client machine is the local machine users are working on. Inevitably this gets shortened to /client/ in polite conversation. The example client used in this distribution is StanleyPark. That is a host name of a computer on our network. - -- Server - -In these document, the server machine is the remote machine that the write guard tunnels to. We have nicknames for machines. The example used here has the server nicknames of x6, and US. -These nicknames are also used for the names of the client machine side interface that connects to the tunnel that leads to said server machine. The nickname is also used for the name of the routing table on the client that routes traffic go said wireguard tunnel. - -Hence, a nickname, like x6 or US, refers to a machine, an interface, and an IP route table. - -* Software Peers - -Programs that run as daemons while listening for connections, and once connected to,k they provide services, are server programs. The program that connects to said software server is called a client program. You guessed it, the terms 'server program' and 'client program' often get shortened to /server/ and /client/. - - -* 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/doc_config.org b/developer/Python/wg/doc_config.org deleted file mode 100644 index 2de0ee4..0000000 --- a/developer/Python/wg/doc_config.org +++ /dev/null @@ -1,9 +0,0 @@ --New interface: - -copy `db_init_iface_x6.py` to `db_init_iface_.py`, replacing with the name of the interface. Then edit `db_init_iface_.py` - --New Client - --New User - - diff --git a/developer/Python/wg/doc_keys.org b/developer/Python/wg/doc_keys.org deleted file mode 100644 index e56bd76..0000000 --- a/developer/Python/wg/doc_keys.org +++ /dev/null @@ -1,14 +0,0 @@ - -From the point of view of setting up the client (we are in the client setup directory after all): - -1. login to the server and get the server public key. - - This public key is written into the db_init_iface_>.py configuration file. Note the examples `db_init_iface_US.py` and `db_init_iface_x6`. `x6` and `US` are nicknames for two servers. These nicknames are also used for the interface names. - - Note that the server private key remains on the server. The client has no knowledge of the server private key. It is not entered anywhere in the client configuration. - -2. run the program `key_client_generate1 - - This will print the client public key. It will also place a copy in the database. - - This will write the client private key into a local directory called `key/`. The admin need not do anything concerning this key. Scripts that need it will find it in the 'key/' directory. diff --git a/developer/Python/wg/doc_stage_progs.org b/developer/Python/wg/doc_stage_progs.org deleted file mode 100644 index a80f789..0000000 --- a/developer/Python/wg/doc_stage_progs.org +++ /dev/null @@ -1,42 +0,0 @@ - -stage programs write to the stage directory. Later install copies from the stage -directory to a provided root, which if it is the local machine, will be '/'. - - -* stage_IP_register_route_table.py - - stages a replacement etc/iproute2/rt_tables file. - -* stage_wg_conf.py - - stages etc/wireguard/ conf files for the configured interfaces - -* stage_IP_routes_script.py - - 1. stages a shell script that when called writes the IP rule table. Said script binds UIDs to route tables. - - 2. stages a priority 10 systemd guard systemd dropin that will call said shell script when - WireGuard is started or restarted. - -* stage_IP_rules_script.py - - 1. stages a shell script that when called writes the required IP route tables - - 2. stages a priority 20 systemd guard systemd dropin that will call said shell script when - WireGuard is started or restarted. - -* stage_client_StanleyPark.py - - A local use client machine configuration file. Calls the other stage programs - while providing the correct parameters for configuring wireguard on the - machine StanleyPark. Typically these will be a database connection and a list of - users. - - The admin will write such a file for each machine he/she/ai is configuring. - -* stage_incommon.py - - Utility functions for stage programs. - - - diff --git a/developer/Python/wg/iface_down.py b/developer/Python/wg/iface_down.py deleted file mode 100755 index a1e6474..0000000 --- a/developer/Python/wg/iface_down.py +++ /dev/null @@ -1,69 +0,0 @@ -#!/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 deleted file mode 100755 index c0a12e9..0000000 --- a/developer/Python/wg/iface_status.py +++ /dev/null @@ -1,131 +0,0 @@ -#!/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 deleted file mode 100755 index e5dbd0a..0000000 --- a/developer/Python/wg/iface_up.sh +++ /dev/null @@ -1,24 +0,0 @@ -#!/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 deleted file mode 100644 index a67a0aa..0000000 --- a/developer/Python/wg/incommon.py +++ /dev/null @@ -1,39 +0,0 @@ -#!/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 deleted file mode 100755 index be2d5ef..0000000 --- a/developer/Python/wg/inspect.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/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 deleted file mode 100755 index e6a179a..0000000 --- a/developer/Python/wg/inspect_1.py +++ /dev/null @@ -1,362 +0,0 @@ -#!/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 deleted file mode 100644 index 53642ce..0000000 --- a/developer/Python/wg/key/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ - -* -!.gitignore - diff --git a/developer/Python/wg/key_client_generate.py b/developer/Python/wg/key_client_generate.py deleted file mode 100755 index 96df023..0000000 --- a/developer/Python/wg/key_client_generate.py +++ /dev/null @@ -1,63 +0,0 @@ -#!/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 deleted file mode 100755 index f53022e..0000000 --- a/developer/Python/wg/key_server_set.py +++ /dev/null @@ -1,47 +0,0 @@ -#!/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 deleted file mode 100755 index e9454f0..0000000 --- a/developer/Python/wg/ls_iface.py +++ /dev/null @@ -1,89 +0,0 @@ -#!/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 deleted file mode 100755 index c616372..0000000 --- a/developer/Python/wg/ls_key.py +++ /dev/null @@ -1,77 +0,0 @@ -#!/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 deleted file mode 100755 index e1ee92d..0000000 --- a/developer/Python/wg/ls_server.py +++ /dev/null @@ -1,91 +0,0 @@ -#!/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 deleted file mode 100755 index 594cd70..0000000 --- a/developer/Python/wg/ls_server_setting.py +++ /dev/null @@ -1,137 +0,0 @@ -#!/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 deleted file mode 100755 index 5d4f4ef..0000000 --- a/developer/Python/wg/ls_servers.sh +++ /dev/null @@ -1,7 +0,0 @@ - -# 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 deleted file mode 100755 index 90c0ef2..0000000 --- a/developer/Python/wg/ls_user.py +++ /dev/null @@ -1,55 +0,0 @@ -#!/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_reference.org b/developer/Python/wg/manual_reference.org deleted file mode 100644 index 6b0b894..0000000 --- a/developer/Python/wg/manual_reference.org +++ /dev/null @@ -1,90 +0,0 @@ -#+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 deleted file mode 100644 index bef4b37..0000000 --- a/developer/Python/wg/manual_user.org +++ /dev/null @@ -1,104 +0,0 @@ -#+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 deleted file mode 100644 index 53642ce..0000000 --- a/developer/Python/wg/mothball/stage/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ - -* -!.gitignore - diff --git a/developer/Python/wg/mothball/stage_IP_routes_script.py b/developer/Python/wg/mothball/stage_IP_routes_script.py deleted file mode 100755 index d1ec126..0000000 --- a/developer/Python/wg/mothball/stage_IP_routes_script.py +++ /dev/null @@ -1,118 +0,0 @@ -#!/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 deleted file mode 100755 index 7fae716..0000000 --- a/developer/Python/wg/mothball/stage_IP_rules_script.py +++ /dev/null @@ -1,78 +0,0 @@ -#!/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 deleted file mode 100644 index c374029..0000000 --- a/developer/Python/wg/mothball/stage_StanleyPark.py +++ /dev/null @@ -1,139 +0,0 @@ -#!/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 deleted file mode 100755 index 7dfeb31..0000000 --- a/developer/Python/wg/mothball/stage_UID_routes.py +++ /dev/null @@ -1,67 +0,0 @@ -#!/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 deleted file mode 100755 index a36657a..0000000 --- a/developer/Python/wg/mothball/stage_list_clients.py +++ /dev/null @@ -1,92 +0,0 @@ -#!/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 deleted file mode 100644 index 5acf312..0000000 --- a/developer/Python/wg/mothball/stage_list_uid.py +++ /dev/null @@ -1,31 +0,0 @@ -#!/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 deleted file mode 100644 index bcb803a..0000000 --- a/developer/Python/wg/mothball/stage_populate.py +++ /dev/null @@ -1,133 +0,0 @@ -#!/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 deleted file mode 100644 index 8e39d4d..0000000 --- a/developer/Python/wg/mothball/stage_preferred_server.py +++ /dev/null @@ -1,35 +0,0 @@ -#!/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 deleted file mode 100644 index a395db2..0000000 --- a/developer/Python/wg/mothball/stage_wg_conf.py +++ /dev/null @@ -1,55 +0,0 @@ -#!/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 deleted file mode 100644 index cef5abb..0000000 --- a/developer/Python/wg/mothball/stage_wg_unit_IP_scripts.py +++ /dev/null @@ -1,36 +0,0 @@ -#!/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 deleted file mode 100755 index 161ab79..0000000 --- a/developer/Python/wg/mothball/stage_wipe.py +++ /dev/null @@ -1,97 +0,0 @@ -#!/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 deleted file mode 100644 index 53642ce..0000000 --- a/developer/Python/wg/scratchpad/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ - -* -!.gitignore - diff --git a/developer/Python/wg/stage/etc/systemd/wg-quick@US.service.d/20-postup-ip-state.conf b/developer/Python/wg/stage/etc/systemd/wg-quick@US.service.d/20-postup-ip-state.conf deleted file mode 100644 index 16b0fde..0000000 --- a/developer/Python/wg/stage/etc/systemd/wg-quick@US.service.d/20-postup-ip-state.conf +++ /dev/null @@ -1,2 +0,0 @@ -[Service] -ExecStartPost=+/usr/local/bin/apply_ip_state.sh US diff --git a/developer/Python/wg/stage/etc/systemd/wg-quick@x6.service.d/20-postup-ip-state.conf b/developer/Python/wg/stage/etc/systemd/wg-quick@x6.service.d/20-postup-ip-state.conf deleted file mode 100644 index 5e8e2ab..0000000 --- a/developer/Python/wg/stage/etc/systemd/wg-quick@x6.service.d/20-postup-ip-state.conf +++ /dev/null @@ -1,2 +0,0 @@ -[Service] -ExecStartPost=+/usr/local/bin/apply_ip_state.sh x6 diff --git a/developer/Python/wg/stage/etc/wireguard/US.conf b/developer/Python/wg/stage/etc/wireguard/US.conf deleted file mode 100644 index 7364c2e..0000000 --- a/developer/Python/wg/stage/etc/wireguard/US.conf +++ /dev/null @@ -1,10 +0,0 @@ -[Interface] -PrivateKey = ACd0vEyoZejb+WkXL1LcheHAYm2oRBbw52dJB5+tmUQ= -Table = off -# ListenPort = 51820 - -[Peer] -PublicKey = h8ZYEEVMForvv9p5Wx+9+eZ87t692hTN7sks5Noedw8= -AllowedIPs = 0.0.0.0/0 -Endpoint = 35.194.71.194:443 -PersistentKeepalive = 25 diff --git a/developer/Python/wg/stage/etc/wireguard/x6.conf b/developer/Python/wg/stage/etc/wireguard/x6.conf deleted file mode 100644 index b343bcc..0000000 --- a/developer/Python/wg/stage/etc/wireguard/x6.conf +++ /dev/null @@ -1,10 +0,0 @@ -[Interface] -PrivateKey = ACd0vEyoZejb+WkXL1LcheHAYm2oRBbw52dJB5+tmUQ= -Table = off -# ListenPort = 51820 - -[Peer] -PublicKey = pcbDlC1ZVoBYaN83/zAsvIvhgw0iQOL1YZKX5hcAqno= -AllowedIPs = 0.0.0.0/0 -Endpoint = 66.248.243.113:51820 -PersistentKeepalive = 25 diff --git a/developer/Python/wg/stage/usr/local/bin/apply_ip_state.sh b/developer/Python/wg/stage/usr/local/bin/apply_ip_state.sh deleted file mode 100755 index 5300313..0000000 --- a/developer/Python/wg/stage/usr/local/bin/apply_ip_state.sh +++ /dev/null @@ -1,68 +0,0 @@ -#!/usr/bin/env bash -# apply IP state for selected interfaces (addresses, routes, rules) — idempotent -set -euo pipefail - -ALL_ARGS=("$@") - -want_iface(){ - local t=$1 - if [ ${#ALL_ARGS[@]} -eq 0 ]; then return 0; fi - for a in "${ALL_ARGS[@]}"; do [ "$a" = "$t" ] && return 0; done - return 1 -} - -exists_iface(){ ip -o link show dev "$1" >/dev/null 2>&1; } - -ensure_addr(){ - local iface=$1; local cidr=$2 - if ip -4 -o addr show dev "$iface" | awk '{print $4}' | grep -Fxq "$cidr"; then - logger "addr ok: $iface $cidr" - else - ip -4 addr add "$cidr" dev "$iface" - logger "addr add: $iface $cidr" - fi -} - -ensure_route(){ - local table=$1; local cidr=$2; local dev=$3; local via=${4:-}; local metric=${5:-} - if [ -n "$via" ] && [ -n "$metric" ]; then - ip -4 route replace "$cidr" via "$via" dev "$dev" table "$table" metric "$metric" - elif [ -n "$via" ]; then - ip -4 route replace "$cidr" via "$via" dev "$dev" table "$table" - elif [ -n "$metric" ]; then - ip -4 route replace "$cidr" dev "$dev" table "$table" metric "$metric" - else - ip -4 route replace "$cidr" dev "$dev" table "$table" - fi - logger "route ensure: table=$table cidr=$cidr dev=$dev${via:+ via=$via}${metric:+ metric=$metric}" -} - -add_ip_rule_if_absent(){ - local needle=$1; shift - if ! ip -4 rule show | grep -F -q -- "$needle"; then - ip -4 rule add "$@" - logger "rule add: $*" - else - logger "rule ok: $needle" - fi -} - -if want_iface x6; then - if exists_iface x6; then ensure_addr x6 10.8.0.2/32; else logger "skip: iface missing: x6"; fi -fi -if want_iface US; then - if exists_iface US; then ensure_addr US 10.0.0.1/32; else logger "skip: iface missing: US"; fi -fi -if want_iface x6; then - add_ip_rule_if_absent "from 10.8.0.2/32 lookup x6" from "10.8.0.2/32" lookup "x6" pref 17000 -fi -if want_iface x6; then - add_ip_rule_if_absent "uidrange 2018-2018 lookup x6" uidrange "2018-2018" lookup "x6" pref 17010 -fi -if want_iface US; then - add_ip_rule_if_absent "from 10.0.0.1/32 lookup US" from "10.0.0.1/32" lookup "US" pref 17000 -fi -if want_iface US; then - add_ip_rule_if_absent "uidrange 2017-2017 lookup US" uidrange "2017-2017" lookup "US" pref 17010 -fi -add_ip_rule_if_absent "from 10.0.0.0/24 prohibit" from "10.0.0.0/24" prohibit pref 18050 diff --git a/developer/Python/wg/stage_IP_apply_script.py b/developer/Python/wg/stage_IP_apply_script.py deleted file mode 100755 index 4e9ad9f..0000000 --- a/developer/Python/wg/stage_IP_apply_script.py +++ /dev/null @@ -1,369 +0,0 @@ -#!/usr/bin/env python3 -""" -stage_IP_apply_script.py - -Given: - - A SQLite DB (schema you’ve defined), with: - * Iface(id ,iface ,local_address_cidr ,rt_table_name) - * v_iface_effective(id ,rt_table_name_eff ,local_address_cidr) - * Route(iface_id ,cidr ,via ,table_name ,metric ,on_up ,on_down) - * "User"(iface_id ,username ,uid) — table formerly User_Binding - * Meta(key='subu_cidr' ,value) - - A list of interface names to include (e.g., ["x6","US"]). - -Does: - - Reads DB once and *synthesizes a single* idempotent runtime script - that, for the selected interfaces, on each `wg-quick@IFACE` start: - 1) ensures IPv4 addresses exist on the iface (if present in DB) - 2) ensures all configured routes exist (using `ip -4 route replace`) - 3) ensures policy rules exist for src-cidr ,uidrange ,and a `prohibit` - - Stages that script under: stage/usr/local/bin/ - - Stages per-iface systemd drop-ins: - stage/etc/systemd/wg-quick@IFACE.service.d/-postup-ip-state.conf - which call the script (default prio = 20). - -Returns: - (script_path ,notes[list of strings]) - -Errors: - - Raises RuntimeError if no interfaces provided or there’s nothing to emit. - - Does not write /etc/iproute2/rt_tables (that’s handled by your registration stager). - - Does not modify kernel state — this is staging only. - -Notes: - - The generated script is idempotent: - * addresses: “add if missing” - * routes: `ip -4 route replace` - * rules: add only if a grep needle is not found - - It accepts optional IFACE args at runtime to limit application to a subset. -""" - -from __future__ import annotations -from pathlib import Path -from typing import Dict ,Iterable ,List ,Optional ,Sequence ,Tuple -import argparse -import sqlite3 -import sys - -import incommon as ic # expected: open_db() - -ROOT = Path(__file__).resolve().parent -STAGE_ROOT = ROOT / "stage" - - -# ---------- DB access ---------- - -def _fetch_meta_subu_cidr(conn: sqlite3.Connection ,default="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 - - -def _fetch_iface_meta(conn: sqlite3.Connection ,iface_names: Sequence[str]) -> Dict[str ,Tuple[int ,str ,Optional[str]]]: - """ - Return {iface_name -> (iface_id ,rt_table_name_eff ,local_address_cidr_or_None)}. - """ - if not iface_names: - return {} - ph = ",".join("?" for _ in iface_names) - sql = f""" - SELECT i.id - , i.iface - , v.rt_table_name_eff - , NULLIF(TRIM(v.local_address_cidr),'') AS cidr - FROM Iface i - JOIN v_iface_effective v ON v.id = i.id - WHERE i.iface IN ({ph}) - ORDER BY i.id; - """ - rows = conn.execute(sql ,tuple(iface_names)).fetchall() - out: Dict[str ,Tuple[int ,str ,Optional[str]]] = {} - for r in rows: - iface_id = int(r[0]); name = str(r[1]); eff = str(r[2]); cidr = (str(r[3]) if r[3] is not None else None) - out[name] = (iface_id ,eff ,cidr) - return out - - -def _fetch_routes_by_iface_id( - conn: sqlite3.Connection - ,iface_ids: Sequence[int] - ,only_on_up: bool = True -) -> Dict[int ,List[Tuple[str ,Optional[str] ,Optional[str] ,Optional[int]]]]: - """ - Return {iface_id -> [(cidr ,via ,table_name_or_None ,metric_or_None),...]}. - """ - if not iface_ids: - return {} - ph = ",".join("?" for _ in iface_ids) - sql = f""" - SELECT iface_id - , cidr - , NULLIF(TRIM(via),'') AS via - , NULLIF(TRIM(table_name),'') AS table_name - , metric - , on_up - FROM Route - WHERE iface_id IN ({ph}) - ORDER BY id; - """ - rows = conn.execute(sql ,tuple(iface_ids)).fetchall() - out: Dict[int ,List[Tuple[str ,Optional[str] ,Optional[str] ,Optional[int]]]] = {} - for iface_id ,cidr ,via ,tname ,metric ,on_up in rows: - if only_on_up and int(on_up) != 1: - continue - out.setdefault(int(iface_id) ,[]).append( - (str(cidr) ,(str(via) if via is not None else None) ,(str(tname) if tname is not None else None) - ,(int(metric) if metric is not None else None)) - ) - return out - - -def _fetch_uids_by_iface_id(conn: sqlite3.Connection ,iface_ids: Sequence[int]) -> Dict[int ,List[int]]: - """ - Return {iface_id -> [uid,...]} using table "User". - """ - if not iface_ids: - return {} - ph = ",".join("?" for _ in iface_ids) - sql = f""" - SELECT iface_id - , uid - FROM "User" - WHERE iface_id IN ({ph}) - AND uid IS NOT NULL - AND CAST(uid AS TEXT) != '' - ORDER BY iface_id ,uid; - """ - rows = conn.execute(sql ,tuple(iface_ids)).fetchall() - out: Dict[int ,List[int]] = {} - for iface_id ,uid in rows: - out.setdefault(int(iface_id) ,[]).append(int(uid)) - return out - - -# ---------- rendering ---------- - -def _render_composite_script( - plan_ifaces: List[str] - ,meta: Dict[str ,Tuple[int ,str ,Optional[str]]] - ,routes_by_id: Dict[int ,List[Tuple[str ,Optional[str] ,Optional[str] ,Optional[int]]]] - ,uids_by_id: Dict[int ,List[int]] - ,subu_cidr: str -) -> str: - """ - Build a single bash script that ensures addresses → routes → rules. - """ - lines: List[str] = [ - "#!/usr/bin/env bash" - ,"# apply IP state for selected interfaces (addresses, routes, rules) — idempotent" - ,"set -euo pipefail" - ,"" - ,"ALL_ARGS=(\"$@\")" - ,"" - ,"want_iface(){" - ," local t=$1" - ," if [ ${#ALL_ARGS[@]} -eq 0 ]; then return 0; fi" - ," for a in \"${ALL_ARGS[@]}\"; do [ \"$a\" = \"$t\" ] && return 0; done" - ," return 1" - ,"}" - ,"" - ,"exists_iface(){ ip -o link show dev \"$1\" >/dev/null 2>&1; }" - ,"" - ,"ensure_addr(){" - ," local iface=$1; local cidr=$2" - ," if ip -4 -o addr show dev \"$iface\" | awk '{print $4}' | grep -Fxq \"$cidr\"; then" - ," logger \"addr ok: $iface $cidr\"" - ," else" - ," ip -4 addr add \"$cidr\" dev \"$iface\"" - ," logger \"addr add: $iface $cidr\"" - ," fi" - ,"}" - ,"" - ,"ensure_route(){" - ," local table=$1; local cidr=$2; local dev=$3; local via=${4:-}; local metric=${5:-}" - ," if [ -n \"$via\" ] && [ -n \"$metric\" ]; then" - ," ip -4 route replace \"$cidr\" via \"$via\" dev \"$dev\" table \"$table\" metric \"$metric\"" - ," elif [ -n \"$via\" ]; then" - ," ip -4 route replace \"$cidr\" via \"$via\" dev \"$dev\" table \"$table\"" - ," elif [ -n \"$metric\" ]; then" - ," ip -4 route replace \"$cidr\" dev \"$dev\" table \"$table\" metric \"$metric\"" - ," else" - ," ip -4 route replace \"$cidr\" dev \"$dev\" table \"$table\"" - ," fi" - ," logger \"route ensure: table=$table cidr=$cidr dev=$dev${via:+ via=$via}${metric:+ metric=$metric}\"" - ,"}" - ,"" - ,"add_ip_rule_if_absent(){" - ," local needle=$1; shift" - ," if ! ip -4 rule show | grep -F -q -- \"$needle\"; then" - ," ip -4 rule add \"$@\"" - ," logger \"rule add: $*\"" - ," else" - ," logger \"rule ok: $needle\"" - ," fi" - ,"}" - ,"" - ] - - any_action = False - - # 1) Addresses - for name in plan_ifaces: - _iid ,rtname ,cidr = meta[name] - if cidr: - lines += [ - f'if want_iface {name}; then' - ,f' if exists_iface {name}; then ensure_addr {name} {cidr}; else logger "skip: iface missing: {name}"; fi' - ,'fi' - ] - any_action = True - - # 2) Routes - for name in plan_ifaces: - iid ,rtname ,_cidr = meta[name] - rows = routes_by_id.get(iid ,[]) - for cidr ,via ,t_override ,metric in rows: - table_eff = t_override or rtname - viastr = (via if via is not None else "") - mstr = (str(metric) if metric is not None else "") - lines += [ - f'if want_iface {name}; then' - ,f' if exists_iface {name}; then ensure_route "{table_eff}" "{cidr}" "{name}" "{viastr}" "{mstr}"; else logger "skip: iface missing: {name}"; fi' - ,'fi' - ] - any_action = True - - # 3) Rules (src, uids, and one prohibit for the subu block) - for name in plan_ifaces: - iid ,rtname ,cidr = meta[name] - if cidr: - lines += [ - f'if want_iface {name}; then' - ,f' add_ip_rule_if_absent "from {cidr} lookup {rtname}" from "{cidr}" lookup "{rtname}" pref 17000' - ,'fi' - ] - any_action = True - uids = uids_by_id.get(iid ,[]) - for u in uids: - lines += [ - f'if want_iface {name}; then' - ,f' add_ip_rule_if_absent "uidrange {u}-{u} lookup {rtname}" uidrange "{u}-{u}" lookup "{rtname}" pref 17010' - ,'fi' - ] - any_action = True - - # One global prohibit for subu block (emit once) - if subu_cidr: - lines += [ - f'add_ip_rule_if_absent "from {subu_cidr} prohibit" from "{subu_cidr}" prohibit pref 18050' - ] - any_action = True - - if not any_action: - raise RuntimeError("no IP state to emit for requested interfaces") - - lines += [""] # trailing newline - return "\n".join(lines) - - -def _write_dropin_for_iface(stage_root: Path ,iface: str ,script_name: str ,priority: int) -> Path: - d = stage_root / "etc" / "systemd" / f"wg-quick@{iface}.service.d" - d.mkdir(parents=True ,exist_ok=True) - p = d / f"{priority}-postup-ip-state.conf" - content = ( - "[Service]\n" - f"ExecStartPost=+/usr/local/bin/{script_name} {iface}\n" - ) - p.write_text(content) - return p - - -# ---------- business ---------- - -def stage_ip_apply_script( - conn: sqlite3.Connection - ,iface_names: Sequence[str] - ,stage_root: Optional[Path] = None - ,script_name: str = "apply_ip_state.sh" - ,dropin_priority: int = 20 - ,only_on_up: bool = True - ,with_dropins: bool = True - ,dry_run: bool = False -) -> Tuple[Path ,List[str]]: - """ - Plan and stage the unified runtime script and per-iface drop-ins. - """ - if not iface_names: - raise RuntimeError("no interfaces provided") - - meta = _fetch_iface_meta(conn ,iface_names) - if not meta: - raise RuntimeError("none of the requested interfaces exist in DB") - - # preserve caller order but skip unknowns (already handled above) - ifaces_in_order = [n for n in iface_names if n in meta] - iface_ids = [meta[n][0] for n in ifaces_in_order] - - routes_by_id = _fetch_routes_by_iface_id(conn ,iface_ids ,only_on_up=only_on_up) - uids_by_id = _fetch_uids_by_iface_id(conn ,iface_ids) - subu_cidr = _fetch_meta_subu_cidr(conn ,default="10.0.0.0/24") - - sr = stage_root or STAGE_ROOT - out = sr / "usr" / "local" / "bin" / script_name - out.parent.mkdir(parents=True ,exist_ok=True) - - content = _render_composite_script(ifaces_in_order ,meta ,routes_by_id ,uids_by_id ,subu_cidr) - - notes: List[str] = [] - if dry_run: - notes.append(f"dry-run: would write {out}") - if with_dropins: - for n in ifaces_in_order: - notes.append(f"dry-run: would write drop-in for {n} at priority {dropin_priority}") - return (out ,notes) - - out.write_text(content) - out.chmod(0o500) - notes.append(f"staged: {out}") - - if with_dropins: - for n in ifaces_in_order: - dp = _write_dropin_for_iface(sr ,n ,script_name ,dropin_priority) - notes.append(f"staged: {dp}") - - return (out ,notes) - - -# ---------- CLI ---------- - -def main(argv=None) -> int: - ap = argparse.ArgumentParser(description="Stage one script that applies addresses, routes, and rules for selected ifaces.") - ap.add_argument("ifaces" ,nargs="+" ,help="interface names to include") - ap.add_argument("--script-name" ,default="apply_ip_state.sh") - ap.add_argument("--dropin-priority" ,type=int ,default=20) - ap.add_argument("--all" ,action="store_true" ,help="include routes where on_up=0 as well") - ap.add_argument("--no-dropins" ,action="store_true" ,help="do not stage systemd drop-ins") - ap.add_argument("--dry-run" ,action="store_true") - args = ap.parse_args(argv) - - with ic.open_db() as conn: - try: - out ,notes = stage_ip_apply_script( - conn - ,args.ifaces - ,script_name=args.script_name - ,dropin_priority=args.dropin_priority - ,only_on_up=(not args.all) - ,with_dropins=(not args.no_dropins) - ,dry_run=args.dry_run - ) - except Exception as e: - print(f"error: {e}" ,file=sys.stderr) - return 2 - - if notes: - print("\n".join(notes)) - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/developer/Python/wg/stage_StanleyPark.py b/developer/Python/wg/stage_StanleyPark.py deleted file mode 100644 index 77264a3..0000000 --- a/developer/Python/wg/stage_StanleyPark.py +++ /dev/null @@ -1,20 +0,0 @@ -#!/usr/bin/env python3 -""" -stage_StanleyPark.py - -Minimal config wrapper for client 'StanleyPark'. -Calls the generic stage orchestrator with the chosen ifaces. -""" - -from __future__ import annotations -from stage_client import stage_client_artifacts - -CLIENT = "StanleyPark" -IFACES = ["x6","US"] # keep this list minimal & declarative - -if __name__ == "__main__": - ok = stage_client_artifacts( - CLIENT - ,IFACES - ) - raise SystemExit(0 if ok else 2) diff --git a/developer/Python/wg/stage_client.py b/developer/Python/wg/stage_client.py deleted file mode 100644 index 7d5f5ba..0000000 --- a/developer/Python/wg/stage_client.py +++ /dev/null @@ -1,160 +0,0 @@ -#!/usr/bin/env python3 -""" -stage_client.py - -Given: - - A SQLite DB reachable via incommon.open_db() - - A client machine name (used to locate ./key/ for WG PrivateKey) - - One or more interface names (e.g., x6, US) - -Does: - 1) Stage WireGuard confs for each iface (Table=off; ListenPort commented if NULL) - 2) Stage /etc/iproute2/rt_tables entries for those ifaces - 3) Stage a unified IP apply script (addresses, routes, rules) - 4) Stage per-iface systemd drop-ins to invoke the apply script on wg-quick up - -Returns: - - True on success, False on failure - - Prints human-readable progress for each step - -Errors: - - Raises or prints clear ❌ messages on failure -""" - -from __future__ import annotations -from pathlib import Path -from typing import Callable ,Optional ,Sequence ,Tuple -import argparse -import subprocess -import sys - -import incommon as ic # open_db() - -ROOT = Path(__file__).resolve().parent -STAGE_ROOT = ROOT / "stage" - - -def _msg_wrapped_call(label: str ,fn: Callable[[], Tuple[Path ,Sequence[str]]]) -> bool: - print(f"→ {label}") - try: - path ,notes = fn() - for n in notes: - print(n) - if path: - print(f"✔ {label}: staged: {path}") - else: - print(f"✔ {label}") - return True - except Exception as e: - print(f"❌ {label}: {e}") - return False - - -def _call_cli(argv: Sequence[str]) -> Tuple[Path ,Sequence[str]]: - cp = subprocess.run(list(argv) ,text=True ,capture_output=True) - if cp.returncode != 0: - raise RuntimeError(cp.stderr.strip() or f"exit {cp.returncode}") - notes = [] - staged_path: Optional[Path] = None - for line in (cp.stdout or "").splitlines(): - notes.append(line) - if line.startswith("staged: "): - try: - staged_path = Path(line.split("staged:",1)[1].strip()) - except Exception: - pass - return (staged_path or STAGE_ROOT ,notes) - - -def _stage_wg_conf_step(client_name: str ,ifaces: Sequence[str]) -> bool: - def _do(): - try: - from stage_wg_conf import stage_wg_conf # type: ignore - with ic.open_db() as conn: - path ,notes = stage_wg_conf( - conn - ,ifaces - ,client_name - ,stage_root=STAGE_ROOT - ,dry_run=False - ) - return (path ,notes) - except Exception: - return _call_cli([str(ROOT / "stage_wg_conf.py") ,client_name ,*ifaces]) - return _msg_wrapped_call(f"stage_wg_conf ({client_name}; {','.join(ifaces)})" ,_do) - - -def _stage_rt_tables_step(ifaces: Sequence[str]) -> bool: - def _do(): - try: - from stage_IP_register_route_table import stage_ip_register_route_table # type: ignore - with ic.open_db() as conn: - path ,notes = stage_ip_register_route_table( - conn - ,ifaces - ,stage_root=STAGE_ROOT - ,dry_run=False - ) - return (path ,notes) - except Exception: - return _call_cli([str(ROOT / "stage_IP_register_route_table.py") ,*ifaces]) - return _msg_wrapped_call(f"stage_IP_register_route_table ({','.join(ifaces)})" ,_do) - - -def _stage_apply_ip_state_step(ifaces: Sequence[str]) -> bool: - def _do(): - try: - from stage_IP_apply_script import stage_ip_apply_script # type: ignore - with ic.open_db() as conn: - path ,notes = stage_ip_apply_script( - conn - ,ifaces - ,stage_root=STAGE_ROOT - ,script_name="apply_ip_state.sh" - ,only_on_up=True - ,dry_run=False - ) - return (path ,notes) - except Exception: - return _call_cli([str(ROOT / "stage_IP_apply_script.py") ,*ifaces]) - return _msg_wrapped_call(f"stage_IP_apply_script ({','.join(ifaces)})" ,_do) - - -def stage_client_artifacts( - client_name: str - ,iface_names: Sequence[str] - ,stage_root: Optional[Path] = None -) -> bool: - """ - Orchestrate staging for a client+ifaces. Prints progress and returns success. - """ - if not iface_names: - raise ValueError("no interfaces provided") - if stage_root: - global STAGE_ROOT - STAGE_ROOT = stage_root - - STAGE_ROOT.mkdir(parents=True ,exist_ok=True) - - ok = True - ok = _stage_wg_conf_step(client_name ,iface_names) and ok - ok = _stage_rt_tables_step(iface_names) and ok - ok = _stage_apply_ip_state_step(iface_names) and ok - return ok - - -def main(argv: Optional[Sequence[str]] = None) -> int: - ap = argparse.ArgumentParser(description="Stage all artifacts for a client.") - ap.add_argument("--client" ,required=True ,help="client machine name (for key lookup)") - ap.add_argument("ifaces" ,nargs="+") - args = ap.parse_args(argv) - - ok = stage_client_artifacts( - args.client - ,args.ifaces - ) - return 0 if ok else 2 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/developer/Python/wg/stage_wg_conf.py b/developer/Python/wg/stage_wg_conf.py deleted file mode 100755 index 28dd4d3..0000000 --- a/developer/Python/wg/stage_wg_conf.py +++ /dev/null @@ -1,254 +0,0 @@ -#!/usr/bin/env python3 -""" -stage_wg_conf.py - -Given: - - SQLite DB reachable via incommon.open_db() - - A list of interface names (e.g., x6 ,US) - - client_machine_name used to locate the private key file under ./key/ - -Does: - - For each iface, stage a minimal WireGuard config to stage/etc/wireguard/.conf: - [Interface] - PrivateKey = > - Table = off - ListenPort = (if the column exists and value is not NULL) - # ListenPort = 51820 (commented if value is absent) - [Peer] (one per Server row for that iface) - PublicKey = - PresharedKey = (only if present) - AllowedIPs = - Endpoint = : - PersistentKeepalive = (only if present) - - Omits Address ,PostUp ,SaveConfig (your systemd drop-in + script handle L3 state) - -Returns: - - (list_of_staged_paths ,notes) - -Errors: - - Missing private key file - - Iface not found - - Server rows missing required fields for that iface -""" - -from __future__ import annotations -from pathlib import Path -from typing import Dict ,Iterable ,List ,Optional ,Sequence ,Tuple -import argparse -import sqlite3 -import sys - -import incommon as ic # expected: open_db() - -ROOT = Path(__file__).resolve().parent -STAGE_ROOT = ROOT / "stage" - - -# ---------- helpers ---------- - -def _has_column(conn: sqlite3.Connection ,table: str ,col: str) -> bool: - cur = conn.execute(f"PRAGMA table_info({table});") - cols = [str(r[1]) for r in cur.fetchall()] - return col in cols - - -def _read_private_key(client_machine_name: str ,key_root: Optional[Path] = None) -> str: - kr = key_root or (ROOT / "key") - path = kr / client_machine_name - if not path.exists(): - raise RuntimeError(f"private key file missing: {path}") - text = path.read_text().strip() - if not text: - raise RuntimeError(f"private key file empty: {path}") - # WireGuard private keys are base64 (typically 44 chars), but don't over-validate here. - return text - - -# ---------- DB ---------- - -def _fetch_iface_ids_and_ports( - conn: sqlite3.Connection - ,iface_names: Sequence[str] -) -> Dict[str ,Tuple[int ,Optional[int]]]: - """ - Return {iface_name -> (iface_id ,listen_port_or_None)} for requested names. - If the listen_port column does not exist, value is None. - """ - if not iface_names: - return {} - ph = ",".join("?" for _ in iface_names) - has_lp = _has_column(conn ,"Iface" ,"listen_port") - select_lp = ", i.listen_port" if has_lp else ", NULL as listen_port" - sql = f""" - SELECT i.id - , i.iface - {select_lp} - FROM Iface i - WHERE i.iface IN ({ph}) - ORDER BY i.id; - """ - rows = conn.execute(sql ,tuple(iface_names)).fetchall() - out: Dict[str ,Tuple[int ,Optional[int]]] = {} - for iid ,name ,lp in rows: - out[str(name)] = (int(iid) ,(int(lp) if lp is not None else None)) - return out - - -def _fetch_peers_for_iface( - conn: sqlite3.Connection - ,iface_id: int -) -> List[Tuple[str ,Optional[str] ,str ,int ,str ,Optional[int] ,int ,int]]: - """ - Return peers as tuples: - (public_key ,preshared_key ,endpoint_host ,endpoint_port ,allowed_ips ,keepalive_s ,priority ,id) - """ - sql = """ - SELECT public_key - , NULLIF(TRIM(preshared_key),'') as preshared_key - , endpoint_host - , endpoint_port - , allowed_ips - , keepalive_s - , priority - , id - FROM Server - WHERE iface_id = ? - ORDER BY priority ASC , id ASC; - """ - rows = conn.execute(sql ,(iface_id,)).fetchall() - out: List[Tuple[str ,Optional[str] ,str ,int ,str ,Optional[int] ,int ,int]] = [] - for pub ,psk ,host ,port ,alips ,ka ,prio ,sid in rows: - out.append((str(pub) ,(str(psk) if psk is not None else None) ,str(host) ,int(port) ,str(alips) ,(int(ka) if ka is not None else None) ,int(prio) ,int(sid))) - return out - - -# ---------- rendering ---------- - -def _render_conf( - iface_name: str - ,private_key: str - ,listen_port: Optional[int] - ,peers: Sequence[Tuple[str ,Optional[str] ,str ,int ,str ,Optional[int] ,int ,int]] -) -> str: - lines: List[str] = [] - lines += [ - "[Interface]" - ,f"PrivateKey = {private_key}" - ,"Table = off" - ] - if listen_port is not None: - lines.append(f"ListenPort = {listen_port}") - else: - lines.append("# ListenPort = 51820") - - lines.append("") # blank before peers - - if not peers: - # You may choose to raise instead; keeping an empty peer set is valid but rarely useful. - lines.append("# (no peers found for this interface)") - - for pub ,psk ,host ,port ,alips ,ka ,_prio ,_sid in peers: - lines += [ - "[Peer]" - ,f"PublicKey = {pub}" - ] - if psk is not None: - lines.append(f"PresharedKey = {psk}") - lines += [ - f"AllowedIPs = {alips}" - ,f"Endpoint = {host}:{port}" - ] - if ka is not None: - lines.append(f"PersistentKeepalive = {ka}") - lines.append("") # blank line between peers - - return "\n".join(lines).rstrip() + "\n" - - -# ---------- business ---------- - -def stage_wg_conf( - conn: sqlite3.Connection - ,iface_names: Sequence[str] - ,client_machine_name: str - ,stage_root: Optional[Path] = None - ,dry_run: bool = False -) -> Tuple[List[Path] ,List[str]]: - """ - Stage /etc/wireguard/.conf for selected ifaces under stage root. - """ - if not iface_names: - raise RuntimeError("no interfaces provided") - priv = _read_private_key(client_machine_name) - - meta = _fetch_iface_ids_and_ports(conn ,iface_names) - if not meta: - raise RuntimeError("none of the requested interfaces exist in DB") - - staged: List[Path] = [] - notes: List[str] = [] - sr = stage_root or STAGE_ROOT - outdir = sr / "etc" / "wireguard" - outdir.mkdir(parents=True ,exist_ok=True) - - for name in iface_names: - if name not in meta: - notes.append(f"skip: iface '{name}' missing from DB") - continue - - iface_id ,listen_port = meta[name] - peers = _fetch_peers_for_iface(conn ,iface_id) - - # basic validation of required peer fields - bad = [] - for pub ,_psk ,host ,port ,alips ,_ka ,_prio ,sid in peers: - if not pub or not host or not alips or not (1 <= int(port) <= 65535): - bad.append(sid) - if bad: - raise RuntimeError(f"iface '{name}': invalid peer rows id={bad}") - - conf_text = _render_conf(name ,priv ,listen_port ,peers) - - out = outdir / f"{name}.conf" - if dry_run: - notes.append(f"dry-run: would write {out}") - else: - out.write_text(conf_text) - out.chmod(0o600) - staged.append(out) - notes.append(f"staged: {out}") - - if not staged and not dry_run: - raise RuntimeError("nothing staged (all missing or skipped)") - - return (staged ,notes) - - -# ---------- CLI ---------- - -def main(argv=None) -> int: - ap = argparse.ArgumentParser(description="Stage minimal WireGuard configs with Table=off and no Address.") - ap.add_argument("client_machine_name" ,help="name used to read ./key/") - ap.add_argument("ifaces" ,nargs="+" ,help="interface names to stage") - ap.add_argument("--dry-run" ,action="store_true") - args = ap.parse_args(argv) - - with ic.open_db() as conn: - try: - paths ,notes = stage_wg_conf( - conn - ,args.ifaces - ,args.client_machine_name - ,dry_run=args.dry_run - ) - except Exception as e: - print(f"error: {e}" ,file=sys.stderr) - return 2 - - if notes: - print("\n".join(notes)) - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/developer/Python/wg/stage_wipe.py b/developer/Python/wg/stage_wipe.py deleted file mode 100755 index 9270e13..0000000 --- a/developer/Python/wg/stage_wipe.py +++ /dev/null @@ -1,71 +0,0 @@ -#!/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/todo.org b/developer/Python/wg/todo.org deleted file mode 100644 index f83e739..0000000 --- a/developer/Python/wg/todo.org +++ /dev/null @@ -1,67 +0,0 @@ -#+TITLE: subu / WireGuard — TODO -#+AUTHOR: Thomas & Nerith (session) -#+LANGUAGE: en -#+OPTIONS: toc:2 num:t -#+TODO: TODO(t) NEXT(n) WAITING(w) BLOCKED(b) | DONE(d) CANCELED(c) - -- Your current DB schema (the one you pasted earlier) does not include a listen-port field on Iface. So if you want ListenPort = … to be driven from the DB, add a column like Iface.listen_port INTEGER CHECK(listen_port BETWEEN 1 AND 65535). - -- have the stage commands echo relative pathnames instead of absolute as they do now. - -- Known gaps / open decisions - - Systemd drop-in to call staged scripts on ~wg-quick@IFACE~ up (IPv4 addrs + policy rules). - - Staged policy-rules script (source-based + uidrange rules) to replace the old global ~IP_rule_add.sh~ usage. - - Installer flow & atomic writes (copy staged files, set owner/perms; safe update of ~/etc/iproute2/rt_tables~). - - Pool size policy: default /16 with /32 hosts is implemented; decision pending on /8 vs /16. - - Style guardrails (RT commas / two-space indent) are manual; optional linter TBD. - -* NEXT wiring (high-level order) -1) Stage: /etc/iproute2/rt_tables (merge) for selected ifaces. -2) Stage: /usr/local/bin/set_iface_ipv4_addrs.sh for same ifaces. -3) Stage: /usr/local/bin/set_policy_rules_for_ifaces.sh (new; replaces old global add tool). -4) Stage: systemd drop-ins for ~wg-quick@IFACE.service.d/10-postup.conf~ to call (2) then (3). -5) Install: copy staged files → system, set perms/owner; ~systemctl daemon-reload~. -6) Bring-up: ~wg-quick up IFACE~; verify routes/rules; smoke tests. - -* TODO Add “missing-iface” guard to staged IPv4 script -- When iface doesn’t exist yet, log and continue (no non-zero exit). - -* TODO Stage policy rules script (idempotent) -- For each iface: - - Source-based rule: =from lookup =. - - UID rules: =uidrange U-U lookup = for each bound UID. -- Only for ifaces passed on the CLI; DB-driven; no kernel writes here. -- Emit with checks (skip if grep finds the exact rule). - -* TODO Systemd drop-in generator -- Emit to: ~stage/etc/systemd/wg-quick@IFACE.service.d/10-postup.conf~. -- Include: - - =ExecStartPre=-/usr/sbin/ip link delete IFACE= (clean stale link). - - =ExecStartPost=+/usr/local/bin/set_iface_ipv4_addrs.sh=. - - =ExecStartPost=+/usr/local/bin/set_policy_rules_for_ifaces.sh=. - - =ExecStartPost=+/usr/bin/logger 'wg-quick@IFACE up: addrs+rules applied'=. - -* TODO Installer flow -- Copy staged files with perms (0500 for scripts; 0644 for rt_tables; 0755 for dirs). -- Atomic update for ~/etc/iproute2/rt_tables~ (write temp + move); keep timestamped backup. -- ~systemctl daemon-reload~ after installing drop-ins. - -* WAITING Decide “no-op staging” policy for rt_tables -- Option A: Always stage a copy (deterministic deployment). -- Option B: Stage only when there are new entries (quieter diffs). - -* TODO Tests -- Unit-ish: parse/plan functions for both staging scripts (dry-run cases, collisions, skip-missing cases). -- Integration: - - Create temp WG iface: ~ip link add dev t0 type wireguard~ (and delete after). - - Run staged scripts; verify ~ip -4 addr show dev t0~, ~ip rule show~, ~ip route show table ~. - - Bring up real ~wg-quick up x6~; repeat verifications. - -* TODO Docs -- Append “operational runbook” to the org manual (bring-up, verify, recover, teardown). - -* DONE What’s already proven by commands (from log) -- all db_init is running, orchestrated by db_init_StanleyPark -- =stage_rt_tables_merge.py --from-db x6 US= created staged rt_tables with merges. -- =stage_iface_ipv4_script.py x6 US= staged ~set_iface_ipv4_addrs.sh~. - diff --git a/developer/Python/wg/wg_keys_incommon.py b/developer/Python/wg/wg_keys_incommon.py deleted file mode 100644 index 1578899..0000000 --- a/developer/Python/wg/wg_keys_incommon.py +++ /dev/null @@ -1,34 +0,0 @@ -#!/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 diff --git a/developer/cc/Db.lib.c b/developer/cc/Db.lib.c deleted file mode 100644 index 44c9b3f..0000000 --- a/developer/cc/Db.lib.c +++ /dev/null @@ -1,200 +0,0 @@ -#ifndef IFACE -#define Db·IMPLEMENTATION -#define IFACE -#endif - -#ifndef Db·IFACE -#define Db·IFACE - - #include - #include - - // Enum for exit codes - typedef enum { - Db·EXIT_SUCCESS = 0, - Db·EXIT_DB_OPEN_ERROR, - Db·EXIT_SCHEMA_LOAD_ERROR, - Db·EXIT_MEMORY_ALLOCATION_ERROR, - Db·EXIT_STATEMENT_PREPARE_ERROR, - Db·EXIT_STATEMENT_EXECUTE_ERROR - } Db·ExitCode; - - // Interface prototypes - sqlite3* Db·open(const char *db_path ,bool create_if_not_exists); - Db·ExitCode Db·load_schema(sqlite3 *db, const char *schema_path); - Db·ExitCode Db·log_event(sqlite3 *db, int event_id, int user_id); - int Db·query( - sqlite3 *db - ,const char *sql - ,int (*callback)(void * ,int ,char ** ,char **) - ,void *callback_arg - ); - void Db·close(sqlite3 *db); - -#endif // Db·IFACE - -#ifndef Db·IMPLEMENTATION - - #include - #include - #include - #include - #include - #include - #include - - sqlite3* Db·open(const char *db_path ,bool create_if_not_exists){ - sqlite3 *db; - FILE *file_check = fopen(db_path ,"r"); - - if(!file_check && create_if_not_exists){ - file_check = fopen(db_path ,"w"); - if(!file_check){ - fprintf( - stderr, - "Db::open failed to create database file '%s': %s\n", - db_path, - strerror(errno) - ); - return NULL; - } - fclose(file_check); - printf("Db::open created new database file '%s'\n", db_path); - }else if(!file_check){ - fprintf(stderr ,"Db::open database file '%s' not found and create flag not set\n" ,db_path); - return NULL; - }else{ - fclose(file_check); - } - - if( sqlite3_open(db_path ,&db) != SQLITE_OK ){ - fprintf( - stderr, - "Db::open failed to open database '%s': %s\n", - db_path, - sqlite3_errmsg(db) - ); - return NULL; - } - - printf("Db::open database '%s' opened successfully\n", db_path); - return db; - } - - // Load schema from a file - Db·ExitCode Db·load_schema(sqlite3 *db ,const char *schema_path){ - FILE *file = fopen(schema_path, "r"); - if(!file){ - fprintf - ( - stderr - ,"Db::load_schema failed to open schema file '%s'\n" - ,schema_path - ); - return Db·EXIT_SCHEMA_LOAD_ERROR; - } - - fseek(file, 0, SEEK_END); - long file_size = ftell(file); - rewind(file); - - char *schema = malloc(file_size + 1); - if(!schema){ - fprintf(stderr, "Db::load_schema memory allocation failed\n"); - fclose(file); - return Db·EXIT_MEMORY_ALLOCATION_ERROR; - } - - fread(schema, 1, file_size, file); - schema[file_size] = '\0'; - fclose(file); - - char *err_msg = NULL; - if( sqlite3_exec(db, schema, NULL, NULL, &err_msg) != SQLITE_OK ){ - fprintf - ( - stderr - ,"Db::load_schema failed to execute schema: %s\n" - ,err_msg - ); - sqlite3_free(err_msg); - free(schema); - return Db·EXIT_STATEMENT_EXECUTE_ERROR; - } - - printf("Db::load_schema schema initialized successfully from '%s'\n", schema_path); - free(schema); - return Db·EXIT_SUCCESS; - } - - // Log an event into the database - Db·ExitCode Db·log_event(sqlite3 *db ,int event_id ,int user_id){ - const char *sql_template = - "INSERT INTO db_event (event_time ,event_id ,user_id) " - "VALUES (CURRENT_TIMESTAMP ,? ,?);"; - sqlite3_stmt *stmt; - - if( sqlite3_prepare_v2(db ,sql_template ,-1 ,&stmt ,NULL) != SQLITE_OK ){ - fprintf - ( - stderr - ,"Db::log_event failed to prepare statement: %s\n" - ,sqlite3_errmsg(db) - ); - return Db·EXIT_STATEMENT_PREPARE_ERROR; - } - - sqlite3_bind_int(stmt, 1, event_id); - sqlite3_bind_int(stmt, 2, user_id); - - if( sqlite3_step(stmt) != SQLITE_DONE ){ - fprintf - ( - stderr - ,"Db::log_event failed to execute statement: %s\n" - ,sqlite3_errmsg(db) - ); - sqlite3_finalize(stmt); - return Db·EXIT_STATEMENT_EXECUTE_ERROR; - } - - sqlite3_finalize(stmt); - return Db·EXIT_SUCCESS; - } - - // Query Execution Function - int Db·query( - sqlite3 *db - ,const char *sql - ,int (*callback)(void * ,int ,char ** ,char **) - ,void *callback_arg - ){ - char *err_msg = NULL; - int rc = sqlite3_exec(db ,sql ,callback ,callback_arg ,&err_msg); - - if( rc != SQLITE_OK ){ - fprintf - ( - stderr - ,"Db::query SQL error: %s\nQuery: %s\n" - ,err_msg - ,sql - ); - sqlite3_free(err_msg); - return rc; - } - - return SQLITE_OK; - } - - // Close the database - void Db·close(sqlite3 *db){ - if( db ){ - sqlite3_close(db); - printf("Db::close database connection closed\n"); - } - } - -#endif // Db·IMPLEMENTATION - - diff --git a/developer/cc/DbSubu.lib.c b/developer/cc/DbSubu.lib.c deleted file mode 100644 index 4274dec..0000000 --- a/developer/cc/DbSubu.lib.c +++ /dev/null @@ -1,157 +0,0 @@ -#ifndef IFACE -#define DbSubu·IMPLEMENTATION -#define IFACE -#endif - -#ifndef DbSubu·IFACE -#define DbSubu·IFACE - - #include - - typedef struct DbSubu { - sqlite3 *db; - } DbSubu; - - - // db connection - DbSubu* DbSubu·open( const char *db_path ); - void DbSubu·close( DbSubu *db ); - int DbSubu·validate_schema( DbSubu *db ); - - // User Management - int DbSubu·add_user( DbSubu *db ,const char *name ,const char *home_directory ,int shell_id ,int parent_id ,int user_type_id ); - int DbSubu·delete_user( DbSubu *db ,int user_id ); - int DbSubu·get_user( DbSubu *db ,int user_id ,char **name ,char **home_directory ,int *shell_id ,int *parent_id ,int *user_type_id ); - - // Sharing Management - int DbSubu·add_share( DbSubu *db ,int user_id ,int other_user_id ,const char *permissions ); - int DbSubu·delete_share( DbSubu *db ,int share_id ); - - // System Resource Management - int DbSubu·grant_resource( DbSubu *db ,int user_id ,int resource_id ,int granted_by ); - int DbSubu·revoke_resource( DbSubu *db ,int user_id ,int resource_id ); - - // Event Logging - int DbSubu·log_event( DbSubu *db ,int event_id ,int user_id ); - -#endif // DbSubu·IFACE - -#ifdef DbSubu·IMPLEMENTATION - - #include - #include - #include - #include "Db.lib.c" - - // Open the database - DbSubu* DbSubu·open( const char *db_path ){ - DbSubu *db = malloc( sizeof(DbSubu) ); - if( !db ){ - fprintf( stderr ,"DbSubu·open:: failed to allocate memory for DbSubu\n" ); - return NULL; - } - db->db = Db·open(db_path ,true); - if( !db->db ){ - free( db ); - return NULL; - } - return db; - } - - // Close the database - void DbSubu·close( DbSubu *db ){ - if( db ){ - Db·close( db->db ); - free( db ); - } - } - - // Validate the schema - int DbSubu·validate_schema( DbSubu *db ){ - // Validation logic for ensuring the schema is correct - return 0; // Placeholder for schema validation implementation - } - - // Add a user - int DbSubu·add_user( DbSubu *db ,const char *name ,const char *home_directory ,int shell_id ,int parent_id ,int user_type_id ){ - char sql[256]; - snprintf - ( - sql - ,sizeof(sql) - ,"INSERT INTO user (name ,home_directory ,shell ,parent_id ,user_type_id) VALUES ('%s' ,'%s' ,%d ,%d ,%d);" - ,name - ,home_directory - ,shell_id - ,parent_id - ,user_type_id - ); - return Db·query( db->db ,sql ,NULL ,NULL ); - } - - // Delete a user - int DbSubu·delete_user( DbSubu *db ,int user_id ){ - char sql[128]; - snprintf( sql ,sizeof(sql) ,"DELETE FROM user WHERE id = %d;" ,user_id ); - return Db·query( db->db ,sql ,NULL ,NULL ); - } - - // Log an event - int DbSubu·log_event( DbSubu *db ,int event_id ,int user_id ){ - char sql[128]; - snprintf - ( - sql - ,sizeof(sql) - ,"INSERT INTO db_event (event_id ,user_id) VALUES (%d ,%d);" - ,event_id - ,user_id - ); - return Db·query( db->db ,sql ,NULL ,NULL ); - } - - // Add to a list (private function) - static int add_to_list( sqlite3 *db ,const char *list_name ,const char *entry_name ){ - char sql[128]; - snprintf - ( - sql - ,sizeof(sql) - ,"INSERT INTO %s (name) VALUES ('%s');" - ,list_name - ,entry_name - ); - return Db·query( db ,sql ,NULL ,NULL ); - } - - // Get list entries (private function) - static char** get_list( sqlite3 *db ,const char *list_name ,int *count ){ - char sql[128]; - snprintf( sql ,sizeof(sql) ,"SELECT name FROM %s;" ,list_name ); - - struct ListResult { - char **entries; - int count; - } result = { NULL ,0 }; - - int callback( void *arg ,int argc ,char **argv ,char **col_names ){ - (void)argc; (void)col_names; - struct ListResult *res = arg; - res->entries = realloc( res->entries ,(res->count + 1) * sizeof(char *) ); - res->entries[res->count++] = strdup( argv[0] ); - return 0; - } - - if( Db·query( db ,sql ,callback ,&result ) != SQLITE_OK ){ - for( int i = 0; i < result.count; ++i ){ - free( result.entries[i] ); - } - free( result.entries ); - return NULL; - } - - *count = result.count; - return result.entries; - } - -#endif // DbSubu·IMPLEMENTATION diff --git a/developer/cc/Db_close.cli.c b/developer/cc/Db_close.cli.c deleted file mode 100644 index 8575260..0000000 --- a/developer/cc/Db_close.cli.c +++ /dev/null @@ -1,32 +0,0 @@ -#define IFACE -#include -#include -#include -#include -#include "Db.lib.c" - -int main(int argc ,char *argv[]){ - if( argc < 2 ){ - fprintf(stderr ,"Usage: %s \n" ,argv[0]); - return EXIT_FAILURE; - } - - // Parse the SQLite handle from the command-line argument - uintptr_t handle_as_int; - if( sscanf(argv[1] ,"%lx" ,&handle_as_int) != 1 ){ - fprintf(stderr ,"%s::main failed to parse handle '%s'\n" ,argv[0] ,argv[1]); - return EXIT_FAILURE; - } - - sqlite3 *db = (sqlite3 *)handle_as_int; - - // Attempt to close the database - if( db ){ - Db·close(db); - printf("Database handle %p closed successfully.\n" ,db); - return EXIT_SUCCESS; - } else { - fprintf(stderr ,"Invalid or NULL database handle: %p\n" ,db); - return EXIT_FAILURE; - } -} diff --git a/developer/cc/Hello.cli.c b/developer/cc/Hello.cli.c deleted file mode 100644 index 2a18583..0000000 --- a/developer/cc/Hello.cli.c +++ /dev/null @@ -1,67 +0,0 @@ - -/* - The subu server command line interface. - - Usage: - server [-s ] [-l ] [arguments...] - - Options: - -s Specify the Unix socket file path. Default: ./socket - -l Specify the log file path. Default: ./log.txt -*/ - -#define IFACE -#include -#include -#include -#include -#include "Hello.lib.c" - -// Define defaults -#define DEFAULT_SOCKET_PATH "socket" - -int main( int argc ,char **argv ){ - char *socket_path = DEFAULT_SOCKET_PATH; - int error_flag = 0; - - int opt; - while( (opt = getopt(argc ,argv ,":s:l:")) != -1 ){ - switch( opt ){ - case 's': - socket_path = optarg; - break; - case '?': // Unknown option - fprintf( stderr ,"%s::main unknown option '-%c'\n" ,argv[0] ,optopt ); - error_flag = 1; - break; - case ':': // Missing argument - fprintf( stderr ,"%s::main missing argument for option '-%c'\n" ,argv[0] ,optopt ); - error_flag = 1; - break; - } - } - - if( optind > argc ){ - fprintf( stderr ,"%s::main optind(%d) > argc(%d), which indicates an option parsing bug\n" ,argv[0] ,optind ,argc ); - error_flag = 1; - } - - // Exit on error after processing all options - if( error_flag ){ - fprintf( stderr ,"%s::main usage: %s [-s ] [arguments...]\n" ,argv[0] ,argv[0] ); - return EXIT_FAILURE; - } - - // Rebase argv to prepare for run - if(optind > 0){ - argv[optind - 1] = argv[0]; // Program name at the new base - argc -= (optind - 1); - argv += (optind - 1); - } - - // Log parsed options - printf( "%s::main socket_path='%s'\n" ,argv[0] ,socket_path ); - - // Call the hello function - return Hello·run(argc ,argv ,socket_path); -} diff --git a/developer/cc/Hello.lib.c b/developer/cc/Hello.lib.c deleted file mode 100644 index 28d0f19..0000000 --- a/developer/cc/Hello.lib.c +++ /dev/null @@ -1,75 +0,0 @@ -#ifndef IFACE -#define Hello·IMPLEMENTATION -#define IFACE -#endif - -#ifndef Hello·IFACE -#define Hello·IFACE - - // Necessary interface includes - // .. none - - // Interface prototypes - int Hello·run(int argc ,char** argv ,char *socket_path); - -#endif // Hello·IFACE - -#ifdef Hello·IMPLEMENTATION - - // Implementation-specific includes - #include - #include - #include - #include - #include - #include - #include - #include - - // Constants - #define Hello·SOCKET_PATH "/var/user_data/Thomas-developer/subu/developer/mockup/subu_server_home/subu_server.sock" - #define Hello·LOG_PATH "server_test.log" - #define Hello·BUFFER_SIZE 256 - - int Hello·run(int argc ,char** argv ,char *socket_path){ - (void)argc; // Suppress unused variable warnings - (void)argv; - - int client_fd; - struct sockaddr_un address; - char buffer[Hello·BUFFER_SIZE]; - - client_fd = socket(AF_UNIX ,SOCK_STREAM ,0); - if( client_fd == -1 ){ - perror("Hello·run:: error opening socket"); - return EXIT_FAILURE; - } - - // Configure server socket address - memset(&address ,0 ,sizeof(address)); - address.sun_family = AF_UNIX; - strncpy(address.sun_path ,socket_path ,sizeof(address.sun_path) - 1); - - // Connect to the server - if( connect(client_fd ,(struct sockaddr *)&address ,sizeof(address)) == -1 ){ - perror("Hello·run:: error connecting to server"); - close(client_fd); - return EXIT_FAILURE; - } - - // Send message to the server - char *out_buf = "hello\n"; - if( write(client_fd ,out_buf ,strlen(out_buf)) == -1 ){ - perror("Hello·run:: error writing to server"); - return EXIT_FAILURE; - } - - printf("Hello·run:: sent \"%s\"\n" ,out_buf); - - // Clean up - close(client_fd); - - return EXIT_SUCCESS; - } - -#endif // Hello·IMPLEMENTATION diff --git a/developer/cc/Server.cli.c b/developer/cc/Server.cli.c deleted file mode 100644 index 6b38b3b..0000000 --- a/developer/cc/Server.cli.c +++ /dev/null @@ -1,92 +0,0 @@ -/* - The subu server command line interface. - - Usage: - server [-s ] [-l ] [arguments...] - - Options: - -s Specify the Unix socket file path. Default: ./socket - -l Specify the log file path. Default: ./log.txt -*/ - -#define IFACE -#include -#include -#include -#include -#include "Server.lib.c" - -// Define defaults -#define DEFAULT_SOCKET_PATH "socket" -#define DEFAULT_LOG_PATH "log.txt" - -int main( int argc ,char **argv ){ - char *socket_path = DEFAULT_SOCKET_PATH; - char *log_path = DEFAULT_LOG_PATH; - int error_flag = 0; - - // Parse command-line options - int opt; - while( (opt = getopt(argc ,argv ,":s:l:")) != -1 ){ - switch( opt ){ - case 's': - socket_path = optarg; - break; - case 'l': - log_path = optarg; - break; - case '?': // Unknown option - fprintf( stderr ,"%s::main unknown option '-%c'\n" ,argv[0] ,optopt ); - error_flag = 1; - break; - case ':': // Missing argument - fprintf( stderr ,"%s::main missing argument for option '-%c'\n" ,argv[0] ,optopt ); - error_flag = 1; - break; - } - } - - if( optind > argc ){ - fprintf( stderr ,"%s::main optind(%d) > argc(%d), which indicates an option parsing bug\n" ,argv[0] ,optind ,argc ); - error_flag = 1; - } - - // Exit on error after processing all options - if( error_flag ){ - fprintf( stderr ,"%s::main usage: %s [-s ] [-l ] [arguments...]\n" ,argv[0] ,argv[0] ); - return EXIT_FAILURE; - } - - // Rebase argv to prepare for run - if(optind > 0){ - argv[optind - 1] = argv[0]; // Program name at the new base - argc -= (optind - 1); - argv += (optind - 1); - } - - // Open the log file - FILE *log_file = Server·open_log(log_path); - if( !log_file ){ - fprintf( stderr ,"%s::main unable to open log file '%s'\n" ,argv[0] ,log_path ); - return Server·EXIT_LOG_FILE_ERROR; - } - - // Log parsed options - fprintf( log_file ,"%s::main socket_path='%s'\n" ,argv[0] ,socket_path ); - fprintf( log_file ,"%s::main log_path='%s'\n" ,argv[0] ,log_path ); - fflush(log_file); - - // Prepare file descriptors for error reporting - int fds[] = { fileno(stderr), fileno(log_file), -1 }; - - // Call the core server function - int exit_code = Server·run(argc ,argv ,fds ,socket_path); - - // Report return condition - Server·return_condition_report(exit_code ,fds); - - // Clean up - fclose(log_file); - - return exit_code; -} diff --git a/developer/cc/Server.lib.c b/developer/cc/Server.lib.c deleted file mode 100644 index 16451a3..0000000 --- a/developer/cc/Server.lib.c +++ /dev/null @@ -1,219 +0,0 @@ -#ifndef IFACE -#define Server·IMPLEMENTATION -#define IFACE -#endif - -#ifndef Server·IFACE -#define Server·IFACE - - #include - #include - - // Exit codes - typedef enum { - Server·EXIT_SUCCESS = 0, - Server·EXIT_LOG_FILE_ERROR, - Server·EXIT_SOCKET_CREATION_ERROR, - Server·EXIT_BIND_ERROR, - Server·EXIT_LISTEN_ERROR, - Server·EXIT_ACCEPT_ERROR - } Server·ExitCode; - - // Interface prototypes - int Server·run( int argc ,char **argv ,int *fds ,char *socket_path ); - void Server·return_condition_report( Server·ExitCode code ,int *fds ); - void Server·report( int *fds ,const char *message ); - FILE* Server·open_log( const char *log_path ); - -#endif // Server·IFACE - -#ifdef Server·IMPLEMENTATION - - // Implementation-specific includes - #include - #include - #include - #include - #include // Ensure full definition of struct ucred - #include - #include - #include - #include - - // Constants - #define Server·BUFFER_SIZE 256 - #define MAX_ARGC 16 - - // Internal function prototypes - static void parse( int *fds ,struct ucred *client_cred ,char *input_line ); - static void hello( int *fds ,int argc ,char *argv[] ,struct ucred *client_cred ); - - // Log a message with time and to multiple destinations - void Server·report( int *fds ,const char *message ){ - time_t now = time(NULL); - char time_buffer[32]; - strftime(time_buffer ,sizeof(time_buffer) ,"%Y-%m-%dT%H:%M:%SZ" ,gmtime(&now)); - - for( int i = 0; fds[i] != -1; ++i ){ - dprintf( fds[i] ,"\n%s:: %s" ,time_buffer ,message ); - } - } - - int Server·run( int argc ,char **argv ,int *fds ,char *socket_path ){ - (void)argc; // Suppress unused variable warnings - (void)argv; - - int server_fd ,client_fd; - struct sockaddr_un address; - - // Create socket - if( (server_fd = socket(AF_UNIX ,SOCK_STREAM ,0)) == -1 ){ - Server·report(fds ,"Socket creation failed."); - return Server·EXIT_SOCKET_CREATION_ERROR; - } - - // Configure socket address - memset(&address ,0 ,sizeof(address)); - address.sun_family = AF_UNIX; - strncpy(address.sun_path ,socket_path ,sizeof(address.sun_path) - 1); - - unlink(socket_path); - if( bind(server_fd ,(struct sockaddr *)&address ,sizeof(address)) == -1 ){ - Server·report(fds ,"Binding socket failed."); - close(server_fd); - return Server·EXIT_BIND_ERROR; - } - - if( listen(server_fd ,5) == -1 ){ - Server·report(fds ,"Listening on socket failed."); - close(server_fd); - return Server·EXIT_LISTEN_ERROR; - } - - char startup_message[Server·BUFFER_SIZE]; - snprintf(startup_message ,Server·BUFFER_SIZE ,"Server running with socket '%s' ,awaiting connections..." ,socket_path); - Server·report(fds ,startup_message); - - while( (client_fd = accept(server_fd ,NULL ,NULL)) != -1 ){ - struct ucred client_cred; - socklen_t len = sizeof(client_cred); - - if( getsockopt(client_fd ,SOL_SOCKET ,SO_PEERCRED ,&client_cred ,&len) == -1 ){ - Server·report(fds ,"Failed to retrieve client credentials."); - close(client_fd); - continue; - } - - char connection_message[Server·BUFFER_SIZE]; - snprintf(connection_message ,Server·BUFFER_SIZE , - "Connection from PID=%d ,UID=%d ,GID=%d" , - client_cred.pid ,client_cred.uid ,client_cred.gid); - Server·report(fds ,connection_message); - - char buffer[Server·BUFFER_SIZE]; - memset(buffer ,0 ,Server·BUFFER_SIZE); - ssize_t bytes_read = read(client_fd ,buffer ,Server·BUFFER_SIZE - 1); - if(bytes_read > 0){ - char *line = strtok(buffer ,"\n"); - while(line != NULL){ - parse(fds ,&client_cred ,line); - line = strtok(NULL ,"\n"); - } - } else if(bytes_read == -1){ - Server·report(fds ,"Error reading from client."); - } - - close(client_fd); - } - - Server·report(fds ,"Error accepting connection."); - close(server_fd); - unlink(socket_path); - return Server·EXIT_ACCEPT_ERROR; - } - - // Parse a single input line and dispatch to the appropriate command - static void parse( int *fds ,struct ucred *client_cred ,char *input_line ){ - char *argv[MAX_ARGC + 1] = {0}; - int argc = 0; - - char *line_copy = strdup(input_line); - if(!line_copy){ - Server·report(fds ,"Failed to duplicate input line."); - return; - } - - char *token = strtok(line_copy ," "); - while(token != NULL && argc < MAX_ARGC){ - argv[argc++] = token; - token = strtok(NULL ," "); - } - - if(argc > 0){ - if( strcmp(argv[0] ,"hello") == 0 ){ - hello(fds ,argc ,argv ,client_cred); - }else{ - char unknown_command_message[Server·BUFFER_SIZE]; - snprintf(unknown_command_message ,Server·BUFFER_SIZE ,"Unknown command '%s'" ,argv[0]); - Server·report(fds ,unknown_command_message); - } - } - - free(line_copy); - } - - // Example command: hello - static void hello( int *fds ,int argc ,char *argv[] ,struct ucred *client_cred ){ - char hello_message[Server·BUFFER_SIZE]; - snprintf(hello_message ,Server·BUFFER_SIZE , - "hello:: invoked by PID=%d ,UID=%d ,GID=%d" , - client_cred->pid ,client_cred->uid ,client_cred->gid); - Server·report(fds ,hello_message); - - for( int i = 1; i < argc; ++i ){ - char argument_message[Server·BUFFER_SIZE]; - snprintf(argument_message ,Server·BUFFER_SIZE ," Arg %d: %s" ,i ,argv[i]); - Server·report(fds ,argument_message); - } - } - - // Error reporting function - void Server·return_condition_report( Server·ExitCode code ,int *fds ){ - const char *message; - switch( code ){ - case Server·EXIT_SUCCESS: - message = "Operation completed successfully."; - break; - case Server·EXIT_LOG_FILE_ERROR: - message = "Failed to open log file."; - break; - case Server·EXIT_SOCKET_CREATION_ERROR: - message = "Socket creation failed."; - break; - case Server·EXIT_BIND_ERROR: - message = "Binding socket failed."; - break; - case Server·EXIT_LISTEN_ERROR: - message = "Listening on socket failed."; - break; - case Server·EXIT_ACCEPT_ERROR: - message = "Error accepting connection."; - break; - default: - message = "Unknown error occurred."; - break; - } - - Server·report(fds ,message); - } - - // Log file opener - FILE* Server·open_log( const char *log_path ){ - FILE *log_file = fopen(log_path ,"a+"); - if( log_file ){ - Server·report( (int[]){fileno(log_file), -1} ,"Log file opened."); - } - return log_file; - } - -#endif // Server·IMPLEMENTATION diff --git a/developer/cc/db_add_user.cli.c b/developer/cc/db_add_user.cli.c deleted file mode 100644 index 9ae9874..0000000 --- a/developer/cc/db_add_user.cli.c +++ /dev/null @@ -1,36 +0,0 @@ -#define IFACE -#include -#include -#include -#include "DbSubu.lib.c" - -int main(int argc ,char *argv[]){ - if( argc < 7 ){ - fprintf(stderr, "Usage: %s \n", argv[0]); - return 1; - } - - const char *db_path = argv[1]; - const char *name = argv[2]; - const char *home_directory = argv[3]; - int shell_id = atoi(argv[4]); - int parent_id = atoi(argv[5]); - int user_type_id = atoi(argv[6]); - - DbSubu *db = DbSubu·open(db_path); - if( !db ){ - fprintf(stderr, "Failed to open database: %s\n", db_path); - return 1; - } - - int result = DbSubu·add_user(db, name, home_directory, shell_id, parent_id, user_type_id); - DbSubu·close(db); - - if( result == 0 ){ - printf("User added successfully.\n"); - return 0; - } else { - fprintf(stderr, "Failed to add user.\n"); - return 1; - } -} diff --git a/developer/cc/db_delete_user.cli.c b/developer/cc/db_delete_user.cli.c deleted file mode 100644 index cf2e621..0000000 --- a/developer/cc/db_delete_user.cli.c +++ /dev/null @@ -1,32 +0,0 @@ -#define IFACE -#include -#include -#include -#include "DbSubu.lib.c" - -int main(int argc ,char *argv[]){ - if( argc < 3 ){ - fprintf(stderr, "Usage: %s \n", argv[0]); - return 1; - } - - const char *db_path = argv[1]; - int user_id = atoi(argv[2]); - - DbSubu *db = DbSubu·open(db_path); - if( !db ){ - fprintf(stderr, "Failed to open database: %s\n", db_path); - return 1; - } - - int result = DbSubu·delete_user(db, user_id); - DbSubu·close(db); - - if( result == 0 ){ - printf("User deleted successfully.\n"); - return 0; - } else { - fprintf(stderr, "Failed to delete user.\n"); - return 1; - } -} diff --git a/developer/cc/db_log_event.cli.c b/developer/cc/db_log_event.cli.c deleted file mode 100644 index cf2e621..0000000 --- a/developer/cc/db_log_event.cli.c +++ /dev/null @@ -1,32 +0,0 @@ -#define IFACE -#include -#include -#include -#include "DbSubu.lib.c" - -int main(int argc ,char *argv[]){ - if( argc < 3 ){ - fprintf(stderr, "Usage: %s \n", argv[0]); - return 1; - } - - const char *db_path = argv[1]; - int user_id = atoi(argv[2]); - - DbSubu *db = DbSubu·open(db_path); - if( !db ){ - fprintf(stderr, "Failed to open database: %s\n", db_path); - return 1; - } - - int result = DbSubu·delete_user(db, user_id); - DbSubu·close(db); - - if( result == 0 ){ - printf("User deleted successfully.\n"); - return 0; - } else { - fprintf(stderr, "Failed to delete user.\n"); - return 1; - } -} diff --git a/developer/cc/db_open.cli.c b/developer/cc/db_open.cli.c deleted file mode 100644 index f64ba5d..0000000 --- a/developer/cc/db_open.cli.c +++ /dev/null @@ -1,34 +0,0 @@ -#define IFACE -#include -#include -#include -#include "Db.lib.c" - -// Define default database path -#define DEFAULT_DB_PATH "db.sqlite" - -int main(int argc ,char *argv[]){ - const char *db_path = (argc > 1) ? argv[1] : DEFAULT_DB_PATH; - - // Open the database using Db·open - sqlite3 *db = Db·open(db_path ,true); - if( !db ){ - fprintf(stderr ,"Failed to open or create database: %s\n" ,db_path); - return EXIT_FAILURE; - } - - // Check if the file was created or already existed - printf("Database %s opened successfully\n" ,db_path); - - // Attempt to close the database - if( db ){ - Db·close(db); - printf("Database handle %p closed successfully.\n" ,db); - return EXIT_SUCCESS; - } else { - fprintf(stderr ,"Invalid or NULL database handle: %p\n" ,db); - return EXIT_FAILURE; - } - - return EXIT_SUCCESS; -} diff --git a/developer/cc/db_validate_schema.cli.c b/developer/cc/db_validate_schema.cli.c deleted file mode 100644 index 88d20e1..0000000 --- a/developer/cc/db_validate_schema.cli.c +++ /dev/null @@ -1,25 +0,0 @@ -#define IFACE -#include -#include -#include -#include "DbSubu.lib.c" - -int main(int argc ,char *argv[]){ - const char *db_path = (argc > 1) ? argv[1] : "db.sqlite"; - DbSubu *db = DbSubu·open(db_path); - if( !db ){ - fprintf(stderr, "Failed to open database: %s\n", db_path); - return 1; - } - - int result = DbSubu·validate_schema(db); - DbSubu·close(db); - - if( result == 0 ){ - printf("Schema validation passed.\n"); - return 0; - } else { - fprintf(stderr, "Schema validation failed.\n"); - return 1; - } -} diff --git a/developer/deprecated/.githolder b/developer/deprecated/.githolder deleted file mode 100644 index e69de29..0000000 diff --git a/developer/deprecated/server.lib.c b/developer/deprecated/server.lib.c deleted file mode 100644 index baf5469..0000000 --- a/developer/deprecated/server.lib.c +++ /dev/null @@ -1,104 +0,0 @@ -#ifndef IFACE -#define Server·IMPLEMENTATION -#define IFACE -#endif - -#ifndef Server·IFACE -#define Server·IFACE - - // Necessary interface includes - #include - #include - #include - - // Interface prototypes - int Server·run(); - -#endif // Server·IFACE - -#ifdef Server·IMPLEMENTATION - - // Implementation-specific includes - #include - #include - #include - #include - #include - - // Constants - #define Server·SOCKET_PATH "/var/user_data/Thomas-developer/subu/developer/mockup/subu_server_home/subu_server.sock" - #define Server·LOG_PATH "server.log" - #define Server·BUFFER_SIZE 256 - - int Server·run(){ - int server_fd ,client_fd; - struct sockaddr_un address; - char buffer[Server·BUFFER_SIZE]; - FILE *log_file; - - // Open the log file - log_file = fopen(Server·LOG_PATH ,"a+"); - if( log_file == NULL ){ - perror("Server·run:: error opening log file"); - return EXIT_FAILURE; - } - - // Create the socket - if( (server_fd = socket(AF_UNIX ,SOCK_STREAM ,0)) == -1 ){ - perror("Server·run:: error creating socket"); - fclose(log_file); - return EXIT_FAILURE; - } - - // Configure socket address - memset(&address ,0 ,sizeof(address)); - address.sun_family = AF_UNIX; - strncpy(address.sun_path ,Server·SOCKET_PATH ,sizeof(address.sun_path) - 1); - - // Bind the socket - unlink(Server·SOCKET_PATH); // Remove existing file if present - if( bind(server_fd ,(struct sockaddr *)&address ,sizeof(address)) == -1 ){ - perror("Server·run:: error binding socket"); - fclose(log_file); - close(server_fd); - return EXIT_FAILURE; - } - - // Listen for connections - if( listen(server_fd ,5) == -1 ){ - perror("Server·run:: error listening on socket"); - fclose(log_file); - close(server_fd); - return EXIT_FAILURE; - } - - printf("Server·run:: server running, waiting for connections...\n"); - - // Accept and handle client connections - while( (client_fd = accept(server_fd ,NULL ,NULL)) != -1 ){ - ssize_t bytes_read; - printf("Server·run:: connection made!\n"); - - memset(buffer ,0 ,Server·BUFFER_SIZE); - bytes_read = read(client_fd ,buffer ,Server·BUFFER_SIZE - 1); - if( bytes_read > 0 ){ - printf("Server·run:: connection said: %s\n" ,buffer); - fprintf(log_file ,"Received: %s\n" ,buffer); - fflush(log_file); - } else if( bytes_read == -1 ){ - perror("Server·run:: error reading from client"); - } - - close(client_fd); - } - - // Clean up - perror("Server·run:: error accepting connection"); - fclose(log_file); - close(server_fd); - unlink(Server·SOCKET_PATH); - - return EXIT_FAILURE; - } - -#endif // Server·IMPLEMENTATION diff --git a/developer/scratchpad/.gitignore b/developer/scratchpad/.gitignore deleted file mode 100644 index 120f485..0000000 --- a/developer/scratchpad/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!/.gitignore diff --git a/developer/source/cc/Db.lib.c b/developer/source/cc/Db.lib.c new file mode 100644 index 0000000..44c9b3f --- /dev/null +++ b/developer/source/cc/Db.lib.c @@ -0,0 +1,200 @@ +#ifndef IFACE +#define Db·IMPLEMENTATION +#define IFACE +#endif + +#ifndef Db·IFACE +#define Db·IFACE + + #include + #include + + // Enum for exit codes + typedef enum { + Db·EXIT_SUCCESS = 0, + Db·EXIT_DB_OPEN_ERROR, + Db·EXIT_SCHEMA_LOAD_ERROR, + Db·EXIT_MEMORY_ALLOCATION_ERROR, + Db·EXIT_STATEMENT_PREPARE_ERROR, + Db·EXIT_STATEMENT_EXECUTE_ERROR + } Db·ExitCode; + + // Interface prototypes + sqlite3* Db·open(const char *db_path ,bool create_if_not_exists); + Db·ExitCode Db·load_schema(sqlite3 *db, const char *schema_path); + Db·ExitCode Db·log_event(sqlite3 *db, int event_id, int user_id); + int Db·query( + sqlite3 *db + ,const char *sql + ,int (*callback)(void * ,int ,char ** ,char **) + ,void *callback_arg + ); + void Db·close(sqlite3 *db); + +#endif // Db·IFACE + +#ifndef Db·IMPLEMENTATION + + #include + #include + #include + #include + #include + #include + #include + + sqlite3* Db·open(const char *db_path ,bool create_if_not_exists){ + sqlite3 *db; + FILE *file_check = fopen(db_path ,"r"); + + if(!file_check && create_if_not_exists){ + file_check = fopen(db_path ,"w"); + if(!file_check){ + fprintf( + stderr, + "Db::open failed to create database file '%s': %s\n", + db_path, + strerror(errno) + ); + return NULL; + } + fclose(file_check); + printf("Db::open created new database file '%s'\n", db_path); + }else if(!file_check){ + fprintf(stderr ,"Db::open database file '%s' not found and create flag not set\n" ,db_path); + return NULL; + }else{ + fclose(file_check); + } + + if( sqlite3_open(db_path ,&db) != SQLITE_OK ){ + fprintf( + stderr, + "Db::open failed to open database '%s': %s\n", + db_path, + sqlite3_errmsg(db) + ); + return NULL; + } + + printf("Db::open database '%s' opened successfully\n", db_path); + return db; + } + + // Load schema from a file + Db·ExitCode Db·load_schema(sqlite3 *db ,const char *schema_path){ + FILE *file = fopen(schema_path, "r"); + if(!file){ + fprintf + ( + stderr + ,"Db::load_schema failed to open schema file '%s'\n" + ,schema_path + ); + return Db·EXIT_SCHEMA_LOAD_ERROR; + } + + fseek(file, 0, SEEK_END); + long file_size = ftell(file); + rewind(file); + + char *schema = malloc(file_size + 1); + if(!schema){ + fprintf(stderr, "Db::load_schema memory allocation failed\n"); + fclose(file); + return Db·EXIT_MEMORY_ALLOCATION_ERROR; + } + + fread(schema, 1, file_size, file); + schema[file_size] = '\0'; + fclose(file); + + char *err_msg = NULL; + if( sqlite3_exec(db, schema, NULL, NULL, &err_msg) != SQLITE_OK ){ + fprintf + ( + stderr + ,"Db::load_schema failed to execute schema: %s\n" + ,err_msg + ); + sqlite3_free(err_msg); + free(schema); + return Db·EXIT_STATEMENT_EXECUTE_ERROR; + } + + printf("Db::load_schema schema initialized successfully from '%s'\n", schema_path); + free(schema); + return Db·EXIT_SUCCESS; + } + + // Log an event into the database + Db·ExitCode Db·log_event(sqlite3 *db ,int event_id ,int user_id){ + const char *sql_template = + "INSERT INTO db_event (event_time ,event_id ,user_id) " + "VALUES (CURRENT_TIMESTAMP ,? ,?);"; + sqlite3_stmt *stmt; + + if( sqlite3_prepare_v2(db ,sql_template ,-1 ,&stmt ,NULL) != SQLITE_OK ){ + fprintf + ( + stderr + ,"Db::log_event failed to prepare statement: %s\n" + ,sqlite3_errmsg(db) + ); + return Db·EXIT_STATEMENT_PREPARE_ERROR; + } + + sqlite3_bind_int(stmt, 1, event_id); + sqlite3_bind_int(stmt, 2, user_id); + + if( sqlite3_step(stmt) != SQLITE_DONE ){ + fprintf + ( + stderr + ,"Db::log_event failed to execute statement: %s\n" + ,sqlite3_errmsg(db) + ); + sqlite3_finalize(stmt); + return Db·EXIT_STATEMENT_EXECUTE_ERROR; + } + + sqlite3_finalize(stmt); + return Db·EXIT_SUCCESS; + } + + // Query Execution Function + int Db·query( + sqlite3 *db + ,const char *sql + ,int (*callback)(void * ,int ,char ** ,char **) + ,void *callback_arg + ){ + char *err_msg = NULL; + int rc = sqlite3_exec(db ,sql ,callback ,callback_arg ,&err_msg); + + if( rc != SQLITE_OK ){ + fprintf + ( + stderr + ,"Db::query SQL error: %s\nQuery: %s\n" + ,err_msg + ,sql + ); + sqlite3_free(err_msg); + return rc; + } + + return SQLITE_OK; + } + + // Close the database + void Db·close(sqlite3 *db){ + if( db ){ + sqlite3_close(db); + printf("Db::close database connection closed\n"); + } + } + +#endif // Db·IMPLEMENTATION + + diff --git a/developer/source/cc/DbSubu.lib.c b/developer/source/cc/DbSubu.lib.c new file mode 100644 index 0000000..4274dec --- /dev/null +++ b/developer/source/cc/DbSubu.lib.c @@ -0,0 +1,157 @@ +#ifndef IFACE +#define DbSubu·IMPLEMENTATION +#define IFACE +#endif + +#ifndef DbSubu·IFACE +#define DbSubu·IFACE + + #include + + typedef struct DbSubu { + sqlite3 *db; + } DbSubu; + + + // db connection + DbSubu* DbSubu·open( const char *db_path ); + void DbSubu·close( DbSubu *db ); + int DbSubu·validate_schema( DbSubu *db ); + + // User Management + int DbSubu·add_user( DbSubu *db ,const char *name ,const char *home_directory ,int shell_id ,int parent_id ,int user_type_id ); + int DbSubu·delete_user( DbSubu *db ,int user_id ); + int DbSubu·get_user( DbSubu *db ,int user_id ,char **name ,char **home_directory ,int *shell_id ,int *parent_id ,int *user_type_id ); + + // Sharing Management + int DbSubu·add_share( DbSubu *db ,int user_id ,int other_user_id ,const char *permissions ); + int DbSubu·delete_share( DbSubu *db ,int share_id ); + + // System Resource Management + int DbSubu·grant_resource( DbSubu *db ,int user_id ,int resource_id ,int granted_by ); + int DbSubu·revoke_resource( DbSubu *db ,int user_id ,int resource_id ); + + // Event Logging + int DbSubu·log_event( DbSubu *db ,int event_id ,int user_id ); + +#endif // DbSubu·IFACE + +#ifdef DbSubu·IMPLEMENTATION + + #include + #include + #include + #include "Db.lib.c" + + // Open the database + DbSubu* DbSubu·open( const char *db_path ){ + DbSubu *db = malloc( sizeof(DbSubu) ); + if( !db ){ + fprintf( stderr ,"DbSubu·open:: failed to allocate memory for DbSubu\n" ); + return NULL; + } + db->db = Db·open(db_path ,true); + if( !db->db ){ + free( db ); + return NULL; + } + return db; + } + + // Close the database + void DbSubu·close( DbSubu *db ){ + if( db ){ + Db·close( db->db ); + free( db ); + } + } + + // Validate the schema + int DbSubu·validate_schema( DbSubu *db ){ + // Validation logic for ensuring the schema is correct + return 0; // Placeholder for schema validation implementation + } + + // Add a user + int DbSubu·add_user( DbSubu *db ,const char *name ,const char *home_directory ,int shell_id ,int parent_id ,int user_type_id ){ + char sql[256]; + snprintf + ( + sql + ,sizeof(sql) + ,"INSERT INTO user (name ,home_directory ,shell ,parent_id ,user_type_id) VALUES ('%s' ,'%s' ,%d ,%d ,%d);" + ,name + ,home_directory + ,shell_id + ,parent_id + ,user_type_id + ); + return Db·query( db->db ,sql ,NULL ,NULL ); + } + + // Delete a user + int DbSubu·delete_user( DbSubu *db ,int user_id ){ + char sql[128]; + snprintf( sql ,sizeof(sql) ,"DELETE FROM user WHERE id = %d;" ,user_id ); + return Db·query( db->db ,sql ,NULL ,NULL ); + } + + // Log an event + int DbSubu·log_event( DbSubu *db ,int event_id ,int user_id ){ + char sql[128]; + snprintf + ( + sql + ,sizeof(sql) + ,"INSERT INTO db_event (event_id ,user_id) VALUES (%d ,%d);" + ,event_id + ,user_id + ); + return Db·query( db->db ,sql ,NULL ,NULL ); + } + + // Add to a list (private function) + static int add_to_list( sqlite3 *db ,const char *list_name ,const char *entry_name ){ + char sql[128]; + snprintf + ( + sql + ,sizeof(sql) + ,"INSERT INTO %s (name) VALUES ('%s');" + ,list_name + ,entry_name + ); + return Db·query( db ,sql ,NULL ,NULL ); + } + + // Get list entries (private function) + static char** get_list( sqlite3 *db ,const char *list_name ,int *count ){ + char sql[128]; + snprintf( sql ,sizeof(sql) ,"SELECT name FROM %s;" ,list_name ); + + struct ListResult { + char **entries; + int count; + } result = { NULL ,0 }; + + int callback( void *arg ,int argc ,char **argv ,char **col_names ){ + (void)argc; (void)col_names; + struct ListResult *res = arg; + res->entries = realloc( res->entries ,(res->count + 1) * sizeof(char *) ); + res->entries[res->count++] = strdup( argv[0] ); + return 0; + } + + if( Db·query( db ,sql ,callback ,&result ) != SQLITE_OK ){ + for( int i = 0; i < result.count; ++i ){ + free( result.entries[i] ); + } + free( result.entries ); + return NULL; + } + + *count = result.count; + return result.entries; + } + +#endif // DbSubu·IMPLEMENTATION diff --git a/developer/source/cc/Db_close.cli.c b/developer/source/cc/Db_close.cli.c new file mode 100644 index 0000000..8575260 --- /dev/null +++ b/developer/source/cc/Db_close.cli.c @@ -0,0 +1,32 @@ +#define IFACE +#include +#include +#include +#include +#include "Db.lib.c" + +int main(int argc ,char *argv[]){ + if( argc < 2 ){ + fprintf(stderr ,"Usage: %s \n" ,argv[0]); + return EXIT_FAILURE; + } + + // Parse the SQLite handle from the command-line argument + uintptr_t handle_as_int; + if( sscanf(argv[1] ,"%lx" ,&handle_as_int) != 1 ){ + fprintf(stderr ,"%s::main failed to parse handle '%s'\n" ,argv[0] ,argv[1]); + return EXIT_FAILURE; + } + + sqlite3 *db = (sqlite3 *)handle_as_int; + + // Attempt to close the database + if( db ){ + Db·close(db); + printf("Database handle %p closed successfully.\n" ,db); + return EXIT_SUCCESS; + } else { + fprintf(stderr ,"Invalid or NULL database handle: %p\n" ,db); + return EXIT_FAILURE; + } +} diff --git a/developer/source/cc/Hello.cli.c b/developer/source/cc/Hello.cli.c new file mode 100644 index 0000000..2a18583 --- /dev/null +++ b/developer/source/cc/Hello.cli.c @@ -0,0 +1,67 @@ + +/* + The subu server command line interface. + + Usage: + server [-s ] [-l ] [arguments...] + + Options: + -s Specify the Unix socket file path. Default: ./socket + -l Specify the log file path. Default: ./log.txt +*/ + +#define IFACE +#include +#include +#include +#include +#include "Hello.lib.c" + +// Define defaults +#define DEFAULT_SOCKET_PATH "socket" + +int main( int argc ,char **argv ){ + char *socket_path = DEFAULT_SOCKET_PATH; + int error_flag = 0; + + int opt; + while( (opt = getopt(argc ,argv ,":s:l:")) != -1 ){ + switch( opt ){ + case 's': + socket_path = optarg; + break; + case '?': // Unknown option + fprintf( stderr ,"%s::main unknown option '-%c'\n" ,argv[0] ,optopt ); + error_flag = 1; + break; + case ':': // Missing argument + fprintf( stderr ,"%s::main missing argument for option '-%c'\n" ,argv[0] ,optopt ); + error_flag = 1; + break; + } + } + + if( optind > argc ){ + fprintf( stderr ,"%s::main optind(%d) > argc(%d), which indicates an option parsing bug\n" ,argv[0] ,optind ,argc ); + error_flag = 1; + } + + // Exit on error after processing all options + if( error_flag ){ + fprintf( stderr ,"%s::main usage: %s [-s ] [arguments...]\n" ,argv[0] ,argv[0] ); + return EXIT_FAILURE; + } + + // Rebase argv to prepare for run + if(optind > 0){ + argv[optind - 1] = argv[0]; // Program name at the new base + argc -= (optind - 1); + argv += (optind - 1); + } + + // Log parsed options + printf( "%s::main socket_path='%s'\n" ,argv[0] ,socket_path ); + + // Call the hello function + return Hello·run(argc ,argv ,socket_path); +} diff --git a/developer/source/cc/Hello.lib.c b/developer/source/cc/Hello.lib.c new file mode 100644 index 0000000..28d0f19 --- /dev/null +++ b/developer/source/cc/Hello.lib.c @@ -0,0 +1,75 @@ +#ifndef IFACE +#define Hello·IMPLEMENTATION +#define IFACE +#endif + +#ifndef Hello·IFACE +#define Hello·IFACE + + // Necessary interface includes + // .. none + + // Interface prototypes + int Hello·run(int argc ,char** argv ,char *socket_path); + +#endif // Hello·IFACE + +#ifdef Hello·IMPLEMENTATION + + // Implementation-specific includes + #include + #include + #include + #include + #include + #include + #include + #include + + // Constants + #define Hello·SOCKET_PATH "/var/user_data/Thomas-developer/subu/developer/mockup/subu_server_home/subu_server.sock" + #define Hello·LOG_PATH "server_test.log" + #define Hello·BUFFER_SIZE 256 + + int Hello·run(int argc ,char** argv ,char *socket_path){ + (void)argc; // Suppress unused variable warnings + (void)argv; + + int client_fd; + struct sockaddr_un address; + char buffer[Hello·BUFFER_SIZE]; + + client_fd = socket(AF_UNIX ,SOCK_STREAM ,0); + if( client_fd == -1 ){ + perror("Hello·run:: error opening socket"); + return EXIT_FAILURE; + } + + // Configure server socket address + memset(&address ,0 ,sizeof(address)); + address.sun_family = AF_UNIX; + strncpy(address.sun_path ,socket_path ,sizeof(address.sun_path) - 1); + + // Connect to the server + if( connect(client_fd ,(struct sockaddr *)&address ,sizeof(address)) == -1 ){ + perror("Hello·run:: error connecting to server"); + close(client_fd); + return EXIT_FAILURE; + } + + // Send message to the server + char *out_buf = "hello\n"; + if( write(client_fd ,out_buf ,strlen(out_buf)) == -1 ){ + perror("Hello·run:: error writing to server"); + return EXIT_FAILURE; + } + + printf("Hello·run:: sent \"%s\"\n" ,out_buf); + + // Clean up + close(client_fd); + + return EXIT_SUCCESS; + } + +#endif // Hello·IMPLEMENTATION diff --git a/developer/source/cc/Server.cli.c b/developer/source/cc/Server.cli.c new file mode 100644 index 0000000..6b38b3b --- /dev/null +++ b/developer/source/cc/Server.cli.c @@ -0,0 +1,92 @@ +/* + The subu server command line interface. + + Usage: + server [-s ] [-l ] [arguments...] + + Options: + -s Specify the Unix socket file path. Default: ./socket + -l Specify the log file path. Default: ./log.txt +*/ + +#define IFACE +#include +#include +#include +#include +#include "Server.lib.c" + +// Define defaults +#define DEFAULT_SOCKET_PATH "socket" +#define DEFAULT_LOG_PATH "log.txt" + +int main( int argc ,char **argv ){ + char *socket_path = DEFAULT_SOCKET_PATH; + char *log_path = DEFAULT_LOG_PATH; + int error_flag = 0; + + // Parse command-line options + int opt; + while( (opt = getopt(argc ,argv ,":s:l:")) != -1 ){ + switch( opt ){ + case 's': + socket_path = optarg; + break; + case 'l': + log_path = optarg; + break; + case '?': // Unknown option + fprintf( stderr ,"%s::main unknown option '-%c'\n" ,argv[0] ,optopt ); + error_flag = 1; + break; + case ':': // Missing argument + fprintf( stderr ,"%s::main missing argument for option '-%c'\n" ,argv[0] ,optopt ); + error_flag = 1; + break; + } + } + + if( optind > argc ){ + fprintf( stderr ,"%s::main optind(%d) > argc(%d), which indicates an option parsing bug\n" ,argv[0] ,optind ,argc ); + error_flag = 1; + } + + // Exit on error after processing all options + if( error_flag ){ + fprintf( stderr ,"%s::main usage: %s [-s ] [-l ] [arguments...]\n" ,argv[0] ,argv[0] ); + return EXIT_FAILURE; + } + + // Rebase argv to prepare for run + if(optind > 0){ + argv[optind - 1] = argv[0]; // Program name at the new base + argc -= (optind - 1); + argv += (optind - 1); + } + + // Open the log file + FILE *log_file = Server·open_log(log_path); + if( !log_file ){ + fprintf( stderr ,"%s::main unable to open log file '%s'\n" ,argv[0] ,log_path ); + return Server·EXIT_LOG_FILE_ERROR; + } + + // Log parsed options + fprintf( log_file ,"%s::main socket_path='%s'\n" ,argv[0] ,socket_path ); + fprintf( log_file ,"%s::main log_path='%s'\n" ,argv[0] ,log_path ); + fflush(log_file); + + // Prepare file descriptors for error reporting + int fds[] = { fileno(stderr), fileno(log_file), -1 }; + + // Call the core server function + int exit_code = Server·run(argc ,argv ,fds ,socket_path); + + // Report return condition + Server·return_condition_report(exit_code ,fds); + + // Clean up + fclose(log_file); + + return exit_code; +} diff --git a/developer/source/cc/Server.lib.c b/developer/source/cc/Server.lib.c new file mode 100644 index 0000000..16451a3 --- /dev/null +++ b/developer/source/cc/Server.lib.c @@ -0,0 +1,219 @@ +#ifndef IFACE +#define Server·IMPLEMENTATION +#define IFACE +#endif + +#ifndef Server·IFACE +#define Server·IFACE + + #include + #include + + // Exit codes + typedef enum { + Server·EXIT_SUCCESS = 0, + Server·EXIT_LOG_FILE_ERROR, + Server·EXIT_SOCKET_CREATION_ERROR, + Server·EXIT_BIND_ERROR, + Server·EXIT_LISTEN_ERROR, + Server·EXIT_ACCEPT_ERROR + } Server·ExitCode; + + // Interface prototypes + int Server·run( int argc ,char **argv ,int *fds ,char *socket_path ); + void Server·return_condition_report( Server·ExitCode code ,int *fds ); + void Server·report( int *fds ,const char *message ); + FILE* Server·open_log( const char *log_path ); + +#endif // Server·IFACE + +#ifdef Server·IMPLEMENTATION + + // Implementation-specific includes + #include + #include + #include + #include + #include // Ensure full definition of struct ucred + #include + #include + #include + #include + + // Constants + #define Server·BUFFER_SIZE 256 + #define MAX_ARGC 16 + + // Internal function prototypes + static void parse( int *fds ,struct ucred *client_cred ,char *input_line ); + static void hello( int *fds ,int argc ,char *argv[] ,struct ucred *client_cred ); + + // Log a message with time and to multiple destinations + void Server·report( int *fds ,const char *message ){ + time_t now = time(NULL); + char time_buffer[32]; + strftime(time_buffer ,sizeof(time_buffer) ,"%Y-%m-%dT%H:%M:%SZ" ,gmtime(&now)); + + for( int i = 0; fds[i] != -1; ++i ){ + dprintf( fds[i] ,"\n%s:: %s" ,time_buffer ,message ); + } + } + + int Server·run( int argc ,char **argv ,int *fds ,char *socket_path ){ + (void)argc; // Suppress unused variable warnings + (void)argv; + + int server_fd ,client_fd; + struct sockaddr_un address; + + // Create socket + if( (server_fd = socket(AF_UNIX ,SOCK_STREAM ,0)) == -1 ){ + Server·report(fds ,"Socket creation failed."); + return Server·EXIT_SOCKET_CREATION_ERROR; + } + + // Configure socket address + memset(&address ,0 ,sizeof(address)); + address.sun_family = AF_UNIX; + strncpy(address.sun_path ,socket_path ,sizeof(address.sun_path) - 1); + + unlink(socket_path); + if( bind(server_fd ,(struct sockaddr *)&address ,sizeof(address)) == -1 ){ + Server·report(fds ,"Binding socket failed."); + close(server_fd); + return Server·EXIT_BIND_ERROR; + } + + if( listen(server_fd ,5) == -1 ){ + Server·report(fds ,"Listening on socket failed."); + close(server_fd); + return Server·EXIT_LISTEN_ERROR; + } + + char startup_message[Server·BUFFER_SIZE]; + snprintf(startup_message ,Server·BUFFER_SIZE ,"Server running with socket '%s' ,awaiting connections..." ,socket_path); + Server·report(fds ,startup_message); + + while( (client_fd = accept(server_fd ,NULL ,NULL)) != -1 ){ + struct ucred client_cred; + socklen_t len = sizeof(client_cred); + + if( getsockopt(client_fd ,SOL_SOCKET ,SO_PEERCRED ,&client_cred ,&len) == -1 ){ + Server·report(fds ,"Failed to retrieve client credentials."); + close(client_fd); + continue; + } + + char connection_message[Server·BUFFER_SIZE]; + snprintf(connection_message ,Server·BUFFER_SIZE , + "Connection from PID=%d ,UID=%d ,GID=%d" , + client_cred.pid ,client_cred.uid ,client_cred.gid); + Server·report(fds ,connection_message); + + char buffer[Server·BUFFER_SIZE]; + memset(buffer ,0 ,Server·BUFFER_SIZE); + ssize_t bytes_read = read(client_fd ,buffer ,Server·BUFFER_SIZE - 1); + if(bytes_read > 0){ + char *line = strtok(buffer ,"\n"); + while(line != NULL){ + parse(fds ,&client_cred ,line); + line = strtok(NULL ,"\n"); + } + } else if(bytes_read == -1){ + Server·report(fds ,"Error reading from client."); + } + + close(client_fd); + } + + Server·report(fds ,"Error accepting connection."); + close(server_fd); + unlink(socket_path); + return Server·EXIT_ACCEPT_ERROR; + } + + // Parse a single input line and dispatch to the appropriate command + static void parse( int *fds ,struct ucred *client_cred ,char *input_line ){ + char *argv[MAX_ARGC + 1] = {0}; + int argc = 0; + + char *line_copy = strdup(input_line); + if(!line_copy){ + Server·report(fds ,"Failed to duplicate input line."); + return; + } + + char *token = strtok(line_copy ," "); + while(token != NULL && argc < MAX_ARGC){ + argv[argc++] = token; + token = strtok(NULL ," "); + } + + if(argc > 0){ + if( strcmp(argv[0] ,"hello") == 0 ){ + hello(fds ,argc ,argv ,client_cred); + }else{ + char unknown_command_message[Server·BUFFER_SIZE]; + snprintf(unknown_command_message ,Server·BUFFER_SIZE ,"Unknown command '%s'" ,argv[0]); + Server·report(fds ,unknown_command_message); + } + } + + free(line_copy); + } + + // Example command: hello + static void hello( int *fds ,int argc ,char *argv[] ,struct ucred *client_cred ){ + char hello_message[Server·BUFFER_SIZE]; + snprintf(hello_message ,Server·BUFFER_SIZE , + "hello:: invoked by PID=%d ,UID=%d ,GID=%d" , + client_cred->pid ,client_cred->uid ,client_cred->gid); + Server·report(fds ,hello_message); + + for( int i = 1; i < argc; ++i ){ + char argument_message[Server·BUFFER_SIZE]; + snprintf(argument_message ,Server·BUFFER_SIZE ," Arg %d: %s" ,i ,argv[i]); + Server·report(fds ,argument_message); + } + } + + // Error reporting function + void Server·return_condition_report( Server·ExitCode code ,int *fds ){ + const char *message; + switch( code ){ + case Server·EXIT_SUCCESS: + message = "Operation completed successfully."; + break; + case Server·EXIT_LOG_FILE_ERROR: + message = "Failed to open log file."; + break; + case Server·EXIT_SOCKET_CREATION_ERROR: + message = "Socket creation failed."; + break; + case Server·EXIT_BIND_ERROR: + message = "Binding socket failed."; + break; + case Server·EXIT_LISTEN_ERROR: + message = "Listening on socket failed."; + break; + case Server·EXIT_ACCEPT_ERROR: + message = "Error accepting connection."; + break; + default: + message = "Unknown error occurred."; + break; + } + + Server·report(fds ,message); + } + + // Log file opener + FILE* Server·open_log( const char *log_path ){ + FILE *log_file = fopen(log_path ,"a+"); + if( log_file ){ + Server·report( (int[]){fileno(log_file), -1} ,"Log file opened."); + } + return log_file; + } + +#endif // Server·IMPLEMENTATION diff --git a/developer/source/cc/db_add_user.cli.c b/developer/source/cc/db_add_user.cli.c new file mode 100644 index 0000000..9ae9874 --- /dev/null +++ b/developer/source/cc/db_add_user.cli.c @@ -0,0 +1,36 @@ +#define IFACE +#include +#include +#include +#include "DbSubu.lib.c" + +int main(int argc ,char *argv[]){ + if( argc < 7 ){ + fprintf(stderr, "Usage: %s \n", argv[0]); + return 1; + } + + const char *db_path = argv[1]; + const char *name = argv[2]; + const char *home_directory = argv[3]; + int shell_id = atoi(argv[4]); + int parent_id = atoi(argv[5]); + int user_type_id = atoi(argv[6]); + + DbSubu *db = DbSubu·open(db_path); + if( !db ){ + fprintf(stderr, "Failed to open database: %s\n", db_path); + return 1; + } + + int result = DbSubu·add_user(db, name, home_directory, shell_id, parent_id, user_type_id); + DbSubu·close(db); + + if( result == 0 ){ + printf("User added successfully.\n"); + return 0; + } else { + fprintf(stderr, "Failed to add user.\n"); + return 1; + } +} diff --git a/developer/source/cc/db_delete_user.cli.c b/developer/source/cc/db_delete_user.cli.c new file mode 100644 index 0000000..cf2e621 --- /dev/null +++ b/developer/source/cc/db_delete_user.cli.c @@ -0,0 +1,32 @@ +#define IFACE +#include +#include +#include +#include "DbSubu.lib.c" + +int main(int argc ,char *argv[]){ + if( argc < 3 ){ + fprintf(stderr, "Usage: %s \n", argv[0]); + return 1; + } + + const char *db_path = argv[1]; + int user_id = atoi(argv[2]); + + DbSubu *db = DbSubu·open(db_path); + if( !db ){ + fprintf(stderr, "Failed to open database: %s\n", db_path); + return 1; + } + + int result = DbSubu·delete_user(db, user_id); + DbSubu·close(db); + + if( result == 0 ){ + printf("User deleted successfully.\n"); + return 0; + } else { + fprintf(stderr, "Failed to delete user.\n"); + return 1; + } +} diff --git a/developer/source/cc/db_log_event.cli.c b/developer/source/cc/db_log_event.cli.c new file mode 100644 index 0000000..cf2e621 --- /dev/null +++ b/developer/source/cc/db_log_event.cli.c @@ -0,0 +1,32 @@ +#define IFACE +#include +#include +#include +#include "DbSubu.lib.c" + +int main(int argc ,char *argv[]){ + if( argc < 3 ){ + fprintf(stderr, "Usage: %s \n", argv[0]); + return 1; + } + + const char *db_path = argv[1]; + int user_id = atoi(argv[2]); + + DbSubu *db = DbSubu·open(db_path); + if( !db ){ + fprintf(stderr, "Failed to open database: %s\n", db_path); + return 1; + } + + int result = DbSubu·delete_user(db, user_id); + DbSubu·close(db); + + if( result == 0 ){ + printf("User deleted successfully.\n"); + return 0; + } else { + fprintf(stderr, "Failed to delete user.\n"); + return 1; + } +} diff --git a/developer/source/cc/db_open.cli.c b/developer/source/cc/db_open.cli.c new file mode 100644 index 0000000..f64ba5d --- /dev/null +++ b/developer/source/cc/db_open.cli.c @@ -0,0 +1,34 @@ +#define IFACE +#include +#include +#include +#include "Db.lib.c" + +// Define default database path +#define DEFAULT_DB_PATH "db.sqlite" + +int main(int argc ,char *argv[]){ + const char *db_path = (argc > 1) ? argv[1] : DEFAULT_DB_PATH; + + // Open the database using Db·open + sqlite3 *db = Db·open(db_path ,true); + if( !db ){ + fprintf(stderr ,"Failed to open or create database: %s\n" ,db_path); + return EXIT_FAILURE; + } + + // Check if the file was created or already existed + printf("Database %s opened successfully\n" ,db_path); + + // Attempt to close the database + if( db ){ + Db·close(db); + printf("Database handle %p closed successfully.\n" ,db); + return EXIT_SUCCESS; + } else { + fprintf(stderr ,"Invalid or NULL database handle: %p\n" ,db); + return EXIT_FAILURE; + } + + return EXIT_SUCCESS; +} diff --git a/developer/source/cc/db_validate_schema.cli.c b/developer/source/cc/db_validate_schema.cli.c new file mode 100644 index 0000000..88d20e1 --- /dev/null +++ b/developer/source/cc/db_validate_schema.cli.c @@ -0,0 +1,25 @@ +#define IFACE +#include +#include +#include +#include "DbSubu.lib.c" + +int main(int argc ,char *argv[]){ + const char *db_path = (argc > 1) ? argv[1] : "db.sqlite"; + DbSubu *db = DbSubu·open(db_path); + if( !db ){ + fprintf(stderr, "Failed to open database: %s\n", db_path); + return 1; + } + + int result = DbSubu·validate_schema(db); + DbSubu·close(db); + + if( result == 0 ){ + printf("Schema validation passed.\n"); + return 0; + } else { + fprintf(stderr, "Schema validation failed.\n"); + return 1; + } +} diff --git a/developer/source/cc/scratchpad/.gitignore b/developer/source/cc/scratchpad/.gitignore new file mode 100644 index 0000000..120f485 --- /dev/null +++ b/developer/source/cc/scratchpad/.gitignore @@ -0,0 +1,2 @@ +* +!/.gitignore diff --git a/developer/source/deprecated/.githolder b/developer/source/deprecated/.githolder new file mode 100644 index 0000000..e69de29 diff --git a/developer/source/deprecated/server.lib.c b/developer/source/deprecated/server.lib.c new file mode 100644 index 0000000..baf5469 --- /dev/null +++ b/developer/source/deprecated/server.lib.c @@ -0,0 +1,104 @@ +#ifndef IFACE +#define Server·IMPLEMENTATION +#define IFACE +#endif + +#ifndef Server·IFACE +#define Server·IFACE + + // Necessary interface includes + #include + #include + #include + + // Interface prototypes + int Server·run(); + +#endif // Server·IFACE + +#ifdef Server·IMPLEMENTATION + + // Implementation-specific includes + #include + #include + #include + #include + #include + + // Constants + #define Server·SOCKET_PATH "/var/user_data/Thomas-developer/subu/developer/mockup/subu_server_home/subu_server.sock" + #define Server·LOG_PATH "server.log" + #define Server·BUFFER_SIZE 256 + + int Server·run(){ + int server_fd ,client_fd; + struct sockaddr_un address; + char buffer[Server·BUFFER_SIZE]; + FILE *log_file; + + // Open the log file + log_file = fopen(Server·LOG_PATH ,"a+"); + if( log_file == NULL ){ + perror("Server·run:: error opening log file"); + return EXIT_FAILURE; + } + + // Create the socket + if( (server_fd = socket(AF_UNIX ,SOCK_STREAM ,0)) == -1 ){ + perror("Server·run:: error creating socket"); + fclose(log_file); + return EXIT_FAILURE; + } + + // Configure socket address + memset(&address ,0 ,sizeof(address)); + address.sun_family = AF_UNIX; + strncpy(address.sun_path ,Server·SOCKET_PATH ,sizeof(address.sun_path) - 1); + + // Bind the socket + unlink(Server·SOCKET_PATH); // Remove existing file if present + if( bind(server_fd ,(struct sockaddr *)&address ,sizeof(address)) == -1 ){ + perror("Server·run:: error binding socket"); + fclose(log_file); + close(server_fd); + return EXIT_FAILURE; + } + + // Listen for connections + if( listen(server_fd ,5) == -1 ){ + perror("Server·run:: error listening on socket"); + fclose(log_file); + close(server_fd); + return EXIT_FAILURE; + } + + printf("Server·run:: server running, waiting for connections...\n"); + + // Accept and handle client connections + while( (client_fd = accept(server_fd ,NULL ,NULL)) != -1 ){ + ssize_t bytes_read; + printf("Server·run:: connection made!\n"); + + memset(buffer ,0 ,Server·BUFFER_SIZE); + bytes_read = read(client_fd ,buffer ,Server·BUFFER_SIZE - 1); + if( bytes_read > 0 ){ + printf("Server·run:: connection said: %s\n" ,buffer); + fprintf(log_file ,"Received: %s\n" ,buffer); + fflush(log_file); + } else if( bytes_read == -1 ){ + perror("Server·run:: error reading from client"); + } + + close(client_fd); + } + + // Clean up + perror("Server·run:: error accepting connection"); + fclose(log_file); + close(server_fd); + unlink(Server·SOCKET_PATH); + + return EXIT_FAILURE; + } + +#endif // Server·IMPLEMENTATION diff --git a/developer/source/wg/.gitignore b/developer/source/wg/.gitignore new file mode 100644 index 0000000..5c016c6 --- /dev/null +++ b/developer/source/wg/.gitignore @@ -0,0 +1,3 @@ + +__pycache__ + diff --git a/developer/source/wg/db/.gitignore b/developer/source/wg/db/.gitignore new file mode 100644 index 0000000..53642ce --- /dev/null +++ b/developer/source/wg/db/.gitignore @@ -0,0 +1,4 @@ + +* +!.gitignore + diff --git a/developer/source/wg/db_bind_user_to_iface.py b/developer/source/wg/db_bind_user_to_iface.py new file mode 100755 index 0000000..1ec4700 --- /dev/null +++ b/developer/source/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/source/wg/db_checks.py b/developer/source/wg/db_checks.py new file mode 100755 index 0000000..ef172de --- /dev/null +++ b/developer/source/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/source/wg/db_init_StanleyPark.py b/developer/source/wg/db_init_StanleyPark.py new file mode 100755 index 0000000..2965415 --- /dev/null +++ b/developer/source/wg/db_init_StanleyPark.py @@ -0,0 +1,78 @@ +#!/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 +from db_init_ip_table_registration import assign_missing_rt_table_ids +from db_init_ip_iface_addr_assign import reconcile_kernel_and_db_ipv4_addresses + +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") + + msg_wrapped_call( + "db_init_ip_table_registration" + ,lambda: assign_missing_rt_table_ids(conn ,low=20000 ,high=29999 ,dry_run=False) + ) + + msg_wrapped_call( + "db_init_ip_iface_addr_assign" + ,lambda: reconcile_kernel_and_db_ipv4_addresses(conn ,pool_cidr="10.0.0.0/16" ,assign_prefix=32 ,reserve_first=0 ,dry_run=False) + ) + + 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/source/wg/db_init_iface.py b/developer/source/wg/db_init_iface.py new file mode 100644 index 0000000..1f9443e --- /dev/null +++ b/developer/source/wg/db_init_iface.py @@ -0,0 +1,72 @@ +#!/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 + +# Normally don't set the addr_cidr, the system will automically +# assign a free address, or reuse one that is already set. + +def upsert_client(conn: sqlite3.Connection, + *, + iface: str, + addr_cidr: Optional[str] = None, + 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/source/wg/db_init_iface_US.py b/developer/source/wg/db_init_iface_US.py new file mode 100755 index 0000000..bf03c95 --- /dev/null +++ b/developer/source/wg/db_init_iface_US.py @@ -0,0 +1,6 @@ +# db_init_iface_US.py +from db_init_iface 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", rt_table_name="US") diff --git a/developer/source/wg/db_init_iface_x6.py b/developer/source/wg/db_init_iface_x6.py new file mode 100755 index 0000000..82eb5fe --- /dev/null +++ b/developer/source/wg/db_init_iface_x6.py @@ -0,0 +1,6 @@ +# db_init_iface_x6.py +from db_init_iface import upsert_client + +def init_iface_x6(conn): + # iface x6 with dedicated table 'x6' and host /32 + return upsert_client(conn, iface="x6", rt_table_name="x6") diff --git a/developer/source/wg/db_init_ip_iface_addr_assign.py b/developer/source/wg/db_init_ip_iface_addr_assign.py new file mode 100755 index 0000000..561635e --- /dev/null +++ b/developer/source/wg/db_init_ip_iface_addr_assign.py @@ -0,0 +1,224 @@ +#!/usr/bin/env python3 +""" +db_init_ip_iface_addr_assign.py + +Business API: + reconcile_kernel_and_db_ipv4_addresses(conn ,pool_cidr="10.0.0.0/16" ,assign_prefix=32 ,reserve_first=0 ,dry_run=False) + -> (updated_count ,notes) +""" + +from __future__ import annotations +import argparse +import ipaddress +import json +import sqlite3 +import subprocess +from typing import Dict ,Iterable ,List ,Optional ,Sequence ,Tuple + +import incommon as ic + + +def fetch_ifaces(conn: sqlite3.Connection) -> List[Tuple[int ,str ,Optional[str]]]: + sql = """ + SELECT id, + iface, + NULLIF(TRIM(local_address_cidr),'') AS local_address_cidr + FROM Iface + ORDER BY id; + """ + cur = conn.execute(sql) + rows = cur.fetchall() + return [ + (int(r[0]) ,str(r[1]) ,(str(r[2]) if r[2] is not None else None)) + for r in rows + ] + + +def update_iface_addresses(conn: sqlite3.Connection ,updates: Dict[int ,str]) -> int: + if not updates: + return 0 + with conn: + for iface_id ,cidr in updates.items(): + conn.execute("UPDATE Iface SET local_address_cidr=? WHERE id=?" ,(cidr ,iface_id)) + return len(updates) + + +def kernel_ipv4_cidr_for(iface: str) -> Optional[str]: + try: + cp = subprocess.run( + ["ip","-j","addr","show","dev",iface] + ,check=False + ,capture_output=True + ,text=True + ) + except Exception: + return None + if cp.returncode != 0 or not cp.stdout.strip(): + return None + try: + data = json.loads(cp.stdout) + except json.JSONDecodeError: + return None + if not isinstance(data ,list) or not data: + return None + addr_info = data[0].get("addr_info") or [] + for a in addr_info: + if a.get("family") == "inet" and a.get("scope") == "global": + local = a.get("local"); plen = a.get("prefixlen") + if local and isinstance(plen ,int): + return f"{local}/{plen}" + for a in addr_info: + if a.get("family") == "inet": + local = a.get("local"); plen = a.get("prefixlen") + if local and isinstance(plen ,int): + return f"{local}/{plen}" + return None + + +def kernel_ipv4_map(ifaces: Sequence[str]) -> Dict[str ,Optional[str]]: + return {name: kernel_ipv4_cidr_for(name) for name in ifaces} + + +def _host_ip_from_cidr(cidr: str): + try: + ipi = ipaddress.ip_interface(cidr) + except ValueError: + return None + if isinstance(ipi.ip ,ipaddress.IPv4Address): + return ipaddress.IPv4Address(int(ipi.ip)) + return None + + +def _collect_used_hosts_from(cidrs: Iterable[str] ,pool: ipaddress.IPv4Network) -> List[ipaddress.IPv4Address]: + used: List[ipaddress.IPv4Address] = [] + for c in cidrs: + hip = _host_ip_from_cidr(c) + if hip is not None and hip in pool: + used.append(hip) + return used + + +def _first_free_hosts( + count: int + ,used_hosts: Iterable[ipaddress.IPv4Address] + ,pool: ipaddress.IPv4Network + ,reserve_first: int = 0 +) -> List[ipaddress.IPv4Address]: + used_set = {int(h) for h in used_hosts} + result: List[ipaddress.IPv4Address] = [] + start = int(pool.network_address) + 1 + max(0 ,reserve_first) + end = int(pool.broadcast_address) - 1 + for val in range(start ,end+1): + if val not in used_set: + result.append(ipaddress.IPv4Address(val)) + if len(result) >= count: + break + if len(result) < count: + raise RuntimeError(f"address pool exhausted in {pool} (needed {count} more)") + return result + + +def plan_address_updates( + rows: Sequence[Tuple[int ,str ,Optional[str]]] + ,pool_cidr: str + ,assign_prefix: int + ,reserve_first: int + ,kmap: Dict[str ,Optional[str]] +) -> Tuple[Dict[int ,str] ,List[str]]: + notes: List[str] = [] + pool = ipaddress.IPv4Network(pool_cidr ,strict=False) + if pool.version != 4: + raise ValueError("only IPv4 pools supported") + + kernel_present = [c for c in kmap.values() if c] + db_present = [c for (_i ,_n ,c) in rows if c] + used_hosts = ( + _collect_used_hosts_from(kernel_present ,pool) + + _collect_used_hosts_from(db_present ,pool) + ) + + alloc_targets: List[Tuple[int ,str]] = [] + updates: Dict[int ,str] = {} + + for iface_id ,iface_name ,db_cidr in rows: + k_cidr = kmap.get(iface_name) + + if k_cidr: + if db_cidr != k_cidr: + updates[iface_id] = k_cidr + if db_cidr: + notes.append(f"sync: iface '{iface_name}' DB {db_cidr} -> kernel {k_cidr}") + else: + notes.append(f"sync: iface '{iface_name}' set from kernel {k_cidr}") + continue + + if db_cidr: + notes.append(f"note: iface '{iface_name}' has DB {db_cidr} but no kernel IPv4") + continue + + alloc_targets.append((iface_id ,iface_name)) + + if alloc_targets: + free = _first_free_hosts(len(alloc_targets) ,used_hosts ,pool ,reserve_first=reserve_first) + for idx ,(iface_id ,iface_name) in enumerate(alloc_targets): + cidr = f"{free[idx]}/{assign_prefix}" + updates[iface_id] = cidr + notes.append(f"assign: iface '{iface_name}' -> {cidr} (from pool {pool_cidr})") + + return (updates ,notes) + + +def reconcile_kernel_and_db_ipv4_addresses( + conn: sqlite3.Connection + ,pool_cidr: str = "10.0.0.0/16" + ,assign_prefix: int = 32 + ,reserve_first: int = 0 + ,dry_run: bool = False +) -> Tuple[int ,List[str]]: + rows = fetch_ifaces(conn) + iface_names = [n for (_i ,n ,_c) in rows] + kmap = kernel_ipv4_map(iface_names) + + updates ,notes = plan_address_updates( + rows + ,pool_cidr + ,assign_prefix + ,reserve_first + ,kmap + ) + if not updates: + return (0 ,notes or ["noop: nothing to change"]) + if dry_run: + return (0 ,notes) + + updated = update_iface_addresses(conn ,updates) + return (updated ,notes) + + +# --- thin CLI --- + +def main(argv=None) -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--pool" ,type=str ,default="10.0.0.0/16") + ap.add_argument("--assign-prefix" ,type=int ,default=32) + ap.add_argument("--reserve-first" ,type=int ,default=0) + ap.add_argument("--dry-run" ,action="store_true") + args = ap.parse_args(argv) + with ic.open_db() as conn: + updated ,notes = reconcile_kernel_and_db_ipv4_addresses( + conn + ,pool_cidr=args.pool + ,assign_prefix=args.assign_prefix + ,reserve_first=args.reserve_first + ,dry_run=args.dry_run + ) + if notes: + print("\n".join(notes)) + if not args.dry_run: + print(f"updated rows: {updated}") + return 0 + + +if __name__ == "__main__": + import sys + sys.exit(main()) diff --git a/developer/source/wg/db_init_ip_table_registration.py b/developer/source/wg/db_init_ip_table_registration.py new file mode 100755 index 0000000..8436a2d --- /dev/null +++ b/developer/source/wg/db_init_ip_table_registration.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +""" +db_init_ip_table_registration.py + +Business API: + assign_missing_rt_table_ids(conn ,low=20000 ,high=29999 ,dry_run=False) + -> (updated_count ,planned_map ,notes) + +Policy: +- Effective table name per iface is COALESCE(rt_table_name ,iface). +- If that name exists in /etc/iproute2/rt_tables, reuse its number. +- Else allocate first free number in [low ,high]. +- Writes DB only. Does NOT write rt_tables. +""" + +from __future__ import annotations +import argparse +import sqlite3 +from pathlib import Path +from typing import Dict ,Iterable ,List ,Optional ,Sequence ,Tuple + +import incommon as ic # for CLI path only + +RT_TABLES_PATH = Path("/etc/iproute2/rt_tables") + + +def parse_rt_tables(path: Path) -> Tuple[List[str] ,Dict[str ,int] ,Dict[int ,str]]: + text = path.read_text() if path.exists() else "" + lines = text.splitlines() + name_to_num: Dict[str ,int] = {} + num_to_name: Dict[int ,str] = {} + for ln in lines: + s = ln.strip() + if not s or s.startswith("#"): + continue + parts = s.split() + if len(parts) >= 2 and parts[0].isdigit(): + n = int(parts[0]); name = parts[1] + if name not in name_to_num and n not in num_to_name: + name_to_num[name] = n + num_to_name[n] = name + return (lines ,name_to_num ,num_to_name) + + +def first_free_id(used: Iterable[int] ,low: int ,high: int) -> int: + used_set = set(u for u in used if low <= u <= high) + for n in range(low ,high+1): + if n not in used_set: + return n + raise RuntimeError(f"no free routing-table IDs in [{low},{high}]") + + +def fetch_effective_ifaces(conn: sqlite3.Connection) -> List[Tuple[int ,str ,Optional[int]]]: + sql = """ + SELECT i.id, + COALESCE(i.rt_table_name, i.iface) AS eff_name, + i.rt_table_id + FROM Iface i + ORDER BY i.id; + """ + cur = conn.execute(sql) + rows = cur.fetchall() + return [ + (int(r[0]) ,str(r[1]) ,(int(r[2]) if r[2] is not None else None)) + for r in rows + ] + + +def update_rt_ids(conn: sqlite3.Connection ,updates: Dict[int ,int]) -> int: + if not updates: + return 0 + with conn: + for iface_id ,rt_id in updates.items(): + conn.execute("UPDATE Iface SET rt_table_id=? WHERE id=?" ,(rt_id ,iface_id)) + return len(updates) + + +def plan_rt_id_assignments( + ifaces: Sequence[Tuple[int ,str ,Optional[int]]] + ,name_to_num_sys: Dict[str ,int] + ,existing_ids_in_db: Iterable[int] + ,low: int + ,high: int +) -> Dict[int ,int]: + used_numbers = set(int(x) for x in existing_ids_in_db) | set(name_to_num_sys.values()) + planned: Dict[int ,int] = {} + + names_seen: Dict[str ,int] = {} + for iface_id ,eff_name ,_ in ifaces: + if eff_name in names_seen and names_seen[eff_name] != iface_id: + raise RuntimeError( + f"duplicate effective table name in DB: '{eff_name}' used by Iface.id {names_seen[eff_name]} and {iface_id}" + ) + names_seen[eff_name] = iface_id + + for iface_id ,eff_name ,current_id in ifaces: + if current_id is not None: + used_numbers.add(int(current_id)) + continue + if eff_name in name_to_num_sys: + rt_id = int(name_to_num_sys[eff_name]) + else: + rt_id = first_free_id(used_numbers ,low ,high) + planned[iface_id] = rt_id + used_numbers.add(rt_id) + + return planned + + +def assign_missing_rt_table_ids( + conn: sqlite3.Connection + ,low: int = 20000 + ,high: int = 29999 + ,dry_run: bool = False +) -> Tuple[int ,Dict[int ,int] ,List[str]]: + _ ,name_to_num_sys ,_ = parse_rt_tables(RT_TABLES_PATH) + notes: List[str] = [] + + rows = fetch_effective_ifaces(conn) + existing_ids = [r[2] for r in rows if r[2] is not None] + planned = plan_rt_id_assignments(rows ,name_to_num_sys ,existing_ids ,low ,high) + + if not planned: + return (0 ,{} ,["noop: all Iface.rt_table_id already set"]) + + for iface_id ,eff_name ,current in rows: + if iface_id in planned: + notes.append(f"Iface.id={iface_id} name='{eff_name}' rt_table_id: {current} -> {planned[iface_id]}") + + if dry_run: + return (0 ,planned ,notes) + + updated = update_rt_ids(conn ,planned) + return (updated ,planned ,notes) + + +# --- thin CLI --- + +def main(argv=None) -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--low" ,type=int ,default=20000) + ap.add_argument("--high" ,type=int ,default=29999) + ap.add_argument("--dry-run" ,action="store_true") + args = ap.parse_args(argv) + if args.low < 0 or args.high < args.low: + print(f"error: invalid range [{args.low},{args.high}]") + return 2 + with ic.open_db() as conn: + updated ,_planned ,notes = assign_missing_rt_table_ids(conn ,low=args.low ,high=args.high ,dry_run=args.dry_run) + if notes: + print("\n".join(notes)) + if not args.dry_run: + print(f"updated rows: {updated}") + return 0 + + +if __name__ == "__main__": + import sys + sys.exit(main()) diff --git a/developer/source/wg/db_init_server_US.py b/developer/source/wg/db_init_server_US.py new file mode 100755 index 0000000..d8cfcd0 --- /dev/null +++ b/developer/source/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=", + 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/source/wg/db_init_server_incommon.py b/developer/source/wg/db_init_server_incommon.py new file mode 100644 index 0000000..18edb1f --- /dev/null +++ b/developer/source/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/source/wg/db_init_server_x6.py b/developer/source/wg/db_init_server_x6.py new file mode 100755 index 0000000..3377d91 --- /dev/null +++ b/developer/source/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/source/wg/db_schema.sql b/developer/source/wg/db_schema.sql new file mode 100644 index 0000000..cf9cdb0 --- /dev/null +++ b/developer/source/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, unused + ,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 -- 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/source/wg/db_schema_load.sh b/developer/source/wg/db_schema_load.sh new file mode 100755 index 0000000..d4718bf --- /dev/null +++ b/developer/source/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/source/wg/db_wipe.sh b/developer/source/wg/db_wipe.sh new file mode 100755 index 0000000..396c9dd --- /dev/null +++ b/developer/source/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/source/wg/deprecated/.gitignore b/developer/source/wg/deprecated/.gitignore new file mode 100644 index 0000000..53642ce --- /dev/null +++ b/developer/source/wg/deprecated/.gitignore @@ -0,0 +1,4 @@ + +* +!.gitignore + diff --git a/developer/source/wg/doc_IP_terminaology.org b/developer/source/wg/doc_IP_terminaology.org new file mode 100644 index 0000000..8f6587b --- /dev/null +++ b/developer/source/wg/doc_IP_terminaology.org @@ -0,0 +1,98 @@ +#+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/. + +* Machine Peers + +- Client + +In these documents, the client machine is the local machine users are working on. Inevitably this gets shortened to /client/ in polite conversation. The example client used in this distribution is StanleyPark. That is a host name of a computer on our network. + +- Server + +In these document, the server machine is the remote machine that the write guard tunnels to. We have nicknames for machines. The example used here has the server nicknames of x6, and US. +These nicknames are also used for the names of the client machine side interface that connects to the tunnel that leads to said server machine. The nickname is also used for the name of the routing table on the client that routes traffic go said wireguard tunnel. + +Hence, a nickname, like x6 or US, refers to a machine, an interface, and an IP route table. + +* Software Peers + +Programs that run as daemons while listening for connections, and once connected to,k they provide services, are server programs. The program that connects to said software server is called a client program. You guessed it, the terms 'server program' and 'client program' often get shortened to /server/ and /client/. + + +* 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/source/wg/doc_config.org b/developer/source/wg/doc_config.org new file mode 100644 index 0000000..2de0ee4 --- /dev/null +++ b/developer/source/wg/doc_config.org @@ -0,0 +1,9 @@ +-New interface: + +copy `db_init_iface_x6.py` to `db_init_iface_.py`, replacing with the name of the interface. Then edit `db_init_iface_.py` + +-New Client + +-New User + + diff --git a/developer/source/wg/doc_keys.org b/developer/source/wg/doc_keys.org new file mode 100644 index 0000000..e56bd76 --- /dev/null +++ b/developer/source/wg/doc_keys.org @@ -0,0 +1,14 @@ + +From the point of view of setting up the client (we are in the client setup directory after all): + +1. login to the server and get the server public key. + + This public key is written into the db_init_iface_>.py configuration file. Note the examples `db_init_iface_US.py` and `db_init_iface_x6`. `x6` and `US` are nicknames for two servers. These nicknames are also used for the interface names. + + Note that the server private key remains on the server. The client has no knowledge of the server private key. It is not entered anywhere in the client configuration. + +2. run the program `key_client_generate1 + + This will print the client public key. It will also place a copy in the database. + + This will write the client private key into a local directory called `key/`. The admin need not do anything concerning this key. Scripts that need it will find it in the 'key/' directory. diff --git a/developer/source/wg/doc_stage_progs.org b/developer/source/wg/doc_stage_progs.org new file mode 100644 index 0000000..a80f789 --- /dev/null +++ b/developer/source/wg/doc_stage_progs.org @@ -0,0 +1,42 @@ + +stage programs write to the stage directory. Later install copies from the stage +directory to a provided root, which if it is the local machine, will be '/'. + + +* stage_IP_register_route_table.py + + stages a replacement etc/iproute2/rt_tables file. + +* stage_wg_conf.py + + stages etc/wireguard/ conf files for the configured interfaces + +* stage_IP_routes_script.py + + 1. stages a shell script that when called writes the IP rule table. Said script binds UIDs to route tables. + + 2. stages a priority 10 systemd guard systemd dropin that will call said shell script when + WireGuard is started or restarted. + +* stage_IP_rules_script.py + + 1. stages a shell script that when called writes the required IP route tables + + 2. stages a priority 20 systemd guard systemd dropin that will call said shell script when + WireGuard is started or restarted. + +* stage_client_StanleyPark.py + + A local use client machine configuration file. Calls the other stage programs + while providing the correct parameters for configuring wireguard on the + machine StanleyPark. Typically these will be a database connection and a list of + users. + + The admin will write such a file for each machine he/she/ai is configuring. + +* stage_incommon.py + + Utility functions for stage programs. + + + diff --git a/developer/source/wg/iface_down.py b/developer/source/wg/iface_down.py new file mode 100755 index 0000000..a1e6474 --- /dev/null +++ b/developer/source/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/source/wg/iface_status.py b/developer/source/wg/iface_status.py new file mode 100755 index 0000000..c0a12e9 --- /dev/null +++ b/developer/source/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/source/wg/iface_up.sh b/developer/source/wg/iface_up.sh new file mode 100755 index 0000000..e5dbd0a --- /dev/null +++ b/developer/source/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/source/wg/incommon.py b/developer/source/wg/incommon.py new file mode 100644 index 0000000..a67a0aa --- /dev/null +++ b/developer/source/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/source/wg/inspect.sh b/developer/source/wg/inspect.sh new file mode 100755 index 0000000..be2d5ef --- /dev/null +++ b/developer/source/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/source/wg/inspect_1.py b/developer/source/wg/inspect_1.py new file mode 100755 index 0000000..e6a179a --- /dev/null +++ b/developer/source/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/source/wg/install.py b/developer/source/wg/install.py new file mode 100644 index 0000000..82e810d --- /dev/null +++ b/developer/source/wg/install.py @@ -0,0 +1,233 @@ +#!/usr/bin/env python3 +""" +install_staged_tree.py + +Given: + - A staged tree (default: ./stage) containing: + /usr/local/bin/apply_ip_state.sh + /etc/wireguard/*.conf + /etc/systemd/wg-quick@IFACE.service.d/*.conf + /etc/iproute2/rt_tables + - A destination root (default: /) whose *parent directories already exist* + +Does: + - For each whitelisted staged file: + * if a target already exists, copy it *back into the stage* as a timestamped backup + * atomically replace target with staged version + * set root:root ownership and deterministic permissions (see MODE_MAP) + - Optionally `systemctl daemon-reload` and restart provided wg-quick@IFACE units + +Returns: + - Exit 0 on success; non-zero on error + - Prints a concise log of actions + +Errors: + - Fails if a target parent directory is missing (unless --create-dirs is given) + - Fails on any copy/permission error and reports which path caused it +""" + +from __future__ import annotations +from pathlib import Path +from typing import Dict ,Iterable ,List ,Optional ,Sequence ,Tuple +import argparse +import datetime as dt +import hashlib +import os +import shutil +import subprocess +import sys + +ROOT = Path(__file__).resolve().parent +DEFAULT_STAGE = ROOT / "stage" + +# Whitelist → permissions +# (relative glob inside stage) → (relative dest base, file mode) +MODE_MAP: Dict[str,Tuple[str,int]] = { + "usr/local/bin/*": ("usr/local/bin",0o500) # scripts: rx for root only + , "etc/wireguard/*.conf": ("etc/wireguard",0o600) # WG confs + , "etc/systemd/wg-quick@" : ("etc/systemd",0o644) # handled per-dropin below + , "etc/iproute2/rt_tables": ("etc/iproute2",0o644) # route tables file +} + +def _sha256(path: Path) -> str: + h = hashlib.sha256() + with path.open("rb") as f: + for chunk in iter(lambda: f.read(1<<20), b""): + h.update(chunk) + return h.hexdigest() + +def _iter_dropins(stage_root: Path) -> List[Tuple[Path,int]]: + """Return [(relpath,mode)] for systemd wg-quick drop-ins.""" + out: List[Tuple[Path,int]] = [] + base = stage_root / "etc" / "systemd" + if not base.exists(): + return out + for p in base.rglob("wg-quick@*.service.d/*.conf"): + rel = p.relative_to(stage_root) + out.append((rel,0o644)) + return out + +def _gather_stage_files(stage_root: Path) -> List[Tuple[Path,int]]: + """Resolve whitelist into [(relpath,mode)].""" + items: List[Tuple[Path,int]] = [] + # explicit patterns + for pat,(_dest_base,mode) in MODE_MAP.items(): + if pat.endswith("@"): # systemd base marker handled separately + continue + for p in (stage_root / pat).parent.glob(Path(pat).name): + rel = p.relative_to(stage_root) + items.append((rel,mode)) + # systemd drop-ins + items += _iter_dropins(stage_root) + # de-dup in order + seen = set() + uniq: List[Tuple[Path,int]] = [] + for rel,mode in items: + if rel not in seen: + uniq.append((rel,mode)) + seen.add(rel) + return uniq + +def _ensure_parents(dest_root: Path ,rel: Path ,create: bool) -> None: + parent = (dest_root / rel).parent + if parent.exists(): + return + if not create: + raise RuntimeError(f"missing parent directory: {parent}") + parent.mkdir(parents=True,exist_ok=True) + +def _backup_existing_to_stage(stage_root: Path ,dest_root: Path ,rel: Path) -> Optional[Path]: + """If target exists, copy it back into stage/_backups// and return backup path.""" + target = dest_root / rel + if not target.exists(): + return None + ts = dt.datetime.utcnow().strftime("%Y%m%dT%H%M%SZ") + backup = stage_root / "_backups" / ts / rel + backup.parent.mkdir(parents=True,exist_ok=True) + shutil.copy2(target,backup) + return backup + +def _atomic_install(src: Path ,dst: Path ,mode: int) -> None: + tmp = dst.with_suffix(dst.suffix + ".tmp") + # copy *bytes*, then set perms/owner, then atomic replace + shutil.copyfile(src,tmp) + os.chmod(tmp,mode) + try: + os.chown(tmp,0,0) # root:root + except PermissionError: + # setuid root expected; if not root, we still proceed for dry-run contexts + pass + os.replace(tmp,dst) + +def _maybe_daemon_reload(perform: bool) -> None: + if not perform: + return + subprocess.run( + ["systemctl","daemon-reload"] + ,check=False + ,stdout=subprocess.DEVNULL + ,stderr=subprocess.DEVNULL + ) + +def _maybe_restart_ifaces(ifaces: Sequence[str]) -> None: + for iface in ifaces: + unit = f"wg-quick@{iface}.service" + subprocess.run( + ["systemctl","restart",unit] + ,check=False + ,stdout=subprocess.DEVNULL + ,stderr=subprocess.DEVNULL + ) + +def install_staged_tree( + stage_root: Path + ,dest_root: Path + ,create_dirs: bool = False + ,skip_identical: bool = True + ,daemon_reload: bool = False + ,restart_ifaces: Sequence[str] = () +) -> List[str]: + """ + Core business function. + + Given: + stage_root, dest_root, flags + Does: + safe, deterministic copy with backups and explicit perms + Returns: + list of log lines + """ + # Do not rely on process umask; set restrictive default, then override per-file. + old_umask = os.umask(0o077) + logs: List[str] = [] + try: + staged = _gather_stage_files(stage_root) + if not staged: + raise RuntimeError("nothing to install (stage is empty or whitelist didn’t match)") + + for rel,mode in staged: + src = stage_root / rel + dst = dest_root / rel + + _ensure_parents(dest_root,rel,create_dirs) + + backup = _backup_existing_to_stage(stage_root,dest_root,rel) + if backup: + logs.append(f"backup: {dst} -> {backup}") + + if skip_identical and dst.exists(): + try: + if _sha256(src) == _sha256(dst): + logs.append(f"identical: skip {rel}") + continue + except Exception: + pass + + _atomic_install(src,dst,mode) + logs.append(f"install: {rel} (mode {oct(mode)})") + + if daemon_reload: + _maybe_daemon_reload(True) + logs.append("systemctl: daemon-reload") + + if restart_ifaces: + _maybe_restart_ifaces(restart_ifaces) + logs.append(f"systemctl: restart wg-quick@{','.join(restart_ifaces)}") + + return logs + finally: + os.umask(old_umask) + +def _require_root() -> None: + if os.geteuid() != 0: + raise RuntimeError("must run as root (installer sets ownership/permissions)") + +def main(argv: Optional[Sequence[str]] = None) -> int: + ap = argparse.ArgumentParser(description="Install staged artifacts into a target root (root-only).") + ap.add_argument("--stage" ,default=str(DEFAULT_STAGE)) + ap.add_argument("--root" ,default="/") + ap.add_argument("--create-dirs" ,action="store_true" ,help="create missing parent directories") + ap.add_argument("--no-skip-identical" ,action="store_true" ,help="always replace even if content identical") + ap.add_argument("--daemon-reload" ,action="store_true" ,help="run systemctl daemon-reload after install") + ap.add_argument("--restart-ifaces" ,nargs="*" ,default=[] ,help="optionally restart these wg-quick@IFACE units") + args = ap.parse_args(argv) + + try: + _require_root() + logs = install_staged_tree( + stage_root=Path(args.stage) + ,dest_root=Path(args.root) + ,create_dirs=args.create_dirs + ,skip_identical=(not args.no_skip_identical) + ,daemon_reload=args.daemon_reload + ,restart_ifaces=args.restart_ifaces + ) + for line in logs: + print(line) + return 0 + except Exception as e: + print(f"❌ install failed: {e}",file=sys.stderr) + return 2 + +if __name__ == "__main__": + sys.exit(main()) diff --git a/developer/source/wg/key/.gitignore b/developer/source/wg/key/.gitignore new file mode 100644 index 0000000..53642ce --- /dev/null +++ b/developer/source/wg/key/.gitignore @@ -0,0 +1,4 @@ + +* +!.gitignore + diff --git a/developer/source/wg/key_client_generate.py b/developer/source/wg/key_client_generate.py new file mode 100755 index 0000000..96df023 --- /dev/null +++ b/developer/source/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/source/wg/key_server_set.py b/developer/source/wg/key_server_set.py new file mode 100755 index 0000000..f53022e --- /dev/null +++ b/developer/source/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/source/wg/ls_iface.py b/developer/source/wg/ls_iface.py new file mode 100755 index 0000000..e9454f0 --- /dev/null +++ b/developer/source/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/source/wg/ls_key.py b/developer/source/wg/ls_key.py new file mode 100755 index 0000000..c616372 --- /dev/null +++ b/developer/source/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/source/wg/ls_server.py b/developer/source/wg/ls_server.py new file mode 100755 index 0000000..e1ee92d --- /dev/null +++ b/developer/source/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/source/wg/ls_server_setting.py b/developer/source/wg/ls_server_setting.py new file mode 100755 index 0000000..594cd70 --- /dev/null +++ b/developer/source/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/source/wg/ls_servers.sh b/developer/source/wg/ls_servers.sh new file mode 100755 index 0000000..5d4f4ef --- /dev/null +++ b/developer/source/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/source/wg/ls_user.py b/developer/source/wg/ls_user.py new file mode 100755 index 0000000..90c0ef2 --- /dev/null +++ b/developer/source/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/source/wg/manual_reference.org b/developer/source/wg/manual_reference.org new file mode 100644 index 0000000..6b0b894 --- /dev/null +++ b/developer/source/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/source/wg/manual_user.org b/developer/source/wg/manual_user.org new file mode 100644 index 0000000..bef4b37 --- /dev/null +++ b/developer/source/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/source/wg/mothball/stage/.gitignore b/developer/source/wg/mothball/stage/.gitignore new file mode 100644 index 0000000..53642ce --- /dev/null +++ b/developer/source/wg/mothball/stage/.gitignore @@ -0,0 +1,4 @@ + +* +!.gitignore + diff --git a/developer/source/wg/mothball/stage_IP_routes_script.py b/developer/source/wg/mothball/stage_IP_routes_script.py new file mode 100755 index 0000000..d1ec126 --- /dev/null +++ b/developer/source/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/source/wg/mothball/stage_IP_rules_script.py b/developer/source/wg/mothball/stage_IP_rules_script.py new file mode 100755 index 0000000..7fae716 --- /dev/null +++ b/developer/source/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/source/wg/mothball/stage_StanleyPark.py b/developer/source/wg/mothball/stage_StanleyPark.py new file mode 100644 index 0000000..c374029 --- /dev/null +++ b/developer/source/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/source/wg/mothball/stage_UID_routes.py b/developer/source/wg/mothball/stage_UID_routes.py new file mode 100755 index 0000000..7dfeb31 --- /dev/null +++ b/developer/source/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/source/wg/mothball/stage_list_clients.py b/developer/source/wg/mothball/stage_list_clients.py new file mode 100755 index 0000000..a36657a --- /dev/null +++ b/developer/source/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/source/wg/mothball/stage_list_uid.py b/developer/source/wg/mothball/stage_list_uid.py new file mode 100644 index 0000000..5acf312 --- /dev/null +++ b/developer/source/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/source/wg/mothball/stage_populate.py b/developer/source/wg/mothball/stage_populate.py new file mode 100644 index 0000000..bcb803a --- /dev/null +++ b/developer/source/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/source/wg/mothball/stage_preferred_server.py b/developer/source/wg/mothball/stage_preferred_server.py new file mode 100644 index 0000000..8e39d4d --- /dev/null +++ b/developer/source/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/source/wg/mothball/stage_wg_conf.py b/developer/source/wg/mothball/stage_wg_conf.py new file mode 100644 index 0000000..a395db2 --- /dev/null +++ b/developer/source/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/source/wg/mothball/stage_wg_unit_IP_scripts.py b/developer/source/wg/mothball/stage_wg_unit_IP_scripts.py new file mode 100644 index 0000000..cef5abb --- /dev/null +++ b/developer/source/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/source/wg/mothball/stage_wipe.py b/developer/source/wg/mothball/stage_wipe.py new file mode 100755 index 0000000..161ab79 --- /dev/null +++ b/developer/source/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/source/wg/scratchpad/.gitignore b/developer/source/wg/scratchpad/.gitignore new file mode 100644 index 0000000..53642ce --- /dev/null +++ b/developer/source/wg/scratchpad/.gitignore @@ -0,0 +1,4 @@ + +* +!.gitignore + diff --git a/developer/source/wg/stage/etc/systemd/wg-quick@US.service.d/20-postup-ip-state.conf b/developer/source/wg/stage/etc/systemd/wg-quick@US.service.d/20-postup-ip-state.conf new file mode 100644 index 0000000..16b0fde --- /dev/null +++ b/developer/source/wg/stage/etc/systemd/wg-quick@US.service.d/20-postup-ip-state.conf @@ -0,0 +1,2 @@ +[Service] +ExecStartPost=+/usr/local/bin/apply_ip_state.sh US diff --git a/developer/source/wg/stage/etc/systemd/wg-quick@x6.service.d/20-postup-ip-state.conf b/developer/source/wg/stage/etc/systemd/wg-quick@x6.service.d/20-postup-ip-state.conf new file mode 100644 index 0000000..5e8e2ab --- /dev/null +++ b/developer/source/wg/stage/etc/systemd/wg-quick@x6.service.d/20-postup-ip-state.conf @@ -0,0 +1,2 @@ +[Service] +ExecStartPost=+/usr/local/bin/apply_ip_state.sh x6 diff --git a/developer/source/wg/stage/etc/wireguard/US.conf b/developer/source/wg/stage/etc/wireguard/US.conf new file mode 100644 index 0000000..7364c2e --- /dev/null +++ b/developer/source/wg/stage/etc/wireguard/US.conf @@ -0,0 +1,10 @@ +[Interface] +PrivateKey = ACd0vEyoZejb+WkXL1LcheHAYm2oRBbw52dJB5+tmUQ= +Table = off +# ListenPort = 51820 + +[Peer] +PublicKey = h8ZYEEVMForvv9p5Wx+9+eZ87t692hTN7sks5Noedw8= +AllowedIPs = 0.0.0.0/0 +Endpoint = 35.194.71.194:443 +PersistentKeepalive = 25 diff --git a/developer/source/wg/stage/etc/wireguard/x6.conf b/developer/source/wg/stage/etc/wireguard/x6.conf new file mode 100644 index 0000000..b343bcc --- /dev/null +++ b/developer/source/wg/stage/etc/wireguard/x6.conf @@ -0,0 +1,10 @@ +[Interface] +PrivateKey = ACd0vEyoZejb+WkXL1LcheHAYm2oRBbw52dJB5+tmUQ= +Table = off +# ListenPort = 51820 + +[Peer] +PublicKey = pcbDlC1ZVoBYaN83/zAsvIvhgw0iQOL1YZKX5hcAqno= +AllowedIPs = 0.0.0.0/0 +Endpoint = 66.248.243.113:51820 +PersistentKeepalive = 25 diff --git a/developer/source/wg/stage/usr/local/bin/apply_ip_state.sh b/developer/source/wg/stage/usr/local/bin/apply_ip_state.sh new file mode 100755 index 0000000..5300313 --- /dev/null +++ b/developer/source/wg/stage/usr/local/bin/apply_ip_state.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# apply IP state for selected interfaces (addresses, routes, rules) — idempotent +set -euo pipefail + +ALL_ARGS=("$@") + +want_iface(){ + local t=$1 + if [ ${#ALL_ARGS[@]} -eq 0 ]; then return 0; fi + for a in "${ALL_ARGS[@]}"; do [ "$a" = "$t" ] && return 0; done + return 1 +} + +exists_iface(){ ip -o link show dev "$1" >/dev/null 2>&1; } + +ensure_addr(){ + local iface=$1; local cidr=$2 + if ip -4 -o addr show dev "$iface" | awk '{print $4}' | grep -Fxq "$cidr"; then + logger "addr ok: $iface $cidr" + else + ip -4 addr add "$cidr" dev "$iface" + logger "addr add: $iface $cidr" + fi +} + +ensure_route(){ + local table=$1; local cidr=$2; local dev=$3; local via=${4:-}; local metric=${5:-} + if [ -n "$via" ] && [ -n "$metric" ]; then + ip -4 route replace "$cidr" via "$via" dev "$dev" table "$table" metric "$metric" + elif [ -n "$via" ]; then + ip -4 route replace "$cidr" via "$via" dev "$dev" table "$table" + elif [ -n "$metric" ]; then + ip -4 route replace "$cidr" dev "$dev" table "$table" metric "$metric" + else + ip -4 route replace "$cidr" dev "$dev" table "$table" + fi + logger "route ensure: table=$table cidr=$cidr dev=$dev${via:+ via=$via}${metric:+ metric=$metric}" +} + +add_ip_rule_if_absent(){ + local needle=$1; shift + if ! ip -4 rule show | grep -F -q -- "$needle"; then + ip -4 rule add "$@" + logger "rule add: $*" + else + logger "rule ok: $needle" + fi +} + +if want_iface x6; then + if exists_iface x6; then ensure_addr x6 10.8.0.2/32; else logger "skip: iface missing: x6"; fi +fi +if want_iface US; then + if exists_iface US; then ensure_addr US 10.0.0.1/32; else logger "skip: iface missing: US"; fi +fi +if want_iface x6; then + add_ip_rule_if_absent "from 10.8.0.2/32 lookup x6" from "10.8.0.2/32" lookup "x6" pref 17000 +fi +if want_iface x6; then + add_ip_rule_if_absent "uidrange 2018-2018 lookup x6" uidrange "2018-2018" lookup "x6" pref 17010 +fi +if want_iface US; then + add_ip_rule_if_absent "from 10.0.0.1/32 lookup US" from "10.0.0.1/32" lookup "US" pref 17000 +fi +if want_iface US; then + add_ip_rule_if_absent "uidrange 2017-2017 lookup US" uidrange "2017-2017" lookup "US" pref 17010 +fi +add_ip_rule_if_absent "from 10.0.0.0/24 prohibit" from "10.0.0.0/24" prohibit pref 18050 diff --git a/developer/source/wg/stage_IP_apply_script.py b/developer/source/wg/stage_IP_apply_script.py new file mode 100755 index 0000000..4e9ad9f --- /dev/null +++ b/developer/source/wg/stage_IP_apply_script.py @@ -0,0 +1,369 @@ +#!/usr/bin/env python3 +""" +stage_IP_apply_script.py + +Given: + - A SQLite DB (schema you’ve defined), with: + * Iface(id ,iface ,local_address_cidr ,rt_table_name) + * v_iface_effective(id ,rt_table_name_eff ,local_address_cidr) + * Route(iface_id ,cidr ,via ,table_name ,metric ,on_up ,on_down) + * "User"(iface_id ,username ,uid) — table formerly User_Binding + * Meta(key='subu_cidr' ,value) + - A list of interface names to include (e.g., ["x6","US"]). + +Does: + - Reads DB once and *synthesizes a single* idempotent runtime script + that, for the selected interfaces, on each `wg-quick@IFACE` start: + 1) ensures IPv4 addresses exist on the iface (if present in DB) + 2) ensures all configured routes exist (using `ip -4 route replace`) + 3) ensures policy rules exist for src-cidr ,uidrange ,and a `prohibit` + - Stages that script under: stage/usr/local/bin/ + - Stages per-iface systemd drop-ins: + stage/etc/systemd/wg-quick@IFACE.service.d/-postup-ip-state.conf + which call the script (default prio = 20). + +Returns: + (script_path ,notes[list of strings]) + +Errors: + - Raises RuntimeError if no interfaces provided or there’s nothing to emit. + - Does not write /etc/iproute2/rt_tables (that’s handled by your registration stager). + - Does not modify kernel state — this is staging only. + +Notes: + - The generated script is idempotent: + * addresses: “add if missing” + * routes: `ip -4 route replace` + * rules: add only if a grep needle is not found + - It accepts optional IFACE args at runtime to limit application to a subset. +""" + +from __future__ import annotations +from pathlib import Path +from typing import Dict ,Iterable ,List ,Optional ,Sequence ,Tuple +import argparse +import sqlite3 +import sys + +import incommon as ic # expected: open_db() + +ROOT = Path(__file__).resolve().parent +STAGE_ROOT = ROOT / "stage" + + +# ---------- DB access ---------- + +def _fetch_meta_subu_cidr(conn: sqlite3.Connection ,default="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 + + +def _fetch_iface_meta(conn: sqlite3.Connection ,iface_names: Sequence[str]) -> Dict[str ,Tuple[int ,str ,Optional[str]]]: + """ + Return {iface_name -> (iface_id ,rt_table_name_eff ,local_address_cidr_or_None)}. + """ + if not iface_names: + return {} + ph = ",".join("?" for _ in iface_names) + sql = f""" + SELECT i.id + , i.iface + , v.rt_table_name_eff + , NULLIF(TRIM(v.local_address_cidr),'') AS cidr + FROM Iface i + JOIN v_iface_effective v ON v.id = i.id + WHERE i.iface IN ({ph}) + ORDER BY i.id; + """ + rows = conn.execute(sql ,tuple(iface_names)).fetchall() + out: Dict[str ,Tuple[int ,str ,Optional[str]]] = {} + for r in rows: + iface_id = int(r[0]); name = str(r[1]); eff = str(r[2]); cidr = (str(r[3]) if r[3] is not None else None) + out[name] = (iface_id ,eff ,cidr) + return out + + +def _fetch_routes_by_iface_id( + conn: sqlite3.Connection + ,iface_ids: Sequence[int] + ,only_on_up: bool = True +) -> Dict[int ,List[Tuple[str ,Optional[str] ,Optional[str] ,Optional[int]]]]: + """ + Return {iface_id -> [(cidr ,via ,table_name_or_None ,metric_or_None),...]}. + """ + if not iface_ids: + return {} + ph = ",".join("?" for _ in iface_ids) + sql = f""" + SELECT iface_id + , cidr + , NULLIF(TRIM(via),'') AS via + , NULLIF(TRIM(table_name),'') AS table_name + , metric + , on_up + FROM Route + WHERE iface_id IN ({ph}) + ORDER BY id; + """ + rows = conn.execute(sql ,tuple(iface_ids)).fetchall() + out: Dict[int ,List[Tuple[str ,Optional[str] ,Optional[str] ,Optional[int]]]] = {} + for iface_id ,cidr ,via ,tname ,metric ,on_up in rows: + if only_on_up and int(on_up) != 1: + continue + out.setdefault(int(iface_id) ,[]).append( + (str(cidr) ,(str(via) if via is not None else None) ,(str(tname) if tname is not None else None) + ,(int(metric) if metric is not None else None)) + ) + return out + + +def _fetch_uids_by_iface_id(conn: sqlite3.Connection ,iface_ids: Sequence[int]) -> Dict[int ,List[int]]: + """ + Return {iface_id -> [uid,...]} using table "User". + """ + if not iface_ids: + return {} + ph = ",".join("?" for _ in iface_ids) + sql = f""" + SELECT iface_id + , uid + FROM "User" + WHERE iface_id IN ({ph}) + AND uid IS NOT NULL + AND CAST(uid AS TEXT) != '' + ORDER BY iface_id ,uid; + """ + rows = conn.execute(sql ,tuple(iface_ids)).fetchall() + out: Dict[int ,List[int]] = {} + for iface_id ,uid in rows: + out.setdefault(int(iface_id) ,[]).append(int(uid)) + return out + + +# ---------- rendering ---------- + +def _render_composite_script( + plan_ifaces: List[str] + ,meta: Dict[str ,Tuple[int ,str ,Optional[str]]] + ,routes_by_id: Dict[int ,List[Tuple[str ,Optional[str] ,Optional[str] ,Optional[int]]]] + ,uids_by_id: Dict[int ,List[int]] + ,subu_cidr: str +) -> str: + """ + Build a single bash script that ensures addresses → routes → rules. + """ + lines: List[str] = [ + "#!/usr/bin/env bash" + ,"# apply IP state for selected interfaces (addresses, routes, rules) — idempotent" + ,"set -euo pipefail" + ,"" + ,"ALL_ARGS=(\"$@\")" + ,"" + ,"want_iface(){" + ," local t=$1" + ," if [ ${#ALL_ARGS[@]} -eq 0 ]; then return 0; fi" + ," for a in \"${ALL_ARGS[@]}\"; do [ \"$a\" = \"$t\" ] && return 0; done" + ," return 1" + ,"}" + ,"" + ,"exists_iface(){ ip -o link show dev \"$1\" >/dev/null 2>&1; }" + ,"" + ,"ensure_addr(){" + ," local iface=$1; local cidr=$2" + ," if ip -4 -o addr show dev \"$iface\" | awk '{print $4}' | grep -Fxq \"$cidr\"; then" + ," logger \"addr ok: $iface $cidr\"" + ," else" + ," ip -4 addr add \"$cidr\" dev \"$iface\"" + ," logger \"addr add: $iface $cidr\"" + ," fi" + ,"}" + ,"" + ,"ensure_route(){" + ," local table=$1; local cidr=$2; local dev=$3; local via=${4:-}; local metric=${5:-}" + ," if [ -n \"$via\" ] && [ -n \"$metric\" ]; then" + ," ip -4 route replace \"$cidr\" via \"$via\" dev \"$dev\" table \"$table\" metric \"$metric\"" + ," elif [ -n \"$via\" ]; then" + ," ip -4 route replace \"$cidr\" via \"$via\" dev \"$dev\" table \"$table\"" + ," elif [ -n \"$metric\" ]; then" + ," ip -4 route replace \"$cidr\" dev \"$dev\" table \"$table\" metric \"$metric\"" + ," else" + ," ip -4 route replace \"$cidr\" dev \"$dev\" table \"$table\"" + ," fi" + ," logger \"route ensure: table=$table cidr=$cidr dev=$dev${via:+ via=$via}${metric:+ metric=$metric}\"" + ,"}" + ,"" + ,"add_ip_rule_if_absent(){" + ," local needle=$1; shift" + ," if ! ip -4 rule show | grep -F -q -- \"$needle\"; then" + ," ip -4 rule add \"$@\"" + ," logger \"rule add: $*\"" + ," else" + ," logger \"rule ok: $needle\"" + ," fi" + ,"}" + ,"" + ] + + any_action = False + + # 1) Addresses + for name in plan_ifaces: + _iid ,rtname ,cidr = meta[name] + if cidr: + lines += [ + f'if want_iface {name}; then' + ,f' if exists_iface {name}; then ensure_addr {name} {cidr}; else logger "skip: iface missing: {name}"; fi' + ,'fi' + ] + any_action = True + + # 2) Routes + for name in plan_ifaces: + iid ,rtname ,_cidr = meta[name] + rows = routes_by_id.get(iid ,[]) + for cidr ,via ,t_override ,metric in rows: + table_eff = t_override or rtname + viastr = (via if via is not None else "") + mstr = (str(metric) if metric is not None else "") + lines += [ + f'if want_iface {name}; then' + ,f' if exists_iface {name}; then ensure_route "{table_eff}" "{cidr}" "{name}" "{viastr}" "{mstr}"; else logger "skip: iface missing: {name}"; fi' + ,'fi' + ] + any_action = True + + # 3) Rules (src, uids, and one prohibit for the subu block) + for name in plan_ifaces: + iid ,rtname ,cidr = meta[name] + if cidr: + lines += [ + f'if want_iface {name}; then' + ,f' add_ip_rule_if_absent "from {cidr} lookup {rtname}" from "{cidr}" lookup "{rtname}" pref 17000' + ,'fi' + ] + any_action = True + uids = uids_by_id.get(iid ,[]) + for u in uids: + lines += [ + f'if want_iface {name}; then' + ,f' add_ip_rule_if_absent "uidrange {u}-{u} lookup {rtname}" uidrange "{u}-{u}" lookup "{rtname}" pref 17010' + ,'fi' + ] + any_action = True + + # One global prohibit for subu block (emit once) + if subu_cidr: + lines += [ + f'add_ip_rule_if_absent "from {subu_cidr} prohibit" from "{subu_cidr}" prohibit pref 18050' + ] + any_action = True + + if not any_action: + raise RuntimeError("no IP state to emit for requested interfaces") + + lines += [""] # trailing newline + return "\n".join(lines) + + +def _write_dropin_for_iface(stage_root: Path ,iface: str ,script_name: str ,priority: int) -> Path: + d = stage_root / "etc" / "systemd" / f"wg-quick@{iface}.service.d" + d.mkdir(parents=True ,exist_ok=True) + p = d / f"{priority}-postup-ip-state.conf" + content = ( + "[Service]\n" + f"ExecStartPost=+/usr/local/bin/{script_name} {iface}\n" + ) + p.write_text(content) + return p + + +# ---------- business ---------- + +def stage_ip_apply_script( + conn: sqlite3.Connection + ,iface_names: Sequence[str] + ,stage_root: Optional[Path] = None + ,script_name: str = "apply_ip_state.sh" + ,dropin_priority: int = 20 + ,only_on_up: bool = True + ,with_dropins: bool = True + ,dry_run: bool = False +) -> Tuple[Path ,List[str]]: + """ + Plan and stage the unified runtime script and per-iface drop-ins. + """ + if not iface_names: + raise RuntimeError("no interfaces provided") + + meta = _fetch_iface_meta(conn ,iface_names) + if not meta: + raise RuntimeError("none of the requested interfaces exist in DB") + + # preserve caller order but skip unknowns (already handled above) + ifaces_in_order = [n for n in iface_names if n in meta] + iface_ids = [meta[n][0] for n in ifaces_in_order] + + routes_by_id = _fetch_routes_by_iface_id(conn ,iface_ids ,only_on_up=only_on_up) + uids_by_id = _fetch_uids_by_iface_id(conn ,iface_ids) + subu_cidr = _fetch_meta_subu_cidr(conn ,default="10.0.0.0/24") + + sr = stage_root or STAGE_ROOT + out = sr / "usr" / "local" / "bin" / script_name + out.parent.mkdir(parents=True ,exist_ok=True) + + content = _render_composite_script(ifaces_in_order ,meta ,routes_by_id ,uids_by_id ,subu_cidr) + + notes: List[str] = [] + if dry_run: + notes.append(f"dry-run: would write {out}") + if with_dropins: + for n in ifaces_in_order: + notes.append(f"dry-run: would write drop-in for {n} at priority {dropin_priority}") + return (out ,notes) + + out.write_text(content) + out.chmod(0o500) + notes.append(f"staged: {out}") + + if with_dropins: + for n in ifaces_in_order: + dp = _write_dropin_for_iface(sr ,n ,script_name ,dropin_priority) + notes.append(f"staged: {dp}") + + return (out ,notes) + + +# ---------- CLI ---------- + +def main(argv=None) -> int: + ap = argparse.ArgumentParser(description="Stage one script that applies addresses, routes, and rules for selected ifaces.") + ap.add_argument("ifaces" ,nargs="+" ,help="interface names to include") + ap.add_argument("--script-name" ,default="apply_ip_state.sh") + ap.add_argument("--dropin-priority" ,type=int ,default=20) + ap.add_argument("--all" ,action="store_true" ,help="include routes where on_up=0 as well") + ap.add_argument("--no-dropins" ,action="store_true" ,help="do not stage systemd drop-ins") + ap.add_argument("--dry-run" ,action="store_true") + args = ap.parse_args(argv) + + with ic.open_db() as conn: + try: + out ,notes = stage_ip_apply_script( + conn + ,args.ifaces + ,script_name=args.script_name + ,dropin_priority=args.dropin_priority + ,only_on_up=(not args.all) + ,with_dropins=(not args.no_dropins) + ,dry_run=args.dry_run + ) + except Exception as e: + print(f"error: {e}" ,file=sys.stderr) + return 2 + + if notes: + print("\n".join(notes)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/developer/source/wg/stage_StanleyPark.py b/developer/source/wg/stage_StanleyPark.py new file mode 100644 index 0000000..77264a3 --- /dev/null +++ b/developer/source/wg/stage_StanleyPark.py @@ -0,0 +1,20 @@ +#!/usr/bin/env python3 +""" +stage_StanleyPark.py + +Minimal config wrapper for client 'StanleyPark'. +Calls the generic stage orchestrator with the chosen ifaces. +""" + +from __future__ import annotations +from stage_client import stage_client_artifacts + +CLIENT = "StanleyPark" +IFACES = ["x6","US"] # keep this list minimal & declarative + +if __name__ == "__main__": + ok = stage_client_artifacts( + CLIENT + ,IFACES + ) + raise SystemExit(0 if ok else 2) diff --git a/developer/source/wg/stage_client.py b/developer/source/wg/stage_client.py new file mode 100644 index 0000000..7d5f5ba --- /dev/null +++ b/developer/source/wg/stage_client.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 +""" +stage_client.py + +Given: + - A SQLite DB reachable via incommon.open_db() + - A client machine name (used to locate ./key/ for WG PrivateKey) + - One or more interface names (e.g., x6, US) + +Does: + 1) Stage WireGuard confs for each iface (Table=off; ListenPort commented if NULL) + 2) Stage /etc/iproute2/rt_tables entries for those ifaces + 3) Stage a unified IP apply script (addresses, routes, rules) + 4) Stage per-iface systemd drop-ins to invoke the apply script on wg-quick up + +Returns: + - True on success, False on failure + - Prints human-readable progress for each step + +Errors: + - Raises or prints clear ❌ messages on failure +""" + +from __future__ import annotations +from pathlib import Path +from typing import Callable ,Optional ,Sequence ,Tuple +import argparse +import subprocess +import sys + +import incommon as ic # open_db() + +ROOT = Path(__file__).resolve().parent +STAGE_ROOT = ROOT / "stage" + + +def _msg_wrapped_call(label: str ,fn: Callable[[], Tuple[Path ,Sequence[str]]]) -> bool: + print(f"→ {label}") + try: + path ,notes = fn() + for n in notes: + print(n) + if path: + print(f"✔ {label}: staged: {path}") + else: + print(f"✔ {label}") + return True + except Exception as e: + print(f"❌ {label}: {e}") + return False + + +def _call_cli(argv: Sequence[str]) -> Tuple[Path ,Sequence[str]]: + cp = subprocess.run(list(argv) ,text=True ,capture_output=True) + if cp.returncode != 0: + raise RuntimeError(cp.stderr.strip() or f"exit {cp.returncode}") + notes = [] + staged_path: Optional[Path] = None + for line in (cp.stdout or "").splitlines(): + notes.append(line) + if line.startswith("staged: "): + try: + staged_path = Path(line.split("staged:",1)[1].strip()) + except Exception: + pass + return (staged_path or STAGE_ROOT ,notes) + + +def _stage_wg_conf_step(client_name: str ,ifaces: Sequence[str]) -> bool: + def _do(): + try: + from stage_wg_conf import stage_wg_conf # type: ignore + with ic.open_db() as conn: + path ,notes = stage_wg_conf( + conn + ,ifaces + ,client_name + ,stage_root=STAGE_ROOT + ,dry_run=False + ) + return (path ,notes) + except Exception: + return _call_cli([str(ROOT / "stage_wg_conf.py") ,client_name ,*ifaces]) + return _msg_wrapped_call(f"stage_wg_conf ({client_name}; {','.join(ifaces)})" ,_do) + + +def _stage_rt_tables_step(ifaces: Sequence[str]) -> bool: + def _do(): + try: + from stage_IP_register_route_table import stage_ip_register_route_table # type: ignore + with ic.open_db() as conn: + path ,notes = stage_ip_register_route_table( + conn + ,ifaces + ,stage_root=STAGE_ROOT + ,dry_run=False + ) + return (path ,notes) + except Exception: + return _call_cli([str(ROOT / "stage_IP_register_route_table.py") ,*ifaces]) + return _msg_wrapped_call(f"stage_IP_register_route_table ({','.join(ifaces)})" ,_do) + + +def _stage_apply_ip_state_step(ifaces: Sequence[str]) -> bool: + def _do(): + try: + from stage_IP_apply_script import stage_ip_apply_script # type: ignore + with ic.open_db() as conn: + path ,notes = stage_ip_apply_script( + conn + ,ifaces + ,stage_root=STAGE_ROOT + ,script_name="apply_ip_state.sh" + ,only_on_up=True + ,dry_run=False + ) + return (path ,notes) + except Exception: + return _call_cli([str(ROOT / "stage_IP_apply_script.py") ,*ifaces]) + return _msg_wrapped_call(f"stage_IP_apply_script ({','.join(ifaces)})" ,_do) + + +def stage_client_artifacts( + client_name: str + ,iface_names: Sequence[str] + ,stage_root: Optional[Path] = None +) -> bool: + """ + Orchestrate staging for a client+ifaces. Prints progress and returns success. + """ + if not iface_names: + raise ValueError("no interfaces provided") + if stage_root: + global STAGE_ROOT + STAGE_ROOT = stage_root + + STAGE_ROOT.mkdir(parents=True ,exist_ok=True) + + ok = True + ok = _stage_wg_conf_step(client_name ,iface_names) and ok + ok = _stage_rt_tables_step(iface_names) and ok + ok = _stage_apply_ip_state_step(iface_names) and ok + return ok + + +def main(argv: Optional[Sequence[str]] = None) -> int: + ap = argparse.ArgumentParser(description="Stage all artifacts for a client.") + ap.add_argument("--client" ,required=True ,help="client machine name (for key lookup)") + ap.add_argument("ifaces" ,nargs="+") + args = ap.parse_args(argv) + + ok = stage_client_artifacts( + args.client + ,args.ifaces + ) + return 0 if ok else 2 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/developer/source/wg/stage_wg_conf.py b/developer/source/wg/stage_wg_conf.py new file mode 100755 index 0000000..28dd4d3 --- /dev/null +++ b/developer/source/wg/stage_wg_conf.py @@ -0,0 +1,254 @@ +#!/usr/bin/env python3 +""" +stage_wg_conf.py + +Given: + - SQLite DB reachable via incommon.open_db() + - A list of interface names (e.g., x6 ,US) + - client_machine_name used to locate the private key file under ./key/ + +Does: + - For each iface, stage a minimal WireGuard config to stage/etc/wireguard/.conf: + [Interface] + PrivateKey = > + Table = off + ListenPort = (if the column exists and value is not NULL) + # ListenPort = 51820 (commented if value is absent) + [Peer] (one per Server row for that iface) + PublicKey = + PresharedKey = (only if present) + AllowedIPs = + Endpoint = : + PersistentKeepalive = (only if present) + - Omits Address ,PostUp ,SaveConfig (your systemd drop-in + script handle L3 state) + +Returns: + - (list_of_staged_paths ,notes) + +Errors: + - Missing private key file + - Iface not found + - Server rows missing required fields for that iface +""" + +from __future__ import annotations +from pathlib import Path +from typing import Dict ,Iterable ,List ,Optional ,Sequence ,Tuple +import argparse +import sqlite3 +import sys + +import incommon as ic # expected: open_db() + +ROOT = Path(__file__).resolve().parent +STAGE_ROOT = ROOT / "stage" + + +# ---------- helpers ---------- + +def _has_column(conn: sqlite3.Connection ,table: str ,col: str) -> bool: + cur = conn.execute(f"PRAGMA table_info({table});") + cols = [str(r[1]) for r in cur.fetchall()] + return col in cols + + +def _read_private_key(client_machine_name: str ,key_root: Optional[Path] = None) -> str: + kr = key_root or (ROOT / "key") + path = kr / client_machine_name + if not path.exists(): + raise RuntimeError(f"private key file missing: {path}") + text = path.read_text().strip() + if not text: + raise RuntimeError(f"private key file empty: {path}") + # WireGuard private keys are base64 (typically 44 chars), but don't over-validate here. + return text + + +# ---------- DB ---------- + +def _fetch_iface_ids_and_ports( + conn: sqlite3.Connection + ,iface_names: Sequence[str] +) -> Dict[str ,Tuple[int ,Optional[int]]]: + """ + Return {iface_name -> (iface_id ,listen_port_or_None)} for requested names. + If the listen_port column does not exist, value is None. + """ + if not iface_names: + return {} + ph = ",".join("?" for _ in iface_names) + has_lp = _has_column(conn ,"Iface" ,"listen_port") + select_lp = ", i.listen_port" if has_lp else ", NULL as listen_port" + sql = f""" + SELECT i.id + , i.iface + {select_lp} + FROM Iface i + WHERE i.iface IN ({ph}) + ORDER BY i.id; + """ + rows = conn.execute(sql ,tuple(iface_names)).fetchall() + out: Dict[str ,Tuple[int ,Optional[int]]] = {} + for iid ,name ,lp in rows: + out[str(name)] = (int(iid) ,(int(lp) if lp is not None else None)) + return out + + +def _fetch_peers_for_iface( + conn: sqlite3.Connection + ,iface_id: int +) -> List[Tuple[str ,Optional[str] ,str ,int ,str ,Optional[int] ,int ,int]]: + """ + Return peers as tuples: + (public_key ,preshared_key ,endpoint_host ,endpoint_port ,allowed_ips ,keepalive_s ,priority ,id) + """ + sql = """ + SELECT public_key + , NULLIF(TRIM(preshared_key),'') as preshared_key + , endpoint_host + , endpoint_port + , allowed_ips + , keepalive_s + , priority + , id + FROM Server + WHERE iface_id = ? + ORDER BY priority ASC , id ASC; + """ + rows = conn.execute(sql ,(iface_id,)).fetchall() + out: List[Tuple[str ,Optional[str] ,str ,int ,str ,Optional[int] ,int ,int]] = [] + for pub ,psk ,host ,port ,alips ,ka ,prio ,sid in rows: + out.append((str(pub) ,(str(psk) if psk is not None else None) ,str(host) ,int(port) ,str(alips) ,(int(ka) if ka is not None else None) ,int(prio) ,int(sid))) + return out + + +# ---------- rendering ---------- + +def _render_conf( + iface_name: str + ,private_key: str + ,listen_port: Optional[int] + ,peers: Sequence[Tuple[str ,Optional[str] ,str ,int ,str ,Optional[int] ,int ,int]] +) -> str: + lines: List[str] = [] + lines += [ + "[Interface]" + ,f"PrivateKey = {private_key}" + ,"Table = off" + ] + if listen_port is not None: + lines.append(f"ListenPort = {listen_port}") + else: + lines.append("# ListenPort = 51820") + + lines.append("") # blank before peers + + if not peers: + # You may choose to raise instead; keeping an empty peer set is valid but rarely useful. + lines.append("# (no peers found for this interface)") + + for pub ,psk ,host ,port ,alips ,ka ,_prio ,_sid in peers: + lines += [ + "[Peer]" + ,f"PublicKey = {pub}" + ] + if psk is not None: + lines.append(f"PresharedKey = {psk}") + lines += [ + f"AllowedIPs = {alips}" + ,f"Endpoint = {host}:{port}" + ] + if ka is not None: + lines.append(f"PersistentKeepalive = {ka}") + lines.append("") # blank line between peers + + return "\n".join(lines).rstrip() + "\n" + + +# ---------- business ---------- + +def stage_wg_conf( + conn: sqlite3.Connection + ,iface_names: Sequence[str] + ,client_machine_name: str + ,stage_root: Optional[Path] = None + ,dry_run: bool = False +) -> Tuple[List[Path] ,List[str]]: + """ + Stage /etc/wireguard/.conf for selected ifaces under stage root. + """ + if not iface_names: + raise RuntimeError("no interfaces provided") + priv = _read_private_key(client_machine_name) + + meta = _fetch_iface_ids_and_ports(conn ,iface_names) + if not meta: + raise RuntimeError("none of the requested interfaces exist in DB") + + staged: List[Path] = [] + notes: List[str] = [] + sr = stage_root or STAGE_ROOT + outdir = sr / "etc" / "wireguard" + outdir.mkdir(parents=True ,exist_ok=True) + + for name in iface_names: + if name not in meta: + notes.append(f"skip: iface '{name}' missing from DB") + continue + + iface_id ,listen_port = meta[name] + peers = _fetch_peers_for_iface(conn ,iface_id) + + # basic validation of required peer fields + bad = [] + for pub ,_psk ,host ,port ,alips ,_ka ,_prio ,sid in peers: + if not pub or not host or not alips or not (1 <= int(port) <= 65535): + bad.append(sid) + if bad: + raise RuntimeError(f"iface '{name}': invalid peer rows id={bad}") + + conf_text = _render_conf(name ,priv ,listen_port ,peers) + + out = outdir / f"{name}.conf" + if dry_run: + notes.append(f"dry-run: would write {out}") + else: + out.write_text(conf_text) + out.chmod(0o600) + staged.append(out) + notes.append(f"staged: {out}") + + if not staged and not dry_run: + raise RuntimeError("nothing staged (all missing or skipped)") + + return (staged ,notes) + + +# ---------- CLI ---------- + +def main(argv=None) -> int: + ap = argparse.ArgumentParser(description="Stage minimal WireGuard configs with Table=off and no Address.") + ap.add_argument("client_machine_name" ,help="name used to read ./key/") + ap.add_argument("ifaces" ,nargs="+" ,help="interface names to stage") + ap.add_argument("--dry-run" ,action="store_true") + args = ap.parse_args(argv) + + with ic.open_db() as conn: + try: + paths ,notes = stage_wg_conf( + conn + ,args.ifaces + ,args.client_machine_name + ,dry_run=args.dry_run + ) + except Exception as e: + print(f"error: {e}" ,file=sys.stderr) + return 2 + + if notes: + print("\n".join(notes)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/developer/source/wg/stage_wipe.py b/developer/source/wg/stage_wipe.py new file mode 100755 index 0000000..9270e13 --- /dev/null +++ b/developer/source/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/source/wg/todo.org b/developer/source/wg/todo.org new file mode 100644 index 0000000..f83e739 --- /dev/null +++ b/developer/source/wg/todo.org @@ -0,0 +1,67 @@ +#+TITLE: subu / WireGuard — TODO +#+AUTHOR: Thomas & Nerith (session) +#+LANGUAGE: en +#+OPTIONS: toc:2 num:t +#+TODO: TODO(t) NEXT(n) WAITING(w) BLOCKED(b) | DONE(d) CANCELED(c) + +- Your current DB schema (the one you pasted earlier) does not include a listen-port field on Iface. So if you want ListenPort = … to be driven from the DB, add a column like Iface.listen_port INTEGER CHECK(listen_port BETWEEN 1 AND 65535). + +- have the stage commands echo relative pathnames instead of absolute as they do now. + +- Known gaps / open decisions + - Systemd drop-in to call staged scripts on ~wg-quick@IFACE~ up (IPv4 addrs + policy rules). + - Staged policy-rules script (source-based + uidrange rules) to replace the old global ~IP_rule_add.sh~ usage. + - Installer flow & atomic writes (copy staged files, set owner/perms; safe update of ~/etc/iproute2/rt_tables~). + - Pool size policy: default /16 with /32 hosts is implemented; decision pending on /8 vs /16. + - Style guardrails (RT commas / two-space indent) are manual; optional linter TBD. + +* NEXT wiring (high-level order) +1) Stage: /etc/iproute2/rt_tables (merge) for selected ifaces. +2) Stage: /usr/local/bin/set_iface_ipv4_addrs.sh for same ifaces. +3) Stage: /usr/local/bin/set_policy_rules_for_ifaces.sh (new; replaces old global add tool). +4) Stage: systemd drop-ins for ~wg-quick@IFACE.service.d/10-postup.conf~ to call (2) then (3). +5) Install: copy staged files → system, set perms/owner; ~systemctl daemon-reload~. +6) Bring-up: ~wg-quick up IFACE~; verify routes/rules; smoke tests. + +* TODO Add “missing-iface” guard to staged IPv4 script +- When iface doesn’t exist yet, log and continue (no non-zero exit). + +* TODO Stage policy rules script (idempotent) +- For each iface: + - Source-based rule: =from lookup =. + - UID rules: =uidrange U-U lookup = for each bound UID. +- Only for ifaces passed on the CLI; DB-driven; no kernel writes here. +- Emit with checks (skip if grep finds the exact rule). + +* TODO Systemd drop-in generator +- Emit to: ~stage/etc/systemd/wg-quick@IFACE.service.d/10-postup.conf~. +- Include: + - =ExecStartPre=-/usr/sbin/ip link delete IFACE= (clean stale link). + - =ExecStartPost=+/usr/local/bin/set_iface_ipv4_addrs.sh=. + - =ExecStartPost=+/usr/local/bin/set_policy_rules_for_ifaces.sh=. + - =ExecStartPost=+/usr/bin/logger 'wg-quick@IFACE up: addrs+rules applied'=. + +* TODO Installer flow +- Copy staged files with perms (0500 for scripts; 0644 for rt_tables; 0755 for dirs). +- Atomic update for ~/etc/iproute2/rt_tables~ (write temp + move); keep timestamped backup. +- ~systemctl daemon-reload~ after installing drop-ins. + +* WAITING Decide “no-op staging” policy for rt_tables +- Option A: Always stage a copy (deterministic deployment). +- Option B: Stage only when there are new entries (quieter diffs). + +* TODO Tests +- Unit-ish: parse/plan functions for both staging scripts (dry-run cases, collisions, skip-missing cases). +- Integration: + - Create temp WG iface: ~ip link add dev t0 type wireguard~ (and delete after). + - Run staged scripts; verify ~ip -4 addr show dev t0~, ~ip rule show~, ~ip route show table ~. + - Bring up real ~wg-quick up x6~; repeat verifications. + +* TODO Docs +- Append “operational runbook” to the org manual (bring-up, verify, recover, teardown). + +* DONE What’s already proven by commands (from log) +- all db_init is running, orchestrated by db_init_StanleyPark +- =stage_rt_tables_merge.py --from-db x6 US= created staged rt_tables with merges. +- =stage_iface_ipv4_script.py x6 US= staged ~set_iface_ipv4_addrs.sh~. + diff --git a/developer/source/wg/wg_keys_incommon.py b/developer/source/wg/wg_keys_incommon.py new file mode 100644 index 0000000..1578899 --- /dev/null +++ b/developer/source/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 diff --git a/developer/subu b/developer/subu new file mode 100755 index 0000000..979c638 --- /dev/null +++ b/developer/subu @@ -0,0 +1,31 @@ +#!/bin/env /bin/bash +# set -x + +subu="$1" +if [ -z "$subu" ]; then + echo "No subuser name supplied" + exit 1 +fi +subu_user="Thomas-$subu" + +# share the X display +# + export DISPLAY=:0 + export XAUTHORITY=${XAUTHORITY:-$(ls -1 /run/user/$(id -u)/.mutter-Xwaylandauth.* 2>/dev/null | head -n 1)} + if [ -z "$XAUTHORITY" ]; then + export XAUTHORITY="$HOME/.Xauthority" + fi + xauth -f "$XAUTHORITY" generate "$DISPLAY" . trusted + xauth extract - "$DISPLAY" | sudo -u "$subu_user" \ + XAUTHORITY="/home/$subu_user/.Xauthority" \ + xauth merge - + +# Login the subu +# + sudo loginctl enable-linger "$subu_user" + sudo machinectl shell "$subu_user"@ /bin/bash -c " + export DISPLAY=:0; + export XAUTHORITY=/home/$subu_user/.Xauthority; + bash -i + " + diff --git a/release/Python/.githolder b/release/Python/.githolder new file mode 100644 index 0000000..e69de29 diff --git a/release/amd54/.githolder b/release/amd54/.githolder new file mode 100644 index 0000000..e69de29 diff --git a/release/shell/.githolder b/release/shell/.githolder new file mode 100644 index 0000000..e69de29 diff --git a/release/subu b/release/subu deleted file mode 100755 index 979c638..0000000 --- a/release/subu +++ /dev/null @@ -1,31 +0,0 @@ -#!/bin/env /bin/bash -# set -x - -subu="$1" -if [ -z "$subu" ]; then - echo "No subuser name supplied" - exit 1 -fi -subu_user="Thomas-$subu" - -# share the X display -# - export DISPLAY=:0 - export XAUTHORITY=${XAUTHORITY:-$(ls -1 /run/user/$(id -u)/.mutter-Xwaylandauth.* 2>/dev/null | head -n 1)} - if [ -z "$XAUTHORITY" ]; then - export XAUTHORITY="$HOME/.Xauthority" - fi - xauth -f "$XAUTHORITY" generate "$DISPLAY" . trusted - xauth extract - "$DISPLAY" | sudo -u "$subu_user" \ - XAUTHORITY="/home/$subu_user/.Xauthority" \ - xauth merge - - -# Login the subu -# - sudo loginctl enable-linger "$subu_user" - sudo machinectl shell "$subu_user"@ /bin/bash -c " - export DISPLAY=:0; - export XAUTHORITY=/home/$subu_user/.Xauthority; - bash -i - " -