minimal testing, but stage scripts look to be complete
authorThomas Walker Lynch <eknp9n@reasoningtechnology.com>
Fri, 12 Sep 2025 15:56:11 +0000 (08:56 -0700)
committerThomas Walker Lynch <eknp9n@reasoningtechnology.com>
Fri, 12 Sep 2025 15:56:11 +0000 (08:56 -0700)
developer/Python/wg/db_init_client_incommon.py [deleted file]
developer/Python/wg/db_init_iface.py [new file with mode: 0644]
developer/Python/wg/db_init_iface_US.py
developer/Python/wg/db_init_iface_x6.py
developer/Python/wg/doc_config.org [new file with mode: 0644]
developer/Python/wg/stage_StanleyPark.py [new file with mode: 0644]
developer/Python/wg/stage_client.py [new file with mode: 0644]

diff --git a/developer/Python/wg/db_init_client_incommon.py b/developer/Python/wg/db_init_client_incommon.py
deleted file mode 100644 (file)
index 1f9443e..0000000
+++ /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.py b/developer/Python/wg/db_init_iface.py
new file mode 100644 (file)
index 0000000..1f9443e
--- /dev/null
@@ -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)
index 45d0447..bf03c95 100755 (executable)
@@ -1,5 +1,5 @@
 # db_init_iface_US.py
-from db_init_client_incommon import upsert_client
+from db_init_iface import upsert_client
 
 def init_iface_US(conn):
   # iface US with dedicated table 'US' and a distinct host /32
index 38794c7..82eb5fe 100755 (executable)
@@ -1,5 +1,5 @@
 # db_init_iface_x6.py
-from db_init_client_incommon import upsert_client
+from db_init_iface import upsert_client
 
 def init_iface_x6(conn):
   # iface x6 with dedicated table 'x6' and host /32
diff --git a/developer/Python/wg/doc_config.org b/developer/Python/wg/doc_config.org
new file mode 100644 (file)
index 0000000..2de0ee4
--- /dev/null
@@ -0,0 +1,9 @@
+-New interface:
+
+copy `db_init_iface_x6.py` to `db_init_iface_<name>.py`, replacing <name> with the name of the interface.  Then edit `db_init_iface_<name>.py`
+
+-New Client
+
+-New User
+
+
diff --git a/developer/Python/wg/stage_StanleyPark.py b/developer/Python/wg/stage_StanleyPark.py
new file mode 100644 (file)
index 0000000..77264a3
--- /dev/null
@@ -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/Python/wg/stage_client.py b/developer/Python/wg/stage_client.py
new file mode 100644 (file)
index 0000000..7d5f5ba
--- /dev/null
@@ -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/<client_machine_name> 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())