From: Thomas Walker Lynch Date: Fri, 12 Sep 2025 15:46:51 +0000 (-0700) Subject: nearing completion of stage scripts X-Git-Url: https://git.reasoningtechnology.com/style/rt_dark_doc.css?a=commitdiff_plain;h=0401e18bce6971060d171cb4bd488ae2560d2c6f;p=subu nearing completion of stage scripts --- diff --git a/developer/Python/wg/db_init_StanleyPark.py b/developer/Python/wg/db_init_StanleyPark.py index fc47797..2965415 100755 --- a/developer/Python/wg/db_init_StanleyPark.py +++ b/developer/Python/wg/db_init_StanleyPark.py @@ -12,6 +12,8 @@ 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 @@ -47,6 +49,16 @@ def db_init_StanleyPark() -> int: 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") diff --git a/developer/Python/wg/db_init_client_incommon.py b/developer/Python/wg/db_init_client_incommon.py index cf7e6f5..1f9443e 100644 --- a/developer/Python/wg/db_init_client_incommon.py +++ b/developer/Python/wg/db_init_client_incommon.py @@ -6,10 +6,13 @@ 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: str, + addr_cidr: Optional[str] = None, rt_table_name: Optional[str] = None, rt_table_id: Optional[int] = None, mtu: Optional[int] = None, diff --git a/developer/Python/wg/db_init_iface_US.py b/developer/Python/wg/db_init_iface_US.py index 42e8fe4..45d0447 100755 --- a/developer/Python/wg/db_init_iface_US.py +++ b/developer/Python/wg/db_init_iface_US.py @@ -3,4 +3,4 @@ from db_init_client_incommon import upsert_client def init_iface_US(conn): # iface US with dedicated table 'US' and a distinct host /32 - return upsert_client(conn, iface="US", addr_cidr="10.8.0.3/32", rt_table_name="US") + 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 index d0dfdb1..38794c7 100755 --- a/developer/Python/wg/db_init_iface_x6.py +++ b/developer/Python/wg/db_init_iface_x6.py @@ -3,4 +3,4 @@ from db_init_client_incommon import upsert_client def init_iface_x6(conn): # iface x6 with dedicated table 'x6' and host /32 - return upsert_client(conn, iface="x6", addr_cidr="10.8.0.2/32", rt_table_name="x6") + 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 new file mode 100755 index 0000000..561635e --- /dev/null +++ b/developer/Python/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/Python/wg/db_init_ip_table_registration.py b/developer/Python/wg/db_init_ip_table_registration.py new file mode 100755 index 0000000..8436a2d --- /dev/null +++ b/developer/Python/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/Python/wg/db_init_server_US.py b/developer/Python/wg/db_init_server_US.py index 7d9a945..d8cfcd0 100755 --- a/developer/Python/wg/db_init_server_US.py +++ b/developer/Python/wg/db_init_server_US.py @@ -7,7 +7,7 @@ def init_server_US(conn): conn, client_iface="US", server_name="US", - server_public_key="h8ZYEEVMForvv9p5Wx+9+eZ87t692hTN7sks5Noedw8=", # placeholder from old wg0.conf snippet + server_public_key="h8ZYEEVMForvv9p5Wx+9+eZ87t692hTN7sks5Noedw8=", endpoint_host="35.194.71.194", endpoint_port=443, allowed_ips="0.0.0.0/0", diff --git a/developer/Python/wg/db_schema.sql b/developer/Python/wg/db_schema.sql index f7f93cd..cf9cdb0 100644 --- a/developer/Python/wg/db_schema.sql +++ b/developer/Python/wg/db_schema.sql @@ -16,12 +16,12 @@ CREATE TABLE IF NOT EXISTS Iface ( ,created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')) ,updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')) ,iface TEXT NOT NULL UNIQUE -- kernel interface name as shown by ip link (e.g., wg0, x6) - ,rt_table_id INTEGER -- e.g. 1002 + ,rt_table_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 NOT NULL -- e.g. '10.8.0.2/32' + ,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 diff --git a/developer/Python/wg/doc_IP_terminaology.org b/developer/Python/wg/doc_IP_terminaology.org new file mode 100644 index 0000000..8f6587b --- /dev/null +++ b/developer/Python/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/Python/wg/doc_keys.org b/developer/Python/wg/doc_keys.org new file mode 100644 index 0000000..e56bd76 --- /dev/null +++ b/developer/Python/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/Python/wg/doc_stage_progs.org b/developer/Python/wg/doc_stage_progs.org new file mode 100644 index 0000000..a80f789 --- /dev/null +++ b/developer/Python/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/Python/wg/manual_IP_terminology.org b/developer/Python/wg/manual_IP_terminology.org deleted file mode 100644 index cc87c81..0000000 --- a/developer/Python/wg/manual_IP_terminology.org +++ /dev/null @@ -1,80 +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/. - -* 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/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 new file mode 100644 index 0000000..16b0fde --- /dev/null +++ b/developer/Python/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/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 new file mode 100644 index 0000000..5e8e2ab --- /dev/null +++ b/developer/Python/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/Python/wg/stage/etc/wireguard/US.conf b/developer/Python/wg/stage/etc/wireguard/US.conf new file mode 100644 index 0000000..7364c2e --- /dev/null +++ b/developer/Python/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/Python/wg/stage/etc/wireguard/x6.conf b/developer/Python/wg/stage/etc/wireguard/x6.conf new file mode 100644 index 0000000..b343bcc --- /dev/null +++ b/developer/Python/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/Python/wg/stage/usr/local/bin/apply_ip_state.sh b/developer/Python/wg/stage/usr/local/bin/apply_ip_state.sh new file mode 100755 index 0000000..5300313 --- /dev/null +++ b/developer/Python/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/Python/wg/stage_IP_apply_script.py b/developer/Python/wg/stage_IP_apply_script.py new file mode 100755 index 0000000..4e9ad9f --- /dev/null +++ b/developer/Python/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/Python/wg/stage_IP_routes_script.py b/developer/Python/wg/stage_IP_routes_script.py deleted file mode 100644 index d00189f..0000000 --- a/developer/Python/wg/stage_IP_routes_script.py +++ /dev/null @@ -1,105 +0,0 @@ -#!/usr/bin/env python3 -# stage_IP_routes_script.py — emit /usr/local/bin/routes_init_.sh - -from __future__ import annotations -import sqlite3, sys -from pathlib import Path -import incommon as ic # open_db, rows (works with DB_PATH) - -ROOT = Path(__file__).resolve().parent -STAGE_ROOT = ROOT / "stage" - -def _sq(s: str) -> str: - return "'" + s.replace("'", "'\"'\"'") + "'" - -def stage_ip_routes_script(iface: str) -> Path: - """ - Given an iface name, queries DB for its table name and preferred server, - and writes a runtime script that: - 1) sets default + blackhole default in the iface's route table - 2) pins the server endpoint /32 via the current GW/NIC (metric 5) - 3) applies extra Route rows (on_up=1) - Returns the path written. - """ - with ic.open_db() as conn: - row = conn.execute( - "SELECT id, rt_table_name_eff FROM v_iface_effective WHERE iface=? LIMIT 1;", - (iface,) - ).fetchone() - if not row: - raise RuntimeError(f"iface not found in DB: {iface}") - iface_id, rtname = int(row[0]), str(row[1]) - - srow = conn.execute( - """ - SELECT s.endpoint_host, s.endpoint_port - FROM Server s - JOIN Iface c ON c.id = s.iface_id - WHERE c.id=? - ORDER BY s.priority ASC, s.id ASC - LIMIT 1; - """, - (iface_id,) - ).fetchone() - ep_host = (srow[0] if srow and srow[0] else "") - ep_port = (srow[1] if srow and srow[1] else "") - - extra = ic.rows(conn, """ - SELECT cidr, COALESCE(via,''), COALESCE(table_name,''), COALESCE(metric,'') - FROM Route - WHERE iface_id=? AND on_up=1 - ORDER BY id; - """, (iface_id,)) - - out = STAGE_ROOT / "usr" / "local" / "bin" / f"routes_init_{iface}.sh" - out.parent.mkdir(parents=True, exist_ok=True) - - lines = [ - "#!/usr/bin/env bash", - "set -euo pipefail", - f"table={_sq(rtname)}", - f"dev={_sq(iface)}", - f"endpoint_host={_sq(str(ep_host))}", - f"endpoint_port={_sq(str(ep_port))}", - "", - "# 1) Default in dedicated table", - 'ip -4 route replace default dev "$dev" table "$table"', - 'ip -4 route replace blackhole default metric 32767 table "$table"', - "", - "# 2) Keep peer endpoint reachable outside the tunnel", - 'ep_ip=$(getent ahostsv4 "$endpoint_host" | awk \'NR==1{print $1}\')', - 'if [[ -n "$ep_ip" ]]; then', - ' gw=$(ip -4 route get "$ep_ip" | awk \'/ via /{print $3; exit}\')', - ' nic=$(ip -4 route get "$ep_ip" | awk \'/ dev /{for(i=1;i<=NF;i++) if ($i=="dev"){print $(i+1); exit}}\')', - ' if [[ -n "$gw" && -n "$nic" ]]; then', - ' ip -4 route replace "${ep_ip}/32" via "$gw" dev "$nic" metric 5', - ' fi', - 'fi', - "", - "# 3) Extra routes from DB", - ] - - for cidr, via, tbl, met in extra: - cidr = str(cidr); via = str(via or ""); tbl = str(tbl or rtname); met = str(met or "") - cmd = ["ip -4 route replace", cidr] - if via: cmd += ["via", via] - cmd += ["table", f'"{tbl}"'] - if met: cmd += ["metric", met] - lines.append(" ".join(cmd)) - - out.write_text("\n".join(lines) + "\n") - out.chmod(0o500) - return out - -def main(argv): - if len(argv) != 1: - print(f"Usage: {Path(sys.argv[0]).name} ", file=sys.stderr); return 2 - try: - p = stage_ip_routes_script(argv[0]) - except (sqlite3.Error, FileNotFoundError, RuntimeError) as e: - print(f"❌ {e}", file=sys.stderr); return 1 - print(f"staged: {p.relative_to(ROOT)}") - return 0 - -if __name__ == "__main__": - sys.exit(main(sys.argv[1:])) diff --git a/developer/Python/wg/stage_IP_rules_script.py b/developer/Python/wg/stage_IP_rules_script.py deleted file mode 100644 index eaa25b1..0000000 --- a/developer/Python/wg/stage_IP_rules_script.py +++ /dev/null @@ -1,272 +0,0 @@ -#!/usr/bin/env python3 -""" -stage_IP_rules_script.py — synthesize & stage an ip-rule script (scoped to given users) -and per-interface systemd drop-ins that invoke it after wg-quick@IFACE up. - -Inputs (CLI): - stage_IP_rules_script.py [ ...] - - Looks up users in DB, finds their iface bindings & UIDs, and emits: - 1) stage/usr/local/bin/set_subu_IP_rules.sh - 2) stage/etc/systemd/wg-quick@IFACE.service.d/10-postup-IP-rules.conf (per iface) - -Notes: - - Script lines are idempotent via a small helper (grep before ip rule add). - - We include per-iface source-CIDR rule and per-user UID rules, plus a SUBU containment rule. - - Only the ifaces required by the provided users are staged. -""" - -from __future__ import annotations -import ipaddress -import sqlite3 -import sys -from dataclasses import dataclass -from pathlib import Path -from typing import Dict, Iterable, List, Optional, Sequence, Tuple - -import incommon as ic # expected to provide: open_db(), rows() - -ROOT = Path(__file__).resolve().parent -STAGE_ROOT = ROOT / "stage" -OUTPUT_SCRIPT_NAME = "set_subu_IP_rules.sh" - - -# ---------- Data models ---------- - -@dataclass -class IfaceInfo: - iface_id: int - iface_name: str - rt_table_name_eff: str - local_address_cidr: str # e.g., '10.8.0.2/32' - - -@dataclass -class UserBinding: - username: str - uid: int - iface: IfaceInfo - - -# ---------- DB access ---------- - -def load_user_bindings(conn: sqlite3.Connection, usernames: Sequence[str]) -> List[UserBinding]: - """ - Return one UserBinding per (username, iface) row for the given usernames. - Requires uid to be non-null; raises if unknown usernames or missing UIDs. - """ - if not usernames: - return [] - - # Fetch bindings joined with effective iface info - placeholders = ",".join("?" for _ in usernames) - sql = f""" - SELECT - ub.username, - ub.uid, - i.id AS iface_id, - i.iface AS iface_name, - v.rt_table_name_eff, - v.local_address_cidr - FROM User ub - JOIN Iface i ON i.id = ub.iface_id - JOIN v_iface_effective v ON v.id = i.id - WHERE ub.username IN ({placeholders}) - ORDER BY ub.username, i.iface; - """ - rows = conn.execute(sql, tuple(usernames)).fetchall() - - found_usernames = {r[0] for r in rows} - missing = [u for u in usernames if u not in found_usernames] - if missing: - raise RuntimeError(f"user(s) not found in User: {', '.join(missing)}") - - bindings: List[UserBinding] = [] - for (username, uid, iface_id, iface_name, rtname, cidr) in rows: - if uid is None or str(uid) == "": - raise RuntimeError(f"user '{username}' has no cached UID in DB (User.uid is NULL)") - bindings.append( - UserBinding( - username=str(username), - uid=int(uid), - iface=IfaceInfo( - iface_id=int(iface_id), - iface_name=str(iface_name), - rt_table_name_eff=str(rtname), - local_address_cidr=str(cidr), - ), - ) - ) - return bindings - - -def load_subu_cidr(conn: sqlite3.Connection, default: str = "10.0.0.0/24") -> str: - row = conn.execute("SELECT value FROM Meta WHERE key='subu_cidr' LIMIT 1;").fetchone() - return str(row[0]) if row and row[0] else default - - -# ---------- Validation ---------- - -def assert_unique_hosts_in_subu(selected_ifaces: Iterable[IfaceInfo], subu_cidr: str) -> None: - """ - Ensure there are no duplicate host IPs within the SUBU network among the selected ifaces. - Only checks addresses that fall inside subu_cidr. - """ - net = ipaddress.IPv4Network(subu_cidr, strict=False) - seen: Dict[str, str] = {} - for info in selected_ifaces: - ip = str(ipaddress.IPv4Interface(info.local_address_cidr).ip) - if ipaddress.IPv4Address(ip) in net: - if ip in seen and seen[ip] != info.iface_name: - raise RuntimeError(f"duplicate SUBU IP {ip} on {seen[ip]} and {info.iface_name}") - seen[ip] = info.iface_name - - -# ---------- Script synthesis ---------- - -def synthesize_ip_rule_script(bindings: List[UserBinding], subu_cidr: str) -> List[str]: - """ - Build the shell script lines implementing: - - per-iface source-based rule: from lookup - - per-user UID rule: uidrange U-U lookup
- - SUBU containment rule: from prohibit - The helper add_IP_rule_if_not_exists makes rules idempotent. - """ - # Group users by iface - by_iface: Dict[int, Dict[str, object]] = {} - for b in bindings: - key = b.iface.iface_id - entry = by_iface.setdefault( - key, - { - "iface": b.iface.iface_name, - "rtname": b.iface.rt_table_name_eff, - "addr": b.iface.local_address_cidr, - "uids": set(), # type: ignore[dict-item] - }, - ) - entry["uids"].add(b.uid) # type: ignore[index] - - lines: List[str] = [ - "#!/usr/bin/env bash", - "# Enforce IPv4 rules for selected users; idempotent per rule.", - "set -euo pipefail", - "", - 'add_IP_rule_if_not_exists(){ local search_phrase=$1; shift; if ! ip -4 rule list | grep -F -q -- "$search_phrase"; then ip -4 rule add "$@"; fi; }', - "", - ] - - # Emit per-iface blocks - for _, data in sorted(by_iface.items(), key=lambda kv: kv[1]["iface"]): # type: ignore[index] - iface = str(data["iface"]) - table = str(data["rtname"]) - src_cidr = str(data["addr"]) - uids = sorted(int(u) for u in data["uids"]) # type: ignore[index] - - lines.append(f"# iface={iface} table={table} src={src_cidr}") - # source-based rule - lines.append( - f'add_IP_rule_if_not_exists "from {src_cidr} lookup {table}" from "{src_cidr}" lookup "{table}" pref 17000' - ) - # uid-based rules - for u in uids: - lines.append( - f'add_IP_rule_if_not_exists "uidrange {u}-{u} lookup {table}" uidrange "{u}-{u}" lookup "{table}" pref 17010' - ) - lines.append("") - - # SUBU containment (keeps traffic within the SUBU prefix from escaping unintended paths) - lines += [ - "# hard containment for SUBU address space", - f'add_IP_rule_if_not_exists "from {subu_cidr} prohibit" from "{subu_cidr}" prohibit pref 18050', - "", - ] - return lines - - -def stage_ip_rule_script(lines: List[str], stage_root: Optional[Path] = None) -> Path: - sr = stage_root or STAGE_ROOT - out = sr / "usr" / "local" / "bin" / OUTPUT_SCRIPT_NAME - out.parent.mkdir(parents=True, exist_ok=True) - out.write_text("\n".join(lines)) - out.chmod(0o500) - return out - - -# ---------- systemd drop-in synthesis ---------- - -def stage_wg_systemd_postup_ip_dropin(iface_name: str, script_path_in_unit: str, stage_root: Optional[Path] = None) -> Path: - """ - Write a per-interface systemd override so wg-quick@IFACE runs the rules script on 'up'. - """ - sr = stage_root or STAGE_ROOT - dropdir = sr / "etc" / "systemd" / f"wg-quick@{iface_name}.service.d" - dropdir.mkdir(parents=True, exist_ok=True) - path = dropdir / "10-postup-IP-rules.conf" - content = f"""[Service] -# Ensure our ip rules are applied after wg-quick brings {iface_name} up -ExecStartPost=+{script_path_in_unit} -ExecStartPost=+/usr/bin/logger 'wg-quick@{iface_name} up: ip rules applied' -""" - path.write_text(content) - return path - - -# ---------- Orchestration ---------- - -def stage_for_users(usernames: Sequence[str], stage_root: Optional[Path] = None) -> Tuple[Path, Dict[str, Path], str]: - """ - High-level: resolve users → bindings, validate, synthesize script, stage drop-ins (only needed ifaces). - Returns (script_path, {iface: dropin_path}, summary) - """ - if not usernames: - raise RuntimeError("no usernames provided") - - with ic.open_db() as conn: - bindings = load_user_bindings(conn, usernames) - # Derive iface set from bindings - iface_infos = {b.iface.iface_id: b.iface for b in bindings}.values() - subu = load_subu_cidr(conn, "10.0.0.0/24") - assert_unique_hosts_in_subu(iface_infos, subu) - - # Synthesize & stage the rules script - script_lines = synthesize_ip_rule_script(bindings, subu) - script_path = stage_ip_rule_script(script_lines, stage_root=stage_root) - - # Stage systemd drop-ins for just the ifaces we touched - dropins: Dict[str, Path] = {} - for iface in sorted({b.iface.iface_name for b in bindings}): - dropins[iface] = stage_wg_systemd_postup_ip_dropin( - iface_name=iface, - script_path_in_unit=f"/usr/local/bin/{OUTPUT_SCRIPT_NAME}", - stage_root=stage_root, - ) - - # Build a compact summary - per_iface = ",".join( - f"{iface}:{','.join(str(b.uid) for b in sorted({ub.uid for ub in bindings if ub.iface.iface_name==iface})) or '-'}" - for iface in sorted({b.iface.iface_name for b in bindings}) - ) - summary = f"users={len(set(b.username for b in bindings))}, ifaces={len(dropins)} ({per_iface}), rules_script={script_path.relative_to(stage_root or STAGE_ROOT)}" - return script_path, dropins, summary - - -# ---------- CLI ---------- - -def _print_usage_and_exit() -> None: - prog = Path(sys.argv[0]).name - print(f"Usage: {prog} [ ...]", file=sys.stderr) - sys.exit(2) - - -if __name__ == "__main__": - if len(sys.argv) < 2: - _print_usage_and_exit() - try: - script_path, dropins, summary = stage_for_users(sys.argv[1:]) - except Exception as e: - print(f"error: {e}", file=sys.stderr) - sys.exit(1) - print(f"staged: stage/{script_path.relative_to(STAGE_ROOT)}") - for iface, p in dropins.items(): - print(f"staged: stage/{p.relative_to(STAGE_ROOT)} (iface {iface})") - print(summary) diff --git a/developer/Python/wg/stage_wg_conf.py b/developer/Python/wg/stage_wg_conf.py old mode 100644 new mode 100755 index 4662d91..28dd4d3 --- a/developer/Python/wg/stage_wg_conf.py +++ b/developer/Python/wg/stage_wg_conf.py @@ -1,81 +1,254 @@ #!/usr/bin/env python3 -# stage_wg_conf.py — write stage/wireguard/.conf (Table=off) +""" +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 Optional, Union +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" -def write_wg_conf( - out_path: Path, - *, - addr: str, - private_key: str, - mtu: Optional[Union[int, str]] = None, - fwmark: Optional[Union[int, str]] = None, - dns_mode: str = "none", - dns_servers: Optional[str] = None, - peer_pub: str, - psk: Optional[str], - host: str, - port: Union[int, str], - allowed: str, - keepalive: Optional[Union[int, str]] = None, -) -> Path: + +# ---------- 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]]: """ - Given WG interface params + peer params, writes a config with Table=off. - Returns the written path. + Stage /etc/wireguard/.conf for selected ifaces under stage root. """ - out_path.parent.mkdir(parents=True, exist_ok=True) - lines = [] - lines.append("[Interface]") - lines.append(f"Address = {addr}") - lines.append(f"PrivateKey = {private_key}") - if mtu not in (None, "", 0): lines.append(f"MTU = {mtu}") - if fwmark not in (None, "", 0): lines.append(f"FwMark = {fwmark}") - if dns_mode == "static" and dns_servers: - lines.append(f"DNS = {dns_servers}") - # policy routing handled by our scripts, not wg-quick - lines.append("Table = off") - lines.append("") - lines.append("[Peer]") - lines.append(f"PublicKey = {peer_pub}") - if psk: lines.append(f"PresharedKey = {psk}") - lines.append(f"Endpoint = {host}:{port}") - lines.append(f"AllowedIPs = {allowed}") - if keepalive not in (None, "", 0): lines.append(f"PersistentKeepalive = {keepalive}") - out_path.write_text("\n".join(lines) + "\n") - out_path.chmod(0o400) - return out_path - -# Optional CLI wrapper + if 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__": - import sys, argparse - ap = argparse.ArgumentParser() - ap.add_argument("out") - ap.add_argument("--addr", required=True) - ap.add_argument("--priv", required=True) - ap.add_argument("--mtu") - ap.add_argument("--fwmark") - ap.add_argument("--dns-mode", default="none") - ap.add_argument("--dns-servers") - ap.add_argument("--peer-pub", required=True) - ap.add_argument("--psk") - ap.add_argument("--host", required=True) - ap.add_argument("--port", required=True) - ap.add_argument("--allowed", required=True) - ap.add_argument("--keepalive") - args = ap.parse_args() - p = write_wg_conf( - Path(args.out), - addr=args.addr, - private_key=args.priv, - mtu=args.mtu, fwmark=args.fwmark, - dns_mode=args.dns_mode, dns_servers=args.dns_servers, - peer_pub=args.peer_pub, psk=args.psk, - host=args.host, port=args.port, allowed=args.allowed, - keepalive=args.keepalive, - ) - print(f"staged: {p}") + sys.exit(main()) diff --git a/developer/Python/wg/stage_wg_systemd_postup_ip_dropin.py b/developer/Python/wg/stage_wg_systemd_postup_ip_dropin.py deleted file mode 100644 index 8c94222..0000000 --- a/developer/Python/wg/stage_wg_systemd_postup_ip_dropin.py +++ /dev/null @@ -1,81 +0,0 @@ -#!/usr/bin/env python3 -# stage_wg_systemd_postup_ip_dropin.py — stage a systemd unit override ("dropin") -# for wg-quick@IFACE to run IP policy + route scripts after the interface comes up. - -from __future__ import annotations -from pathlib import Path -from typing import Optional -import argparse -import re - -ROOT = Path(__file__).resolve().parent -STAGE_ROOT = ROOT / "stage" - -_IFACE_WELLFORMED_RE = re.compile(r"^[A-Za-z0-9_.-]+$") - -def wellformed_iface_name_guard(iface: str) - if not iface or not _IFACE_WELLFORMED_RE.match(iface): - raise ValueError(f"Invalid iface '{iface}': allowed chars are A–Z, a–z, 0–9, _ . -") - -def stage_wg_systemd_postup_ip_dropin(iface: str ,*) -> Path: - """ - Create and stage a systemd droppin for setting wg rules and routes - - ExecStartPre: delete a stale dev 'iface' before wg-quick runs. - - ExecStartPost: set the tunnel ip rules and routes - /usr/local/bin/wg_IP_rules.sh - - Emit a log line via logger. - - Returns: Path to the staged drop-in file. - """ - wellformed_iface_name_guard(iface) - sr = stage_root or STAGE_ROOT - dropin_dir = sr / "etc" / "systemd" / f"wg-quick@{iface}.service.d" - dropin_dir.mkdir(parents=True, exist_ok=True) - dropin_conf_path = dropin_dir / "10-postup-IP-scripts.conf" - - restart_lines = "Restart=on-failure\nRestartSec=5" if restart_on_failure else "" - pre_line = f"ExecStartPre=-/usr/sbin/ip link delete {iface}" if delete_iface_pre else "" - policy_line = ( - "ExecStartPost=+/usr/local/bin/set_subu_IP_rules.sh" - if use_global_policy_script - else f"ExecStartPost=+/usr/local/bin/policy_init_{iface}.sh" - ) - - content = f"""[Service] -{restart_lines} -{pre_line} -{policy_line} -ExecStartPost=+/usr/local/bin/routes_init_{iface}.sh -ExecStartPost=+/usr/bin/logger 'wg-quick@{iface} up: rules+routes applied' -""" - # Remove blank lines if some options are disabled - content = "\n".join(ln for ln in content.splitlines() if ln.strip()) + "\n" - dropin_conf_path.write_text(content) - return dropin_conf_path - -# ----- CLI ----- -if __name__ == "__main__": - p = argparse.ArgumentParser( - description="Stage a systemd drop-in to run IP policy+routes after wg-quick@IFACE up" - ) - p.add_argument("iface", help="WireGuard interface name (e.g., x6)") - p.add_argument("--per-iface-policy", action="store_true", - help="Use /usr/local/bin/policy_init_.sh instead of global set_subu_IP_rules.sh") - p.add_argument("--stage-root", type=Path, default=None, - help="Custom staging root (default: ./stage next to this script)") - p.add_argument("--no-delete-pre", action="store_true", - help="Do not delete a stale iface before wg-quick runs") - p.add_argument("--no-restart", action="store_true", - help="Do not set Restart=on-failure/RestartSec=5") - args = p.parse_args() - - out = stage_wg_systemd_postup_ip_dropin( - args.iface, - use_global_policy_script=not args.per_iface_policy, - stage_root=args.stage_root, - delete_iface_pre=not args.no_delete_pre, - restart_on_failure=not args.no_restart, - ) - print(f"staged: {out.relative_to(ROOT)}") - - diff --git a/developer/Python/wg/stage_wipe.py b/developer/Python/wg/stage_wipe.py old mode 100644 new mode 100755 diff --git a/developer/Python/wg/todo.org b/developer/Python/wg/todo.org new file mode 100644 index 0000000..f83e739 --- /dev/null +++ b/developer/Python/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~. +