From 977d3c9b1ba2765412fa9673692c022879be4249 Mon Sep 17 00:00:00 2001 From: Thomas Walker Lynch Date: Sat, 3 Jan 2026 11:29:02 +0000 Subject: [PATCH] . --- developer/clean_upload.zip | Bin 0 -> 48741 bytes .../login/{login_to_subu.sh => subu_login} | 2 +- developer/manager/{ => CLI}/CLI.py | 37 +- developer/manager/CLI/dispatch.py | 168 +++++ developer/manager/CLI/domain/device.py | 114 ++++ developer/manager/{ => CLI}/domain/exec.py | 0 developer/manager/{ => CLI}/domain/network.py | 0 developer/manager/{ => CLI}/domain/options.py | 0 developer/manager/{ => CLI}/domain/subu.py | 11 +- developer/manager/{ => CLI}/domain/wg.py | 0 developer/manager/{ => CLI}/env.py | 0 .../manager/{ => CLI}/infrastructure/bpf.py | 0 .../infrastructure/bpf_force_egress.c | 0 .../{ => CLI}/infrastructure/bpf_worker.py | 0 .../manager/{ => CLI}/infrastructure/db.py | 0 .../{domain => CLI/infrastructure}/device.py | 0 .../{ => CLI}/infrastructure/options_store.py | 0 .../{ => CLI}/infrastructure/schema.sql | 0 developer/manager/CLI/infrastructure/unix.py | 105 +++ developer/manager/{ => CLI}/text.py | 92 ++- developer/manager/dispatch.py | 608 ------------------ developer/manager/infrastructure/device.py | 308 --------- developer/manager/infrastructure/unix.py | 106 --- developer/manager/install | 128 ++++ developer/manager/systemd/boot_attach.service | 12 + developer/manager/systemd/subu_resume.service | 11 + developer/manager/uncatelogued/parser.py | 32 - developer/manager/usr_local_bin/boot_attach | 64 ++ 28 files changed, 676 insertions(+), 1122 deletions(-) create mode 100644 developer/clean_upload.zip rename developer/login/{login_to_subu.sh => subu_login} (99%) rename developer/manager/{ => CLI}/CLI.py (88%) create mode 100644 developer/manager/CLI/dispatch.py create mode 100644 developer/manager/CLI/domain/device.py rename developer/manager/{ => CLI}/domain/exec.py (100%) rename developer/manager/{ => CLI}/domain/network.py (100%) rename developer/manager/{ => CLI}/domain/options.py (100%) rename developer/manager/{ => CLI}/domain/subu.py (94%) rename developer/manager/{ => CLI}/domain/wg.py (100%) rename developer/manager/{ => CLI}/env.py (100%) rename developer/manager/{ => CLI}/infrastructure/bpf.py (100%) rename developer/manager/{ => CLI}/infrastructure/bpf_force_egress.c (100%) rename developer/manager/{ => CLI}/infrastructure/bpf_worker.py (100%) rename developer/manager/{ => CLI}/infrastructure/db.py (100%) rename developer/manager/{domain => CLI/infrastructure}/device.py (100%) rename developer/manager/{ => CLI}/infrastructure/options_store.py (100%) rename developer/manager/{ => CLI}/infrastructure/schema.sql (100%) create mode 100644 developer/manager/CLI/infrastructure/unix.py rename developer/manager/{ => CLI}/text.py (50%) delete mode 100644 developer/manager/dispatch.py delete mode 100644 developer/manager/infrastructure/device.py delete mode 100644 developer/manager/infrastructure/unix.py create mode 100644 developer/manager/install create mode 100644 developer/manager/systemd/boot_attach.service create mode 100644 developer/manager/systemd/subu_resume.service delete mode 100644 developer/manager/uncatelogued/parser.py create mode 100644 developer/manager/usr_local_bin/boot_attach diff --git a/developer/clean_upload.zip b/developer/clean_upload.zip new file mode 100644 index 0000000000000000000000000000000000000000..b1ab3ffeb1691e3da989ff4e272b4eec16c36032 GIT binary patch literal 48741 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

$PisU`-xqnnH`J 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 literal 0 HcmV?d00001 diff --git a/developer/login/login_to_subu.sh b/developer/login/subu_login similarity index 99% rename from developer/login/login_to_subu.sh rename to developer/login/subu_login index ca28743..17d5290 100755 --- a/developer/login/login_to_subu.sh +++ b/developer/login/subu_login @@ -10,7 +10,7 @@ if [ -z "$subu" ]; then exit 1 fi -subu_user="Thomas-$subu" +subu_user="Thomas_$subu" if ! id "$subu_user" &>/dev/null; then echo "❌ User $subu_user does not exist" exit 1 diff --git a/developer/manager/CLI.py b/developer/manager/CLI/CLI.py similarity index 88% rename from developer/manager/CLI.py rename to developer/manager/CLI/CLI.py index b4c00db..d84ba82 100755 --- a/developer/manager/CLI.py +++ b/developer/manager/CLI/CLI.py @@ -26,15 +26,9 @@ def register_device_commands(subparsers): For v1, we only support scanning already-mounted devices under /mnt. """ ap = subparsers.add_parser("device") - ap.add_argument( - "action", - choices =["scan"], - ) - ap.add_argument( - "--base-dir", - default ="/mnt", - help ="root under which to scan for /user_data (default: /mnt)", - ) + ap.add_argument("action", choices=["scan","attach","detach"]) + ap.add_argument("mapname", nargs="?") + ap.add_argument("--base-dir", default="/mnt") def register_db_commands(subparsers): @@ -90,6 +84,11 @@ def register_subu_commands(subparsers): ap.add_argument("target") ap.add_argument("rest", nargs="*") +def register_subu_option_commands(subparsers): + ap = subparsers.add_parser("subu") + ap.add_argument("subverb", choices=["make","remove","list","info","capture","option"]) + ap.add_argument("args", nargs=argparse.REMAINDER) + def register_wireguard_commands(subparsers): """Register WireGuard related commands, grouped under 'WG'.""" @@ -136,6 +135,7 @@ def register_network_commands(subparsers): ap.add_argument("subu_id") + def register_option_commands(subparsers): """Register global option commands (non-subu-specific for now): @@ -282,12 +282,27 @@ def CLI(argv=None) -> int: if ns.db_verb == "load" and ns.what == "schema": return dispatch.db_load_schema() + if ns.verb == "device": + if ns.action == "scan": return dispatch.device_scan(ns.base_dir) + if ns.action == "attach": return dispatch.device_attach(ns.mapname) + if ns.action == "detach": return dispatch.device_detach(ns.mapname) + if ns.verb == "subu": + if ns.subverb == "capture": + # args: [.]* + return dispatch.subu_capture(ns.args) + if ns.subverb == "option": + # expected: set|clear incommon [.]* + if len(ns.args) < 3: ... + action, which, *rest = ns.args + owner, *parts = rest + if action == "set" and which == "incommon": + return dispatch.subu_option_incommon_set(owner, parts) + if action == "clear" and which == "incommon": + return dispatch.subu_option_incommon_clear(owner, parts) sv = ns.subu_verb if sv == "make": return dispatch.subu_make(ns.path) - if sv == "capture": - return dispatch.subu_capture(ns.path) if sv == "list": return dispatch.subu_list() if sv == "info": diff --git a/developer/manager/CLI/dispatch.py b/developer/manager/CLI/dispatch.py new file mode 100644 index 0000000..b9aa68e --- /dev/null +++ b/developer/manager/CLI/dispatch.py @@ -0,0 +1,168 @@ +# dispatch.py (additions) +# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*- + +import os, sys, sqlite3, subprocess +import env +from infrastructure.db import open_db +from domain.subu import ensure_chain, find_by_path, subu_username +from domain import device as device_domain + +def device_scan(base_dir: str ="/mnt") -> int: + try: + conn = open_db() + except Exception as e: + print(f"subu: cannot open database at '{env.db_path()}': {e}", file =sys.stderr) + return 1 + try: + n = device_domain.scan_and_reconcile(conn, base_dir) + print(f"scanned {n} device(s) under {base_dir}") + return 0 + finally: + conn.close() + +def subu_capture(path: list[str], device_mapname: str|None =None) -> int: + """ + path: ['masu','s0','s1', ...] + device_mapname: optional mapname to associate (must already be visible under /mnt) + """ + if not path or len(path) < 2: + print("subu: capture requires [.]*", file =sys.stderr) + return 2 + owner, parts = path[0], path[1:] + try: + conn = open_db() + except Exception as e: + print(f"subu: cannot open database at '{env.db_path()}': {e}", file =sys.stderr) + return 1 + try: + device_id = None + if device_mapname: + conn.row_factory = sqlite3.Row + row = conn.execute("SELECT id FROM device WHERE mapname=?", (device_mapname,)).fetchone() + if not row: + print(f"subu: device '{device_mapname}' not known; run 'device scan' first", file =sys.stderr) + return 2 + device_id = int(row["id"]) + leaf = ensure_chain(conn, owner, parts, device_id, True) + conn.commit() + print(leaf["full_unix_name"]) + return 0 + finally: + conn.close() + +def subu_list() -> int: + """ + Print a flat list: id owner full_path full_unix_name device online + """ + try: + conn = open_db() + except Exception as e: + print(f"subu: cannot open database at '{env.db_path()}': {e}", file =sys.stderr) + return 1 + try: + conn.row_factory = sqlite3.Row + rows = conn.execute( + """SELECT n.id, n.owner, n.full_path, n.full_unix_name, n.is_online, d.mapname AS device + FROM subu_node n + LEFT JOIN device d ON d.id = n.device_id + ORDER BY n.owner, n.full_path""" + ).fetchall() + if not rows: + print("(no subu in database)") + return 0 + for r in rows: + dev = r["device"] or "local" + on = "1" if int(r["is_online"] or 0) else "0" + print(f'{r["id"]}\t{r["owner"]}\t{r["full_path"]}\t{r["full_unix_name"]}\t{dev}\t{on}') + return 0 + finally: + conn.close() + +def subu_option_incommon_set(spec_owner: str, spec_parts: list[str]) -> int: + """ + Make a subu 'incommon': grant g+rx on its home dir and add all sibling subu users + under the same owner into its group. Unix work is delegated to infrastructure.unix. + """ + from infrastructure.unix import incommon_set_for_subu # keep import local + try: + conn = open_db() + except Exception as e: + print(f"subu: cannot open database: {e}", file =sys.stderr) + return 1 + try: + # Ensure the node exists in DB (don’t change device) + leaf = find_by_path(conn, spec_owner, spec_parts) + if not leaf: + print("subu: specified subu not found in DB; capture or make it first", file =sys.stderr) + return 2 + incommon_set_for_subu(spec_owner, spec_parts) + return 0 + finally: + conn.close() + +def subu_option_incommon_clear(spec_owner: str, spec_parts: list[str]) -> int: + """ + Reverse of set: remove g+rx and drop sibling subu users from its group. + """ + from infrastructure.unix import incommon_clear_for_subu + try: + conn = open_db() + except Exception as e: + print(f"subu: cannot open database: {e}", file =sys.stderr) + return 1 + try: + leaf = find_by_path(conn, spec_owner, spec_parts) + if not leaf: + print("subu: specified subu not found in DB; capture or make it first", file =sys.stderr) + return 2 + incommon_clear_for_subu(spec_owner, spec_parts) + return 0 + finally: + conn.close() + +def device_attach(mapname: str) -> int: + """ + Call your existing shell to open+mount /mnt/, then reconcile. + (No mid-session home swapping here; policy enforcement to be added around callers.) + """ + # You can parameterize paths via env.py if preferred. + opener = "/root/mount/device_mapname__open_mount.sh" + if not os.path.exists(opener): + print(f"subu: cannot find opener script at {opener}", file =sys.stderr) + return 1 + # We don’t guess /dev/sdX here; you pass it in your wrapper. + # For now just ensure /mnt/ is mounted by your own workflow, + # then call reconcile: + try: + conn = open_db() + except Exception as e: + print(f"subu: cannot open database: {e}", file =sys.stderr); return 1 + try: + processed = device_domain.scan_and_reconcile(conn, "/mnt") + print(f"scanned {processed} device(s) under /mnt") + return 0 + finally: + conn.close() + +def device_detach(mapname: str) -> int: + """ + Delegate to your logout/unmount scripts, then mark DB offline. + """ + from infrastructure.unix import mark_device_offline + try: + conn = open_db() + except Exception as e: + print(f"subu: cannot open database: {e}", file =sys.stderr); return 1 + try: + # Your script already unmounts and closes; afterwards, mark offline in DB: + conn.execute("UPDATE device SET state='offline' WHERE mapname=?", (mapname,)) + conn.execute( + "UPDATE subu_node SET is_online=0, updated_at=datetime('now') " + "WHERE device_id=(SELECT id FROM device WHERE mapname=?)", + (mapname,), + ) + conn.commit() + print(f"device '{mapname}' marked offline") + return 0 + finally: + conn.close() diff --git a/developer/manager/CLI/domain/device.py b/developer/manager/CLI/domain/device.py new file mode 100644 index 0000000..a35b61f --- /dev/null +++ b/developer/manager/CLI/domain/device.py @@ -0,0 +1,114 @@ +# domain/device.py +# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*- + +import os, sqlite3 +from datetime import datetime +from pathlib import Path + +from domain.subu import ensure_chain + +def _utc_now() -> str: + return datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ") + +def _walk_subu_paths(subu_root: Path): + """ + Yield subu component lists by descending the nested subu_data tree. + e.g. ['developer'], ['developer','bolt'], ... + """ + stack: list[tuple[Path, list[str]]] = [(subu_root, [])] + while stack: + base, prefix = stack.pop() + try: + entries = sorted(p for p in base.iterdir() if p.is_dir()) + except FileNotFoundError: + continue + for d in entries: + name = d.name + path = prefix + [name] + yield path + nxt = d / "subu_data" + if nxt.is_dir(): + stack.append((nxt, path)) + +def _upsert_device(conn, mapname: str, mount_point: str, kind: str ="external") -> int: + now = _utc_now() + conn.row_factory = sqlite3.Row + row = conn.execute("SELECT id FROM device WHERE mapname=?", (mapname,)).fetchone() + if row: + dev_id = row["id"] + conn.execute( + "UPDATE device SET mount_point=?, kind=?, state='online', last_seen=? WHERE id=?", + (mount_point, kind, now, dev_id), + ) + return int(dev_id) + cur = conn.execute( + "INSERT INTO device(mapname,mount_point,kind,state,last_seen) VALUES(?,?,?,'online',?)", + (mapname, mount_point, kind, now), + ) + return int(cur.lastrowid) + +def _mark_missing_offline(conn, device_id: int, seen_keys: set[tuple[str,int]]): + """ + Mark rows in subu_node for this device as offline if leaf id not seen. + We compare by (owner_id, node_id) but since we don’t store owner ids, + we key by (owner, id) indirectly via a select. + """ + conn.row_factory = sqlite3.Row + now = _utc_now() + cur = conn.execute("SELECT id FROM subu_node WHERE device_id=?", (device_id,)) + for r in cur.fetchall(): + node_id = r["id"] + # Seen set uses node ids only (device scoping suffices) + if (device_id, node_id) in seen_keys: + continue + conn.execute( + "UPDATE subu_node SET is_online=0, updated_at=? WHERE id=?", + (now, node_id), + ) + +def reconcile_device(conn, mapname: str, mount_point: str) -> int: + """ + Reconcile a single already-mounted device (/mnt/). + Returns number of subu nodes (leaf count) discovered/refreshed. + """ + user_data = Path(mount_point) / "user_data" + if not user_data.is_dir(): + return 0 + + device_id = _upsert_device(conn, mapname, mount_point) + conn.row_factory = sqlite3.Row + now = _utc_now() + refreshed = 0 + seen: set[tuple[int,int]] = set() # (device_id, node_id) + + for masu_dir in sorted(p for p in user_data.iterdir() if p.is_dir()): + owner = masu_dir.name + subu_root = masu_dir / "subu_data" + if not subu_root.is_dir(): + continue + for parts in _walk_subu_paths(subu_root): + # Ensure the chain exists and is marked online on this device + leaf = ensure_chain(conn, owner, parts, device_id, True) + seen.add((device_id, int(leaf["id"]))) + refreshed += 1 + + _mark_missing_offline(conn, device_id, seen) + conn.commit() + return refreshed + +def scan_and_reconcile(conn, base_dir: str ="/mnt") -> int: + """ + Scan /mnt/* for mapnames that contain a top-level user_data/ and reconcile each. + Returns the number of devices processed. + """ + root = Path(base_dir) + if not root.is_dir(): + return 0 + processed = 0 + for mp in sorted(p for p in root.iterdir() if p.is_dir()): + if not (mp / "user_data").is_dir(): + continue + refreshed = reconcile_device(conn, mp.name, str(mp)) + # Count device even if zero subu (e.g. only user_data/ present) + processed += 1 + return processed diff --git a/developer/manager/domain/exec.py b/developer/manager/CLI/domain/exec.py similarity index 100% rename from developer/manager/domain/exec.py rename to developer/manager/CLI/domain/exec.py diff --git a/developer/manager/domain/network.py b/developer/manager/CLI/domain/network.py similarity index 100% rename from developer/manager/domain/network.py rename to developer/manager/CLI/domain/network.py diff --git a/developer/manager/domain/options.py b/developer/manager/CLI/domain/options.py similarity index 100% rename from developer/manager/domain/options.py rename to developer/manager/CLI/domain/options.py diff --git a/developer/manager/domain/subu.py b/developer/manager/CLI/domain/subu.py similarity index 94% rename from developer/manager/domain/subu.py rename to developer/manager/CLI/domain/subu.py index 4abfa13..f3eb3b0 100644 --- a/developer/manager/domain/subu.py +++ b/developer/manager/CLI/domain/subu.py @@ -13,12 +13,21 @@ import sqlite3, datetime def _now(): return datetime.datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ") def subu_username(owner: str, parts: list[str]) -> str: - return "_".join([owner] + parts) + """ + Build the Unix login name, enforcing 'no underscore in tokens'. + """ + owner_ok = _validate_token("masu", owner) + parts_ok = [_validate_token("subu", p) for p in parts] + return "_".join([owner_ok] + parts_ok) def ensure_chain(conn, owner: str, parts: list[str], device_id: int|None, online: bool): """ Ensure that owner/parts[...] exists as a chain; return leaf row (dict). """ + # Validate once up-front + owner = _validate_token("masu", owner) + parts = [_validate_token("subu", p) for p in parts] + conn.row_factory = sqlite3.Row parent_id = None chain: list[str] = [] diff --git a/developer/manager/domain/wg.py b/developer/manager/CLI/domain/wg.py similarity index 100% rename from developer/manager/domain/wg.py rename to developer/manager/CLI/domain/wg.py diff --git a/developer/manager/env.py b/developer/manager/CLI/env.py similarity index 100% rename from developer/manager/env.py rename to developer/manager/CLI/env.py diff --git a/developer/manager/infrastructure/bpf.py b/developer/manager/CLI/infrastructure/bpf.py similarity index 100% rename from developer/manager/infrastructure/bpf.py rename to developer/manager/CLI/infrastructure/bpf.py diff --git a/developer/manager/infrastructure/bpf_force_egress.c b/developer/manager/CLI/infrastructure/bpf_force_egress.c similarity index 100% rename from developer/manager/infrastructure/bpf_force_egress.c rename to developer/manager/CLI/infrastructure/bpf_force_egress.c diff --git a/developer/manager/infrastructure/bpf_worker.py b/developer/manager/CLI/infrastructure/bpf_worker.py similarity index 100% rename from developer/manager/infrastructure/bpf_worker.py rename to developer/manager/CLI/infrastructure/bpf_worker.py diff --git a/developer/manager/infrastructure/db.py b/developer/manager/CLI/infrastructure/db.py similarity index 100% rename from developer/manager/infrastructure/db.py rename to developer/manager/CLI/infrastructure/db.py diff --git a/developer/manager/domain/device.py b/developer/manager/CLI/infrastructure/device.py similarity index 100% rename from developer/manager/domain/device.py rename to developer/manager/CLI/infrastructure/device.py diff --git a/developer/manager/infrastructure/options_store.py b/developer/manager/CLI/infrastructure/options_store.py similarity index 100% rename from developer/manager/infrastructure/options_store.py rename to developer/manager/CLI/infrastructure/options_store.py diff --git a/developer/manager/infrastructure/schema.sql b/developer/manager/CLI/infrastructure/schema.sql similarity index 100% rename from developer/manager/infrastructure/schema.sql rename to developer/manager/CLI/infrastructure/schema.sql diff --git a/developer/manager/CLI/infrastructure/unix.py b/developer/manager/CLI/infrastructure/unix.py new file mode 100644 index 0000000..4889645 --- /dev/null +++ b/developer/manager/CLI/infrastructure/unix.py @@ -0,0 +1,105 @@ +# infrastructure/unix.py +# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*- + +import os, subprocess, pwd, grp + +def _run(cmd: list[str]) -> int: + return subprocess.run(cmd, check =False).returncode + +def user_exists(name: str) -> bool: + try: + pwd.getpwnam(name); return True + except KeyError: + return False + +def group_exists(name: str) -> bool: + try: + grp.getgrnam(name); return True + except KeyError: + return False + +def ensure_group(name: str) -> None: + if group_exists(name): return + _run(["groupadd", "--force", name]) + +def ensure_unix_user(user: str, primary_group: str) -> None: + """ + Ensure Unix user and primary group exist with matching names. + """ + ensure_group(primary_group) + if user_exists(user): return + # Create with home disabled; your tooling manages home dirs. + _run([ + "useradd", + "--create-home", # harmless if home already bind-mounted later + "--shell", "/bin/bash", + "--gid", primary_group, + user, + ]) + +def ensure_user_in_group(user: str, group: str) -> None: + ensure_group(group) + # usermod -a -G keeps existing supplementary groups + _run(["usermod", "-a", "-G", group, user]) + +def remove_unix_user_and_group(user: str) -> None: + # Remove user, then drop group if empty + _run(["userdel", "-r", user]) + if group_exists(user): + _run(["groupdel", user]) + +def incommon_set_for_subu(masu: str, parts: list[str]) -> None: + """ + Grant g+rx on the subu home dir and add all sibling subu users + under the same owner into this subu's group. + """ + # Compute Unix names + owner_group = masu + subu_user = "_".join([masu] + parts) + subu_group = subu_user + # Directory path (owner’s subu_data path) + if not parts: + return + home = f"/home/{masu}/subu_data" + for seg in parts[:-1]: + home = f"{home}/{seg}/subu_data" + home = f"{home}/{parts[-1]}" + # chmod g+rx on the incommon subu home + _run(["chmod", "g+rx", home]) + # Add all other subu under the owner into this group + # (simple, local discovery; DB-driven selection is also possible) + base = f"/home/{masu}/subu_data" + for entry in os.listdir(base): + # first-level siblings only; deeper policies can be added later + u = f"{masu}_{entry}" + if u == subu_user: # skip self + continue + if user_exists(u): + ensure_user_in_group(u, subu_group) + +def incommon_clear_for_subu(masu: str, parts: list[str]) -> None: + """ + Revoke g+rx (set back to 700) and drop sibling subu from the group. + """ + if not parts: + return + subu_user = "_".join([masu] + parts) + subu_group = subu_user + home = f"/home/{masu}/subu_data" + for seg in parts[:-1]: + home = f"{home}/{seg}/subu_data" + home = f"{home}/{parts[-1]}" + _run(["chmod", "0700", home]) + # Remove siblings from the group + base = f"/home/{masu}/subu_data" + for entry in os.listdir(base): + u = f"{masu}_{entry}" + if u == subu_user: # skip self + continue + if user_exists(u): + _run(["gpasswd", "-d", u, subu_group]) + +def mark_device_offline(mapname: str) -> None: + # reserved for later; actual DB write is done in dispatch.device_detach + pass + diff --git a/developer/manager/text.py b/developer/manager/CLI/text.py similarity index 50% rename from developer/manager/text.py rename to developer/manager/CLI/text.py index 4fa2f77..2b04247 100644 --- a/developer/manager/text.py +++ b/developer/manager/CLI/text.py @@ -1,61 +1,52 @@ # text.py # -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*- - """ text.py — user-facing text for the subu manager CLI. """ - class _Text: def __init__(self, program_name: str): self.program_name = program_name - # Keep version string in one place here for now. self._version = "0.3.4" # ---- Public API expected by CLI.py --------------------------------------- def version(self) -> str: - """ - Return a short version string suitable for 'PROG version'. - """ return f"{self._version}\n" def usage(self) -> str: - """ - Return a short usage summary including the command surface. - """ p = self.program_name v = self._version return ( f"{p} — Subu manager (v{v})\n" "\n" "Usage:\n" - f" {p} # usage\n" f" {p} help # detailed help\n" f" {p} example # example workflow\n" f" {p} version # print version\n" "\n" - f" {p} db load schema\n" + f" {p} db migrate subu->subu_node\n" "\n" - - f" {p} subu make [ ...]\n" + f" {p} device scan [--base-dir DIR]\n" + f" {p} device attach \n" + f" {p} device detach \n" + "\n" + f" {p} subu make [ ...]\n" f" {p} subu capture [ ...]\n" f" {p} subu list\n" - f" {p} subu info subu_\n" - f" {p} subu info [ ...]\n" - f" {p} subu remove subu_\n" - f" {p} subu remove [ ...]\n" + f" {p} subu info subu_\n" + f" {p} subu info [ ...]\n" + f" {p} subu remove subu_\n" + f" {p} subu remove [ ...]\n" f" {p} subu option set incommon subu_\n" f" {p} subu option set incommon [ ...]\n" f" {p} subu option clear incommon subu_\n" f" {p} subu option clear incommon [ ...]\n" "\n" - f" {p} lo up|down \n" "\n" - f" {p} WG global \n" f" {p} WG make \n" f" {p} WG server_provided_public_key \n" @@ -63,74 +54,65 @@ class _Text: f" {p} WG up \n" f" {p} WG down \n" "\n" - f" {p} attach WG \n" f" {p} detach WG \n" "\n" - f" {p} network up|down \n" "\n" - - f" {p} option set \n" - f" {p} option get \n" + f" {p} option set \n" + f" {p} option get \n" f" {p} option list \n" "\n" - f" {p} exec -- ...\n" ) def help(self) -> str: - """ - Return a more detailed help text. - - For now this is usage plus a short explanatory block. - """ p = self.program_name return ( self.usage() + "\n" "Notes:\n" - f" * '{p} db load schema' must be run as root and will create/update the\n" - " manager's SQLite database (schema only).\n" - " * 'subu' commands manage subu records and their corresponding Unix users.\n" - " They accept either a numeric Subu_ID (e.g. 'subu_3') or a path\n" - " ( [ ...]) where noted.\n" - " * WireGuard, attach/detach, network, option, and exec commands are\n" - " reserved for managing networking and runtime behavior of existing subu.\n" + f" * '{p} db load schema' must be run as root; it creates/updates the SQLite schema.\n" + f" * '{p} db migrate subu->subu_node' migrates legacy flat rows into the hierarchical table.\n" + " * Device commands work on already-mounted mapnames under /mnt (v1). 'scan' discovers\n" + " /mnt//user_data and captures all /subu_data trees into the DB.\n" + " * 'subu' commands manage both DB rows and their corresponding Unix users/groups.\n" + " You may address a subu by numeric ID (e.g. 'subu_3') or by path tokens:\n" + " [ ...]\n" + " Path tokens must be non-empty and contain no underscore '_'. Proper nouns/acronyms\n" + " may be capitalized; hyphens are allowed inside tokens.\n" + " * 'subu option incommon' grants or revokes g+rx on the chosen subu home and adjusts\n" + " sibling subu group membership under the same (see policy in infrastructure/unix.py).\n" + " * WireGuard, attach/detach, network, option, and exec manage runtime properties of existing subu.\n" "\n" ) def example(self) -> str: - """ - Return an example workflow. - """ p = self.program_name return ( - f"Example workflow:\n" + "Example workflow:\n" "\n" - f" # 1. As root, create or update the manager database schema\n" + f" # 1) Initialize schema (root)\n" f" sudo {p} db load schema\n" "\n" - f" # 2. As root, create a developer subu for Thomas\n" - f" sudo {p} subu make Thomas developer\n" + f" # 2) Scan devices already mounted under /mnt (root)\n" + f" sudo {p} device scan\n" "\n" - f" # 3. As root, create a nested subu 'bolt' under Thomas/developer\n" - f" sudo {p} subu make Thomas developer bolt\n" + f" # 3) Capture a legacy subu present in /home//subu_data (root)\n" + f" sudo {p} subu capture Thomas developer\n" "\n" - f" # 4. As any user, list all known subu\n" + f" # 4) List everything (any user)\n" f" {p} subu list\n" "\n" - f" # 5. Show detailed info by path\n" - f" {p} subu info Thomas developer bolt\n" + f" # 5) Make a nested subu and then mark a top-level as incommon (root)\n" + f" sudo {p} subu make Thomas developer bolt\n" + f" sudo {p} subu option set incommon Thomas developer\n" "\n" - f" # 6. Later, remove the nested subu by ID\n" - f" sudo {p} subu remove subu_3\n" + f" # 6) Query by ID or by path (any user)\n" + f" {p} subu info subu_7\n" + f" {p} subu info Thomas developer bolt\n" "\n" ) - def make_text(program_name: str) -> _Text: - """ - Factory used by CLI.py to get a text provider for the given program name. - """ return _Text(program_name) diff --git a/developer/manager/dispatch.py b/developer/manager/dispatch.py deleted file mode 100644 index fccb3a2..0000000 --- a/developer/manager/dispatch.py +++ /dev/null @@ -1,608 +0,0 @@ -# dispatch.py -# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*- - -import os, sys -import env -from domain import subu as subu_domain -from domain import device as device_domain -from infrastructure.db import open_db, ensure_schema -from infrastructure.options_store import set_option - -from infrastructure.unix import ( - ensure_unix_group, - ensure_unix_user, - ensure_user_in_group, - remove_user_from_group, - user_exists, -) - - - -# lo_toggle, WG, attach, network, exec stubs remain below. - - -def _require_root(action: str) -> bool: - try: - euid = os.geteuid() - except AttributeError: - return True - if euid != 0: - print(f"{action}: must be run as root", file=sys.stderr) - return False - return True - - -def _db_path() -> str: - return env.db_path() - - -def _open_existing_db() -> sqlite3.Connection | None: - path = _db_path() - if not os.path.exists(path): - print( - f"subu: database does not exist at '{path}'.\n" - f" Run 'db load schema' as root first.", - file=sys.stderr, - ) - return None - try: - conn = open_db(path) - except Exception as e: - print(f"subu: unable to open database '{path}': {e}", file=sys.stderr) - return None - - conn.row_factory = sqlite3.Row - return conn - - -def db_load_schema() -> int: - if not _require_root("db load schema"): - return 1 - - path = _db_path() - db_dir = os.path.dirname(path) or "." - - try: - os.makedirs(db_dir, mode=0o750, exist_ok=True) - except PermissionError as e: - print(f"subu: cannot create db directory '{db_dir}': {e}", file=sys.stderr) - return 1 - - try: - conn = open_db(path) - except Exception as e: - print(f"subu: unable to open database '{path}': {e}", file=sys.stderr) - return 1 - - try: - ensure_schema(conn) - finally: - conn.close() - - print(f"subu: schema loaded into {path}") - return 0 - - -def device_scan(base_dir: str ="/mnt") -> int: - """ - Handle: - - CLI.py device scan [--base-dir /mnt] - - Behavior: - * Open the subu SQLite database. - * Scan all directories under base_dir that contain 'user_data'. - * For each such device: - - Upsert a row in 'device'. - - Reconcile all subu under user_data into 'subu', marking - them as online and associating them with the device. - - Mark any previously-known subu on that device that are not - seen in this scan as offline. - - This function does NOT perform any cryptsetup, mount, or bindfs work. - It assumes devices are already mounted at /mnt/. - """ - try: - conn = open_db() - except Exception as e: - print( - f"subu: cannot open database at '{env.db_path()}': {e}", - file =sys.stderr, - ) - return 1 - - try: - count = device_domain.scan_and_reconcile(conn, base_dir) - if count == 0: - print(f"no user_data devices found under {base_dir}") - else: - print(f"scanned {count} device(s) under {base_dir}") - return 0 - finally: - conn.close() - - -def _insert_subu_row(conn, owner: str, subu_path: list[str], username: str) -> int | None: - """Insert a row into subu table and return its id.""" - leaf_name = subu_path[-1] - full_unix_name = username - path_str = " ".join([owner] + subu_path) - netns_name = full_unix_name - - from datetime import datetime, timezone - - now = datetime.now(timezone.utc).isoformat() - - try: - cur = conn.execute( - """INSERT INTO subu - (owner, name, full_unix_name, path, netns_name, wg_id, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, NULL, ?, ?)""", - (owner, leaf_name, full_unix_name, path_str, netns_name, now, now), - ) - conn.commit() - return cur.lastrowid - except sqlite3.IntegrityError as e: - print( - f"subu: database already has an entry for '{full_unix_name}': {e}", - file=sys.stderr, - ) - return None - except Exception as e: - print(f"subu: error recording subu in database: {e}", file=sys.stderr) - return None - - -def _maybe_add_to_incommon(conn, owner: str, new_username: str) -> None: - """If owner has an incommon subu configured, add new_username to that group.""" - key = f"incommon.{owner}" - spec = get_option(key, None) - if not spec: - return - if not isinstance(spec, str) or not spec.startswith("subu_"): - print( - f"subu: warning: option {key} has unexpected value '{spec}', " - "expected 'subu_'", - file=sys.stderr, - ) - return - try: - subu_numeric_id = int(spec.split("_", 1)[1]) - except ValueError: - print( - f"subu: warning: option {key} has invalid Subu_ID '{spec}'", - file=sys.stderr, - ) - return - - row = conn.execute( - "SELECT full_unix_name FROM subu WHERE id = ? AND owner = ?", - (subu_numeric_id, owner), - ).fetchone() - if row is None: - print( - f"subu: warning: option {key} refers to missing subu id {subu_numeric_id}", - file=sys.stderr, - ) - return - - incommon_unix = row["full_unix_name"] - ensure_user_in_group(new_username, incommon_unix) - - -def subu_make(path_tokens: list[str]) -> int: - if not path_tokens or len(path_tokens) < 2: - print( - "subu: make requires at least and one component", - file=sys.stderr, - ) - return 2 - - if not _require_root("subu make"): - return 1 - - masu = path_tokens[0] - subu_path = path_tokens[1:] - - try: - username = subu_domain.make_subu(masu, subu_path) - except SystemExit as e: - print(f"subu: {e}", file=sys.stderr) - return 2 - except Exception as e: - print(f"subu: error creating Unix user for {path_tokens}: {e}", file=sys.stderr) - return 1 - - conn = _open_existing_db() - if conn is None: - return 1 - - subu_id = _insert_subu_row(conn, masu, subu_path, username) - if subu_id is None: - conn.close() - return 1 - - # If this owner has an incommon subu, join that group. - _maybe_add_to_incommon(conn, masu, username) - - conn.close() - print(f"subu_{subu_id}") - return 0 - - -def subu_capture(path_tokens: list[str]) -> int: - """Handle: subu capture [ ...] - - Capture an existing Unix user into the database and fix its groups. - """ - if not path_tokens or len(path_tokens) < 2: - print( - "subu: capture requires at least and one component", - file=sys.stderr, - ) - return 2 - - if not _require_root("subu capture"): - return 1 - - masu = path_tokens[0] - subu_path = path_tokens[1:] - - # Compute expected Unix username. - try: - username = subu_domain.subu_username(masu, subu_path) - except SystemExit as e: - print(f"subu: {e}", file=sys.stderr) - return 2 - - if not user_exists(username): - print(f"subu: capture: Unix user '{username}' does not exist", file=sys.stderr) - return 1 - - # Ensure the primary group exists (legacy systems should already have it). - ensure_unix_group(username) - - # Ensure membership in ancestor groups for traversal. - ancestor_groups = subu_domain._ancestor_group_names(masu, subu_path) - for gname in ancestor_groups: - ensure_user_in_group(username, gname) - - conn = _open_existing_db() - if conn is None: - return 1 - - subu_id = _insert_subu_row(conn, masu, subu_path, username) - if subu_id is None: - conn.close() - return 1 - - # Honor any incommon config for this owner. - _maybe_add_to_incommon(conn, masu, username) - - conn.close() - print(f"subu_{subu_id}") - return 0 - - -def _resolve_subu(conn: sqlite3.Connection, target: str, rest: list[str]) -> sqlite3.Row | None: - """Resolve a subu either by ID (subu_7) or by path.""" - if target.startswith("subu_") and not rest: - try: - subu_numeric_id = int(target.split("_", 1)[1]) - except ValueError: - print(f"subu: invalid Subu_ID '{target}'", file=sys.stderr) - return None - - row = conn.execute("SELECT * FROM subu WHERE id = ?", (subu_numeric_id,)).fetchone() - if row is None: - print(f"subu: no such subu with id {subu_numeric_id}", file=sys.stderr) - return row - - path_tokens = [target] + list(rest) - if len(path_tokens) < 2: - print( - "subu: path form requires at least and one component", - file=sys.stderr, - ) - return None - - owner = path_tokens[0] - path_str = " ".join(path_tokens) - - row = conn.execute( - "SELECT * FROM subu WHERE owner = ? AND path = ?", - (owner, path_str), - ).fetchone() - - if row is None: - print(f"subu: no such subu with owner='{owner}' and path='{path_str}'", file=sys.stderr) - return row - - -def subu_list() -> int: - conn = _open_existing_db() - if conn is None: - return 1 - - cur = conn.execute( - "SELECT id, owner, path, full_unix_name, netns_name, wg_id FROM subu ORDER BY id" - ) - rows = cur.fetchall() - conn.close() - - if not rows: - print("(no subu in database)") - return 0 - - for row in rows: - subu_id = row[0] - owner = row[1] - path = row[2] - full_unix_name = row[3] - netns_name = row[4] - wg_id = row[5] - wg_display = "-" if wg_id is None else f"WG_{wg_id}" - print(f"subu_{subu_id}\t{owner}\t{path}\t{full_unix_name}\t{netns_name}\t{wg_display}") - - return 0 - - -def subu_info(target: str, rest: list[str]) -> int: - conn = _open_existing_db() - if conn is None: - return 1 - - row = _resolve_subu(conn, target, rest) - if row is None: - conn.close() - return 1 - - subu_id = row["id"] - owner = row["owner"] - name = row["name"] - full_unix_name = row["full_unix_name"] - path = row["path"] - netns_name = row["netns_name"] - wg_id = row["wg_id"] - created_at = row["created_at"] - updated_at = row["updated_at"] - - conn.close() - - print(f"Subu_ID: subu_{subu_id}") - print(f"Owner: {owner}") - print(f"Name: {name}") - print(f"Path: {path}") - print(f"Unix user: {full_unix_name}") - print(f"Netns: {netns_name}") - print(f"WG_ID: {wg_id if wg_id is not None else '-'}") - print(f"Created: {created_at}") - print(f"Updated: {updated_at}") - return 0 - - -def subu_remove(target: str, rest: list[str]) -> int: - if not _require_root("subu remove"): - return 1 - - conn = _open_existing_db() - if conn is None: - return 1 - - row = _resolve_subu(conn, target, rest) - if row is None: - conn.close() - return 1 - - subu_id = row["id"] - path_str = row["path"] - path_tokens = path_str.split(" ") - if len(path_tokens) < 2: - print(f"subu: stored path is invalid for id {subu_id}: '{path_str}'", file=sys.stderr) - conn.close() - return 1 - - masu = path_tokens[0] - subu_path = path_tokens[1:] - - try: - username = subu_domain.remove_subu(masu, subu_path) - except SystemExit as e: - print(f"subu: {e}", file=sys.stderr) - conn.close() - return 2 - except Exception as e: - print(f"subu: error removing Unix user for id subu_{subu_id}: {e}", file=sys.stderr) - conn.close() - return 1 - - try: - conn.execute("DELETE FROM subu WHERE id = ?", (subu_id,)) - conn.commit() - except Exception as e: - print(f"subu: error removing database row for id subu_{subu_id}: {e}", file=sys.stderr) - conn.close() - return 1 - - conn.close() - print(f"removed subu_{subu_id} {username}") - return 0 - - -def _subu_home_path(owner: str, path_str: str) -> str: - """Compute subu home dir from owner and path string.""" - tokens = path_str.split(" ") - if not tokens or tokens[0] != owner: - return "" - subu_tokens = tokens[1:] - path = os.path.join("/home", owner) - for t in subu_tokens: - path = os.path.join(path, "subu_data", t) - return path - - -def _chmod_incommon(home: str) -> None: - try: - st = os.stat(home) - except FileNotFoundError: - print(f"subu: warning: incommon home '{home}' does not exist", file=sys.stderr) - return - - mode = st.st_mode - mode |= (stat.S_IRGRP | stat.S_IXGRP) - mode &= ~(stat.S_IROTH | stat.S_IWOTH | stat.S_IXOTH) - os.chmod(home, mode) - - -def _chmod_private(home: str) -> None: - try: - st = os.stat(home) - except FileNotFoundError: - print(f"subu: warning: home '{home}' does not exist for clear incommon", file=sys.stderr) - return - - mode = st.st_mode - mode &= ~(stat.S_IRGRP | stat.S_IWGRP | stat.S_IXGRP) - os.chmod(home, mode) - - -def subu_option_incommon(action: str, target: str, rest: list[str]) -> int: - """Handle: - - subu option set incommon | [ ...] - subu option clear incommon | [ ...] - """ - if not _require_root(f"subu option {action} incommon"): - return 1 - - conn = _open_existing_db() - if conn is None: - return 1 - - row = _resolve_subu(conn, target, rest) - if row is None: - conn.close() - return 1 - - subu_id = row["id"] - owner = row["owner"] - full_unix_name = row["full_unix_name"] - path_str = row["path"] - - key = f"incommon.{owner}" - spec = f"subu_{subu_id}" - - if action == "set": - # Record mapping. - set_option(key, spec) - - # Make all subu of this owner members of this group. - cur = conn.execute( - "SELECT full_unix_name FROM subu WHERE owner = ?", - (owner,), - ) - rows = cur.fetchall() - for r in rows: - uname = r["full_unix_name"] - if uname == full_unix_name: - continue - ensure_user_in_group(uname, full_unix_name) - - # Adjust directory permissions on incommon home. - home = _subu_home_path(owner, path_str) - if home: - _chmod_incommon(home) - - conn.close() - print(f"incommon for {owner} set to subu_{subu_id}") - return 0 - - # clear - current = get_option(key, "") - if current and current != spec: - print( - f"subu: incommon for owner '{owner}' is currently {current}, not {spec}", - file=sys.stderr, - ) - conn.close() - return 1 - - # Clear mapping. - set_option(key, "") - - # Remove other subu from this group. - cur = conn.execute( - "SELECT full_unix_name FROM subu WHERE owner = ?", - (owner,), - ) - rows = cur.fetchall() - for r in rows: - uname = r["full_unix_name"] - if uname == full_unix_name: - continue - remove_user_from_group(uname, full_unix_name) - - home = _subu_home_path(owner, path_str) - if home: - _chmod_private(home) - - conn.close() - print(f"incommon for {owner} cleared from subu_{subu_id}") - return 0 - - -# --- existing stubs (unchanged) ------------------------------------------- - -def wg_global(arg1: str | None) -> int: - print("WG global: not yet implemented", file=sys.stderr) - return 1 - - -def wg_make(arg1: str | None) -> int: - print("WG make: not yet implemented", file=sys.stderr) - return 1 - - -def wg_server_public_key(arg1: str | None, arg2: str | None) -> int: - print("WG server_provided_public_key: not yet implemented", file=sys.stderr) - return 1 - - -def wg_info(arg1: str | None) -> int: - print("WG info: not yet implemented", file=sys.stderr) - return 1 - - -def wg_up(arg1: str | None) -> int: - print("WG up: not yet implemented", file=sys.stderr) - return 1 - - -def wg_down(arg1: str | None) -> int: - print("WG down: not yet implemented", file=sys.stderr) - return 1 - - -def attach_wg(subu_id: str, wg_id: str) -> int: - print("attach WG: not yet implemented", file=sys.stderr) - return 1 - - -def detach_wg(subu_id: str) -> int: - print("detach WG: not yet implemented", file=sys.stderr) - return 1 - - -def network_toggle(subu_id: str, state: str) -> int: - print("network up/down: not yet implemented", file=sys.stderr) - return 1 - - -def lo_toggle(subu_id: str, state: str) -> int: - print("lo up/down: not yet implemented", file=sys.stderr) - return 1 - - -def exec(subu_id: str, cmd_argv: list[str]) -> int: - print("exec: not yet implemented", file=sys.stderr) - return 1 diff --git a/developer/manager/infrastructure/device.py b/developer/manager/infrastructure/device.py deleted file mode 100644 index 5556098..0000000 --- a/developer/manager/infrastructure/device.py +++ /dev/null @@ -1,308 +0,0 @@ -# domain/device.py -# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*- - -""" -Device-aware reconciliation of subu state. - -This module assumes: - * Devices with user data are mounted as: /mnt/ - * On each device, user data lives under: /mnt//user_data/ - * Subu home directories follow the pattern: - - /mnt//user_data//subu_data//subu_data//... - - i.e., each subu directory may contain a 'subu_data' directory for children. - -Given an open SQLite connection, scan_and_reconcile() will: - - * Discover all devices under a base directory (default: /mnt) - * For each device that has 'user_data': - - Upsert a row in the 'device' table. - - Discover all subu paths for all masus on that device. - - Upsert/refresh rows in 'subu' with device_id + is_online=1. - - Mark any previously-known subu on that device that are not seen - in the current scan as is_online=0. -""" - -import os -from datetime import datetime -from pathlib import Path - -from domain.subu import subu_username - - -def _utc_now() -> str: - """ - Return a UTC timestamp string suitable for created_at/updated_at/last_seen. - Example: '2025-11-11T05:30:12Z' - """ - return datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ") - - -def _walk_subu_paths(subu_root: Path): - """ - Yield all subu paths under a root 'subu_data' directory. - - Layout assumption: - - subu_root/ - S0/ - ...files... - subu_data/ - S1/ - ... - subu_data/ - S2/ - ... - - For each logical path: - ['S0'] (top-level) - ['S0','S1'] (child) - ['S0','S1','S2'] (grand-child) - ... - - we yield the list of path components. - """ - stack: list[tuple[Path, list[str]]] = [(subu_root, [])] - - while stack: - current_root, prefix = stack.pop() - try: - entries = sorted(current_root.iterdir(), key =lambda p: p.name) - except FileNotFoundError: - continue - - for entry in entries: - if not entry.is_dir(): - continue - name = entry.name - path_components = prefix + [name] - yield path_components - - child_subu_data = entry / "subu_data" - if child_subu_data.is_dir(): - stack.append((child_subu_data, path_components)) - - -def _upsert_device( - conn, - mapname: str, - mount_point: str, - kind: str ="external", -) -> int: - """ - Ensure a row exists for this device and return its id. - - We do NOT try to discover fs_uuid/luks_uuid here; those can be filled - in later if desired. - """ - now = _utc_now() - - cur = conn.execute( - "SELECT id FROM device WHERE mapname = ?", - (mapname,), - ) - row = cur.fetchone() - - if row: - device_id = row["id"] - conn.execute( - """ - UPDATE device - SET mount_point = ?, - kind = ?, - state = 'online', - last_seen = ? - WHERE id = ? - """, - (mount_point, kind, now, device_id), - ) - else: - cur = conn.execute( - """ - INSERT INTO device (mapname, mount_point, kind, state, last_seen) - VALUES (?, ?, ?, 'online', ?) - """, - (mapname, mount_point, kind, now), - ) - device_id = cur.lastrowid - - return int(device_id) - - -def _ensure_subu_row( - conn, - device_id: int, - owner: str, - subu_path_components: list[str], - full_path_str: str, - now: str, -): - """ - Upsert a row in 'subu' for (owner, subu_path_components) on device_id. - - full_path_str is the human-readable path, e.g. 'Thomas local' or - 'Thomas developer bolt'. - """ - if not subu_path_components: - return - - leaf_name = subu_path_components[-1] - full_unix_name = subu_username(owner, subu_path_components) - - # For now, we simply reuse full_unix_name as netns_name. - netns_name = full_unix_name - - # See if a row already exists for this owner + path. - cur = conn.execute( - "SELECT id FROM subu WHERE owner = ? AND path = ?", - (owner, full_path_str), - ) - row = cur.fetchone() - - if row: - subu_id = row["id"] - conn.execute( - """ - UPDATE subu - SET device_id = ?, - is_online = 1, - updated_at = ? - WHERE id = ? - """, - (device_id, now, subu_id), - ) - return - - # Insert new row - conn.execute( - """ - INSERT INTO subu ( - owner, - name, - full_unix_name, - path, - netns_name, - wg_id, - device_id, - is_online, - created_at, - updated_at - ) - VALUES (?, ?, ?, ?, ?, NULL, ?, 1, ?, ?) - """, - ( - owner, - leaf_name, - full_unix_name, - full_path_str, - netns_name, - device_id, - now, - now, - ), - ) - - -def _reconcile_device_for_mount(conn, device_id: int, user_data_dir: Path): - """ - Reconcile all subu on a particular device. - - user_data_dir is a path like: - - /mnt/Eagle/user_data - - Under which we expect: - - /mnt/Eagle/user_data//subu_data/... - """ - now = _utc_now() - discovered: set[tuple[str, str]] = set() - - try: - owners = sorted(user_data_dir.iterdir(), key =lambda p: p.name) - except FileNotFoundError: - return - - for owner_entry in owners: - if not owner_entry.is_dir(): - continue - - owner = owner_entry.name - subu_root = owner_entry / "subu_data" - if not subu_root.is_dir(): - # masu with no subu_data; skip - continue - - for subu_components in _walk_subu_paths(subu_root): - # Full logical path is: [owner] + subu_components - path_tokens = [owner] + subu_components - path_str = " ".join(path_tokens) - discovered.add((owner, path_str)) - - _ensure_subu_row( - conn =conn, - device_id =device_id, - owner =owner, - subu_path_components =subu_components, - full_path_str =path_str, - now =now, - ) - - # Mark any existing subu on this device that we did NOT see as offline. - cur = conn.execute( - "SELECT id, owner, path FROM subu WHERE device_id = ?", - (device_id,), - ) - existing = cur.fetchall() - for row in existing: - key = (row["owner"], row["path"]) - if key in discovered: - continue - conn.execute( - """ - UPDATE subu - SET is_online = 0, - updated_at = ? - WHERE id = ? - """, - (now, row["id"]), - ) - - -def scan_and_reconcile(conn, base_dir: str ="/mnt") -> int: - """ - Scan all mounted devices under base_dir for 'user_data' trees and - reconcile them into the database. - - For each directory 'base_dir/': - - * If it contains 'user_data', it is treated as a device. - * A 'device' row is upserted (mapname = basename). - * All subu under the corresponding user_data tree are reconciled. - - Returns: - Number of devices that were processed. - """ - root = Path(base_dir) - if not root.is_dir(): - return 0 - - processed = 0 - - for entry in sorted(root.iterdir(), key =lambda p: p.name): - if not entry.is_dir(): - continue - - mapname = entry.name - user_data_dir = entry / "user_data" - if not user_data_dir.is_dir(): - continue - - mount_point = str(entry) - device_id = _upsert_device(conn, mapname, mount_point) - _reconcile_device_for_mount(conn, device_id, user_data_dir) - processed += 1 - - conn.commit() - return processed diff --git a/developer/manager/infrastructure/unix.py b/developer/manager/infrastructure/unix.py deleted file mode 100644 index 71cb93c..0000000 --- a/developer/manager/infrastructure/unix.py +++ /dev/null @@ -1,106 +0,0 @@ -# infrastructure/unix.py -# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*- - -import subprocess, pwd, grp - - -def run(cmd, check =True): - """ - Run a Unix command, capturing output. - - Raises RuntimeError if check is True and the command fails. - """ - r = subprocess.run( - cmd, - stdout =subprocess.PIPE, - stderr =subprocess.PIPE, - text =True, - ) - if check and r.returncode != 0: - raise RuntimeError(f"cmd failed: {' '.join(cmd)}\n{r.stderr}") - return r - - -def group_exists(name: str) -> bool: - try: - grp.getgrnam(name) - return True - except KeyError: - return False - - -def user_exists(name: str) -> bool: - try: - pwd.getpwnam(name) - return True - except KeyError: - return False - - -def ensure_unix_group(name: str): - """ - Ensure a Unix group with this name exists. - """ - if not group_exists(name): - run(["groupadd", name]) - - -def ensure_unix_user(name: str, primary_group: str): - """ - Ensure a Unix user with this name exists and has the given primary group. - - The primary group is made if needed. - """ - ensure_unix_group(primary_group) - if not user_exists(name): - run(["useradd", "-m", "-g", primary_group, "-s", "/bin/bash", name]) - - -def ensure_user_in_group(user: str, group: str): - """ - Ensure 'user' is a member of supplementary group 'group'. - - No-op if already present. - """ - if not user_exists(user): - raise RuntimeError(f"ensure_user_in_group: user '{user}' does not exist") - if not group_exists(group): - raise RuntimeError(f"ensure_user_in_group: group '{group}' does not exist") - - g = grp.getgrnam(group) - if user in g.gr_mem: - return - - run(["usermod", "-a", "-G", group, user]) - - -def remove_user_from_group(user: str, group: str): - """ - Ensure 'user' is NOT a member of supplementary group 'group'. - - No-op if user or group is missing, or if user is not a member. - """ - if not user_exists(user): - return - if not group_exists(group): - return - - g = grp.getgrnam(group) - if user not in g.gr_mem: - return - - # gpasswd -d user group is the standard way on Debian/Ubuntu. - # We treat failures as non-fatal. - run(["gpasswd", "-d", user, group], check =False) - - -def remove_unix_user_and_group(name: str): - """ - Remove a Unix user and group that match this name, if they exist. - - The user is removed first, then the group. - """ - if user_exists(name): - run(["userdel", name]) - if group_exists(name): - run(["groupdel", name]) diff --git a/developer/manager/install b/developer/manager/install new file mode 100644 index 0000000..b6be1cf --- /dev/null +++ b/developer/manager/install @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*- +""" +install/subu_install.py — one-shot installer for subu boot wiring + +What it does: + 1) Copies usr_local_bin/boot_attach -> /usr/local/bin/subu-boot-attach (0755) + 2) Installs systemd units: + systemd/boot_attach.service -> /etc/systemd/system/subu-boot-attach.service + systemd/subu_resume.service -> /etc/systemd/system/subu-resume.service + and enables them (boot + resume). + 3) Ensures SQLite schema exists by importing env/db and executing schema.sql. + (No CLI parsing; direct function calls.) +""" + +import os, sys, shutil, stat, subprocess, pathlib, sqlite3 +from pathlib import Path + +# --- utils ------------------------------------------------------------------- + +def must_root(): + if os.geteuid() != 0: + print("error: must run as root (sudo)", file=sys.stderr) + sys.exit(1) + +def run(*args): + return subprocess.run(args, check=False) + +def die(msg: str): + print(f"error: {msg}", file=sys.stderr); sys.exit(1) + +def install_file(src: Path, dst: Path, mode: int): + dst.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(src, dst) + os.chmod(dst, mode) + +def install_text(src: Path, dst: Path, mode: int, substitutions: dict[str,str]|None=None): + dst.parent.mkdir(parents=True, exist_ok=True) + text = src.read_text(encoding="utf-8") + if substitutions: + for k,v in substitutions.items(): + text = text.replace(k, v) + dst.write_text(text, encoding="utf-8") + os.chmod(dst, mode) + +# --- schema loader (direct, no CLI) ------------------------------------------ + +def ensure_schema(repo_root: Path): + # import from the repo directly + cli_dir = repo_root / "CLI" + infra_dir = cli_dir / "infrastructure" + schema_sql = infra_dir / "schema.sql" + + if not schema_sql.is_file(): + die(f"schema.sql not found at {schema_sql}") + + # make repo modules importable + sys.path.insert(0, str(cli_dir)) + + try: + import env # CLI/env.py + from infrastructure import db as dbmod # CLI/infrastructure/db.py + except Exception as e: + die(f"failed to import env/db from {cli_dir}: {e}") + + db_path = Path(env.db_path()) + db_dir = db_path.parent + db_dir.mkdir(parents=True, exist_ok=True) + # lock down dir for root + os.chmod(db_dir, 0o700) + + # open and apply schema idempotently + conn = dbmod.open_db() + try: + sql = schema_sql.read_text(encoding="utf-8") + conn.executescript(sql) + conn.commit() + finally: + conn.close() + print(f"✅ schema ensured at {db_path}") + +# --- main -------------------------------------------------------------------- + +def main(): + must_root() + repo_root = Path(__file__).resolve().parents[1] + + # 1) install helper + src_boot = repo_root / "usr_local_bin" / "boot_attach" + dst_boot = Path("/usr/local/bin/subu-boot-attach") + if not src_boot.is_file(): + die(f"missing helper: {src_boot}") + install_file(src_boot, dst_boot, 0o755) + print(f"✅ installed helper -> {dst_boot}") + + # 2) install systemd units (with light substitution if needed) + sysd_src_attach = repo_root / "systemd" / "boot_attach.service" + sysd_src_resume = repo_root / "systemd" / "subu_resume.service" + sysd_dst_attach = Path("/etc/systemd/system/subu-boot-attach.service") + sysd_dst_resume = Path("/etc/systemd/system/subu-resume.service") + + if not sysd_src_attach.is_file(): + die(f"missing unit: {sysd_src_attach}") + if not sysd_src_resume.is_file(): + die(f"missing unit: {sysd_src_resume}") + + # Allow service templates to reference {{BOOT_ATTACH}} if they want + subs = {"{{BOOT_ATTACH}}": str(dst_boot)} + install_text(sysd_src_attach, sysd_dst_attach, 0o644, substitutions=subs) + install_text(sysd_src_resume, sysd_dst_resume, 0o644, substitutions=subs) + print(f"✅ installed units -> {sysd_dst_attach}, {sysd_dst_resume}") + + # 3) ensure schema exists (direct call) + ensure_schema(repo_root) + + # 4) reload + enable + run("systemctl", "daemon-reload") + run("systemctl", "enable", "--now", "subu-boot-attach.service") + run("systemctl", "enable", "subu-resume.service") + + print("✅ systemd units enabled (boot attach now active)") + print("All set.\n" + "- On next boot (or now via: systemctl start subu-boot-attach.service),\n" + " homes under /mnt/*/user_data/ will be bound to /home/,\n" + " the DB will be reconciled, and subu bindfs mounts reopened.") + +if __name__ == "__main__": + main() diff --git a/developer/manager/systemd/boot_attach.service b/developer/manager/systemd/boot_attach.service new file mode 100644 index 0000000..5f9f7f5 --- /dev/null +++ b/developer/manager/systemd/boot_attach.service @@ -0,0 +1,12 @@ +[Unit] +Description=Attach /home/ from mounted devices and reconcile subu DB +After=local-fs.target network-online.target +Wants=local-fs.target + +[Service] +Type=oneshot +ExecStart=/usr/bin/env python3 /usr/local/lib/subu/boot_attach.py +RemainAfterExit=yes + +[Install] +WantedBy=multi-user.target diff --git a/developer/manager/systemd/subu_resume.service b/developer/manager/systemd/subu_resume.service new file mode 100644 index 0000000..434f39c --- /dev/null +++ b/developer/manager/systemd/subu_resume.service @@ -0,0 +1,11 @@ +[Unit] +Description=Re-attach subu homes after suspend/resume +After=suspend.target +Wants=suspend.target + +[Service] +Type=oneshot +ExecStart=/usr/bin/systemctl start subu-boot-attach.service + +[Install] +WantedBy=suspend.target diff --git a/developer/manager/uncatelogued/parser.py b/developer/manager/uncatelogued/parser.py deleted file mode 100644 index cf2dd84..0000000 --- a/developer/manager/uncatelogued/parser.py +++ /dev/null @@ -1,32 +0,0 @@ -verbs = [ - "usage", - "help", - "example", - "version", - "init", - "make", - "make", - "info", - "information", - "WG", - "attach", - "detach", - "network", - "lo", - "option", - "exec", -] - -p_make = subparsers.add_parser( - "make", - help="Create a Subu with hierarchical name + Unix user/groups + netns", -) -p_make.add_argument( - "path", - nargs="+", - help="Full Subu path, e.g. 'Thomas US' or 'Thomas new-subu Rabbit'", -) - -elif args.verb == "make": - subu_id = core.make_subu(args.path) - print(subu_id) diff --git a/developer/manager/usr_local_bin/boot_attach b/developer/manager/usr_local_bin/boot_attach new file mode 100644 index 0000000..f774bbb --- /dev/null +++ b/developer/manager/usr_local_bin/boot_attach @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*- +import os, subprocess, sys +from pathlib import Path + +BASE = Path("/mnt") +SM = os.environ.get("SUBU_CLI", "/usr/local/bin/sm") # your wrapper; fallback to sm +MAP_OWN_ALL = "/root/mount/masu__map_own_all.sh" # your existing script + +def sh(*args) -> int: + return subprocess.run(list(args), check=False).returncode + +def mounted(target: Path) -> bool: + try: + out = subprocess.check_output(["findmnt", "-T", str(target), "-n"], stderr=subprocess.DEVNULL) + return bool(out.strip()) + except subprocess.CalledProcessError: + return False + +def ensure_bind_home(mapname: str, masu: str) -> None: + src = BASE / mapname / "user_data" / masu + dst = Path("/home") / masu + dst.mkdir(parents=True, exist_ok=True) + if mounted(dst): + return + # transient automount bind via systemd + # (automount improves UX on resume; bind is idempotent) + subprocess.run([ + "systemd-mount", + "--quiet", + "--no-block", + "--type", "none", + "--automount", + "--options", "bind", + str(src), str(dst) + ], check=False) + +def main(): + # 1) attach each /mnt//user_data/ as /home/ + for mp in sorted(p for p in BASE.iterdir() if p.is_dir()): + user_data = mp / "user_data" + if not user_data.is_dir(): + continue + for masu_dir in sorted(p for p in user_data.iterdir() if p.is_dir()): + ensure_bind_home(mp.name, masu_dir.name) + + # 2) reconcile DB (safe if DB absent: service ordering should run after schema load) + sh(SM, "device", "scan") + + # 3) optional: reopen bindfs subu mounts for each mounted + if os.path.exists(MAP_OWN_ALL): + for home in sorted(Path("/home").iterdir()): + if not home.is_dir(): + continue + # heuristics: only run when this /home/ is a bind from /mnt/*/user_data/ + try: + src = subprocess.check_output(["findmnt", "-no", "SOURCE", "-T", str(home)], text=True).strip() + except subprocess.CalledProcessError: + continue + if "/user_data/" in src: + subprocess.run([MAP_OWN_ALL, home.name], check=False) + +if __name__ == "__main__": + sys.exit(main()) -- 2.20.1