From: Thomas Walker Lynch Date: Tue, 4 Nov 2025 14:48:47 +0000 (+0000) Subject: 0.3.2 X-Git-Url: https://git.reasoningtechnology.com/style/rt_dark_doc.css?a=commitdiff_plain;h=c7e00fb722a5dee41400ebe69dfcd55210d20059;p=subu 0.3.2 --- diff --git a/developer/document/manager.org b/developer/document/manager.org new file mode 100644 index 0000000..77dcebb --- /dev/null +++ b/developer/document/manager.org @@ -0,0 +1,289 @@ +#+TITLE: Subu Manager Specification +#+AUTHOR: Reasoning Technology / Thomas Walker Lynch +#+DATE: 2025-11-03 +#+LANGUAGE: en +#+STARTUP: overview +#+PROPERTY: header-args :results output + +* Overview +The *Subu Manager* is a command-line orchestration tool for creating and managing +*lightweight user-containers* (“subus”) with isolated namespaces, private +WireGuard interfaces, and enforced network routing rules. + +It unifies several Linux primitives: + +- Unix users and groups (for identity & filesystem isolation) +- Network namespaces (for network isolation) +- WireGuard interfaces (for VPN / tunnel endpoints) +- eBPF cgroup programs (for routing enforcement) +- SQLite database (for persistence and state tracking) + +The manager is designed to evolve toward a full *Subu Light Container System*, +where each user has nested subordinate users, and each subu can have its own +network, security policies, and forwarding rules. + +--- + +* Architecture Summary +** Components +1. =CLI.py= :: command-line interface +2. =core.py= :: high-level orchestration logic +3. =db.py= (planned) :: schema definition and migration +4. =userutil.py= (planned) :: Unix account and group management helpers +5. =netutil.py= (planned) :: namespace and interface creation +6. =bpfutil.py= (planned) :: cgroup/eBPF setup + +** Data persistence +All persistent configuration lives in =subu.db= (SQLite). +This file contains: +- *meta* :: creation time, schema version +- *subu* :: all subuser accounts and their namespace info +- *wg* :: WireGuard endpoints +- *links* :: relationships between subu and wg interfaces +- *options* :: boolean or key/value runtime options + +--- + +* Command Overview +Each =CLI.py= command corresponds to a top-level operation. The CLI delegates +to core functions in =core.py=. + +| Command | Description | Implementation | +|----------+--------------+----------------| +| =init= | Create the SQLite DB and schema | `core.init_db()` | +| =make= | Create a new subu hierarchy (user, netns, groups) | `core.make_subu(path_tokens)` | +| =info= / =information= | Print full record of a subu | `core.get_subu_info()` | +| =WG= | Manage WireGuard objects and their mapping | `core.create_wg()`, `core.attach_wg()` | +| =attach= / =detach= | Link or unlink WG interface to subu namespace | `core.attach_wg_to_subu()` | +| =network up/down= | Bring up or down all attached ifaces | `core.network_toggle()` | +| =lo up/down= | Bring loopback up/down in subu netns | `core.lo_toggle()` | +| =option add/remove/list= | Manage options | `core.option_add()` etc. | +| =exec= | Run command inside subu netns | `core.exec_in_netns()` | +| =help= / =usage= / =example= | Documentation commands | CLI only | +| =version= | Print program version | constant in `core.VERSION` | + +--- + +* Subu Creation Flow (=make=) + +** Syntax +#+begin_example +./CLI.py make Thomas new-subu Rabbit +#+end_example + +** Behavior +- Verifies that *parent path* (all but last token) exists. + - If two-level (e.g. =Thomas US=), requires Unix user =Thomas= exists. + - If deeper (e.g. =Thomas new-subu Rabbit=), requires DB entry for + =Thomas_new-subu=. +- Allocates next available subu ID (first free integer). +- Inserts row in DB with: + - =id=, =owner=, =name=, =full_unix_name=, =path=, =netns_name= +- Creates network namespace =ns-subu_= +- Brings =lo= down inside that namespace. +- Ensures Unix groups: + - == + - =-incommon= +- Ensures Unix user: + - =_...= (underscores for hierarchy) +- Adds new user to both groups. + +** Implementation +#+begin_src python +def make_subu(path_tokens: list[str]) -> str: + # 1. Validate hierarchy, check parent + # 2. Allocate ID (via _first_free_id) + # 3. Insert into DB (open_db) + # 4. Create netns (ip netns add ...) + # 5. Ensure groups/users (useradd, groupadd) + # 6. Return subu_X identifier +#+end_src + +--- + +* User and Group Management + +** Goals +Each subu is a Linux user; hierarchy is mirrored in usernames: +#+begin_example +Thomas_US +Thomas_US_Rabbit +Thomas_local +#+end_example + +Each subu belongs to: +- group =Thomas= +- group =Thomas-incommon= + +** Implementation Functions +#+begin_src python +def _group_exists(name): ... +def _user_exists(name): ... +def _ensure_group(name): ... +def _ensure_user(name, primary_group): ... +def _add_user_to_group(user, group): ... +#+end_src + +--- + +* Database Schema (summary) + +#+begin_src sql +CREATE TABLE meta ( + key TEXT PRIMARY KEY, + value TEXT +); + +CREATE TABLE subu ( + id INTEGER PRIMARY KEY, + owner TEXT NOT NULL, + name TEXT NOT NULL, + full_unix_name TEXT NOT NULL UNIQUE, + path TEXT NOT NULL, + netns_name TEXT NOT NULL, + wg_id INTEGER, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE wg ( + id INTEGER PRIMARY KEY, + endpoint TEXT, + local_ip TEXT, + server_pubkey TEXT, + created_at TEXT, + updated_at TEXT +); + +CREATE TABLE links ( + subu_id INTEGER, + wg_id INTEGER, + FOREIGN KEY(subu_id) REFERENCES subu(id), + FOREIGN KEY(wg_id) REFERENCES wg(id) +); + +CREATE TABLE options ( + subu_id INTEGER, + name TEXT, + value TEXT, + FOREIGN KEY(subu_id) REFERENCES subu(id) +); +#+end_src + +--- + +* Networking and Namespaces +Each subu has a private namespace. + +** Steps +1. =ip netns add ns-subu_= +2. =ip netns exec ns-subu_ ip link set lo down= +3. Optionally attach WG interfaces (later). + +** Implementation +#+begin_src python +def _create_netns_for_subu(subu_id_num, netns_name): + run(["ip", "netns", "add", netns_name]) + run(["ip", "netns", "exec", netns_name, "ip", "link", "set", "lo", "down"]) +#+end_src + +--- + +* WireGuard Integration +Each subu may have exactly one WG interface. + +** Workflow +1. Allocate new WG object via =subu WG create = +2. Record server-provided key via =subu WG server_provided_public_key= +3. Attach interface via =subu attach WG = +4. Bring network up (includes WG admin up). + +** Implementation (planned) +#+begin_src python +def create_wg(endpoint): ... +def attach_wg_to_subu(subu_id, wg_id): ... +def wg_up(wg_id): ... +def wg_down(wg_id): ... +#+end_src + +--- + +* eBPF Steering (Planned) +The manager will attach an eBPF program to the subu’s cgroup that: + +- Hooks =connect()=, =bind()=, =sendmsg()= +- Forces =SO_BINDTOIFINDEX=subu_= for all sockets created by the subu +- Guarantees all UID traffic egresses through its WG interface +- Reuses kernel routing for MTU/GSO logic, but overrides device binding + +** Implementation Sketch +#+begin_src python +def attach_egress_bpf(subu_id, ifindex): + # load compiled eBPF ELF (bpf_prog_load) + # attach to cgroup of the subu user (BPF_PROG_ATTACH) + pass +#+end_src + +--- + +* Options and Policies + +Options are persisted flags controlling runtime behavior. + +| Option Name | Purpose | Default | +|--------------+----------+----------| +| =local_forwarding= | Enable 127/8 forwarding to WG peer | off | +| =steer_enabled= | Enable cgroup eBPF steering | on | + +** Implementation +#+begin_src python +def option_add(subu_id, name): + set_option(subu_id, name, "1") + +def option_remove(subu_id, name): + db.execute("DELETE FROM options WHERE subu_id=? AND name=?", ...) +#+end_src + +--- + +* Command Examples + +#+begin_example +# 0) Initialize +CLI.py init +# -> creates ./subu.db + +# 1) Create first subu +CLI.py make Thomas US +# -> user Thomas_US, netns ns-subu_0 + +# 2) Create hierarchical subu +CLI.py make Thomas new-subu Rabbit +# -> requires Thomas_new-subu exists + +# 3) Bring network up +CLI.py network up subu_0 + +# 4) Create WireGuard pool and object +CLI.py WG global 192.168.112.0/24 +CLI.py WG create ReasoningTechnology.com:51820 + +# 5) Attach and activate +CLI.py attach WG subu_0 WG_0 +CLI.py WG up WG_0 + +# 6) Inspect +CLI.py info subu_0 +CLI.py option list subu_0 +#+end_example + +--- + +* Future Work +1. 127/8 forwarding rewrite & mapping +2. Server-side sifter for mapped local addresses +3. GUI configuration (subu-light control panel) +4. BPF loader / verifier integration +5. Persistent daemon mode for live control +6. Automated namespace cleanup and audit +7. JSON-RPC or REST management API diff --git a/developer/document/notes.txt b/developer/document/notes.txt new file mode 100644 index 0000000..ed89643 --- /dev/null +++ b/developer/document/notes.txt @@ -0,0 +1,28 @@ +Domain modules + +subu.py + +wg.py + +network.py + +options.py + +exec.py (as in “execute command in subu”) + + +Infrastructure modules + +db.py + +unix.py + +bpf.py + +bpf_worker.py + +bpf_force_egress.c + +schema.sql + +parser.py (either merged into CLI.py or deleted, see below) diff --git a/developer/manager.tgz b/developer/manager.tgz deleted file mode 100644 index f802d65..0000000 Binary files a/developer/manager.tgz and /dev/null differ diff --git a/developer/manager/CLI.py b/developer/manager/CLI.py index a79691e..23278d7 100755 --- a/developer/manager/CLI.py +++ b/developer/manager/CLI.py @@ -1,146 +1,252 @@ #!/usr/bin/env python3 # -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*- """ -CLI.py — thin command-line harness -Version: 0.2.0 +1. CLI.py + dispatch. + +Role: parse argv, choose command, call +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 sys, argparse -from text import USAGE, HELP, EXAMPLE, VERSION -import core +from text import make_text +import dispatch -def CLI(argv=None) -> int: - argv = argv or sys.argv[1:] - if not argv: - print(USAGE) - return 0 - # simple verbs that bypass argparse (so `help/version/example` always work) - simple = {"help": HELP, "--help": HELP, "-h": HELP, "usage": USAGE, "example": EXAMPLE, "version": VERSION} - if argv[0] in simple: - out = simple[argv[0]] - print(out if isinstance(out, str) else out()) - return 0 +def build_arg_parser(program_name): + """ + 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") - p = argparse.ArgumentParser(prog="subu", add_help=False) - p.add_argument("-V", "--Version", action="store_true", help="print version") - sub = p.add_subparsers(dest="verb") + register_subu_commands(subparsers) + register_wireguard_commands(subparsers) + register_attach_commands(subparsers) + register_network_commands(subparsers) + register_option_commands(subparsers) + register_exec_commands(subparsers) + return parser + + +def register_subu_commands(subparsers): + """ + Register subu related commands: + init, make, list, info, information, lo + """ # init - ap = sub.add_parser("init") + ap = subparsers.add_parser("init") ap.add_argument("token", nargs="?") - # create/list/info - ap = sub.add_parser("create") + # make + ap = subparsers.add_parser("make") ap.add_argument("owner") ap.add_argument("name") - sub.add_parser("list") - ap = sub.add_parser("info"); ap.add_argument("subu_id") - ap = sub.add_parser("information"); ap.add_argument("subu_id") + # list + subparsers.add_parser("list") + + # info / information + ap = subparsers.add_parser("info") + ap.add_argument("subu_id") + ap = subparsers.add_parser("information") + ap.add_argument("subu_id") # lo - ap = sub.add_parser("lo") + ap = subparsers.add_parser("lo") ap.add_argument("state", choices=["up","down"]) ap.add_argument("subu_id") - # WG - ap = sub.add_parser("WG") - ap.add_argument("verb", choices=["global","create","server_provided_public_key","info","information","up","down"]) + +def register_wireguard_commands(subparsers): + """ + Register WireGuard related commands, grouped under 'WG': + WG global + WG make + WG server_provided_public_key + WG info|information + WG up|down + """ + 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="?") - # attach/detach - ap = sub.add_parser("attach") + +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 = sub.add_parser("detach") + ap = subparsers.add_parser("detach") ap.add_argument("what", choices=["WG"]) ap.add_argument("subu_id") - # network - ap = sub.add_parser("network") + +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") - # option - ap = sub.add_parser("option") - ap.add_argument("verb", choices=["set","get","list"]) + +def register_option_commands(subparsers): + """ + Register option commands: + 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="?") - # exec - ap = sub.add_parser("exec") + +def register_exec_commands(subparsers): + """ + Register exec command: + exec -- ... + """ + ap = subparsers.add_parser("exec") ap.add_argument("subu_id") + # Use a dedicated "--" argument so that: + # subu exec subu_7 -- curl -4v https://ifconfig.me + # works as before. ap.add_argument("--", dest="cmd", nargs=argparse.REMAINDER, default=[]) - ns = p.parse_args(argv) - if ns.Version: - print(VERSION); return 0 + +def CLI(argv=None) -> int: + """ + Top level entry point for the subu manager CLI. + """ + if argv is None: + argv = sys.argv[1:] + + # For now we fix the program name to "subu". + # A release wrapper can later pass a different program name. + program_name = "subu" + text = make_text(program_name) + + # No arguments is the same as "help". + if not argv: + print(text.help(), end="") + return 0 + + # Simple verbs that bypass argparse so they always work. + 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 try: if ns.verb == "init": - return core.cmd_init(ns.token) + return dispatch.init(ns.token) + + if ns.verb == "make": + return dispatch.subu_make(ns.owner, ns.name) - if ns.verb == "create": - core.create_subu(ns.owner, ns.name); return 0 if ns.verb == "list": - core.list_subu(); return 0 + return dispatch.subu_list() + if ns.verb in ("info","information"): - core.info_subu(ns.subu_id); return 0 + return dispatch.subu_info(ns.subu_id) if ns.verb == "lo": - core.lo_toggle(ns.subu_id, ns.state); return 0 + return dispatch.lo_toggle(ns.subu_id, ns.state) if ns.verb == "WG": - v = ns.verb - if ns.arg1 is None and v in ("info","information"): - print("WG info requires WG_ID"); return 2 + 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": - core.wg_global(ns.arg1); return 0 - if v == "create": - wid = core.wg_create(ns.arg1); print(wid); return 0 + return dispatch.wg_global(ns.arg1) + if v == "make": + return dispatch.wg_make(ns.arg1) if v == "server_provided_public_key": - core.wg_set_pubkey(ns.arg1, ns.arg2); return 0 + return dispatch.wg_server_public_key(ns.arg1, ns.arg2) if v in ("info","information"): - core.wg_info(ns.arg1); return 0 + return dispatch.wg_info(ns.arg1) if v == "up": - core.wg_up(ns.arg1); return 0 + return dispatch.wg_up(ns.arg1) if v == "down": - core.wg_down(ns.arg1); return 0 + return dispatch.wg_down(ns.arg1) if ns.verb == "attach": if ns.what == "WG": - core.attach_wg(ns.subu_id, ns.wg_id); return 0 + return dispatch.attach_wg(ns.subu_id, ns.wg_id) if ns.verb == "detach": if ns.what == "WG": - core.detach_wg(ns.subu_id); return 0 + return dispatch.detach_wg(ns.subu_id) if ns.verb == "network": - core.network_toggle(ns.subu_id, ns.state); return 0 + return dispatch.network_toggle(ns.subu_id, ns.state) if ns.verb == "option": - if ns.verb == "option" and ns.name is None and ns.value is None and ns.verb == "list": - core.option_list(ns.subu_id); return 0 - if ns.verb == "set": - core.option_set(ns.subu_id, ns.name, ns.value); return 0 - if ns.verb == "get": - core.option_get(ns.subu_id, ns.name); return 0 - if ns.verb == "list": - core.option_list(ns.subu_id); return 0 + if ns.action == "set": + return dispatch.option_set(ns.subu_id, ns.name, ns.value) + if ns.action == "get": + return dispatch.option_get(ns.subu_id, ns.name) + if ns.action == "list": + return dispatch.option_list(ns.subu_id) if ns.verb == "exec": if not ns.cmd: - print("subu exec -- ..."); return 2 - core.exec_in_subu(ns.subu_id, ns.cmd); return 0 + print(f"{program_name} exec -- ...", file=sys.stderr) + return 2 + return dispatch.exec(ns.subu_id, ns.cmd) + + # If we reach here, the verb was not recognised. + print(text.usage(), end="") + return 2 - print(USAGE); return 2 except Exception as e: - print(f"error: {e}") + print(f"error: {e}", file=sys.stderr) return 1 + if __name__ == "__main__": sys.exit(CLI()) diff --git a/developer/manager/bpf.py b/developer/manager/bpf.py deleted file mode 100644 index 527d419..0000000 --- a/developer/manager/bpf.py +++ /dev/null @@ -1,52 +0,0 @@ - -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}" - # create 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: create 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/bpf_force_egress.c b/developer/manager/bpf_force_egress.c deleted file mode 100644 index c3aedec..0000000 --- a/developer/manager/bpf_force_egress.c +++ /dev/null @@ -1,43 +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 -// Version 0.2.0 -#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/bpf_worker.py b/developer/manager/bpf_worker.py deleted file mode 100644 index 96aef14..0000000 --- a/developer/manager/bpf_worker.py +++ /dev/null @@ -1,78 +0,0 @@ -# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*- -""" -worker_bpf.py — create per-subu cgroups and load eBPF (MVP) -Version: 0.2.0 -""" -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/core.py b/developer/manager/core.py deleted file mode 100644 index f66cdf9..0000000 --- a/developer/manager/core.py +++ /dev/null @@ -1,22 +0,0 @@ -# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*- -""" -core.py — worker API for subu manager -Version: 0.2.0 -""" -import os, sqlite3, subprocess -from pathlib import Path -from contextlib import closing -from text import VERSION -from worker_bpf import ensure_mounts, install_steering, remove_steering, BpfError -import db - -DB_FILE = Path("./subu.db") -WG_GLOBAL_FILE = Path("./WG_GLOBAL") - -def run(cmd, check=True): - 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.stdout.strip() - - diff --git a/developer/manager/db.py b/developer/manager/db.py deleted file mode 100644 index c42b2bc..0000000 --- a/developer/manager/db.py +++ /dev/null @@ -1,86 +0,0 @@ -import os -import pwd -import grp -import subprocess -from contextlib import closing - -def _db(): - if not DB_FILE.exists(): - raise FileNotFoundError("subu.db not found; run `subu init ` first") - return sqlite3.connect(DB_FILE) - -def init_db(path: str = DB_PATH): - """ - Initialise subu.db if missing; refuse to overwrite existing file. - """ - if os.path.exists(path): - print(f"subu: db already exists at {path}") - return - - with closing(sqlite3.connect(path)) as db: - db.executescript(SCHEMA_SQL) - db.execute( - "INSERT INTO meta(key,value) VALUES ('created_at', datetime('now'))" - ) - db.commit() - print(f"subu: created new db at {path}") - - -def cmd_init(token: str|None): - if DB_FILE.exists(): - raise FileExistsError("db already exists") - if not token or len(token) < 6: - raise ValueError("init requires a 6+ char token") - with closing(sqlite3.connect(DB_FILE)) as db: - c = db.cursor() - c.executescript(""" - CREATE TABLE subu ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - owner TEXT, - name TEXT, - netns TEXT, - lo_state TEXT DEFAULT 'down', - wg_id INTEGER, - network_state TEXT DEFAULT 'down' - ); - CREATE TABLE wg ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - endpoint TEXT, - local_ip TEXT, - allowed_ips TEXT, - pubkey TEXT, - state TEXT DEFAULT 'down' - ); - CREATE TABLE options ( - subu_id INTEGER, - name TEXT, - value TEXT, - PRIMARY KEY (subu_id, name) - ); - """) - db.commit() - print(f"created subu.db (v{VERSION})") - -def _first_free_id(db, table: str) -> int: - """ - Return the smallest non-negative integer not in table.id. - Assumes 'id' INTEGER PRIMARY KEY in that table. - """ - rows = db.execute(f"SELECT id FROM {table} ORDER BY id ASC").fetchall() - used = {r[0] for r in rows} - i = 0 - while i in used: - i += 1 - return i - -def get_subu_by_full_unix_name(full_unix_name: str): - """ - Return the DB row for a subu with this full_unix_name, or None. - """ - with closing(open_db()) as db: - row = db.execute( - "SELECT id, owner, name, full_unix_name, path, netns_name " - "FROM subu WHERE full_unix_name = ?", - (full_unix_name,) - ).fetchone() - return row diff --git a/developer/manager/dispatch.py b/developer/manager/dispatch.py new file mode 100644 index 0000000..d0cce3c --- /dev/null +++ b/developer/manager/dispatch.py @@ -0,0 +1,167 @@ +#!/usr/bin/env python3 +# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*- +""" +dispatch.py + +Role: provide one function for each CLI verb so that: + + * CLI.py can call these functions + * Other Python code can also import and call them directly + +Each function should return an integer status code where practical. + +Implementation note: + + At this stage of the refactor, the functions are stubs. They define the + public interface and may raise NotImplementedError. As the domain modules + under domain/ are completed (subu.py, wg.py, network.py, options.py, + exec.py), these functions should be updated to call into those modules. +""" +# dispatch.py +from domain import subu as subu_domain + +def init(token=None): + """ + Initialize ./subu.db using schema.sql + token is currently unused but kept for CLI compatibility. + """ + # open_db + ensure_schema via domain layer convenience + from infrastructure.db import open_db, ensure_schema + conn = open_db("subu.db") + try: + ensure_schema(conn) + return 0 + finally: + conn.close() + + +def subu_make(owner, name): + try: + s = subu_domain.make_subu(owner, name) + # print the made ID or username like your older CLI did + print(f"made subu: id={s.id} username={s.username}") + return 0 + except Exception as e: + print(f"error creating subu: {e}", file=sys.stderr) + return 1 + + +def subu_list(): + try: + subs = subu_domain.list_subu() + if not subs: + print("no subu found") + return 0 + # simple table + print("ID OWNER NAME USERNAME CREATED_AT") + for s in subs: + print(f"{s.id} {s.owner} {s.name} {s.username} {s.made_at}") + return 0 + except Exception as e: + print(f"error listing subu: {e}", file=sys.stderr) + return 1 + + +def subu_info(subu_id): + """ + Handle: subu info|information + """ + raise NotImplementedError("subu_info is not yet implemented") + + +def lo_toggle(subu_id, state): + """ + Handle: subu lo up|down + """ + raise NotImplementedError("lo_toggle is not yet implemented") + + +def wg_global(base_cidr): + """ + Handle: subu WG global + """ + raise NotImplementedError("wg_global is not yet implemented") + + +def wg_make(endpoint): + """ + Handle: subu WG make + """ + raise NotImplementedError("wg_make is not yet implemented") + + +def wg_server_public_key(wg_id, key): + """ + Handle: subu WG server_provided_public_key + """ + raise NotImplementedError("wg_server_public_key is not yet implemented") + + +def wg_info(wg_id): + """ + Handle: subu WG info|information + """ + raise NotImplementedError("wg_info is not yet implemented") + + +def wg_up(wg_id): + """ + Handle: subu WG up + """ + raise NotImplementedError("wg_up is not yet implemented") + + +def wg_down(wg_id): + """ + Handle: subu WG down + """ + raise NotImplementedError("wg_down is not yet implemented") + + +def attach_wg(subu_id, wg_id): + """ + Handle: subu attach WG + """ + raise NotImplementedError("attach_wg is not yet implemented") + + +def detach_wg(subu_id): + """ + Handle: subu detach WG + """ + raise NotImplementedError("detach_wg is not yet implemented") + + +def network_toggle(subu_id, state): + """ + Handle: subu network up|down + """ + raise NotImplementedError("network_toggle is not yet implemented") + + +def option_set(subu_id, name, value): + """ + Handle: subu option set + """ + raise NotImplementedError("option_set is not yet implemented") + + +def option_get(subu_id, name): + """ + Handle: subu option get + """ + raise NotImplementedError("option_get is not yet implemented") + + +def option_list(subu_id): + """ + Handle: subu option list + """ + raise NotImplementedError("option_list is not yet implemented") + + +def exec(subu_id, cmd_argv): + """ + Handle: subu exec -- ... + """ + raise NotImplementedError("exec is not yet implemented") diff --git a/developer/manager/domain/exec.py b/developer/manager/domain/exec.py new file mode 100644 index 0000000..0826560 --- /dev/null +++ b/developer/manager/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/domain/network.py b/developer/manager/domain/network.py new file mode 100644 index 0000000..2ea77b4 --- /dev/null +++ b/developer/manager/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/domain/options.py b/developer/manager/domain/options.py new file mode 100644 index 0000000..76fd97e --- /dev/null +++ b/developer/manager/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/domain/subu.py b/developer/manager/domain/subu.py new file mode 100644 index 0000000..7f77f86 --- /dev/null +++ b/developer/manager/domain/subu.py @@ -0,0 +1,200 @@ +""" +4.1 domain/subu.py + +Subu objects: creation, lookup, hierarchy, netns identity. + +4.1.1 make_subu(owner: str, name: str) -> Subu +4.1.2 list_subu() -> list[Subu] +4.1.3 get_subu(subu_id: str) -> Subu +4.1.4 ensure_unix_identity(subu: Subu) -> None +4.1.5 ensure_netns(subu: Subu) -> None + +(A Subu can be a dataclass or NamedTuple.) +""" + +# domain/subu.py +from dataclasses import dataclass +from infrastructure.db import open_db, ensure_schema +import sqlite3 +import time + +DB_PATH = "subu.db" + + +@dataclass +class Subu: + id: int + owner: str + name: str + username: str + made_at: str + + +def _make_username(owner, name): + # simple deterministic username: owner_name -> owner_name (no spaces) + owner_s = owner.replace(" ", "_") + name_s = name.replace(" ", "_") + return f"{owner_s}_{name_s}" + + +def make_subu(owner: str, name: str) -> Subu: + """ + Create a subu row in subu.db and return the Subu dataclass. + """ + conn = open_db(DB_PATH) + try: + ensure_schema(conn) + cur = conn.cursor() + username = _make_username(owner, name) + made_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + cur.execute( + "INSERT INTO subu (owner, name, username, made_at) VALUES (?, ?, ?, ?)", + (owner, name, username, made_at), + ) + conn.commit() + rowid = cur.lastrowid + row = conn.execute("SELECT id, owner, name, username, made_at FROM subu WHERE id = ?", (rowid,)).fetchone() + return Subu(row["id"], row["owner"], row["name"], row["username"], row["made_at"]) + finally: + conn.close() + + +def list_subu(): + """ + Return a list of Subu objects currently in the DB. + """ + conn = open_db(DB_PATH) + try: + ensure_schema(conn) + rows = conn.execute("SELECT id, owner, name, username, made_at FROM subu ORDER BY id").fetchall() + return [Subu(r["id"], r["owner"], r["name"], r["username"], r["made_at"]) for r in rows] + finally: + conn.close() + +def info_subu(subu_id: str): + sid = int(subu_id.split("_")[1]) + with closing(_db()) as db: + row = db.execute("SELECT * FROM subu WHERE id=?", (sid,)).fetchone() + if not row: + print("not found"); return + print(row) + wg = db.execute("SELECT wg_id FROM subu WHERE id=?", (sid,)).fetchone()[0] + if wg is not None: + wrow = db.execute("SELECT * FROM wg WHERE id=?", (wg,)).fetchone() + print("WG:", wrow) + opts = db.execute("SELECT name,value FROM options WHERE subu_id=?", (sid,)).fetchall() + print("Options:", opts) + +def lo_toggle(subu_id: str, state: str): + sid = int(subu_id.split("_")[1]) + with closing(_db()) as db: + ns = db.execute("SELECT netns FROM subu WHERE id=?", (sid,)).fetchone() + if not ns: raise ValueError("subu not found") + ns = ns[0] + run(["ip", "netns", "exec", ns, "ip", "link", "set", "lo", state]) + db.execute("UPDATE subu SET lo_state=? WHERE id=?", (state, sid)) + db.commit() + print(f"{subu_id}: lo {state}") + +# ---------------- High-level Subu factory ---------------- + +def make_subu(path_tokens: list[str]) -> str: + """ + Create a new Subu with hierarchical name and full wiring: + + path_tokens: ['Thomas', 'US'] or ['Thomas', 'new-subu', 'Rabbit'] + + Rules: + - len(path_tokens) >= 2 + - parent path (everything except last token) must already exist + as: + * a Unix user (for len==2: just the top-level user, e.g. 'Thomas') + * and as a Subu in our DB if len > 2 (e.g. 'Thomas_new-subu') + - new Unix user name is path joined by '_', e.g. 'Thomas_new-subu_Rabbit' + - mas u(root) is path_tokens[0] + - groups: + + -incommon + + Side effects: + - DB row in 'subu' (id, owner, name, full_unix_name, path, netns_name, ...) + - netns ns-subu_ made with lo down + - Unix user made/ensured + - Unix groups ensured and membership updated + + Returns: textual Subu_ID, e.g. 'subu_7'. + """ + if not path_tokens or len(path_tokens) < 2: + raise SystemExit("subu: make requires at least two path elements, e.g. 'Thomas US'") + + # Normalised pieces + path_tokens = [p.strip() for p in path_tokens if p.strip()] + if len(path_tokens) < 2: + raise SystemExit("subu: make requires at least two non-empty path elements") + + masu = path_tokens[0] # root user / owner + leaf = path_tokens[-1] # new subu leaf + parent_tokens = path_tokens[:-1] # parent path + full_unix_name = "_".join(path_tokens) # e.g. 'Thomas_new-subu_Rabbit' + parent_unix_name = "_".join(parent_tokens) + path_str = " ".join(path_tokens) # e.g. 'Thomas new-subu Rabbit' + + # 1) Enforce parent existing + + # Case A: top-level subu (e.g. ['Thomas', 'US']) + if len(path_tokens) == 2: + # Require the root user to exist as a Unix user + if not _user_exists(masu): + raise SystemExit( + f"subu: cannot make '{path_str}': root user '{masu}' does not exist" + ) + else: + # Case B: deeper subu: require parent subu exists in our DB + parent_row = get_subu_by_full_unix_name(parent_unix_name) + if not parent_row: + raise SystemExit( + f"subu: cannot make '{path_str}': parent subu '{parent_unix_name}' does not exist" + ) + + # Also forbid duplicate full_unix_name + existing = get_subu_by_full_unix_name(full_unix_name) + if existing: + raise SystemExit( + f"subu: subu with name '{full_unix_name}' already exists (id=subu_{existing[0]})" + ) + + # 2) Insert DB row and allocate ID + netns_name + + with closing(open_db()) as db: + subu_id_num = _first_free_id(db, "subu") + netns_name = f"ns-subu_{subu_id_num}" + + db.execute( + "INSERT INTO subu(id, owner, name, full_unix_name, path, netns_name, wg_id, made_at, updated_at) " + "VALUES (?, ?, ?, ?, ?, ?, NULL, datetime('now'), datetime('now'))", + (subu_id_num, masu, leaf, full_unix_name, path_str, netns_name) + ) + db.commit() + + subu_id = f"subu_{subu_id_num}" + + # 3) Create netns + lo down + _make_netns_for_subu(subu_id_num, netns_name) + + # 4) Ensure Unix user + groups + + unix_user = full_unix_name + group_masu = masu + group_incommon = f"{masu}-incommon" + + _ensure_group(group_masu) + _ensure_group(group_incommon) + + _ensure_user(unix_user, group_masu) + _add_user_to_group(unix_user, group_masu) # mostly redundant but explicit + _add_user_to_group(unix_user, group_incommon) + + print(f"Created Subu {subu_id} for path '{path_str}' with Unix user '{unix_user}' " + f"and netns '{netns_name}'") + + return subu_id diff --git a/developer/manager/domain/wg.py b/developer/manager/domain/wg.py new file mode 100644 index 0000000..0cf6126 --- /dev/null +++ b/developer/manager/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/exec.py b/developer/manager/exec.py deleted file mode 100644 index f823d9a..0000000 --- a/developer/manager/exec.py +++ /dev/null @@ -1,6 +0,0 @@ - -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/infrastructure/bpf.py b/developer/manager/infrastructure/bpf.py new file mode 100644 index 0000000..16c4388 --- /dev/null +++ b/developer/manager/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/infrastructure/bpf_force_egress.c b/developer/manager/infrastructure/bpf_force_egress.c new file mode 100644 index 0000000..628cc83 --- /dev/null +++ b/developer/manager/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/infrastructure/bpf_worker.py b/developer/manager/infrastructure/bpf_worker.py new file mode 100644 index 0000000..66aaf6d --- /dev/null +++ b/developer/manager/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/infrastructure/db.py b/developer/manager/infrastructure/db.py new file mode 100644 index 0000000..83fb5c5 --- /dev/null +++ b/developer/manager/infrastructure/db.py @@ -0,0 +1,136 @@ +# infrastructure/db.py +import os +import pwd +import grp +import subprocess +from contextlib import closing +import sqlite3 +from pathlib import Path + +""" +5.1 infrastructure/db.py + +All SQLite access. + +5.1.1 open_db(path: str = "subu.db") -> sqlite3.Connection +5.1.2 ensure_schema(conn) -> None +5.1.3 insert_subu(conn, subu: Subu) -> None +5.1.4 fetch_subu(conn, subu_id: str) -> Subu +5.1.5 list_subu(conn) -> list[Subu] +5.1.6 insert_wg(conn, wg: WG) -> None +5.1.7 fetch_wg(conn, wg_id: str) -> WG +5.1.8 update_wg(conn, wg: WG) -> None +5.1.9 set_option_row(conn, subu_id: str, name: str, value: str) -> None +5.1.10 get_option_row(conn, subu_id: str, name: str) -> str | None +5.1.11 list_option_rows(conn, subu_id: str) -> dict[str, str] + +(Exact breakdown can be tuned when we see schema.sql.) +""" + +# infrastructure/db.py +# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*- + +def schema_path_default(): return Path(__file__).with_name("schema.sql") +def db_path_default(): return "." + +def open_db(path ="subu.db"): + """ + Return a sqlite3.Connection with sensible pragmas. + Caller is responsible for closing. + """ + conn = sqlite3.connect(path) + 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() + +def _db(): + if not DB_FILE.exists(): + raise FileNotFoundError("subu.db not found; run `subu init ` first") + return sqlite3.connect(DB_FILE) + +def init_db(path: str = db_path_default()): + """ + Initialise subu.db if missing; refuse to overwrite existing file. + """ + if os.path.exists(path): + print(f"subu: db already exists at {path}") + return + + with closing(sqlite3.connect(path)) as db: + db.executescript(SCHEMA_SQL) + db.execute( + "INSERT INTO meta(key,value) VALUES ('made_at', datetime('now'))" + ) + db.commit() + print(f"subu: made new db at {path}") + + +def cmd_init(token: str|None): + if DB_FILE.exists(): + raise FileExistsError("db already exists") + if not token or len(token) < 6: + raise ValueError("init requires a 6+ char token") + with closing(sqlite3.connect(DB_FILE)) as db: + c = db.cursor() + c.executescript(""" + CREATE TABLE subu ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + owner TEXT, + name TEXT, + netns TEXT, + lo_state TEXT DEFAULT 'down', + wg_id INTEGER, + network_state TEXT DEFAULT 'down' + ); + CREATE TABLE wg ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + endpoint TEXT, + local_ip TEXT, + allowed_ips TEXT, + pubkey TEXT, + state TEXT DEFAULT 'down' + ); + CREATE TABLE options ( + subu_id INTEGER, + name TEXT, + value TEXT, + PRIMARY KEY (subu_id, name) + ); + """) + db.commit() + print(f"made subu.db (v{VERSION})") + +def _first_free_id(db, table: str) -> int: + """ + Return the smallest non-negative integer not in table.id. + Assumes 'id' INTEGER PRIMARY KEY in that table. + """ + rows = db.execute(f"SELECT id FROM {table} ORDER BY id ASC").fetchall() + used = {r[0] for r in rows} + i = 0 + while i in used: + i += 1 + return i + +def get_subu_by_full_unix_name(full_unix_name: str): + """ + Return the DB row for a subu with this full_unix_name, or None. + """ + with closing(open_db()) as db: + row = db.execute( + "SELECT id, owner, name, full_unix_name, path, netns_name " + "FROM subu WHERE full_unix_name = ?", + (full_unix_name,) + ).fetchone() + return row diff --git a/developer/manager/infrastructure/schema.sql b/developer/manager/infrastructure/schema.sql new file mode 100644 index 0000000..ab8d80a --- /dev/null +++ b/developer/manager/infrastructure/schema.sql @@ -0,0 +1,17 @@ +-- schema.sql +-- +-- 5.6.1 read and executed by db.ensure_schema + + + +CREATE TABLE subu ( + id INTEGER PRIMARY KEY, + owner TEXT NOT NULL, -- root user, e.g. 'Thomas' + name TEXT NOT NULL, -- leaf, e.g. 'US', 'Rabbit' + full_unix_name TEXT NOT NULL UNIQUE, -- e.g. 'Thomas_US_Rabbit' + path TEXT NOT NULL, -- e.g. 'Thomas US Rabbit' + netns_name TEXT NOT NULL, + wg_id INTEGER, -- nullable for now + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); diff --git a/developer/manager/infrastructure/unix.py b/developer/manager/infrastructure/unix.py new file mode 100644 index 0000000..fec6d9a --- /dev/null +++ b/developer/manager/infrastructure/unix.py @@ -0,0 +1,41 @@ +""" +unix.py + +Thin wrappers for OS commands. + +5.2.1 run(cmd: list[str], check: bool = True) -> subprocess.CompletedProcess +5.2.2 ip(*args: str, check: bool = True) +5.2.3 ip_netns(*args: str, check: bool = True) +5.2.4 wg(*args: str, check: bool = True) + +Optional later: logging, dry-run, etc. +""" + +# ---------------- Unix users & groups ---------------- + +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_group(name: str): + if not _group_exists(name): + # groupadd + run(["groupadd", name]) + +def _ensure_user(name: str, primary_group: str): + if not _user_exists(name): + # useradd -m -g -s /bin/bash + run(["useradd", "-m", "-g", primary_group, "-s", "/bin/bash", name]) + +def _add_user_to_group(user: str, group: str): + run(["usermod", "-aG", group, user]) diff --git a/developer/manager/network.py b/developer/manager/network.py deleted file mode 100644 index 000bbf8..0000000 --- a/developer/manager/network.py +++ /dev/null @@ -1,24 +0,0 @@ - -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 _create_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/options.py b/developer/manager/options.py deleted file mode 100644 index 76b5caa..0000000 --- a/developer/manager/options.py +++ /dev/null @@ -1,23 +0,0 @@ - -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/parser.py b/developer/manager/parser.py deleted file mode 100644 index d0c2f47..0000000 --- a/developer/manager/parser.py +++ /dev/null @@ -1,32 +0,0 @@ -verbs = [ - "usage", - "help", - "example", - "version", - "init", - "make", - "create", - "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/schema.sql b/developer/manager/schema.sql deleted file mode 100644 index a33ae95..0000000 --- a/developer/manager/schema.sql +++ /dev/null @@ -1,11 +0,0 @@ -CREATE TABLE subu ( - id INTEGER PRIMARY KEY, - owner TEXT NOT NULL, -- root user, e.g. 'Thomas' - name TEXT NOT NULL, -- leaf, e.g. 'US', 'Rabbit' - full_unix_name TEXT NOT NULL UNIQUE, -- e.g. 'Thomas_US_Rabbit' - path TEXT NOT NULL, -- e.g. 'Thomas US Rabbit' - netns_name TEXT NOT NULL, - wg_id INTEGER, -- nullable for now - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL -); diff --git a/developer/manager/subu.db b/developer/manager/subu.db new file mode 100644 index 0000000..aff27ef Binary files /dev/null and b/developer/manager/subu.db differ diff --git a/developer/manager/subu.py b/developer/manager/subu.py deleted file mode 100644 index ea5ad0c..0000000 --- a/developer/manager/subu.py +++ /dev/null @@ -1,150 +0,0 @@ -# ------------- Subu ops ------------- -def create_subu(owner: str, name: str) -> str: - with closing(_db()) as db: - c = db.cursor() - subu_netns = f"ns-subu_tmp" # temp; we rename after ID known - c.execute("INSERT INTO subu (owner, name, netns) VALUES (?, ?, ?)", - (owner, name, subu_netns)) - sid = c.lastrowid - netns = f"ns-subu_{sid}" - c.execute("UPDATE subu SET netns=? WHERE id=?", (netns, sid)) - db.commit() - - # create netns - run(["ip", "netns", "add", netns]) - run(["ip", "-n", netns, "link", "set", "lo", "down"]) - print(f"Created subu_{sid} ({owner}:{name}) with netns {netns}") - return f"subu_{sid}" - -def list_subu(): - with closing(_db()) as db: - for row in db.execute("SELECT id, owner, name, netns, lo_state, wg_id, network_state FROM subu"): - print(row) - -def info_subu(subu_id: str): - sid = int(subu_id.split("_")[1]) - with closing(_db()) as db: - row = db.execute("SELECT * FROM subu WHERE id=?", (sid,)).fetchone() - if not row: - print("not found"); return - print(row) - wg = db.execute("SELECT wg_id FROM subu WHERE id=?", (sid,)).fetchone()[0] - if wg is not None: - wrow = db.execute("SELECT * FROM wg WHERE id=?", (wg,)).fetchone() - print("WG:", wrow) - opts = db.execute("SELECT name,value FROM options WHERE subu_id=?", (sid,)).fetchall() - print("Options:", opts) - -def lo_toggle(subu_id: str, state: str): - sid = int(subu_id.split("_")[1]) - with closing(_db()) as db: - ns = db.execute("SELECT netns FROM subu WHERE id=?", (sid,)).fetchone() - if not ns: raise ValueError("subu not found") - ns = ns[0] - run(["ip", "netns", "exec", ns, "ip", "link", "set", "lo", state]) - db.execute("UPDATE subu SET lo_state=? WHERE id=?", (state, sid)) - db.commit() - print(f"{subu_id}: lo {state}") - -# ---------------- High-level Subu factory ---------------- - -def make_subu(path_tokens: list[str]) -> str: - """ - Create a new Subu with hierarchical name and full wiring: - - path_tokens: ['Thomas', 'US'] or ['Thomas', 'new-subu', 'Rabbit'] - - Rules: - - len(path_tokens) >= 2 - - parent path (everything except last token) must already exist - as: - * a Unix user (for len==2: just the top-level user, e.g. 'Thomas') - * and as a Subu in our DB if len > 2 (e.g. 'Thomas_new-subu') - - new Unix user name is path joined by '_', e.g. 'Thomas_new-subu_Rabbit' - - mas u(root) is path_tokens[0] - - groups: - - -incommon - - Side effects: - - DB row in 'subu' (id, owner, name, full_unix_name, path, netns_name, ...) - - netns ns-subu_ created with lo down - - Unix user created/ensured - - Unix groups ensured and membership updated - - Returns: textual Subu_ID, e.g. 'subu_7'. - """ - if not path_tokens or len(path_tokens) < 2: - raise SystemExit("subu: make requires at least two path elements, e.g. 'Thomas US'") - - # Normalised pieces - path_tokens = [p.strip() for p in path_tokens if p.strip()] - if len(path_tokens) < 2: - raise SystemExit("subu: make requires at least two non-empty path elements") - - masu = path_tokens[0] # root user / owner - leaf = path_tokens[-1] # new subu leaf - parent_tokens = path_tokens[:-1] # parent path - full_unix_name = "_".join(path_tokens) # e.g. 'Thomas_new-subu_Rabbit' - parent_unix_name = "_".join(parent_tokens) - path_str = " ".join(path_tokens) # e.g. 'Thomas new-subu Rabbit' - - # 1) Enforce parent existing - - # Case A: top-level subu (e.g. ['Thomas', 'US']) - if len(path_tokens) == 2: - # Require the root user to exist as a Unix user - if not _user_exists(masu): - raise SystemExit( - f"subu: cannot make '{path_str}': root user '{masu}' does not exist" - ) - else: - # Case B: deeper subu: require parent subu exists in our DB - parent_row = get_subu_by_full_unix_name(parent_unix_name) - if not parent_row: - raise SystemExit( - f"subu: cannot make '{path_str}': parent subu '{parent_unix_name}' does not exist" - ) - - # Also forbid duplicate full_unix_name - existing = get_subu_by_full_unix_name(full_unix_name) - if existing: - raise SystemExit( - f"subu: subu with name '{full_unix_name}' already exists (id=subu_{existing[0]})" - ) - - # 2) Insert DB row and allocate ID + netns_name - - with closing(open_db()) as db: - subu_id_num = _first_free_id(db, "subu") - netns_name = f"ns-subu_{subu_id_num}" - - db.execute( - "INSERT INTO subu(id, owner, name, full_unix_name, path, netns_name, wg_id, created_at, updated_at) " - "VALUES (?, ?, ?, ?, ?, ?, NULL, datetime('now'), datetime('now'))", - (subu_id_num, masu, leaf, full_unix_name, path_str, netns_name) - ) - db.commit() - - subu_id = f"subu_{subu_id_num}" - - # 3) Create netns + lo down - _create_netns_for_subu(subu_id_num, netns_name) - - # 4) Ensure Unix user + groups - - unix_user = full_unix_name - group_masu = masu - group_incommon = f"{masu}-incommon" - - _ensure_group(group_masu) - _ensure_group(group_incommon) - - _ensure_user(unix_user, group_masu) - _add_user_to_group(unix_user, group_masu) # mostly redundant but explicit - _add_user_to_group(unix_user, group_incommon) - - print(f"Created Subu {subu_id} for path '{path_str}' with Unix user '{unix_user}' " - f"and netns '{netns_name}'") - - return subu_id diff --git a/developer/manager/text.py b/developer/manager/text.py index d5ff982..0b1b364 100644 --- a/developer/manager/text.py +++ b/developer/manager/text.py @@ -1,128 +1,133 @@ -# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*- -VERSION = "0.2.0" +# text.py -USAGE = """\ -subu — Subu manager (v0.2.0) +from version import version as current_version + + +class Text: + """ + Program text bound to a specific command name. + + Usage: + text_1 = Text("subu") + text_2 = Text("manager") + + print(text_1.usage()) + print(text_2.help()) + """ + + def __init__(self, program_name ="subu"): + self.program_name = program_name + + def usage(self): + program_name = self.program_name + return f"""{program_name} — Subu manager (v{current_version()}) Usage: - subu # usage - subu help # detailed help - subu example # example workflow - subu version # print version + {program_name} # usage + {program_name} help # detailed help + {program_name} example # example workflow + {program_name} version # print version - subu init - subu create - subu list - subu info | subu information + {program_name} init + {program_name} make + {program_name} list + {program_name} info | {program_name} information - subu lo up|down + {program_name} lo up|down - subu WG global - subu WG create - subu WG server_provided_public_key - subu WG info|information - subu WG up - subu WG down + {program_name} WG global + {program_name} WG make + {program_name} WG server_provided_public_key + {program_name} WG info|information + {program_name} WG up + {program_name} WG down - subu attach WG - subu detach WG + {program_name} attach WG + {program_name} detach WG - subu network up|down + {program_name} network up|down - subu option set - subu option get - subu option list + {program_name} option set + {program_name} option get + {program_name} option list - subu exec -- ... + {program_name} exec -- ... """ -HELP = """\ -Subu manager (v0.2.0) + def help(self, verbose =False): + program_name = self.program_name + return f"""Subu manager (v{current_version()}) 1) Init - subu init - Creates ./subu.db. Refuses to run if db exists. + {program_name} init + Makes ./subu.db. Refuses to run if db exists. 2) Subu - subu create - subu list - subu info + {program_name} make + {program_name} list + {program_name} info 3) Loopback - subu lo up|down + {program_name} lo up|down 4) WireGuard objects (independent of subu) - subu WG global # e.g., 192.168.112.0/24 - subu WG create # allocates next /32 - subu WG server_provided_public_key - subu WG info - subu WG up / subu WG down # admin toggle after attached - -5) Attach/detach + eBPF steering - subu attach WG - - Creates WG dev as subu_ inside ns-subu_, assigns /32, MTU 1420 - - Installs per-subu cgroup + loads eBPF scaffold (UID check, metadata map) - - Keeps device admin-down until `subu network up` - subu detach WG - - Deletes device, removes cgroup + BPF + {program_name} WG global # for example, 192.168.112.0/24 + {program_name} WG make # allocates next /32 + {program_name} WG server_provided_public_key + {program_name} WG info + {program_name} WG up / {program_name} WG down # administrative toggle after attached + +5) Attach or detach and eBPF steering + {program_name} attach WG + - Makes WireGuard device as subu_ inside ns-subu_, assigns /32, MTU 1420 + - Installs per-subu cgroup and loads eBPF scaffold (user identifier check, metadata map) + - Keeps device administrative-down until `{program_name} network up` + {program_name} detach WG + - Deletes device, removes cgroup and eBPF program 6) Network aggregate - subu network up|down - - Ensures lo up on 'up', toggles attached WG ifaces + {program_name} network up|down + - Ensures loopback is up on 'up', toggles attached WireGuard interfaces 7) Options - subu option set|get|list ... + {program_name} option set|get|list ... 8) Exec - subu exec -- ... + {program_name} exec -- ... """ -EXAMPLE = """\ -# 0) Initialise the subu database (once per directory) -subu init -# -> created ./subu.db -# If ./subu.db already exists, init will fail with an error and do nothing. - -# 1) Create a Subu “US” owned by user Thomas -subu create Thomas US -# -> Subu_ID: subu_7 -# -> netns: ns-subu_7 with lo (down) - -# 2) Define a global WireGuard address pool (once per host) -subu WG global 192.168.112.0/24 -# -> base set; next free: 192.168.112.2/32 - -# 3) Create a WG object with endpoint (ReasoningTechnology server) -subu WG create 35.194.71.194:51820 -# or: subu WG create ReasoningTechnology.com:51820 -# -> WG_ID: WG_0 -# -> local IP: 192.168.112.2/32 -# -> AllowedIPs: 0.0.0.0/0 - -# 4) Add server public key (example key) -subu WG server_provided_public_key WG_0 ABCDEFG...xyz= -# -> saved - -# 5) Attach WG to the Subu -subu attach WG subu_7 WG_0 -# -> creates device ns-subu_7/subu_0 -# -> assigns 192.168.112.2/32, MTU 1420, accept_local=1 -# -> enforces egress steering via cgroup/eBPF for UID(s) of subu_7 -# -> warns if lo is down in the netns - -# 6) Bring networking up for the Subu -subu network up subu_7 -# -> brings lo up in ns-subu_7 -# -> brings subu_0 admin up - -# 7) Start the WireGuard engine for this WG -subu WG up WG_0 -# -> interface up; handshake should start if keys/endpoint are correct - -# 8) Run a command inside the Subu’s netns -subu exec subu_7 -- curl -4v https://ifconfig.me -# Traffic from this process should egress via subu_0/US tunnel. + def example(self): + program_name = self.program_name + return f"""# 0) Initialise the subu database (once per directory) +{program_name} init dzkq7b + +# 1) Make Subu +{program_name} make Thomas US +# -> subu_1 + +# 2) WireGuard pool once +{program_name} WG global 192.168.112.0/24 + +# 3) Make WireGuard object with endpoint +{program_name} WG make ReasoningTechnology.com:51820 +# -> WG_1 + +# 4) Server public key (placeholder) +{program_name} WG server_provided_public_key WG_1 ABCDEFG...xyz= + +# 5) Attach device and install cgroup and eBPF steering +{program_name} attach WG subu_1 WG_1 + +# 6) Bring network up (loopback and WireGuard) +{program_name} network up subu_1 + +# 7) Test inside namespace +{program_name} exec subu_1 -- curl -4v https://ifconfig.me """ -def VERSION_string(): - return VERSION + def version(self): + return current_version() + + +def make_text(program_name ="subu"): + return Text(program_name) diff --git a/developer/manager/uncatelogued/parser.py b/developer/manager/uncatelogued/parser.py new file mode 100644 index 0000000..cf2dd84 --- /dev/null +++ b/developer/manager/uncatelogued/parser.py @@ -0,0 +1,32 @@ +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/unix.py b/developer/manager/unix.py deleted file mode 100644 index 7773e4c..0000000 --- a/developer/manager/unix.py +++ /dev/null @@ -1,28 +0,0 @@ -# ---------------- Unix users & groups ---------------- - -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_group(name: str): - if not _group_exists(name): - # groupadd - run(["groupadd", name]) - -def _ensure_user(name: str, primary_group: str): - if not _user_exists(name): - # useradd -m -g -s /bin/bash - run(["useradd", "-m", "-g", primary_group, "-s", "/bin/bash", name]) - -def _add_user_to_group(user: str, group: str): - run(["usermod", "-aG", group, user]) diff --git a/developer/manager/version.py b/developer/manager/version.py new file mode 100644 index 0000000..ed3750c --- /dev/null +++ b/developer/manager/version.py @@ -0,0 +1,2 @@ +def version(): + return "0.3.2" diff --git a/developer/manager/wg.py b/developer/manager/wg.py deleted file mode 100644 index 3be049c..0000000 --- a/developer/manager/wg.py +++ /dev/null @@ -1,48 +0,0 @@ - -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_create(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/document/manager.org b/document/manager.org deleted file mode 100644 index 77dcebb..0000000 --- a/document/manager.org +++ /dev/null @@ -1,289 +0,0 @@ -#+TITLE: Subu Manager Specification -#+AUTHOR: Reasoning Technology / Thomas Walker Lynch -#+DATE: 2025-11-03 -#+LANGUAGE: en -#+STARTUP: overview -#+PROPERTY: header-args :results output - -* Overview -The *Subu Manager* is a command-line orchestration tool for creating and managing -*lightweight user-containers* (“subus”) with isolated namespaces, private -WireGuard interfaces, and enforced network routing rules. - -It unifies several Linux primitives: - -- Unix users and groups (for identity & filesystem isolation) -- Network namespaces (for network isolation) -- WireGuard interfaces (for VPN / tunnel endpoints) -- eBPF cgroup programs (for routing enforcement) -- SQLite database (for persistence and state tracking) - -The manager is designed to evolve toward a full *Subu Light Container System*, -where each user has nested subordinate users, and each subu can have its own -network, security policies, and forwarding rules. - ---- - -* Architecture Summary -** Components -1. =CLI.py= :: command-line interface -2. =core.py= :: high-level orchestration logic -3. =db.py= (planned) :: schema definition and migration -4. =userutil.py= (planned) :: Unix account and group management helpers -5. =netutil.py= (planned) :: namespace and interface creation -6. =bpfutil.py= (planned) :: cgroup/eBPF setup - -** Data persistence -All persistent configuration lives in =subu.db= (SQLite). -This file contains: -- *meta* :: creation time, schema version -- *subu* :: all subuser accounts and their namespace info -- *wg* :: WireGuard endpoints -- *links* :: relationships between subu and wg interfaces -- *options* :: boolean or key/value runtime options - ---- - -* Command Overview -Each =CLI.py= command corresponds to a top-level operation. The CLI delegates -to core functions in =core.py=. - -| Command | Description | Implementation | -|----------+--------------+----------------| -| =init= | Create the SQLite DB and schema | `core.init_db()` | -| =make= | Create a new subu hierarchy (user, netns, groups) | `core.make_subu(path_tokens)` | -| =info= / =information= | Print full record of a subu | `core.get_subu_info()` | -| =WG= | Manage WireGuard objects and their mapping | `core.create_wg()`, `core.attach_wg()` | -| =attach= / =detach= | Link or unlink WG interface to subu namespace | `core.attach_wg_to_subu()` | -| =network up/down= | Bring up or down all attached ifaces | `core.network_toggle()` | -| =lo up/down= | Bring loopback up/down in subu netns | `core.lo_toggle()` | -| =option add/remove/list= | Manage options | `core.option_add()` etc. | -| =exec= | Run command inside subu netns | `core.exec_in_netns()` | -| =help= / =usage= / =example= | Documentation commands | CLI only | -| =version= | Print program version | constant in `core.VERSION` | - ---- - -* Subu Creation Flow (=make=) - -** Syntax -#+begin_example -./CLI.py make Thomas new-subu Rabbit -#+end_example - -** Behavior -- Verifies that *parent path* (all but last token) exists. - - If two-level (e.g. =Thomas US=), requires Unix user =Thomas= exists. - - If deeper (e.g. =Thomas new-subu Rabbit=), requires DB entry for - =Thomas_new-subu=. -- Allocates next available subu ID (first free integer). -- Inserts row in DB with: - - =id=, =owner=, =name=, =full_unix_name=, =path=, =netns_name= -- Creates network namespace =ns-subu_= -- Brings =lo= down inside that namespace. -- Ensures Unix groups: - - == - - =-incommon= -- Ensures Unix user: - - =_...= (underscores for hierarchy) -- Adds new user to both groups. - -** Implementation -#+begin_src python -def make_subu(path_tokens: list[str]) -> str: - # 1. Validate hierarchy, check parent - # 2. Allocate ID (via _first_free_id) - # 3. Insert into DB (open_db) - # 4. Create netns (ip netns add ...) - # 5. Ensure groups/users (useradd, groupadd) - # 6. Return subu_X identifier -#+end_src - ---- - -* User and Group Management - -** Goals -Each subu is a Linux user; hierarchy is mirrored in usernames: -#+begin_example -Thomas_US -Thomas_US_Rabbit -Thomas_local -#+end_example - -Each subu belongs to: -- group =Thomas= -- group =Thomas-incommon= - -** Implementation Functions -#+begin_src python -def _group_exists(name): ... -def _user_exists(name): ... -def _ensure_group(name): ... -def _ensure_user(name, primary_group): ... -def _add_user_to_group(user, group): ... -#+end_src - ---- - -* Database Schema (summary) - -#+begin_src sql -CREATE TABLE meta ( - key TEXT PRIMARY KEY, - value TEXT -); - -CREATE TABLE subu ( - id INTEGER PRIMARY KEY, - owner TEXT NOT NULL, - name TEXT NOT NULL, - full_unix_name TEXT NOT NULL UNIQUE, - path TEXT NOT NULL, - netns_name TEXT NOT NULL, - wg_id INTEGER, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL -); - -CREATE TABLE wg ( - id INTEGER PRIMARY KEY, - endpoint TEXT, - local_ip TEXT, - server_pubkey TEXT, - created_at TEXT, - updated_at TEXT -); - -CREATE TABLE links ( - subu_id INTEGER, - wg_id INTEGER, - FOREIGN KEY(subu_id) REFERENCES subu(id), - FOREIGN KEY(wg_id) REFERENCES wg(id) -); - -CREATE TABLE options ( - subu_id INTEGER, - name TEXT, - value TEXT, - FOREIGN KEY(subu_id) REFERENCES subu(id) -); -#+end_src - ---- - -* Networking and Namespaces -Each subu has a private namespace. - -** Steps -1. =ip netns add ns-subu_= -2. =ip netns exec ns-subu_ ip link set lo down= -3. Optionally attach WG interfaces (later). - -** Implementation -#+begin_src python -def _create_netns_for_subu(subu_id_num, netns_name): - run(["ip", "netns", "add", netns_name]) - run(["ip", "netns", "exec", netns_name, "ip", "link", "set", "lo", "down"]) -#+end_src - ---- - -* WireGuard Integration -Each subu may have exactly one WG interface. - -** Workflow -1. Allocate new WG object via =subu WG create = -2. Record server-provided key via =subu WG server_provided_public_key= -3. Attach interface via =subu attach WG = -4. Bring network up (includes WG admin up). - -** Implementation (planned) -#+begin_src python -def create_wg(endpoint): ... -def attach_wg_to_subu(subu_id, wg_id): ... -def wg_up(wg_id): ... -def wg_down(wg_id): ... -#+end_src - ---- - -* eBPF Steering (Planned) -The manager will attach an eBPF program to the subu’s cgroup that: - -- Hooks =connect()=, =bind()=, =sendmsg()= -- Forces =SO_BINDTOIFINDEX=subu_= for all sockets created by the subu -- Guarantees all UID traffic egresses through its WG interface -- Reuses kernel routing for MTU/GSO logic, but overrides device binding - -** Implementation Sketch -#+begin_src python -def attach_egress_bpf(subu_id, ifindex): - # load compiled eBPF ELF (bpf_prog_load) - # attach to cgroup of the subu user (BPF_PROG_ATTACH) - pass -#+end_src - ---- - -* Options and Policies - -Options are persisted flags controlling runtime behavior. - -| Option Name | Purpose | Default | -|--------------+----------+----------| -| =local_forwarding= | Enable 127/8 forwarding to WG peer | off | -| =steer_enabled= | Enable cgroup eBPF steering | on | - -** Implementation -#+begin_src python -def option_add(subu_id, name): - set_option(subu_id, name, "1") - -def option_remove(subu_id, name): - db.execute("DELETE FROM options WHERE subu_id=? AND name=?", ...) -#+end_src - ---- - -* Command Examples - -#+begin_example -# 0) Initialize -CLI.py init -# -> creates ./subu.db - -# 1) Create first subu -CLI.py make Thomas US -# -> user Thomas_US, netns ns-subu_0 - -# 2) Create hierarchical subu -CLI.py make Thomas new-subu Rabbit -# -> requires Thomas_new-subu exists - -# 3) Bring network up -CLI.py network up subu_0 - -# 4) Create WireGuard pool and object -CLI.py WG global 192.168.112.0/24 -CLI.py WG create ReasoningTechnology.com:51820 - -# 5) Attach and activate -CLI.py attach WG subu_0 WG_0 -CLI.py WG up WG_0 - -# 6) Inspect -CLI.py info subu_0 -CLI.py option list subu_0 -#+end_example - ---- - -* Future Work -1. 127/8 forwarding rewrite & mapping -2. Server-side sifter for mapped local addresses -3. GUI configuration (subu-light control panel) -4. BPF loader / verifier integration -5. Persistent daemon mode for live control -6. Automated namespace cleanup and audit -7. JSON-RPC or REST management API