From: Thomas Walker Lynch Date: Sat, 3 Jan 2026 11:29:02 +0000 (+0000) Subject: . X-Git-Url: https://git.reasoningtechnology.com/style/article_generic.js?a=commitdiff_plain;h=refs%2Fheads%2Fcore_developer_branch;p=subu . --- diff --git a/developer/clean_upload.zip b/developer/clean_upload.zip new file mode 100644 index 0000000..b1ab3ff Binary files /dev/null and b/developer/clean_upload.zip differ diff --git a/developer/login/login_to_subu.sh b/developer/login/login_to_subu.sh deleted file mode 100755 index ca28743..0000000 --- a/developer/login/login_to_subu.sh +++ /dev/null @@ -1,135 +0,0 @@ -#!/bin/bash -# launch_subu.sh — Start a subuser shell (console or GUI-aware, with systemd user session) - -set -euo pipefail -umask 0077 - -subu="$1" -if [ -z "$subu" ]; then - echo "❌ No subuser name supplied" - exit 1 -fi - -subu_user="Thomas-$subu" -if ! id "$subu_user" &>/dev/null; then - echo "❌ User $subu_user does not exist" - exit 1 -fi - -# Check required commands -error_flag=0 -for cmd in machinectl xauth xhost dbus-run-session; do - if ! command -v "$cmd" &>/dev/null; then - echo "❌ $cmd not found" - error_flag=1 - fi -done -if [ "$error_flag" -eq 1 ]; then - exit 1 -fi - -# don't use sudo -v, because it will echo the password into the emacs shell -sudo echo >& /dev/null - - -# Something broke when I turned this off. What was it. Will have to turn it off again and -# test. -# -# Enable lingering so user services can persist -sudo loginctl enable-linger "$subu_user" - -# Decide how to set the use_xauth and use_xhost flags. -# -# As of the time of this writing, on my machines, Wayland insists on -# xauth, while my X11 is refuses to use it, thus it needs xhost control. -# So this is how I determine how to set the flags here. -# - -# bash will evaluate this variables inside a quoted if even when the -# gate is falase, so everything needs to be initialized, whether used -# or not. -subu_Xauthority_path="" -use_xauth=0 -use_xhost=0 -if [[ -n "${WAYLAND_DISPLAY:-}" ]]; then - has_display=true - XDG_SESSION_TYPE="wayland" - subu_Xauthority_path="$HOME/subu/$subu/.Xauthority" - use_xauth=1 - use_xhost=0 - echo "🌀 Wayland session - Using xauth for access control" - -elif [[ -n "${DISPLAY:-}" ]]; then - has_display=true - XDG_SESSION_TYPE="x11" - use_xauth=0 - use_xhost=1 - echo "🧱 X11 session - Using xhost for access control" - -else - has_display=false - XDG_SESSION_TYPE="tty" - use_xauth=0 - use_xhost=0 - echo "🖳 Console session (no X detected)" -fi - -if [[ "$use_xhost" -eq 1 ]]; then - xhost +SI:localuser:"$subu_user" -fi -if [[ "$use_xauth" -eq 1 ]]; then - mkdir -p "$(dirname "$subu_Xauthority_path")" - touch "$subu_Xauthority_path" - xauth extract "$subu_Xauthority_path" "$DISPLAY" -fi - -if $has_display; then - - - sudo machinectl shell "$subu_user"@ /bin/bash -c " - - # --- session env from parent --- - export DISPLAY=\"${DISPLAY:-${WAYLAND_DISPLAY}}\"; - export XDG_RUNTIME_DIR='/run/user/$(id -u "$subu_user")'; - export XDG_SESSION_TYPE=\"$XDG_SESSION_TYPE\"; - export XDG_SESSION_CLASS=\"user\"; - export XDG_DATA_DIRS=\"/usr/share/gnome:/usr/local/share/:/usr/share/\"; - export USE_XAUTH=$use_xauth - - # Only set XAUTHORITY when we actually prepared it (Wayland/xauth case) - if [[ \"\$USE_XAUTH\" -eq 1 ]]; then - export XAUTHORITY=\"$subu_Xauthority_path\" - fi - - if command -v /usr/bin/gnome-keyring-daemon &>/dev/null; then - eval \$(/usr/bin/gnome-keyring-daemon --start) - export GNOME_KEYRING_CONTROL GNOME_KEYRING_PID - fi - - # WirePlumber: ignore logind (subuser isn't the active seat) - systemctl --user set-environment WIREPLUMBER_DISABLE_PLUGINS=logind - systemctl --user import-environment DISPLAY XAUTHORITY WAYLAND_DISPLAY XDG_RUNTIME_DIR XDG_SESSION_TYPE - - # Bring up audio (sockets first, then services) - systemctl --user enable --now pipewire.socket pipewire-pulse.socket >/dev/null 2>&1 || true - systemctl --user restart wireplumber pipewire pipewire-pulse - - exec dbus-run-session -- bash -l - " - -else - - # Console mode with DBus session (give it audio too) - sudo machinectl shell "$subu_user"@ /bin/bash -c " - export XDG_RUNTIME_DIR='/run/user/$(id -u "$subu_user")}'; - - systemctl --user set-environment WIREPLUMBER_DISABLE_PLUGINS=logind - systemctl --user import-environment XDG_RUNTIME_DIR - systemctl --user enable --now pipewire.socket pipewire-pulse.socket >/dev/null 2>&1 || true - systemctl --user restart wireplumber pipewire pipewire-pulse - - exec dbus-run-session -- bash -l - " -fi - - diff --git a/developer/login/subu_login b/developer/login/subu_login new file mode 100755 index 0000000..17d5290 --- /dev/null +++ b/developer/login/subu_login @@ -0,0 +1,135 @@ +#!/bin/bash +# launch_subu.sh — Start a subuser shell (console or GUI-aware, with systemd user session) + +set -euo pipefail +umask 0077 + +subu="$1" +if [ -z "$subu" ]; then + echo "❌ No subuser name supplied" + exit 1 +fi + +subu_user="Thomas_$subu" +if ! id "$subu_user" &>/dev/null; then + echo "❌ User $subu_user does not exist" + exit 1 +fi + +# Check required commands +error_flag=0 +for cmd in machinectl xauth xhost dbus-run-session; do + if ! command -v "$cmd" &>/dev/null; then + echo "❌ $cmd not found" + error_flag=1 + fi +done +if [ "$error_flag" -eq 1 ]; then + exit 1 +fi + +# don't use sudo -v, because it will echo the password into the emacs shell +sudo echo >& /dev/null + + +# Something broke when I turned this off. What was it. Will have to turn it off again and +# test. +# +# Enable lingering so user services can persist +sudo loginctl enable-linger "$subu_user" + +# Decide how to set the use_xauth and use_xhost flags. +# +# As of the time of this writing, on my machines, Wayland insists on +# xauth, while my X11 is refuses to use it, thus it needs xhost control. +# So this is how I determine how to set the flags here. +# + +# bash will evaluate this variables inside a quoted if even when the +# gate is falase, so everything needs to be initialized, whether used +# or not. +subu_Xauthority_path="" +use_xauth=0 +use_xhost=0 +if [[ -n "${WAYLAND_DISPLAY:-}" ]]; then + has_display=true + XDG_SESSION_TYPE="wayland" + subu_Xauthority_path="$HOME/subu/$subu/.Xauthority" + use_xauth=1 + use_xhost=0 + echo "🌀 Wayland session - Using xauth for access control" + +elif [[ -n "${DISPLAY:-}" ]]; then + has_display=true + XDG_SESSION_TYPE="x11" + use_xauth=0 + use_xhost=1 + echo "🧱 X11 session - Using xhost for access control" + +else + has_display=false + XDG_SESSION_TYPE="tty" + use_xauth=0 + use_xhost=0 + echo "🖳 Console session (no X detected)" +fi + +if [[ "$use_xhost" -eq 1 ]]; then + xhost +SI:localuser:"$subu_user" +fi +if [[ "$use_xauth" -eq 1 ]]; then + mkdir -p "$(dirname "$subu_Xauthority_path")" + touch "$subu_Xauthority_path" + xauth extract "$subu_Xauthority_path" "$DISPLAY" +fi + +if $has_display; then + + + sudo machinectl shell "$subu_user"@ /bin/bash -c " + + # --- session env from parent --- + export DISPLAY=\"${DISPLAY:-${WAYLAND_DISPLAY}}\"; + export XDG_RUNTIME_DIR='/run/user/$(id -u "$subu_user")'; + export XDG_SESSION_TYPE=\"$XDG_SESSION_TYPE\"; + export XDG_SESSION_CLASS=\"user\"; + export XDG_DATA_DIRS=\"/usr/share/gnome:/usr/local/share/:/usr/share/\"; + export USE_XAUTH=$use_xauth + + # Only set XAUTHORITY when we actually prepared it (Wayland/xauth case) + if [[ \"\$USE_XAUTH\" -eq 1 ]]; then + export XAUTHORITY=\"$subu_Xauthority_path\" + fi + + if command -v /usr/bin/gnome-keyring-daemon &>/dev/null; then + eval \$(/usr/bin/gnome-keyring-daemon --start) + export GNOME_KEYRING_CONTROL GNOME_KEYRING_PID + fi + + # WirePlumber: ignore logind (subuser isn't the active seat) + systemctl --user set-environment WIREPLUMBER_DISABLE_PLUGINS=logind + systemctl --user import-environment DISPLAY XAUTHORITY WAYLAND_DISPLAY XDG_RUNTIME_DIR XDG_SESSION_TYPE + + # Bring up audio (sockets first, then services) + systemctl --user enable --now pipewire.socket pipewire-pulse.socket >/dev/null 2>&1 || true + systemctl --user restart wireplumber pipewire pipewire-pulse + + exec dbus-run-session -- bash -l + " + +else + + # Console mode with DBus session (give it audio too) + sudo machinectl shell "$subu_user"@ /bin/bash -c " + export XDG_RUNTIME_DIR='/run/user/$(id -u "$subu_user")}'; + + systemctl --user set-environment WIREPLUMBER_DISABLE_PLUGINS=logind + systemctl --user import-environment XDG_RUNTIME_DIR + systemctl --user enable --now pipewire.socket pipewire-pulse.socket >/dev/null 2>&1 || true + systemctl --user restart wireplumber pipewire pipewire-pulse + + exec dbus-run-session -- bash -l + " +fi + + diff --git a/developer/manager/CLI.py b/developer/manager/CLI.py deleted file mode 100755 index b4c00db..0000000 --- a/developer/manager/CLI.py +++ /dev/null @@ -1,353 +0,0 @@ -#!/usr/bin/env python3 -# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*- -"""CLI.py — subu manager front-end. - -Role: parse argv, choose command, call dispatch. - -CLI should not do any work beyond: - - * figure out program_name (for example, manager/CLI.py or wrapper name) - * call the right function in dispatch - * print text from text.py when needed - * exit with the returned status code -""" - -import os, sys, argparse -from text import make_text -import dispatch - - -def register_device_commands(subparsers): - """ - Register device-related commands: - - device scan [--base-dir DIR] - - 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)", - ) - - -def register_db_commands(subparsers): - """Register DB-related commands under 'db'. - - db load schema - """ - ap_db = subparsers.add_parser("db") - db_sub = ap_db.add_subparsers(dest="db_verb") - - ap = db_sub.add_parser("load") - ap.add_argument("what", choices=["schema"]) - - -def register_subu_commands(subparsers): - """Register subu related commands under 'subu': - - subu make []* - subu capture []* - subu remove | []* - subu list - subu info | []* - subu option set|clear incommon | []* - """ - ap_subu = subparsers.add_parser("subu") - subu_sub = ap_subu.add_subparsers(dest="subu_verb") - - # make: path[0] is masu, remaining elements are the subu chain - ap = subu_sub.add_parser("make") - ap.add_argument("path", nargs="+") - - # capture: path[0] is masu, remaining elements are the subu chain - ap = subu_sub.add_parser("capture") - ap.add_argument("path", nargs="+") - - # remove: either ID or path - ap = subu_sub.add_parser("remove") - ap.add_argument("target") - ap.add_argument("rest", nargs="*") - - # list - subu_sub.add_parser("list") - - # info - ap = subu_sub.add_parser("info") - ap.add_argument("target") - ap.add_argument("rest", nargs="*") - - # option incommon - ap = subu_sub.add_parser("option") - ap.add_argument("opt_action", choices=["set", "clear"]) - ap.add_argument("opt_name", choices=["incommon"]) - ap.add_argument("target") - ap.add_argument("rest", nargs="*") - - -def register_wireguard_commands(subparsers): - """Register WireGuard related commands, grouped under 'WG'.""" - ap = subparsers.add_parser("WG") - ap.add_argument( - "wg_verb", - choices=[ - "global", - "make", - "server_provided_public_key", - "info", - "information", - "up", - "down", - ], - ) - ap.add_argument("arg1", nargs="?") - ap.add_argument("arg2", nargs="?") - - -def register_attach_commands(subparsers): - """Register attach and detach commands: - - attach WG - detach WG - """ - ap = subparsers.add_parser("attach") - ap.add_argument("what", choices=["WG"]) - ap.add_argument("subu_id") - ap.add_argument("wg_id") - - ap = subparsers.add_parser("detach") - ap.add_argument("what", choices=["WG"]) - ap.add_argument("subu_id") - - -def register_network_commands(subparsers): - """Register network aggregate commands: - - network up|down - """ - ap = subparsers.add_parser("network") - ap.add_argument("state", choices=["up", "down"]) - ap.add_argument("subu_id") - - -def register_option_commands(subparsers): - """Register global option commands (non-subu-specific for now): - - option set|get|list ... - """ - ap = subparsers.add_parser("option") - ap.add_argument("action", choices=["set", "get", "list"]) - ap.add_argument("subu_id") - ap.add_argument("name", nargs="?") - ap.add_argument("value", nargs="?") - - -def register_exec_commands(subparsers): - """Register exec command: - - exec -- ... - """ - ap = subparsers.add_parser("exec") - ap.add_argument("subu_id") - ap.add_argument("--", dest="cmd", nargs=argparse.REMAINDER, default=[]) - - -def register_lo_commands(subparsers): - """Register lo command: - - lo up|down - """ - ap = subparsers.add_parser("lo") - ap.add_argument("state", choices=["up", "down"]) - ap.add_argument("subu_id") - - -def build_arg_parser(program_name: str) -> argparse.ArgumentParser: - """Build the top level argument parser for the subu manager.""" - parser = argparse.ArgumentParser(prog=program_name, add_help=False) - parser.add_argument("-V", "--Version", action="store_true", help="print version") - - subparsers = parser.add_subparsers(dest="verb") - - register_db_commands(subparsers) - register_subu_commands(subparsers) - register_wireguard_commands(subparsers) - register_attach_commands(subparsers) - register_network_commands(subparsers) - register_option_commands(subparsers) - register_exec_commands(subparsers) - register_device_commands(subparsers) - register_lo_commands(subparsers) - - return parser - - -def _collect_parse_errors(ns, program_name: str) -> list[str]: - """Check for semantic argument problems and collect error strings.""" - errors: list[str] = [] - - if ns.verb == "device": - if ns.action == "scan": - return dispatch.device_scan(ns.base_dir) - - if ns.verb == "subu": - sv = getattr(ns, "subu_verb", None) - if sv in ("make", "capture"): - if not ns.path or len(ns.path) < 2: - errors.append( - f"subu {sv} requires at least and one component" - ) - elif sv in ("remove", "info"): - if ns.target.startswith("subu_"): - if ns.rest: - errors.append( - f"{program_name} subu {sv} with an ID form must not have extra path tokens" - ) - else: - if len([ns.target] + list(ns.rest)) < 2: - errors.append( - f"{program_name} subu {sv} [ ...] requires at least two tokens" - ) - elif sv == "option": - # For incommon, same ID vs path rules as info/remove. - if ns.opt_name == "incommon": - if ns.target.startswith("subu_"): - if ns.rest: - errors.append( - f"{program_name} subu option {ns.opt_action} incommon with an ID form " - "must not have extra path tokens" - ) - else: - if len([ns.target] + list(ns.rest)) < 2: - errors.append( - f"{program_name} subu option {ns.opt_action} incommon " - " [ ...] requires at least two tokens" - ) - - return errors - - -def CLI(argv=None) -> int: - """Top level entry point for the subu manager CLI.""" - if argv is None: - argv = sys.argv[1:] - - prog_override = os.environ.get("SUBU_PROGNAME") - if prog_override: - program_name = prog_override - else: - raw0 = sys.argv[0] or "subu" - program_name = os.path.basename(raw0) or "subu" - - text = make_text(program_name) - - # No arguments is the same as "usage". - if not argv: - print(text.usage(), end="") - return 0 - - simple = { - "help": text.help, - "--help": text.help, - "-h": text.help, - "usage": text.usage, - "example": text.example, - "version": text.version, - } - if argv[0] in simple: - print(simple[argv[0]](), end="") - return 0 - - parser = build_arg_parser(program_name) - ns = parser.parse_args(argv) - - if getattr(ns, "Version", False): - print(text.version(), end="") - return 0 - - errors = _collect_parse_errors(ns, program_name) - if errors: - for msg in errors: - print(f"error: {msg}", file=sys.stderr) - return 2 - - try: - if ns.verb == "db": - if ns.db_verb == "load" and ns.what == "schema": - return dispatch.db_load_schema() - - if ns.verb == "subu": - 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": - return dispatch.subu_info(ns.target, ns.rest) - if sv == "remove": - return dispatch.subu_remove(ns.target, ns.rest) - if sv == "option": - # For now only 'incommon' is supported. - return dispatch.subu_option_incommon(ns.opt_action, ns.target, ns.rest) - - if ns.verb == "lo": - return dispatch.lo_toggle(ns.subu_id, ns.state) - - if ns.verb == "WG": - v = ns.wg_verb - if v in ("info", "information") and ns.arg1 is None: - print("WG info requires WG_ID", file=sys.stderr) - return 2 - if v == "global": - return dispatch.wg_global(ns.arg1) - if v == "make": - return dispatch.wg_make(ns.arg1) - if v == "server_provided_public_key": - return dispatch.wg_server_public_key(ns.arg1, ns.arg2) - if v in ("info", "information"): - return dispatch.wg_info(ns.arg1) - if v == "up": - return dispatch.wg_up(ns.arg1) - if v == "down": - return dispatch.wg_down(ns.arg1) - - if ns.verb == "attach": - if ns.what == "WG": - return dispatch.attach_wg(ns.subu_id, ns.wg_id) - - if ns.verb == "detach": - if ns.what == "WG": - return dispatch.detach_wg(ns.subu_id) - - if ns.verb == "network": - return dispatch.network_toggle(ns.subu_id, ns.state) - - if ns.verb == "option": - # global options still placeholder - print("option: not yet implemented", file=sys.stderr) - return 1 - - if ns.verb == "exec": - if not ns.cmd: - print(f"{program_name} exec -- ...", file=sys.stderr) - return 2 - return dispatch.exec(ns.subu_id, ns.cmd) - - print(text.usage(), end="") - return 2 - - except Exception as e: - print(f"error: {e}", file=sys.stderr) - return 1 - - -if __name__ == "__main__": - sys.exit(CLI()) diff --git a/developer/manager/CLI/CLI.py b/developer/manager/CLI/CLI.py new file mode 100755 index 0000000..d84ba82 --- /dev/null +++ b/developer/manager/CLI/CLI.py @@ -0,0 +1,368 @@ +#!/usr/bin/env python3 +# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*- +"""CLI.py — subu manager front-end. + +Role: parse argv, choose command, call dispatch. + +CLI should not do any work beyond: + + * figure out program_name (for example, manager/CLI.py or wrapper name) + * call the right function in dispatch + * print text from text.py when needed + * exit with the returned status code +""" + +import os, sys, argparse +from text import make_text +import dispatch + + +def register_device_commands(subparsers): + """ + Register device-related commands: + + device scan [--base-dir DIR] + + For v1, we only support scanning already-mounted devices under /mnt. + """ + ap = subparsers.add_parser("device") + 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): + """Register DB-related commands under 'db'. + + db load schema + """ + ap_db = subparsers.add_parser("db") + db_sub = ap_db.add_subparsers(dest="db_verb") + + ap = db_sub.add_parser("load") + ap.add_argument("what", choices=["schema"]) + + +def register_subu_commands(subparsers): + """Register subu related commands under 'subu': + + subu make []* + subu capture []* + subu remove | []* + subu list + subu info | []* + subu option set|clear incommon | []* + """ + ap_subu = subparsers.add_parser("subu") + subu_sub = ap_subu.add_subparsers(dest="subu_verb") + + # make: path[0] is masu, remaining elements are the subu chain + ap = subu_sub.add_parser("make") + ap.add_argument("path", nargs="+") + + # capture: path[0] is masu, remaining elements are the subu chain + ap = subu_sub.add_parser("capture") + ap.add_argument("path", nargs="+") + + # remove: either ID or path + ap = subu_sub.add_parser("remove") + ap.add_argument("target") + ap.add_argument("rest", nargs="*") + + # list + subu_sub.add_parser("list") + + # info + ap = subu_sub.add_parser("info") + ap.add_argument("target") + ap.add_argument("rest", nargs="*") + + # option incommon + ap = subu_sub.add_parser("option") + ap.add_argument("opt_action", choices=["set", "clear"]) + ap.add_argument("opt_name", choices=["incommon"]) + 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'.""" + ap = subparsers.add_parser("WG") + ap.add_argument( + "wg_verb", + choices=[ + "global", + "make", + "server_provided_public_key", + "info", + "information", + "up", + "down", + ], + ) + ap.add_argument("arg1", nargs="?") + ap.add_argument("arg2", nargs="?") + + +def register_attach_commands(subparsers): + """Register attach and detach commands: + + attach WG + detach WG + """ + ap = subparsers.add_parser("attach") + ap.add_argument("what", choices=["WG"]) + ap.add_argument("subu_id") + ap.add_argument("wg_id") + + ap = subparsers.add_parser("detach") + ap.add_argument("what", choices=["WG"]) + ap.add_argument("subu_id") + + +def register_network_commands(subparsers): + """Register network aggregate commands: + + network up|down + """ + ap = subparsers.add_parser("network") + ap.add_argument("state", choices=["up", "down"]) + ap.add_argument("subu_id") + + + +def register_option_commands(subparsers): + """Register global option commands (non-subu-specific for now): + + option set|get|list ... + """ + ap = subparsers.add_parser("option") + ap.add_argument("action", choices=["set", "get", "list"]) + ap.add_argument("subu_id") + ap.add_argument("name", nargs="?") + ap.add_argument("value", nargs="?") + + +def register_exec_commands(subparsers): + """Register exec command: + + exec -- ... + """ + ap = subparsers.add_parser("exec") + ap.add_argument("subu_id") + ap.add_argument("--", dest="cmd", nargs=argparse.REMAINDER, default=[]) + + +def register_lo_commands(subparsers): + """Register lo command: + + lo up|down + """ + ap = subparsers.add_parser("lo") + ap.add_argument("state", choices=["up", "down"]) + ap.add_argument("subu_id") + + +def build_arg_parser(program_name: str) -> argparse.ArgumentParser: + """Build the top level argument parser for the subu manager.""" + parser = argparse.ArgumentParser(prog=program_name, add_help=False) + parser.add_argument("-V", "--Version", action="store_true", help="print version") + + subparsers = parser.add_subparsers(dest="verb") + + register_db_commands(subparsers) + register_subu_commands(subparsers) + register_wireguard_commands(subparsers) + register_attach_commands(subparsers) + register_network_commands(subparsers) + register_option_commands(subparsers) + register_exec_commands(subparsers) + register_device_commands(subparsers) + register_lo_commands(subparsers) + + return parser + + +def _collect_parse_errors(ns, program_name: str) -> list[str]: + """Check for semantic argument problems and collect error strings.""" + errors: list[str] = [] + + if ns.verb == "device": + if ns.action == "scan": + return dispatch.device_scan(ns.base_dir) + + if ns.verb == "subu": + sv = getattr(ns, "subu_verb", None) + if sv in ("make", "capture"): + if not ns.path or len(ns.path) < 2: + errors.append( + f"subu {sv} requires at least and one component" + ) + elif sv in ("remove", "info"): + if ns.target.startswith("subu_"): + if ns.rest: + errors.append( + f"{program_name} subu {sv} with an ID form must not have extra path tokens" + ) + else: + if len([ns.target] + list(ns.rest)) < 2: + errors.append( + f"{program_name} subu {sv} [ ...] requires at least two tokens" + ) + elif sv == "option": + # For incommon, same ID vs path rules as info/remove. + if ns.opt_name == "incommon": + if ns.target.startswith("subu_"): + if ns.rest: + errors.append( + f"{program_name} subu option {ns.opt_action} incommon with an ID form " + "must not have extra path tokens" + ) + else: + if len([ns.target] + list(ns.rest)) < 2: + errors.append( + f"{program_name} subu option {ns.opt_action} incommon " + " [ ...] requires at least two tokens" + ) + + return errors + + +def CLI(argv=None) -> int: + """Top level entry point for the subu manager CLI.""" + if argv is None: + argv = sys.argv[1:] + + prog_override = os.environ.get("SUBU_PROGNAME") + if prog_override: + program_name = prog_override + else: + raw0 = sys.argv[0] or "subu" + program_name = os.path.basename(raw0) or "subu" + + text = make_text(program_name) + + # No arguments is the same as "usage". + if not argv: + print(text.usage(), end="") + return 0 + + simple = { + "help": text.help, + "--help": text.help, + "-h": text.help, + "usage": text.usage, + "example": text.example, + "version": text.version, + } + if argv[0] in simple: + print(simple[argv[0]](), end="") + return 0 + + parser = build_arg_parser(program_name) + ns = parser.parse_args(argv) + + if getattr(ns, "Version", False): + print(text.version(), end="") + return 0 + + errors = _collect_parse_errors(ns, program_name) + if errors: + for msg in errors: + print(f"error: {msg}", file=sys.stderr) + return 2 + + try: + if ns.verb == "db": + 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 == "list": + return dispatch.subu_list() + if sv == "info": + return dispatch.subu_info(ns.target, ns.rest) + if sv == "remove": + return dispatch.subu_remove(ns.target, ns.rest) + if sv == "option": + # For now only 'incommon' is supported. + return dispatch.subu_option_incommon(ns.opt_action, ns.target, ns.rest) + + if ns.verb == "lo": + return dispatch.lo_toggle(ns.subu_id, ns.state) + + if ns.verb == "WG": + v = ns.wg_verb + if v in ("info", "information") and ns.arg1 is None: + print("WG info requires WG_ID", file=sys.stderr) + return 2 + if v == "global": + return dispatch.wg_global(ns.arg1) + if v == "make": + return dispatch.wg_make(ns.arg1) + if v == "server_provided_public_key": + return dispatch.wg_server_public_key(ns.arg1, ns.arg2) + if v in ("info", "information"): + return dispatch.wg_info(ns.arg1) + if v == "up": + return dispatch.wg_up(ns.arg1) + if v == "down": + return dispatch.wg_down(ns.arg1) + + if ns.verb == "attach": + if ns.what == "WG": + return dispatch.attach_wg(ns.subu_id, ns.wg_id) + + if ns.verb == "detach": + if ns.what == "WG": + return dispatch.detach_wg(ns.subu_id) + + if ns.verb == "network": + return dispatch.network_toggle(ns.subu_id, ns.state) + + if ns.verb == "option": + # global options still placeholder + print("option: not yet implemented", file=sys.stderr) + return 1 + + if ns.verb == "exec": + if not ns.cmd: + print(f"{program_name} exec -- ...", file=sys.stderr) + return 2 + return dispatch.exec(ns.subu_id, ns.cmd) + + print(text.usage(), end="") + return 2 + + except Exception as e: + print(f"error: {e}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(CLI()) 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/CLI/domain/exec.py b/developer/manager/CLI/domain/exec.py new file mode 100644 index 0000000..0826560 --- /dev/null +++ b/developer/manager/CLI/domain/exec.py @@ -0,0 +1,12 @@ +""" +4.5 domain/exec.py + +Run a command inside a subu’s namespace and UID. + +4.5.1 run_in_subu(subu: Subu, cmd_argv: list[str]) -> int +""" +def exec_in_subu(subu_id: str, cmd: list): + sid = int(subu_id.split("_")[1]) + with closing(_db()) as db: + ns = db.execute("SELECT netns FROM subu WHERE id=?", (sid,)).fetchone()[0] + os.execvp("ip", ["ip","netns","exec", ns] + cmd) diff --git a/developer/manager/CLI/domain/network.py b/developer/manager/CLI/domain/network.py new file mode 100644 index 0000000..2ea77b4 --- /dev/null +++ b/developer/manager/CLI/domain/network.py @@ -0,0 +1,33 @@ +""" +4.3 domain/network.py + +Netns + device wiring, including aggregate “network up/down”. + +4.3.1 lo_toggle(subu: Subu, state: str) -> None +4.3.2 attach_wg(subu: Subu, wg: WG) -> None +4.3.3 detach_wg(subu: Subu) -> None +4.3.4 network_toggle(subu: Subu, state: str) -> None +""" +def network_toggle(subu_id: str, state: str): + sid = int(subu_id.split("_")[1]) + with closing(_db()) as db: + ns, wid = db.execute("SELECT netns,wg_id FROM subu WHERE id=?", (sid,)).fetchone() + # always make sure lo up on 'up' + if state == "up": + run(["ip", "netns", "exec", ns, "ip", "link", "set", "lo", "up"], check=False) + if wid is not None: + ifname = f"subu_{wid}" + run(["ip", "-n", ns, "link", "set", "dev", ifname, state], check=False) + with closing(_db()) as db: + db.execute("UPDATE subu SET network_state=? WHERE id=?", (state, sid)) + db.commit() + print(f"{subu_id}: network {state}") + +def _make_netns_for_subu(subu_id_num: int, netns_name: str): + """ + Create the network namespace & bring lo down. + """ + # ip netns add ns-subu_ + run(["ip", "netns", "add", netns_name]) + # ip netns exec ns-subu_ ip link set lo down + run(["ip", "netns", "exec", netns_name, "ip", "link", "set", "lo", "down"]) diff --git a/developer/manager/CLI/domain/options.py b/developer/manager/CLI/domain/options.py new file mode 100644 index 0000000..76fd97e --- /dev/null +++ b/developer/manager/CLI/domain/options.py @@ -0,0 +1,31 @@ +""" +4.4 domain/options.py + +Per-subu options, backed by DB. + +4.4.1 set_option(subu_id: str, name: str, value: str) -> None +4.4.2 get_option(subu_id: str, name: str) -> str | None +4.4.3 list_options(subu_id: str) -> dict[str, str] +""" +def option_set(subu_id: str, name: str, value: str): + sid = int(subu_id.split("_")[1]) + with closing(_db()) as db: + db.execute("INSERT INTO options (subu_id,name,value) VALUES(?,?,?) " + "ON CONFLICT(subu_id,name) DO UPDATE SET value=excluded.value", + (sid, name, value)) + db.commit() + print("ok") + +def option_get(subu_id: str, name: str): + sid = int(subu_id.split("_")[1]) + with closing(_db()) as db: + row = db.execute("SELECT value FROM options WHERE subu_id=? AND name=?", (sid,name)).fetchone() + print(row[0] if row else "") + +def option_list(subu_id: str): + sid = int(subu_id.split("_")[1]) + with closing(_db()) as db: + rows = db.execute("SELECT name,value FROM options WHERE subu_id=?", (sid,)).fetchall() + for n,v in rows: + print(f"{n}={v}") + diff --git a/developer/manager/CLI/domain/subu.py b/developer/manager/CLI/domain/subu.py new file mode 100644 index 0000000..f3eb3b0 --- /dev/null +++ b/developer/manager/CLI/domain/subu.py @@ -0,0 +1,216 @@ +# domain/subu.py +# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*- + +from infrastructure.unix import ( + ensure_unix_user, + ensure_user_in_group, + remove_unix_user_and_group, + user_exists, +) +from typing import Iterable +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: + """ + 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] = [] + now = _now() + for seg in parts: + row = conn.execute( + "SELECT * FROM subu_node WHERE owner=? AND name=? AND parent_id IS ?", + (owner, seg, parent_id) + ).fetchone() + if row: + parent_id = row["id"] + chain.append(seg) + continue + chain.append(seg) + full_path = " ".join([owner] + chain) + full_unix = subu_username(owner, chain) + netns = full_unix + conn.execute( + """INSERT INTO subu_node(owner,name,parent_id,full_unix_name,full_path,netns_name, + device_id,is_online,created_at,updated_at) + VALUES(?,?,?,?,?,?,?, ?,?,?)""", + (owner, seg, parent_id, full_unix, full_path, netns, + device_id, 1 if online else 0, now, now) + ) + parent_id = conn.execute("SELECT last_insert_rowid() id").fetchone()["id"] + leaf = conn.execute("SELECT * FROM subu_node WHERE id=?", (parent_id,)).fetchone() + return dict(leaf) + +def find_by_path(conn, owner: str, parts: list[str]): + conn.row_factory = sqlite3.Row + parent_id = None + for seg in parts: + row = conn.execute( + "SELECT * FROM subu_node WHERE owner=? AND name=? AND parent_id IS ?", + (owner, seg, parent_id) + ).fetchone() + if not row: + return None + parent_id = row["id"] + return dict(row) + +def list_children(conn, node_id: int|None, owner: str): + """ + node_id=None lists top-level subu of owner; otherwise children of node_id. + """ + conn.row_factory = sqlite3.Row + if node_id is None: + cur = conn.execute("SELECT * FROM subu_node WHERE owner=? AND parent_id IS NULL ORDER BY name", (owner,)) + else: + cur = conn.execute("SELECT * FROM subu_node WHERE owner=? AND parent_id=? ORDER BY name", (owner, node_id)) + return [dict(r) for r in cur.fetchall()] + +def _validate_token(label: str, token: str) -> str: + """ + Validate a single path token (masu or subu). + + Rules: + - must be non-empty after stripping whitespace + - must not contain underscore '_' + """ + token_stripped = token.strip() + if not token_stripped: + raise SystemExit(f"subu: {label} name must be non-empty") + if "_" in token_stripped: + raise SystemExit( + f"subu: {label} name '{token_stripped}' must not contain underscore '_'" + ) + # dashes are fine; acronyms and proper nouns are fine. + return token_stripped + + +def _parent_username(masu: str, path_components: list[str]) -> str | None: + """ + Return the Unix username of the parent subu, or None if this is top-level. + + Examples: + masu="Thomas", path=["S0"] -> None (parent is just the masu) + masu="Thomas", path=["S0","S1"] -> "Thomas_S0" + """ + if len(path_components) <= 1: + return None + # parent path is everything except last token + parent_path = path_components[:-1] + return subu_username(masu, parent_path) + + +def _ancestor_group_names(masu: str, path_components: list[str]) -> list[str]: + """ + Compute ancestor groups that a subu must join for directory traversal. + + For path: + [masu, s1, s2, ..., sk] + + we return: + [masu, + masu_s1, + masu_s1_s2, + ..., + masu_s1_..._s{k-1}] + + The last element (full username) is NOT included, because that is + the subu's own primary group. + """ + groups: list[str] = [] + # masu group (allows traversal of /home/masu and /home/masu/subu_data) + groups.append(_validate_token("masu", masu)) + + # For deeper subu, add each ancestor subu's group + for depth in range(1, len(path_components)): + prefix = path_components[:depth] + groups.append(subu_username(masu, prefix)) + + return groups + + +def make_subu(masu: str, path_components: list[str]) -> str: + """ + Make the Unix user and group for this subu. + + The subu path is: + masu subu subu ... + + Rules: + - len(path_components) >= 1 + - tokens must not contain '_' + - parent must exist: + * for first-level subu: Unix user 'masu' must exist + * for deeper subu: parent subu unix user must exist + + Returns: + Unix username, for example 'Thomas_S0' or 'Thomas_S0_S1'. + """ + if not path_components: + raise SystemExit("subu: make requires at least one subu component") + + # Normalize and validate tokens (this will raise SystemExit on error). + # subu_username will call _validate_token internally. + username = subu_username(masu, path_components) + + # Enforce parent existence + parent_uname = _parent_username(masu, path_components) + if parent_uname is None: + # Top-level subu: require the masu Unix user to exist + masu_name = _validate_token("masu", masu) + if not user_exists(masu_name): + raise SystemExit( + f"subu: cannot make '{username}': " + f"masu Unix user '{masu_name}' does not exist" + ) + else: + # Deeper subu: require parent subu Unix user to exist + if not user_exists(parent_uname): + raise SystemExit( + f"subu: cannot make '{username}': " + f"parent subu unix user '{parent_uname}' does not exist" + ) + + # For now, group and user share the same name. + ensure_unix_user(username, username) + + # Add this subu to the ancestor groups so that directory traversal works: + # /home/masu + # /home/masu/subu_data + # /home/masu/subu_data//subu_data/... + ancestor_groups = _ancestor_group_names(masu, path_components) + for gname in ancestor_groups: + ensure_user_in_group(username, gname) + + return username + +def remove_subu(masu: str, path_components: list[str]) -> str: + """ + Remove the Unix user and group for this subu, if they exist. + + The subu path is: + masu subu subu ... + + Returns: + Unix username that was targeted. + """ + if not path_components: + raise SystemExit("subu: remove requires at least one subu component") + + username = subu_username(masu, path_components) + remove_unix_user_and_group(username) + return username diff --git a/developer/manager/CLI/domain/wg.py b/developer/manager/CLI/domain/wg.py new file mode 100644 index 0000000..0cf6126 --- /dev/null +++ b/developer/manager/CLI/domain/wg.py @@ -0,0 +1,60 @@ +""" +4.2 domain/wg.py + +WireGuard objects, independent of subu. + +4.2.1 set_global_pool(base_cidr: str) -> None +4.2.2 make_wg(endpoint: str) -> WG +4.2.3 set_server_public_key(wg_id: str, key: str) -> None +4.2.4 get_wg(wg_id: str) -> WG +4.2.5 bring_up(wg_id: str) -> None +4.2.6 bring_down(wg_id: str) -> None +""" + +def wg_global(basecidr: str): + WG_GLOBAL_FILE.write_text(basecidr.strip()+"\n") + print(f"WG pool base = {basecidr}") + +def _alloc_ip(idx: int, base: str) -> str: + # simplistic /24 allocator: base must be x.y.z.0/24 + prefix = base.split("/")[0].rsplit(".", 1)[0] + host = 2 + idx + return f"{prefix}.{host}/32" + +def wg_make(endpoint: str) -> str: + if not WG_GLOBAL_FILE.exists(): + raise RuntimeError("set WG base with `subu WG global ` first") + base = WG_GLOBAL_FILE.read_text().strip() + with closing(_db()) as db: + c = db.cursor() + idx = c.execute("SELECT COUNT(*) FROM wg").fetchone()[0] + local_ip = _alloc_ip(idx, base) + c.execute("INSERT INTO wg (endpoint, local_ip, allowed_ips) VALUES (?, ?, ?)", + (endpoint, local_ip, "0.0.0.0/0")) + wid = c.lastrowid + db.commit() + print(f"WG_{wid} endpoint={endpoint} ip={local_ip}") + return f"WG_{wid}" + +def wg_set_pubkey(wg_id: str, key: str): + wid = int(wg_id.split("_")[1]) + with closing(_db()) as db: + db.execute("UPDATE wg SET pubkey=? WHERE id=?", (key, wid)) + db.commit() + print("ok") + +def wg_info(wg_id: str): + wid = int(wg_id.split("_")[1]) + with closing(_db()) as db: + row = db.execute("SELECT * FROM wg WHERE id=?", (wid,)).fetchone() + print(row if row else "not found") + +def wg_up(wg_id: str): + wid = int(wg_id.split("_")[1]) + # Admin-up of WG device handled via network_toggle once attached. + print(f"{wg_id}: up (noop until attached)") + +def wg_down(wg_id: str): + wid = int(wg_id.split("_")[1]) + print(f"{wg_id}: down (noop until attached)") + diff --git a/developer/manager/CLI/env.py b/developer/manager/CLI/env.py new file mode 100644 index 0000000..e89d52a --- /dev/null +++ b/developer/manager/CLI/env.py @@ -0,0 +1,49 @@ +# env.py +# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*- + +from pathlib import Path + + +def version() -> str: + """ + Software / CLI version. + """ + return "0.3.5" + + +def db_schema_version() -> str: + """ + Database schema version (used in the DB filename). + + Bump this only when the DB layout/semantics change, + not for every CLI code change. + """ + return "0.1" + + +def db_root_dir() -> Path: + """ + Root directory for the manager database. + """ + return Path("/opt/subu") + + +def db_filename() -> str: + """ + Filename of the SQLite database, relative to db_root_dir. + """ + return f"subu_{db_schema_version()}.sqlite3" + + +def db_path() -> str: + """ + Full path to the SQLite database file. + + Currently this is: + + /opt/subu/subu_.sqlite3 + + There is deliberately no environment override here; this path + defines the canonical system-wide DB used by all manager invocations. + """ + return str(db_root_dir() / db_filename()) diff --git a/developer/manager/CLI/infrastructure/bpf.py b/developer/manager/CLI/infrastructure/bpf.py new file mode 100644 index 0000000..16c4388 --- /dev/null +++ b/developer/manager/CLI/infrastructure/bpf.py @@ -0,0 +1,60 @@ +""" +bpf.py + +Compile/load the BPF program. + +5.3.1 compile_bpf(source_path: str, output_path: str) -> None +5.3.2 load_bpf(obj_path: str) -> BpfHandle +""" + +def attach_wg(subu_id: str, wg_id: str): + ensure_mounts() + sid = int(subu_id.split("_")[1]); wid = int(wg_id.split("_")[1]) + with closing(_db()) as db: + r = db.execute("SELECT netns FROM subu WHERE id=?", (sid,)).fetchone() + if not r: raise ValueError("subu not found") + ns = r[0] + w = db.execute("SELECT endpoint, local_ip, pubkey FROM wg WHERE id=?", (wid,)).fetchone() + if not w: raise ValueError("WG not found") + endpoint, local_ip, pubkey = w + + ifname = f"subu_{wid}" + # make WG link in init ns, move to netns + run(["ip", "link", "add", ifname, "type", "wireguard"]) + run(["ip", "link", "set", ifname, "netns", ns]) + run(["ip", "-n", ns, "addr", "add", local_ip, "dev", ifname], check=False) + run(["ip", "-n", ns, "link", "set", "dev", ifname, "mtu", "1420"]) + run(["ip", "-n", ns, "link", "set", "dev", ifname, "down"]) # keep engine down until `network up` + + # install steering (MVP: make cgroup + attach bpf program) + try: + install_steering(subu_id, ns, ifname) + print(f"{subu_id}: eBPF steering installed -> {ifname}") + except BpfError as e: + print(f"{subu_id}: steering warning: {e}") + + with closing(_db()) as db: + db.execute("UPDATE subu SET wg_id=? WHERE id=?", (wid, sid)) + db.commit() + print(f"attached {wg_id} to {subu_id} in {ns} as {ifname}") + +def detach_wg(subu_id: str): + ensure_mounts() + sid = int(subu_id.split("_")[1]) + with closing(_db()) as db: + r = db.execute("SELECT netns,wg_id FROM subu WHERE id=?", (sid,)).fetchone() + if not r: print("not found"); return + ns, wid = r + if wid is None: + print("nothing attached"); return + ifname = f"subu_{wid}" + run(["ip", "-n", ns, "link", "del", ifname], check=False) + try: + remove_steering(subu_id) + except BpfError as e: + print(f"steering remove warn: {e}") + with closing(_db()) as db: + db.execute("UPDATE subu SET wg_id=NULL WHERE id=?", (sid,)) + db.commit() + print(f"detached WG_{wid} from {subu_id}") + diff --git a/developer/manager/CLI/infrastructure/bpf_force_egress.c b/developer/manager/CLI/infrastructure/bpf_force_egress.c new file mode 100644 index 0000000..628cc83 --- /dev/null +++ b/developer/manager/CLI/infrastructure/bpf_force_egress.c @@ -0,0 +1,48 @@ +// -*- mode: c; c-basic-offset: 2; indent-tabs-mode: nil; tab-width: 8 -*- +// bpf_force_egress.c — MVP scaffold to validate UID and prep metadata +/* + bpf_force_egress.c + +5.5.1 no callable Python API; compiled/used via bpf.py. +*/ +#include +#include +#include + + +char LICENSE[] SEC("license") = "GPL"; + +struct { + __uint(type, BPF_MAP_TYPE_HASH); + __type(key, __u32); // tgid + __type(value, __u32); // reserved (target ifindex placeholder) + __uint(max_entries, 1024); +} subu_tgid2if SEC(".maps"); + +// Helper: return 0 = allow, <0 reject +static __always_inline int allow_uid(struct bpf_sock_addr *ctx) { + // MVP: just accept everyone; you can gate on UID 2017 with bpf_get_current_uid_gid() + // __u32 uid = (__u32)(bpf_get_current_uid_gid() & 0xffffffff); + // if (uid != 2017) return -1; + return 0; +} + +// Hook: cgroup/connect4 — runs before connect(2) proceeds +SEC("cgroup/connect4") +int subu_connect4(struct bpf_sock_addr *ctx) +{ + if (allow_uid(ctx) < 0) return -1; + // Future: read pinned map/meta, set SO_* via bpf_setsockopt when permitted + return 0; +} + +// Hook: cgroup/post_bind4 — runs after a local bind is chosen +SEC("cgroup/post_bind4") +int subu_post_bind4(struct bpf_sock *sk) +{ + // Future: enforce bound dev if kernel helper allows; record tgid->ifindex + __u32 tgid = bpf_get_current_pid_tgid() >> 32; + __u32 val = 0; + bpf_map_update_elem(&subu_tgid2if, &tgid, &val, BPF_ANY); + return 0; +} diff --git a/developer/manager/CLI/infrastructure/bpf_worker.py b/developer/manager/CLI/infrastructure/bpf_worker.py new file mode 100644 index 0000000..66aaf6d --- /dev/null +++ b/developer/manager/CLI/infrastructure/bpf_worker.py @@ -0,0 +1,84 @@ +# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*- +""" +bpf_worker.py + +Cgroup + BPF orchestration for per-subu steering. + +5.4.1 ensure_mounts() -> None +5.4.2 install_steering(subu: Subu, wg_iface: str) -> None +5.4.3 remove_steering(subu: Subu) -> None +5.4.4 class BpfError(Exception) +""" +import os, subprocess, json +from pathlib import Path + +class BpfError(RuntimeError): pass + +def run(cmd, check=True): + r = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + if check and r.returncode != 0: + raise BpfError(f"cmd failed: {' '.join(cmd)}\n{r.stderr}") + return r.stdout.strip() + +def ensure_mounts(): + # ensure bpf and cgroup v2 are mounted + try: + Path("/sys/fs/bpf").mkdir(parents=True, exist_ok=True) + run(["mount","-t","bpf","bpf","/sys/fs/bpf"], check=False) + except Exception: + pass + try: + Path("/sys/fs/cgroup").mkdir(parents=True, exist_ok=True) + run(["mount","-t","cgroup2","none","/sys/fs/cgroup"], check=False) + except Exception: + pass + +def cgroup_path(subu_id: str) -> str: + return f"/sys/fs/cgroup/{subu_id}" + +def install_steering(subu_id: str, netns: str, ifname: str): + ensure_mounts() + cg = Path(cgroup_path(subu_id)) + cg.mkdir(parents=True, exist_ok=True) + + # compile BPF + obj = Path("./bpf_force_egress.o") + src = Path("./bpf_force_egress.c") + if not src.exists(): + raise BpfError("bpf_force_egress.c missing next to manager") + + # Build object (requires clang/llc/bpftool) + run(["clang","-O2","-g","-target","bpf","-c",str(src),"-o",str(obj)]) + + # Load program into bpffs; attach to cgroup/inet4_connect + inet4_post_bind (MVP) + pinned = f"/sys/fs/bpf/{subu_id}_egress" + run(["bpftool","prog","loadall",str(obj),pinned], check=True) + + # Attach to hooks (MVP validation hooks) + # NOTE: these are safe no-ops for now; they validate UID and stash ifindex map. + for hook in ("cgroup/connect4","cgroup/post_bind4"): + run(["bpftool","cgroup","attach",cgroup_path(subu_id),"attach",hook,"pinned",f"{pinned}/prog_0"], check=False) + + # Write metadata for ifname (saved for future prog versions) + meta = {"ifname": ifname} + Path(f"/sys/fs/bpf/{subu_id}_meta.json").write_text(json.dumps(meta)) + +def remove_steering(subu_id: str): + cg = cgroup_path(subu_id) + # Detach whatever is attached + for hook in ("cgroup/connect4","cgroup/post_bind4"): + subprocess.run(["bpftool","cgroup","detach",cg,"detach",hook], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + # Remove pinned prog dir + pinned = Path(f"/sys/fs/bpf/{subu_id}_egress") + if pinned.exists(): + subprocess.run(["bpftool","prog","detach",str(pinned)], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + try: + for p in pinned.glob("*"): p.unlink() + pinned.rmdir() + except Exception: + pass + # Remove cgroup dir + try: + Path(cg).rmdir() + except Exception: + pass diff --git a/developer/manager/CLI/infrastructure/db.py b/developer/manager/CLI/infrastructure/db.py new file mode 100644 index 0000000..e4ef48c --- /dev/null +++ b/developer/manager/CLI/infrastructure/db.py @@ -0,0 +1,50 @@ +# infrastructure/db.py +# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*- + +import sqlite3 +from pathlib import Path +import env + + +def schema_path_default(): + """ + Path to schema.sql, assumed to live next to this file. + """ + return Path(__file__).with_name("schema.sql") + + +def open_db(path=None): + """ + Return a sqlite3.Connection with sensible pragmas. + Caller is responsible for closing. + + If path is None, the canonical manager DB path from env.db_path() + is used. The parent directory is created if it does not exist. + """ + if path is None: + path = env.db_path() + + path_obj = Path(path) + parent = path_obj.parent + + try: + parent.mkdir(parents=True, exist_ok=True) + except PermissionError as e: + raise RuntimeError(f"cannot create DB directory '{parent}': {e}") from e + + conn = sqlite3.connect(str(path_obj)) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA foreign_keys = ON") + conn.execute("PRAGMA journal_mode = WAL") + conn.execute("PRAGMA synchronous = NORMAL") + return conn + + +def ensure_schema(conn): + """ + Ensure the schema in schema.sql is applied. + This is idempotent: executing the DDL again is acceptable. + """ + sql = schema_path_default().read_text(encoding="utf-8") + conn.executescript(sql) + conn.commit() diff --git a/developer/manager/CLI/infrastructure/device.py b/developer/manager/CLI/infrastructure/device.py new file mode 100644 index 0000000..5556098 --- /dev/null +++ b/developer/manager/CLI/infrastructure/device.py @@ -0,0 +1,308 @@ +# 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/CLI/infrastructure/options_store.py b/developer/manager/CLI/infrastructure/options_store.py new file mode 100644 index 0000000..913a383 --- /dev/null +++ b/developer/manager/CLI/infrastructure/options_store.py @@ -0,0 +1,58 @@ +# infrastructure/options_store.py +# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*- + +from pathlib import Path + +# Options file lives next to CLI in the manager release tree. +# In dev it will be the same relative layout. +OPTIONS_FILE = Path("subu.options") + + +def load_options(): + """ + Load options from subu.options into a dictionary. + + Lines are of the form: key=value + Lines starting with '#' or blank lines are ignored. + """ + opts = {} + if not OPTIONS_FILE.exists(): + return opts + text = OPTIONS_FILE.read_text(encoding="utf-8") + for line in text.splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + if "=" not in line: + continue + k, v = line.split("=", 1) + opts[k.strip()] = v.strip() + return opts + + +def save_options(opts: dict): + """ + Save a dictionary of options back to subu.options. + """ + lines = [] + for k in sorted(opts.keys()): + v = opts[k] + lines.append(f"{k}={v}\n") + OPTIONS_FILE.write_text("".join(lines), encoding="utf-8") + + +def set_option(name: str, value: str): + """ + Set a single option key to a value. + """ + opts = load_options() + opts[name] = value + save_options(opts) + + +def get_option(name: str, default=None): + """ + Get an option value by name, or default if missing. + """ + opts = load_options() + return opts.get(name, default) diff --git a/developer/manager/CLI/infrastructure/schema.sql b/developer/manager/CLI/infrastructure/schema.sql new file mode 100644 index 0000000..6ed1fd3 --- /dev/null +++ b/developer/manager/CLI/infrastructure/schema.sql @@ -0,0 +1,39 @@ +-- schema.sql +-- +-- Schema for subu manager, including device-aware subu tracking. + +-- Devices that can hold one or more masu homes. +-- Each row represents a physical (or logical) storage volume +-- identified by a mapname like 'Eagle' and optionally by UUIDs. +CREATE TABLE device ( + id INTEGER PRIMARY KEY, + mapname TEXT NOT NULL UNIQUE, -- e.g. 'Eagle' + fs_uuid TEXT, -- filesystem UUID (optional) + luks_uuid TEXT, -- LUKS UUID (optional) + mount_point TEXT NOT NULL, -- e.g. '/mnt/Eagle' + kind TEXT NOT NULL DEFAULT 'external', -- 'local','external','encrypted',... + state TEXT NOT NULL DEFAULT 'offline', -- 'online','offline','error' + last_seen TEXT NOT NULL -- ISO8601 UTC timestamp +); + +-- parents via parent_id; one row per node in the tree +CREATE TABLE subu_node ( + id INTEGER PRIMARY KEY, + owner TEXT NOT NULL, -- masu + name TEXT NOT NULL, -- this segment (e.g., developer, bolt) + parent_id INTEGER, -- NULL for top-level subu under owner + full_unix_name TEXT NOT NULL UNIQUE, -- e.g., Thomas_developer_bolt + full_path TEXT NOT NULL, -- e.g., "Thomas developer bolt" + netns_name TEXT NOT NULL, -- default = full_unix_name + device_id INTEGER, -- NULL=local + is_online INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY(parent_id) REFERENCES subu_node(id), + FOREIGN KEY(device_id) REFERENCES device(id), + UNIQUE(owner, name, parent_id) -- no duplicate siblings +); + +CREATE INDEX idx_node_owner_parent ON subu_node(owner, parent_id); +CREATE INDEX idx_node_device ON subu_node(device_id); + 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/CLI/text.py b/developer/manager/CLI/text.py new file mode 100644 index 0000000..2b04247 --- /dev/null +++ b/developer/manager/CLI/text.py @@ -0,0 +1,118 @@ +# 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 + self._version = "0.3.4" + + # ---- Public API expected by CLI.py --------------------------------------- + + def version(self) -> str: + return f"{self._version}\n" + + def usage(self) -> str: + 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} 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 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" + f" {p} WG info|information \n" + 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 list \n" + "\n" + f" {p} exec -- ...\n" + ) + + def help(self) -> str: + p = self.program_name + return ( + self.usage() + + "\n" + "Notes:\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: + p = self.program_name + return ( + "Example workflow:\n" + "\n" + f" # 1) Initialize schema (root)\n" + f" sudo {p} db load schema\n" + "\n" + f" # 2) Scan devices already mounted under /mnt (root)\n" + f" sudo {p} device scan\n" + "\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) List everything (any user)\n" + f" {p} subu list\n" + "\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) 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: + 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/domain/device.py b/developer/manager/domain/device.py deleted file mode 100644 index 5556098..0000000 --- a/developer/manager/domain/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/domain/exec.py b/developer/manager/domain/exec.py deleted file mode 100644 index 0826560..0000000 --- a/developer/manager/domain/exec.py +++ /dev/null @@ -1,12 +0,0 @@ -""" -4.5 domain/exec.py - -Run a command inside a subu’s namespace and UID. - -4.5.1 run_in_subu(subu: Subu, cmd_argv: list[str]) -> int -""" -def exec_in_subu(subu_id: str, cmd: list): - sid = int(subu_id.split("_")[1]) - with closing(_db()) as db: - ns = db.execute("SELECT netns FROM subu WHERE id=?", (sid,)).fetchone()[0] - os.execvp("ip", ["ip","netns","exec", ns] + cmd) diff --git a/developer/manager/domain/network.py b/developer/manager/domain/network.py deleted file mode 100644 index 2ea77b4..0000000 --- a/developer/manager/domain/network.py +++ /dev/null @@ -1,33 +0,0 @@ -""" -4.3 domain/network.py - -Netns + device wiring, including aggregate “network up/down”. - -4.3.1 lo_toggle(subu: Subu, state: str) -> None -4.3.2 attach_wg(subu: Subu, wg: WG) -> None -4.3.3 detach_wg(subu: Subu) -> None -4.3.4 network_toggle(subu: Subu, state: str) -> None -""" -def network_toggle(subu_id: str, state: str): - sid = int(subu_id.split("_")[1]) - with closing(_db()) as db: - ns, wid = db.execute("SELECT netns,wg_id FROM subu WHERE id=?", (sid,)).fetchone() - # always make sure lo up on 'up' - if state == "up": - run(["ip", "netns", "exec", ns, "ip", "link", "set", "lo", "up"], check=False) - if wid is not None: - ifname = f"subu_{wid}" - run(["ip", "-n", ns, "link", "set", "dev", ifname, state], check=False) - with closing(_db()) as db: - db.execute("UPDATE subu SET network_state=? WHERE id=?", (state, sid)) - db.commit() - print(f"{subu_id}: network {state}") - -def _make_netns_for_subu(subu_id_num: int, netns_name: str): - """ - Create the network namespace & bring lo down. - """ - # ip netns add ns-subu_ - run(["ip", "netns", "add", netns_name]) - # ip netns exec ns-subu_ ip link set lo down - run(["ip", "netns", "exec", netns_name, "ip", "link", "set", "lo", "down"]) diff --git a/developer/manager/domain/options.py b/developer/manager/domain/options.py deleted file mode 100644 index 76fd97e..0000000 --- a/developer/manager/domain/options.py +++ /dev/null @@ -1,31 +0,0 @@ -""" -4.4 domain/options.py - -Per-subu options, backed by DB. - -4.4.1 set_option(subu_id: str, name: str, value: str) -> None -4.4.2 get_option(subu_id: str, name: str) -> str | None -4.4.3 list_options(subu_id: str) -> dict[str, str] -""" -def option_set(subu_id: str, name: str, value: str): - sid = int(subu_id.split("_")[1]) - with closing(_db()) as db: - db.execute("INSERT INTO options (subu_id,name,value) VALUES(?,?,?) " - "ON CONFLICT(subu_id,name) DO UPDATE SET value=excluded.value", - (sid, name, value)) - db.commit() - print("ok") - -def option_get(subu_id: str, name: str): - sid = int(subu_id.split("_")[1]) - with closing(_db()) as db: - row = db.execute("SELECT value FROM options WHERE subu_id=? AND name=?", (sid,name)).fetchone() - print(row[0] if row else "") - -def option_list(subu_id: str): - sid = int(subu_id.split("_")[1]) - with closing(_db()) as db: - rows = db.execute("SELECT name,value FROM options WHERE subu_id=?", (sid,)).fetchall() - for n,v in rows: - print(f"{n}={v}") - diff --git a/developer/manager/domain/subu.py b/developer/manager/domain/subu.py deleted file mode 100644 index 4abfa13..0000000 --- a/developer/manager/domain/subu.py +++ /dev/null @@ -1,207 +0,0 @@ -# domain/subu.py -# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*- - -from infrastructure.unix import ( - ensure_unix_user, - ensure_user_in_group, - remove_unix_user_and_group, - user_exists, -) -from typing import Iterable -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) - -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). - """ - conn.row_factory = sqlite3.Row - parent_id = None - chain: list[str] = [] - now = _now() - for seg in parts: - row = conn.execute( - "SELECT * FROM subu_node WHERE owner=? AND name=? AND parent_id IS ?", - (owner, seg, parent_id) - ).fetchone() - if row: - parent_id = row["id"] - chain.append(seg) - continue - chain.append(seg) - full_path = " ".join([owner] + chain) - full_unix = subu_username(owner, chain) - netns = full_unix - conn.execute( - """INSERT INTO subu_node(owner,name,parent_id,full_unix_name,full_path,netns_name, - device_id,is_online,created_at,updated_at) - VALUES(?,?,?,?,?,?,?, ?,?,?)""", - (owner, seg, parent_id, full_unix, full_path, netns, - device_id, 1 if online else 0, now, now) - ) - parent_id = conn.execute("SELECT last_insert_rowid() id").fetchone()["id"] - leaf = conn.execute("SELECT * FROM subu_node WHERE id=?", (parent_id,)).fetchone() - return dict(leaf) - -def find_by_path(conn, owner: str, parts: list[str]): - conn.row_factory = sqlite3.Row - parent_id = None - for seg in parts: - row = conn.execute( - "SELECT * FROM subu_node WHERE owner=? AND name=? AND parent_id IS ?", - (owner, seg, parent_id) - ).fetchone() - if not row: - return None - parent_id = row["id"] - return dict(row) - -def list_children(conn, node_id: int|None, owner: str): - """ - node_id=None lists top-level subu of owner; otherwise children of node_id. - """ - conn.row_factory = sqlite3.Row - if node_id is None: - cur = conn.execute("SELECT * FROM subu_node WHERE owner=? AND parent_id IS NULL ORDER BY name", (owner,)) - else: - cur = conn.execute("SELECT * FROM subu_node WHERE owner=? AND parent_id=? ORDER BY name", (owner, node_id)) - return [dict(r) for r in cur.fetchall()] - -def _validate_token(label: str, token: str) -> str: - """ - Validate a single path token (masu or subu). - - Rules: - - must be non-empty after stripping whitespace - - must not contain underscore '_' - """ - token_stripped = token.strip() - if not token_stripped: - raise SystemExit(f"subu: {label} name must be non-empty") - if "_" in token_stripped: - raise SystemExit( - f"subu: {label} name '{token_stripped}' must not contain underscore '_'" - ) - # dashes are fine; acronyms and proper nouns are fine. - return token_stripped - - -def _parent_username(masu: str, path_components: list[str]) -> str | None: - """ - Return the Unix username of the parent subu, or None if this is top-level. - - Examples: - masu="Thomas", path=["S0"] -> None (parent is just the masu) - masu="Thomas", path=["S0","S1"] -> "Thomas_S0" - """ - if len(path_components) <= 1: - return None - # parent path is everything except last token - parent_path = path_components[:-1] - return subu_username(masu, parent_path) - - -def _ancestor_group_names(masu: str, path_components: list[str]) -> list[str]: - """ - Compute ancestor groups that a subu must join for directory traversal. - - For path: - [masu, s1, s2, ..., sk] - - we return: - [masu, - masu_s1, - masu_s1_s2, - ..., - masu_s1_..._s{k-1}] - - The last element (full username) is NOT included, because that is - the subu's own primary group. - """ - groups: list[str] = [] - # masu group (allows traversal of /home/masu and /home/masu/subu_data) - groups.append(_validate_token("masu", masu)) - - # For deeper subu, add each ancestor subu's group - for depth in range(1, len(path_components)): - prefix = path_components[:depth] - groups.append(subu_username(masu, prefix)) - - return groups - - -def make_subu(masu: str, path_components: list[str]) -> str: - """ - Make the Unix user and group for this subu. - - The subu path is: - masu subu subu ... - - Rules: - - len(path_components) >= 1 - - tokens must not contain '_' - - parent must exist: - * for first-level subu: Unix user 'masu' must exist - * for deeper subu: parent subu unix user must exist - - Returns: - Unix username, for example 'Thomas_S0' or 'Thomas_S0_S1'. - """ - if not path_components: - raise SystemExit("subu: make requires at least one subu component") - - # Normalize and validate tokens (this will raise SystemExit on error). - # subu_username will call _validate_token internally. - username = subu_username(masu, path_components) - - # Enforce parent existence - parent_uname = _parent_username(masu, path_components) - if parent_uname is None: - # Top-level subu: require the masu Unix user to exist - masu_name = _validate_token("masu", masu) - if not user_exists(masu_name): - raise SystemExit( - f"subu: cannot make '{username}': " - f"masu Unix user '{masu_name}' does not exist" - ) - else: - # Deeper subu: require parent subu Unix user to exist - if not user_exists(parent_uname): - raise SystemExit( - f"subu: cannot make '{username}': " - f"parent subu unix user '{parent_uname}' does not exist" - ) - - # For now, group and user share the same name. - ensure_unix_user(username, username) - - # Add this subu to the ancestor groups so that directory traversal works: - # /home/masu - # /home/masu/subu_data - # /home/masu/subu_data//subu_data/... - ancestor_groups = _ancestor_group_names(masu, path_components) - for gname in ancestor_groups: - ensure_user_in_group(username, gname) - - return username - -def remove_subu(masu: str, path_components: list[str]) -> str: - """ - Remove the Unix user and group for this subu, if they exist. - - The subu path is: - masu subu subu ... - - Returns: - Unix username that was targeted. - """ - if not path_components: - raise SystemExit("subu: remove requires at least one subu component") - - username = subu_username(masu, path_components) - remove_unix_user_and_group(username) - return username diff --git a/developer/manager/domain/wg.py b/developer/manager/domain/wg.py deleted file mode 100644 index 0cf6126..0000000 --- a/developer/manager/domain/wg.py +++ /dev/null @@ -1,60 +0,0 @@ -""" -4.2 domain/wg.py - -WireGuard objects, independent of subu. - -4.2.1 set_global_pool(base_cidr: str) -> None -4.2.2 make_wg(endpoint: str) -> WG -4.2.3 set_server_public_key(wg_id: str, key: str) -> None -4.2.4 get_wg(wg_id: str) -> WG -4.2.5 bring_up(wg_id: str) -> None -4.2.6 bring_down(wg_id: str) -> None -""" - -def wg_global(basecidr: str): - WG_GLOBAL_FILE.write_text(basecidr.strip()+"\n") - print(f"WG pool base = {basecidr}") - -def _alloc_ip(idx: int, base: str) -> str: - # simplistic /24 allocator: base must be x.y.z.0/24 - prefix = base.split("/")[0].rsplit(".", 1)[0] - host = 2 + idx - return f"{prefix}.{host}/32" - -def wg_make(endpoint: str) -> str: - if not WG_GLOBAL_FILE.exists(): - raise RuntimeError("set WG base with `subu WG global ` first") - base = WG_GLOBAL_FILE.read_text().strip() - with closing(_db()) as db: - c = db.cursor() - idx = c.execute("SELECT COUNT(*) FROM wg").fetchone()[0] - local_ip = _alloc_ip(idx, base) - c.execute("INSERT INTO wg (endpoint, local_ip, allowed_ips) VALUES (?, ?, ?)", - (endpoint, local_ip, "0.0.0.0/0")) - wid = c.lastrowid - db.commit() - print(f"WG_{wid} endpoint={endpoint} ip={local_ip}") - return f"WG_{wid}" - -def wg_set_pubkey(wg_id: str, key: str): - wid = int(wg_id.split("_")[1]) - with closing(_db()) as db: - db.execute("UPDATE wg SET pubkey=? WHERE id=?", (key, wid)) - db.commit() - print("ok") - -def wg_info(wg_id: str): - wid = int(wg_id.split("_")[1]) - with closing(_db()) as db: - row = db.execute("SELECT * FROM wg WHERE id=?", (wid,)).fetchone() - print(row if row else "not found") - -def wg_up(wg_id: str): - wid = int(wg_id.split("_")[1]) - # Admin-up of WG device handled via network_toggle once attached. - print(f"{wg_id}: up (noop until attached)") - -def wg_down(wg_id: str): - wid = int(wg_id.split("_")[1]) - print(f"{wg_id}: down (noop until attached)") - diff --git a/developer/manager/env.py b/developer/manager/env.py deleted file mode 100644 index e89d52a..0000000 --- a/developer/manager/env.py +++ /dev/null @@ -1,49 +0,0 @@ -# env.py -# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*- - -from pathlib import Path - - -def version() -> str: - """ - Software / CLI version. - """ - return "0.3.5" - - -def db_schema_version() -> str: - """ - Database schema version (used in the DB filename). - - Bump this only when the DB layout/semantics change, - not for every CLI code change. - """ - return "0.1" - - -def db_root_dir() -> Path: - """ - Root directory for the manager database. - """ - return Path("/opt/subu") - - -def db_filename() -> str: - """ - Filename of the SQLite database, relative to db_root_dir. - """ - return f"subu_{db_schema_version()}.sqlite3" - - -def db_path() -> str: - """ - Full path to the SQLite database file. - - Currently this is: - - /opt/subu/subu_.sqlite3 - - There is deliberately no environment override here; this path - defines the canonical system-wide DB used by all manager invocations. - """ - return str(db_root_dir() / db_filename()) diff --git a/developer/manager/infrastructure/bpf.py b/developer/manager/infrastructure/bpf.py deleted file mode 100644 index 16c4388..0000000 --- a/developer/manager/infrastructure/bpf.py +++ /dev/null @@ -1,60 +0,0 @@ -""" -bpf.py - -Compile/load the BPF program. - -5.3.1 compile_bpf(source_path: str, output_path: str) -> None -5.3.2 load_bpf(obj_path: str) -> BpfHandle -""" - -def attach_wg(subu_id: str, wg_id: str): - ensure_mounts() - sid = int(subu_id.split("_")[1]); wid = int(wg_id.split("_")[1]) - with closing(_db()) as db: - r = db.execute("SELECT netns FROM subu WHERE id=?", (sid,)).fetchone() - if not r: raise ValueError("subu not found") - ns = r[0] - w = db.execute("SELECT endpoint, local_ip, pubkey FROM wg WHERE id=?", (wid,)).fetchone() - if not w: raise ValueError("WG not found") - endpoint, local_ip, pubkey = w - - ifname = f"subu_{wid}" - # make WG link in init ns, move to netns - run(["ip", "link", "add", ifname, "type", "wireguard"]) - run(["ip", "link", "set", ifname, "netns", ns]) - run(["ip", "-n", ns, "addr", "add", local_ip, "dev", ifname], check=False) - run(["ip", "-n", ns, "link", "set", "dev", ifname, "mtu", "1420"]) - run(["ip", "-n", ns, "link", "set", "dev", ifname, "down"]) # keep engine down until `network up` - - # install steering (MVP: make cgroup + attach bpf program) - try: - install_steering(subu_id, ns, ifname) - print(f"{subu_id}: eBPF steering installed -> {ifname}") - except BpfError as e: - print(f"{subu_id}: steering warning: {e}") - - with closing(_db()) as db: - db.execute("UPDATE subu SET wg_id=? WHERE id=?", (wid, sid)) - db.commit() - print(f"attached {wg_id} to {subu_id} in {ns} as {ifname}") - -def detach_wg(subu_id: str): - ensure_mounts() - sid = int(subu_id.split("_")[1]) - with closing(_db()) as db: - r = db.execute("SELECT netns,wg_id FROM subu WHERE id=?", (sid,)).fetchone() - if not r: print("not found"); return - ns, wid = r - if wid is None: - print("nothing attached"); return - ifname = f"subu_{wid}" - run(["ip", "-n", ns, "link", "del", ifname], check=False) - try: - remove_steering(subu_id) - except BpfError as e: - print(f"steering remove warn: {e}") - with closing(_db()) as db: - db.execute("UPDATE subu SET wg_id=NULL WHERE id=?", (sid,)) - db.commit() - print(f"detached WG_{wid} from {subu_id}") - diff --git a/developer/manager/infrastructure/bpf_force_egress.c b/developer/manager/infrastructure/bpf_force_egress.c deleted file mode 100644 index 628cc83..0000000 --- a/developer/manager/infrastructure/bpf_force_egress.c +++ /dev/null @@ -1,48 +0,0 @@ -// -*- mode: c; c-basic-offset: 2; indent-tabs-mode: nil; tab-width: 8 -*- -// bpf_force_egress.c — MVP scaffold to validate UID and prep metadata -/* - bpf_force_egress.c - -5.5.1 no callable Python API; compiled/used via bpf.py. -*/ -#include -#include -#include - - -char LICENSE[] SEC("license") = "GPL"; - -struct { - __uint(type, BPF_MAP_TYPE_HASH); - __type(key, __u32); // tgid - __type(value, __u32); // reserved (target ifindex placeholder) - __uint(max_entries, 1024); -} subu_tgid2if SEC(".maps"); - -// Helper: return 0 = allow, <0 reject -static __always_inline int allow_uid(struct bpf_sock_addr *ctx) { - // MVP: just accept everyone; you can gate on UID 2017 with bpf_get_current_uid_gid() - // __u32 uid = (__u32)(bpf_get_current_uid_gid() & 0xffffffff); - // if (uid != 2017) return -1; - return 0; -} - -// Hook: cgroup/connect4 — runs before connect(2) proceeds -SEC("cgroup/connect4") -int subu_connect4(struct bpf_sock_addr *ctx) -{ - if (allow_uid(ctx) < 0) return -1; - // Future: read pinned map/meta, set SO_* via bpf_setsockopt when permitted - return 0; -} - -// Hook: cgroup/post_bind4 — runs after a local bind is chosen -SEC("cgroup/post_bind4") -int subu_post_bind4(struct bpf_sock *sk) -{ - // Future: enforce bound dev if kernel helper allows; record tgid->ifindex - __u32 tgid = bpf_get_current_pid_tgid() >> 32; - __u32 val = 0; - bpf_map_update_elem(&subu_tgid2if, &tgid, &val, BPF_ANY); - return 0; -} diff --git a/developer/manager/infrastructure/bpf_worker.py b/developer/manager/infrastructure/bpf_worker.py deleted file mode 100644 index 66aaf6d..0000000 --- a/developer/manager/infrastructure/bpf_worker.py +++ /dev/null @@ -1,84 +0,0 @@ -# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*- -""" -bpf_worker.py - -Cgroup + BPF orchestration for per-subu steering. - -5.4.1 ensure_mounts() -> None -5.4.2 install_steering(subu: Subu, wg_iface: str) -> None -5.4.3 remove_steering(subu: Subu) -> None -5.4.4 class BpfError(Exception) -""" -import os, subprocess, json -from pathlib import Path - -class BpfError(RuntimeError): pass - -def run(cmd, check=True): - r = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) - if check and r.returncode != 0: - raise BpfError(f"cmd failed: {' '.join(cmd)}\n{r.stderr}") - return r.stdout.strip() - -def ensure_mounts(): - # ensure bpf and cgroup v2 are mounted - try: - Path("/sys/fs/bpf").mkdir(parents=True, exist_ok=True) - run(["mount","-t","bpf","bpf","/sys/fs/bpf"], check=False) - except Exception: - pass - try: - Path("/sys/fs/cgroup").mkdir(parents=True, exist_ok=True) - run(["mount","-t","cgroup2","none","/sys/fs/cgroup"], check=False) - except Exception: - pass - -def cgroup_path(subu_id: str) -> str: - return f"/sys/fs/cgroup/{subu_id}" - -def install_steering(subu_id: str, netns: str, ifname: str): - ensure_mounts() - cg = Path(cgroup_path(subu_id)) - cg.mkdir(parents=True, exist_ok=True) - - # compile BPF - obj = Path("./bpf_force_egress.o") - src = Path("./bpf_force_egress.c") - if not src.exists(): - raise BpfError("bpf_force_egress.c missing next to manager") - - # Build object (requires clang/llc/bpftool) - run(["clang","-O2","-g","-target","bpf","-c",str(src),"-o",str(obj)]) - - # Load program into bpffs; attach to cgroup/inet4_connect + inet4_post_bind (MVP) - pinned = f"/sys/fs/bpf/{subu_id}_egress" - run(["bpftool","prog","loadall",str(obj),pinned], check=True) - - # Attach to hooks (MVP validation hooks) - # NOTE: these are safe no-ops for now; they validate UID and stash ifindex map. - for hook in ("cgroup/connect4","cgroup/post_bind4"): - run(["bpftool","cgroup","attach",cgroup_path(subu_id),"attach",hook,"pinned",f"{pinned}/prog_0"], check=False) - - # Write metadata for ifname (saved for future prog versions) - meta = {"ifname": ifname} - Path(f"/sys/fs/bpf/{subu_id}_meta.json").write_text(json.dumps(meta)) - -def remove_steering(subu_id: str): - cg = cgroup_path(subu_id) - # Detach whatever is attached - for hook in ("cgroup/connect4","cgroup/post_bind4"): - subprocess.run(["bpftool","cgroup","detach",cg,"detach",hook], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) - # Remove pinned prog dir - pinned = Path(f"/sys/fs/bpf/{subu_id}_egress") - if pinned.exists(): - subprocess.run(["bpftool","prog","detach",str(pinned)], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) - try: - for p in pinned.glob("*"): p.unlink() - pinned.rmdir() - except Exception: - pass - # Remove cgroup dir - try: - Path(cg).rmdir() - except Exception: - pass diff --git a/developer/manager/infrastructure/db.py b/developer/manager/infrastructure/db.py deleted file mode 100644 index e4ef48c..0000000 --- a/developer/manager/infrastructure/db.py +++ /dev/null @@ -1,50 +0,0 @@ -# infrastructure/db.py -# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*- - -import sqlite3 -from pathlib import Path -import env - - -def schema_path_default(): - """ - Path to schema.sql, assumed to live next to this file. - """ - return Path(__file__).with_name("schema.sql") - - -def open_db(path=None): - """ - Return a sqlite3.Connection with sensible pragmas. - Caller is responsible for closing. - - If path is None, the canonical manager DB path from env.db_path() - is used. The parent directory is created if it does not exist. - """ - if path is None: - path = env.db_path() - - path_obj = Path(path) - parent = path_obj.parent - - try: - parent.mkdir(parents=True, exist_ok=True) - except PermissionError as e: - raise RuntimeError(f"cannot create DB directory '{parent}': {e}") from e - - conn = sqlite3.connect(str(path_obj)) - conn.row_factory = sqlite3.Row - conn.execute("PRAGMA foreign_keys = ON") - conn.execute("PRAGMA journal_mode = WAL") - conn.execute("PRAGMA synchronous = NORMAL") - return conn - - -def ensure_schema(conn): - """ - Ensure the schema in schema.sql is applied. - This is idempotent: executing the DDL again is acceptable. - """ - sql = schema_path_default().read_text(encoding="utf-8") - conn.executescript(sql) - conn.commit() 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/options_store.py b/developer/manager/infrastructure/options_store.py deleted file mode 100644 index 913a383..0000000 --- a/developer/manager/infrastructure/options_store.py +++ /dev/null @@ -1,58 +0,0 @@ -# infrastructure/options_store.py -# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*- - -from pathlib import Path - -# Options file lives next to CLI in the manager release tree. -# In dev it will be the same relative layout. -OPTIONS_FILE = Path("subu.options") - - -def load_options(): - """ - Load options from subu.options into a dictionary. - - Lines are of the form: key=value - Lines starting with '#' or blank lines are ignored. - """ - opts = {} - if not OPTIONS_FILE.exists(): - return opts - text = OPTIONS_FILE.read_text(encoding="utf-8") - for line in text.splitlines(): - line = line.strip() - if not line or line.startswith("#"): - continue - if "=" not in line: - continue - k, v = line.split("=", 1) - opts[k.strip()] = v.strip() - return opts - - -def save_options(opts: dict): - """ - Save a dictionary of options back to subu.options. - """ - lines = [] - for k in sorted(opts.keys()): - v = opts[k] - lines.append(f"{k}={v}\n") - OPTIONS_FILE.write_text("".join(lines), encoding="utf-8") - - -def set_option(name: str, value: str): - """ - Set a single option key to a value. - """ - opts = load_options() - opts[name] = value - save_options(opts) - - -def get_option(name: str, default=None): - """ - Get an option value by name, or default if missing. - """ - opts = load_options() - return opts.get(name, default) diff --git a/developer/manager/infrastructure/schema.sql b/developer/manager/infrastructure/schema.sql deleted file mode 100644 index 6ed1fd3..0000000 --- a/developer/manager/infrastructure/schema.sql +++ /dev/null @@ -1,39 +0,0 @@ --- schema.sql --- --- Schema for subu manager, including device-aware subu tracking. - --- Devices that can hold one or more masu homes. --- Each row represents a physical (or logical) storage volume --- identified by a mapname like 'Eagle' and optionally by UUIDs. -CREATE TABLE device ( - id INTEGER PRIMARY KEY, - mapname TEXT NOT NULL UNIQUE, -- e.g. 'Eagle' - fs_uuid TEXT, -- filesystem UUID (optional) - luks_uuid TEXT, -- LUKS UUID (optional) - mount_point TEXT NOT NULL, -- e.g. '/mnt/Eagle' - kind TEXT NOT NULL DEFAULT 'external', -- 'local','external','encrypted',... - state TEXT NOT NULL DEFAULT 'offline', -- 'online','offline','error' - last_seen TEXT NOT NULL -- ISO8601 UTC timestamp -); - --- parents via parent_id; one row per node in the tree -CREATE TABLE subu_node ( - id INTEGER PRIMARY KEY, - owner TEXT NOT NULL, -- masu - name TEXT NOT NULL, -- this segment (e.g., developer, bolt) - parent_id INTEGER, -- NULL for top-level subu under owner - full_unix_name TEXT NOT NULL UNIQUE, -- e.g., Thomas_developer_bolt - full_path TEXT NOT NULL, -- e.g., "Thomas developer bolt" - netns_name TEXT NOT NULL, -- default = full_unix_name - device_id INTEGER, -- NULL=local - is_online INTEGER NOT NULL DEFAULT 1, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - FOREIGN KEY(parent_id) REFERENCES subu_node(id), - FOREIGN KEY(device_id) REFERENCES device(id), - UNIQUE(owner, name, parent_id) -- no duplicate siblings -); - -CREATE INDEX idx_node_owner_parent ON subu_node(owner, parent_id); -CREATE INDEX idx_node_device ON subu_node(device_id); - 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/text.py b/developer/manager/text.py deleted file mode 100644 index 4fa2f77..0000000 --- a/developer/manager/text.py +++ /dev/null @@ -1,136 +0,0 @@ -# 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" - "\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 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" - f" {p} WG info|information \n" - 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 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" - "\n" - ) - - def example(self) -> str: - """ - Return an example workflow. - """ - p = self.program_name - return ( - f"Example workflow:\n" - "\n" - f" # 1. As root, create or update the manager database schema\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" - "\n" - f" # 3. As root, create a nested subu 'bolt' under Thomas/developer\n" - f" sudo {p} subu make Thomas developer bolt\n" - "\n" - f" # 4. As any user, list all known subu\n" - f" {p} subu list\n" - "\n" - f" # 5. Show detailed info by path\n" - f" {p} subu info Thomas developer bolt\n" - "\n" - f" # 6. Later, remove the nested subu by ID\n" - f" sudo {p} subu remove subu_3\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/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())