-__pycache__
+# no archive or temporary files
+*.tar
+*.tgz
+*.zip
+*.
+temp.*
+**/scratchpad/*
+!**/scratchpad/.gitignore
+
+
+# Python
+__pycache__/
+*.py[cod]
+*.pyo
+*.pyd
+.ipynb_checkpoints/
+.pytest_cache/
+
+# C / compiled artifacts
+*.o
+*.a
+*.out
+*.so
+
+# OS and editor backup files
*~
-*.sqlite3
\ No newline at end of file
+*.bak
+*.swp
+.DS_Store
+
+# SubU
+__pycache__
+*.sqlite3
--- /dev/null
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta charset="UTF-8">
+ <title>How to add a project dependency</title>
+ <script>
+ {
+ let dirpr_library = null;
+ try{ dirpr_library = localStorage.getItem("RT.dirpr_library"); }catch(e){}
+ window.RT = window.RT || {};
+ RT.dirpr_library = RT.dirpr_library || dirpr_library || "https://style.ReasoningTechnology.com/Manuscript";
+ document.write('<script src="' + RT.dirpr_library + '/Core/RT-Manuscript_make.js"><\/script>');
+ }
+ </script>
+ <script>
+ window.RT.theme_preference('inverse_wheat');
+ window.RT.load('Layout/paginate');
+ window.RT.load('Layout/article_tech_ref');
+ window.RT.load('Element/theme_selector');
+ </script>
+ </head>
+ <body>
+ <RT·theme-selector></RT·theme-selector>
+ <RT·article>
+ <RT·title
+ author="Thomas Walker Lynch"
+ date="2026-10-09"
+ title="How to add a project dependency">
+ </RT·title>
+
+ <RT·TOC level="1"></RT·TOC>
+
+ <h1>Project dependencies</h1>
+
+ <p>
+ A <RT·term>project dependency</RT·term> is software the project uses but did not author: a language toolchain, a library, a tool, or another project. A dependency is installed outside the project, as a sibling directory of it, and is linked into the project under <RT·code>shared/project-dependency</RT·code>.
+ </p>
+
+ <p>
+ Getting an arbitrary piece of software working is different every time, so the administrator is free to do whatever a dependency needs inside <RT·code>shared/project-dependency</RT·code>. The skeleton relies on only the few conventions given in the next section.
+ </p>
+
+ <h1>What the skeleton relies on</h1>
+
+ <ul>
+ <li><RT·code>project</RT·code> : A link to <RT·code>../../../</RT·code>, the directory that holds this project and its siblings. It comes with the skeleton. Links to dependencies are made through it, so they are relative and stay correct in every clone.</li>
+ <li><RT·code><name>.init</RT·code> : A bash script that every role setup sources, for every role, each time a person takes on a role. It puts the dependency into the environment, typically by adding to <RT·code>PATH</RT·code> or exporting variables.</li>
+ <li><RT·code><name>.org</RT·code> or <RT·code><name>.html</RT·code> : Notes on how the dependency was installed, so that each team member can repeat the installation after cloning. Short notes may instead be comments at the top of <RT·code><name>.init</RT·code>.</li>
+ <li><RT·code><name>.install</RT·code> : Optional. A script that carries out the installation, where it can be automated. A person runs it; setup never does.</li>
+ <li><RT·code>.gitignore</RT·code> : Belongs to the administrator. It decides which of the files in the directory are tracked.</li>
+ </ul>
+
+ <p>
+ Files for one dependency share a base name, so that <RT·code>Python.init</RT·code>, <RT·code>Python.org</RT·code>, and the link they describe sit together in a listing.
+ </p>
+
+ <h1>Steps</h1>
+
+ <h2>Install the dependency beside the project</h2>
+ <p>
+ Download, clone, or extract the dependency into the directory that holds the project, so that it sits as a sibling of the project directory. Build it there if it needs building.
+ </p>
+
+ <h2>Link it</h2>
+ <p>
+ Make the link through the <RT·code>project</RT·code> link. The word <RT·code>project</RT·code> in the link target is literal.
+ </p>
+ <RT·code>
+ > cd $REPO_HOME/shared/project-dependency
+ > ln -s project/<dependency directory> <name>
+ </RT·code>
+
+ <h2>Write the init file</h2>
+ <p>
+ Write <RT·code><name>.init</RT·code> in the same directory. Because it runs on every setup for every role, keep it to setting the environment:
+ </p>
+ <ul>
+ <li>Make it quick, and safe to run any number of times.</li>
+ <li>Do not install, build, or download anything from it.</li>
+ <li>Test that the dependency is present before using it, so that a clone without the dependency still sets up.</li>
+ <li>Print nothing in normal use. A notice for the administrator role, when the dependency is missing, is fine.</li>
+ </ul>
+ <p>
+ <RT·code>Python.init</RT·code>, which comes with the skeleton, is an example:
+ </p>
+ <RT·code>
+ if [[ -d "$REPO_HOME/shared/project-dependency/Python-3.12.3/bin" ]]; then
+ PATH="$REPO_HOME/shared/project-dependency/Python-3.12.3/bin:$PATH"
+ else
+ if [[ "$ROLE" == "administrator" ]]; then
+ echo "Notice: Local Python-3.12.3 bin not found. Using system Python."
+ fi
+ fi
+ </RT·code>
+
+ <h2>Write the installation notes</h2>
+ <p>
+ Record what was installed, where it came from, and the commands used, in <RT·code><name>.org</RT·code>, <RT·code><name>.html</RT·code>, or comments in the init file. A team member who has cloned the project follows these notes to install the dependency, and then recreates the link if it is not tracked.
+ </p>
+
+ <h2>Decide what is tracked</h2>
+ <p>
+ The skeleton's <RT·code>.gitignore</RT·code> tracks itself, the <RT·code>project</RT·code> link, and files ending in <RT·code>.init</RT·code>, <RT·code>.install</RT·code>, <RT·code>.org</RT·code>, and <RT·code>.html</RT·code>. Everything else in the directory is ignored. To track a dependency's link, so that it arrives with every clone, add a line naming it:
+ </p>
+ <RT·code>
+ !<name>
+ </RT·code>
+ <p>
+ A tracked link points at nothing until the dependency is installed beside the project, which is why init files test for the dependency before using it.
+ </p>
+
+ <h2>Commit</h2>
+ <p>
+ Add the init file, the notes, the <RT·code>.gitignore</RT·code> change, and any tracked link, then commit.
+ </p>
+
+ </RT·article>
+ </body>
+</html>
<!DOCTYPE html>
+<html lang="en">
+ <!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Release howto</title>
- <script src="setup.js"></script>
<script>
- window.StyleRT.include('RT/theme');
- window.StyleRT.include('RT/layout/article_tech_ref');
+ {
+ let dirpr_library = null;
+ try{ dirpr_library = localStorage.getItem("RT.dirpr_library"); }catch(e){}
+ window.RT = window.RT || {};
+ RT.dirpr_library = RT.dirpr_library || dirpr_library || "https://style.ReasoningTechnology.com/Manuscript";
+ document.write('<script src="' + RT.dirpr_library + '/Core/RT-Manuscript_make.js"><\/script>');
+ }
+ </script>
+ <script>
+ window.RT.theme_preference('inverse_wheat');
+ window.RT.load('Layout/paginate');
+ window.RT.load('Layout/article_tech_ref');
+ window.RT.load('Element/theme_selector');
</script>
</head>
<body>
- <RT-article>
- <RT-title
+ <RT·theme-selector></RT·theme-selector>
+ <RT·article>
+ <RT·title
author="Thomas Walker Lynch"
- date="2026-03-10"
+ date="2026-06-24"
title="How to release a project">
- </RT-title>
+ </RT·title>
- <RT-TOC level="1"></RT-TOC>
+ <RT·TOC level="1"></RT·TOC>
<h1>Prerequisites</h1>
<p>
- A release requires a tested promotion candidate. The developer compiles the code and promotes it to the <RT-code>consumer/made</RT-code> directory. The tester validates the candidate. The product manager gives the order to release.
+ A developer works in the developer branch of the project, and makes use of the <RT·code>build</RT·code> and <RT·code>promote</RT·code> tools to stage release candidates into the <RT·code>$REPO_HOME/consumer</RT·code> directory. The <RT·code>tester</RT·code> then validates the release candidate. This constitutes a cycle of work, developing, promoting candidates, testing, and then further developing. Once the project manager agrees this cycle is complete, the release flow moves to the next step.
</p>
- <h1>Create the release branch</h1>
+ <h1>Make the release branch</h1>
<p>
- The <RT-term>administrator</RT-term> executes the release. Open a terminal. Enter the project workspace.
+ The <RT·term>administrator</RT·term> executes the release. Open a terminal. Enter the project workspace.
</p>
- <RT-code>
+ <RT·code>
> . setup administrator
- </RT-code>
+ </RT·code>
<p>
Switch to the core development branch. Ensure the local repository is current.
</p>
- <RT-code>
- > git checkout core_developer_branch
+ <RT·code>
+ > git checkout core-developer_branch
> git pull
- </RT-code>
+ </RT·code>
<p>
- Create a new branch for the release. Use the major version number.
+ Make a new branch for the release. Use the major version number.
</p>
- <RT-code>
+ <RT·code>
> git checkout -b release_v<major>
- </RT-code>
+ </RT·code>
<h1>Issue management and patching</h1>
<p>
- Only critical issues receive patches on a release branch. The product manager defines what is critical. If approved, the release team applies the fix directly to the existing <RT-code>release_v<major></RT-code> branch.
+ Only critical issues receive patches on a release branch. The product manager defines what is critical. If approved, the release team applies the fix directly to the existing <RT·code>release_v<major></RT·code> branch.
</p>
<p>
- The branch name <RT-code>release_v<major></RT-code> remains static. The code on the branch is updated. The administrator advances the minor release number in the <RT-code>shared/tool/version</RT-code> file.
+ The branch name <RT·code>release_v<major></RT·code> remains static. The code on the branch is updated. The administrator advances the minor release number in the <RT·code>shared/tool/version</RT·code> file.
</p>
<p>
- The administrator tags the new commit with the full version number, such as <RT-code>v<major>.<minor></RT-code>. Minor versions are edits on the major release branch. They are visible to a person by running the version command.
+ The administrator tags the new commit with the full version number, such as <RT·code>v<major>.<minor></RT·code>. Minor versions are edits on the major release branch. They are visible to a person by running the version command.
</p>
<p>
- The developer must ensure the fix is also ported to the <RT-code>core_developer_branch</RT-code>. This prevents the defect from reappearing in future major releases.
+ The developer must ensure the fix is also ported to the <RT·code>core-developer_branch</RT·code>. This prevents the defect from reappearing in future major releases.
</p>
<h1>Tag and push</h1>
<p>
Tag the release with the exact version number.
</p>
- <RT-code>
+ <RT·code>
> git tag -a v<major>.<minor> -m "Release v<major>.<minor>"
- </RT-code>
+ </RT·code>
<p>
Push the branch to the remote repository. Push the tags.
</p>
- <RT-code>
+ <RT·code>
> git push origin release_v<major>
> git push origin --tags
- </RT-code>
+ </RT·code>
<h1>Repository default branch</h1>
<p>
- Set the repository default branch to the new <RT-code>release_v<major></RT-code> branch on the hosting platform. This ensures a person cloning or pulling the repository receives the most recent major release code by default.
+ Set the repository default branch to the new <RT·code>release_v<major></RT·code> branch on the hosting platform. This ensures a person cloning or pulling the repository receives the most recent major release code by default.
</p>
<h1>Verification</h1>
<li><strong>Project Manager:</strong> Participates in release issue triage. Assesses impact to the overall project schedule.</li>
</ul>
- </RT-article>
+ </RT·article>
</body>
</html>
+++ /dev/null
-window.RT_REPO_ROOT = "../../";
-document.write('<script src="' + window.RT_REPO_ROOT + 'shared/dictionary_style-directory.js"></script>');
-document.write('<script src="' + window.RT_REPO_ROOT + 'shared/linked-project/RT-style-JS_public/consumer/release/RT/core/loader.js"></script>');
-document.write('<script src="' + window.RT_REPO_ROOT + 'shared/linked-project/RT-style-JS_public/consumer/release/RT/core/body_visibility_hidden.js"></script>');
--- /dev/null
+#!/usr/bin/env python3
+# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*-
+
+"""
+RT-Style_header_v5_to_v6
+
+Converts RT-Style version 5 document headers to version 6.
+
+A version 5 document loads the engine through a locator file beside it:
+
+ <script src="RT-Manuscript_locator.js"></script>
+
+A version 6 document carries an inline header block instead. The block takes
+the library from the record made by opening a Manuscript directory's
+setup.html, and falls back to the Reasoning Technology server.
+
+For each version 5 .html file found, the tool replaces the locator line in
+<head> with the version 6 block, keeping its indentation. It then deletes
+RT-Manuscript_locator.js from that file's directory, unless another version 5
+document there, not converted in this run, still uses it. Any other mention of
+RT-Manuscript_locator in the file (an escaped example, prose) is logged for a
+person to edit.
+
+Usage: RT-Style_header_v5_to_v6 <command> [file ...]
+ RT-Style_header_v5_to_v6 preview <command> [file ...]
+
+ help | -h | --help print this message (also printed when given no arguments)
+ project walk the directory tree rooted at $REPO_HOME
+ tree walk the directory tree rooted at the current directory
+ local only the current directory, no subdirectories
+ file <name> ... only the named .html files
+ preview <command> print the log of what <command> would do, change nothing
+
+Every directory is walked, whatever its name. Symbolic links to directories
+are not followed, so a link back up the tree cannot cause a loop or reach
+outside it.
+"""
+
+import sys ,os ,re
+from pathlib import Path
+
+LOCATOR_FILE = "RT-Manuscript_locator.js"
+V6_MARK = 'localStorage.getItem("RT.dirpr_library")'
+
+V6_BLOCK = [
+ '<script>'
+ ,' {'
+ ,' let dirpr_library = null;'
+ ,' try{ dirpr_library = localStorage.getItem("RT.dirpr_library"); }catch(e){}'
+ ,' window.RT = window.RT || {};'
+ ,' RT.dirpr_library = RT.dirpr_library || dirpr_library || "https://style.ReasoningTechnology.com/Manuscript";'
+ ,' document.write(\'<script src="\' + RT.dirpr_library + \'/Core/RT-Manuscript_make.js"><\\/script>\');'
+ ,' }'
+ ,'</script>'
+]
+
+LOCATOR_LINE = re.compile(
+ r'^(?P<indent>[ \t]*)<script\s+src\s*=\s*["\'](?:[^"\']*/)?RT-Manuscript_locator\.js["\']\s*>\s*</script>\s*$'
+ ,re.IGNORECASE
+)
+HEAD_END = re.compile(r'</head\s*>' ,re.IGNORECASE)
+
+
+# ---------------------------------------------------------------- log
+
+class Log:
+ def __init__(self ,preview: bool ,base: Path):
+ self.preview = preview
+ self.base = base
+ self.count = {"converted": 0 ,"deleted": 0 ,"already": 0 ,"hand": 0
+ ,"untouched": 0 ,"stray": 0 ,"error": 0}
+
+ def rel(self ,p: Path) -> str:
+ try: return str(p.resolve().relative_to(self.base))
+ except ValueError: return str(p)
+
+ def line(self ,tag: str ,text: str):
+ print(f" {tag:<15} {text}")
+
+ def act(self ,done: str ,would: str ,p: Path ,key: str):
+ self.line(would if self.preview else done ,self.rel(p))
+ self.count[key] += 1
+
+ def summary(self):
+ c = self.count
+ v = "would be " if self.preview else ""
+ print()
+ print("summary:")
+ print(f" {c['converted']:>4} .html files {v}converted to version 6")
+ print(f" {c['deleted']:>4} {LOCATOR_FILE} files {v}deleted")
+ print(f" {c['already']:>4} .html files already version 6")
+ print(f" {c['hand']:>4} .html files with mentions to edit by hand")
+ print(f" {c['untouched']:>4} other .html files left alone")
+ if c['stray']:
+ print(f" {c['stray']:>4} {LOCATOR_FILE} files kept (see 'kept' lines)")
+ if c['error']:
+ print(f" {c['error']:>4} errors")
+ if self.preview:
+ print("preview only: nothing was changed")
+
+
+# ---------------------------------------------------------------- finding files
+
+def walk(root: Path ,recursive: bool):
+ """Yields (directory ,[.html files] ,has_locator) for each directory."""
+ if not recursive:
+ entries = sorted(root.iterdir())
+ yield root ,[e for e in entries if e.is_file() and e.suffix == ".html"] ,(root / LOCATOR_FILE).is_file()
+ return
+ for d ,dirs ,files in os.walk(root):
+ dirs.sort()
+ files = sorted(files)
+ yield Path(d) ,[Path(d) / f for f in files if f.endswith(".html")] ,LOCATOR_FILE in files
+
+
+# ---------------------------------------------------------------- conversion
+
+def convert(text: str):
+ """Returns (new_text ,replaced_count ,leftover). leftover lists
+ (line_number ,line) for locator mentions that were not replaced."""
+ lines = text.split("\n")
+ out = []
+ replaced = 0
+ leftover = []
+ in_head = True
+ for n ,line in enumerate(lines ,1):
+ m = LOCATOR_LINE.match(line) if in_head else None
+ if m:
+ eol = "\r" if line.endswith("\r") else ""
+ out.extend(m.group("indent") + b + eol for b in V6_BLOCK)
+ replaced += 1
+ else:
+ out.append(line)
+ if LOCATOR_FILE.removesuffix(".js") in line:
+ leftover.append((n ,line.strip()))
+ if in_head and HEAD_END.search(line):
+ in_head = False
+ return "\n".join(out) ,replaced ,leftover
+
+def do_file(f: Path ,log: Log) -> bool:
+ """Converts one .html file. Returns True when it was version 5."""
+ try:
+ text = f.read_text(encoding="utf-8")
+ except Exception as e:
+ log.line("error" ,f"{log.rel(f)}: cannot read: {e}")
+ log.count["error"] += 1
+ return False
+
+ if V6_MARK in text and LOCATOR_FILE.removesuffix(".js") not in text:
+ log.line("already v6" ,log.rel(f))
+ log.count["already"] += 1
+ return False
+
+ new_text ,replaced ,leftover = convert(text)
+
+ if replaced:
+ if not log.preview:
+ try:
+ mode = f.stat().st_mode
+ f.write_text(new_text ,encoding="utf-8")
+ os.chmod(f ,mode)
+ except Exception as e:
+ log.line("error" ,f"{log.rel(f)}: cannot write: {e}")
+ log.count["error"] += 1
+ return False
+ log.act("converted" ,"would convert" ,f ,"converted")
+ if replaced > 1:
+ log.line("note" ,f"{log.rel(f)}: {replaced} locator lines in <head>, all replaced")
+ elif not leftover:
+ log.count["untouched"] += 1
+
+ if leftover:
+ log.count["hand"] += 1
+ for n ,line in leftover:
+ log.line("edit by hand" ,f"{log.rel(f)}:{n}: {line[:100]}")
+
+ return replaced > 0
+
+def uses_locator(f: Path) -> bool:
+ """True when f still loads the engine through a locator line in <head>."""
+ try:
+ text = f.read_text(encoding="utf-8")
+ except Exception:
+ return False
+ for line in text.split("\n"):
+ if LOCATOR_LINE.match(line): return True
+ if HEAD_END.search(line): return False
+ return False
+
+def do_locator(d: Path ,converted: set ,log: Log):
+ """Deletes d's locator unless a version 5 document not converted in this
+ run still uses it."""
+ loc = d / LOCATOR_FILE
+ users = sorted(f.name for f in d.iterdir()
+ if f.is_file() and f.suffix == ".html"
+ and f.resolve() not in converted and uses_locator(f))
+ if users:
+ shown = ", ".join(users[:3]) + (" ..." if len(users) > 3 else "")
+ log.line("kept" ,f"{log.rel(loc)} (still used by {shown})")
+ log.count["stray"] += 1
+ return
+ if not log.preview:
+ try:
+ loc.unlink()
+ except Exception as e:
+ log.line("error" ,f"{log.rel(loc)}: cannot delete: {e}")
+ log.count["error"] += 1
+ return
+ log.act("deleted" ,"would delete" ,loc ,"deleted")
+
+def run_dirs(root: Path ,recursive: bool ,log: Log):
+ for d ,html ,has_locator in walk(root ,recursive):
+ converted = set()
+ for f in html:
+ if do_file(f ,log): converted.add(f.resolve())
+ if has_locator:
+ if converted:
+ do_locator(d ,converted ,log)
+ else:
+ log.line("kept" ,f"{log.rel(d / LOCATOR_FILE)} (no version 5 document beside it)")
+ log.count["stray"] += 1
+
+def run_files(files: list[Path] ,log: Log):
+ converted = set()
+ dirs = []
+ for f in files:
+ if do_file(f ,log):
+ converted.add(f.resolve())
+ d = f.parent.resolve()
+ if d not in dirs: dirs.append(d)
+ for d in dirs:
+ if (d / LOCATOR_FILE).is_file():
+ do_locator(d ,converted ,log)
+
+
+# ---------------------------------------------------------------- command line
+
+def print_help() -> int:
+ print(__doc__.strip())
+ return 0
+
+def CLI(argv=None) -> int:
+ if argv is None:
+ argv = sys.argv[1:]
+ if not argv or argv[0] in ("help" ,"-h" ,"--help"):
+ return print_help()
+
+ preview = False
+ if argv[0] == "preview":
+ preview = True
+ argv = argv[1:]
+ if not argv:
+ print("Error: 'preview' needs a command after it.\n" ,file=sys.stderr)
+ print_help()
+ return 1
+
+ command ,rest = argv[0] ,argv[1:]
+
+ if command == "file":
+ if not rest:
+ print("Error: 'file' needs at least one file name." ,file=sys.stderr)
+ return 1
+ files = [Path(a) for a in rest]
+ for f in files:
+ if not f.is_file():
+ print(f"Error: {f} is not a file." ,file=sys.stderr)
+ return 1
+ base = Path.cwd().resolve()
+ log = Log(preview ,base)
+ print(f"RT-Style_header_v5_to_v6 {'preview ' if preview else ''}file in {base}")
+ run_files(files ,log)
+ log.summary()
+ return 1 if log.count["error"] else 0
+
+ if rest:
+ print(f"Error: '{command}' takes no arguments." ,file=sys.stderr)
+ return 1
+
+ if command == "project":
+ repo_home = os.environ.get("REPO_HOME")
+ if not repo_home:
+ print("Error: 'project' needs REPO_HOME to be set." ,file=sys.stderr)
+ return 1
+ root ,recursive = Path(repo_home) ,True
+ elif command == "tree":
+ root ,recursive = Path.cwd() ,True
+ elif command == "local":
+ root ,recursive = Path.cwd() ,False
+ else:
+ print(f"Error: unknown command '{command}'.\n" ,file=sys.stderr)
+ print_help()
+ return 1
+
+ if not root.is_dir():
+ print(f"Error: {root} is not a directory." ,file=sys.stderr)
+ return 1
+
+ base = root.resolve()
+ log = Log(preview ,base)
+ print(f"RT-Style_header_v5_to_v6 {'preview ' if preview else ''}{command} in {base}")
+ run_dirs(base ,recursive ,log)
+ log.summary()
+ return 1 if log.count["error"] else 0
+
+if __name__ == "__main__":
+ sys.exit(CLI())
--- /dev/null
+#!/usr/bin/env python3
+# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*-
+"""
+html_update_to_3-1
+
+Replaces specific StyleRT script blocks with RT 3.1 loader blocks using
+literal string replacement.
+"""
+
+import sys ,os
+
+OLD_BLOCK = \
+""" <script src="setup.js"></script>
+ <script>
+ window.StyleRT.include('RT/theme');
+ window.StyleRT.include('RT/layout/article_tech_ref');
+ </script>"""
+
+NEW_BLOCK = \
+""" <script>
+ window.RT = window.RT || {};
+ window.RT.dirpr_library = "RT";
+ document.write('<script src="' + window.RT.dirpr_library + '/core/loader.js"></' + 'script>');
+ </script>
+ <script>
+ window.RT.load('core/utility');
+ window.RT.load('core/block_visibility_during_layout');
+ window.RT.load('theme');
+ window.RT.load('layout/article_tech_ref');
+ </script>"""
+
+def process_file(file_path: str) -> None:
+ try:
+ with open(file_path ,"r" ,encoding="utf-8") as f:
+ content = f.read()
+ except Exception as e:
+ print(f"Error reading {file_path}: {e}" ,file=sys.stderr)
+ return
+
+ # Normalize line endings to ensure a match across environments
+ normalized_content = content.replace("\r\n" ,"\n")
+ normalized_old = OLD_BLOCK.replace("\r\n" ,"\n")
+
+ if( normalized_old in normalized_content ):
+ new_content = normalized_content.replace(normalized_old ,NEW_BLOCK)
+ with open(file_path ,"w" ,encoding="utf-8") as f:
+ f.write(new_content)
+ print(f"Updated: {file_path}")
+ else:
+ print(f"Target lines not found in: {file_path}")
+
+def CLI() -> None:
+ args = sys.argv[1:]
+
+ if( not args or "-h" in args or "--help" in args ):
+ print("Usage: html_update_to_3-1 <file.html...>")
+ sys.exit(0)
+
+ for TM_f in args:
+ process_file(TM_f)
+
+if __name__ == "__main__":
+ CLI()
#!/usr/bin/env python3
# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*-
+"""
+new-project
+
+This administrative tool instantiates a fresh project workspace modeled after
+the Harmony skeleton architecture.
+
+The script executes the following sequential lifecycle:
+ 1. Validates the execution environment (requires an active role session).
+ 2. Checks the git status of the skeleton. The new project is made from the
+ last commit, so uncommitted changes and untracked files would be left
+ out. When there are any, lists them and asks whether to continue.
+ 3. Safely extracts a clean copy of all Git-tracked files using an archive stream.
+ 4. Renames the boilerplate 0pus_Harmony anchor file to reflect the new project.
+ 5. Injects an initial version tracking entry into the shared configuration space.
+ 6. Initializes a fresh Git repository bound to the core-developer_branch.
+ 7. Stages and commits the pristine skeleton infrastructure as a baseline.
+ 8. Maps any upstream remote network targets passed via CLI parameters.
+"""
+
import sys ,os ,subprocess
from pathlib import Path
def get_Z_date() -> str:
+ """Retrieves the current date utilizing the system Z timestamp tool."""
try:
res = subprocess.run(["Z" ,"format-%year-%month-%day"] ,capture_output=True ,text=True ,check=True)
return res.stdout.strip()
except Exception:
return "1970-01-01"
-def work(project_name: str ,remotes: list[str]) -> int:
- repo_home = os.environ.get("REPO_HOME")
- if not repo_home:
- print("Error: REPO_HOME is not set. A person must run this from within a sourced role environment." ,file=sys.stderr)
- return 1
+def uncommitted(repo_path: Path) -> list[str] | None:
+ """Returns the lines of 'git status --porcelain' for the skeleton: one per
+ file with uncommitted changes or untracked. None when git cannot report."""
+ try:
+ res = subprocess.run(
+ ["git" ,"status" ,"--porcelain" ,"--untracked-files=normal"]
+ ,cwd=str(repo_path) ,capture_output=True ,text=True ,check=True
+ )
+ except Exception:
+ return None
+ return [line for line in res.stdout.splitlines() if line.strip()]
+
+def confirm(question: str) -> bool:
+ """Asks a yes or no question at the terminal. Without a terminal to ask
+ at, the answer is no."""
+ if not sys.stdin.isatty():
+ return False
+ try:
+ answer = input(question + " [y/N] ")
+ except EOFError:
+ return False
+ return answer.strip().lower() in ("y" ,"yes")
- repo_path = Path(repo_home).resolve()
+def work(repo_path: Path ,project_name: str ,remotes: list[str]) -> int:
+ """Orchestrates the assembly of the new skeleton instance."""
+ # Resolve destination relative to the parent space of the current repository
dest_path = Path(project_name)
if not dest_path.is_absolute():
dest_path = (repo_path.parent / project_name).resolve()
print(f"Error: Destination {dest_path} already exists." ,file=sys.stderr)
return 1
- print(f"Creating new project at: {dest_path}")
+ print(f"Making new project at: {dest_path}")
dest_path.mkdir(parents=True)
+ # Export tracked files without duplicating untracked scratchpads or git histories
archive_cmd = "git archive HEAD | tar -x -C " + str(dest_path)
res = subprocess.run(archive_cmd ,shell=True ,cwd=str(repo_path))
if res.returncode != 0:
print("Error extracting archive." ,file=sys.stderr)
return 1
+ # Personalize the project baseline anchor file
opus_src = dest_path / "0pus_Harmony"
opus_dst = dest_path / f"0pus_{dest_path.name}"
if opus_src.exists():
opus_src.rename(opus_dst)
+ # Record the lineage in the shared version history
version_file = dest_path / "shared" / "tool" / "version"
date_str = get_Z_date()
version_line = f"echo \"{dest_path.name} v0.1 {date_str}\"\n"
with open(version_file ,"w" ,encoding="utf-8") as vf:
vf.write(version_line)
+ # Initialize the pristine Git environment tracking system
print("Initializing git repository...")
- subprocess.run(["git" ,"init" ,"-b" ,"core_developer_branch"] ,cwd=str(dest_path) ,check=True)
+ subprocess.run(["git" ,"init" ,"-b" ,"core-developer_branch"] ,cwd=str(dest_path) ,check=True)
subprocess.run(["git" ,"add" ,"."] ,cwd=str(dest_path) ,check=True)
subprocess.run(["git" ,"commit" ,"-m" ,f"Initial commit of {dest_path.name} from Harmony skeleton"] ,cwd=str(dest_path) ,check=True)
+ # Configure tracking addresses for upstreams
for idx ,remote_url in enumerate(remotes):
remote_name = f"remote_{idx}"
if "github.com" in remote_url:
print(f"Adding remote '{remote_name}': {remote_url}")
subprocess.run(["git" ,"remote" ,"add" ,remote_name ,remote_url] ,cwd=str(dest_path) ,check=True)
- print(f"Project '{dest_path.name}' successfully created.")
+ print(f"Project '{dest_path.name}' successfully made.")
return 0
def CLI(argv=None) -> int:
+ """Parses user input tokens from the shell execution frame."""
if argv is None:
argv = sys.argv[1:]
print("Error: project name is required." ,file=sys.stderr)
return 1
- return work(project_name ,remotes)
+ repo_home = os.environ.get("REPO_HOME")
+ if not repo_home:
+ print("Error: REPO_HOME is not set. A person must run this from within a sourced role environment." ,file=sys.stderr)
+ return 1
+ repo_path = Path(repo_home).resolve()
+
+ status = uncommitted(repo_path)
+ if status is None:
+ print(f"Error: could not read the git status of {repo_path}." ,file=sys.stderr)
+ return 1
+ if status:
+ shown = status[:20]
+ print(f"Warning: {repo_path} has {len(status)} uncommitted or untracked file(s):")
+ for line in shown:
+ print(" " + line)
+ if len(status) > len(shown):
+ print(f" ... and {len(status) - len(shown)} more (see 'git status')")
+ print("The new project is made from the last commit, so these will not be in it.")
+ if not confirm("Continue anyway?"):
+ print("Stopped. Nothing was made. Commit or stash the changes, then run new-project again.")
+ return 1
+
+ return work(repo_path ,project_name ,remotes)
if __name__ == "__main__":
sys.exit(CLI())
--- /dev/null
+#!/usr/bin/env python3
+# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*-
+
+import sys ,os ,filecmp ,stat ,subprocess
+from pathlib import Path
+
+def get_project_name(repo_path: Path) -> str:
+ return repo_path.resolve().name
+
+def get_git_branch(repo_path: Path) -> str:
+ try:
+ result = subprocess.run(
+ ["git" ,"-C" ,str(repo_path) ,"branch" ,"--show-current"],
+ capture_output=True ,text=True ,check=True
+ )
+ branch = result.stdout.strip()
+ return branch if branch else "unknown_branch"
+ except (subprocess.CalledProcessError ,FileNotFoundError):
+ return "unknown_branch"
+
+def format_perms(mode: int) -> str:
+ # Extract the 9-character permission string (e.g., rwxr-xr-x), dropping the leading file type char
+ return stat.filemode(mode)[1:]
+
+def CLI(argv=None) -> int:
+ if argv is None:
+ argv = sys.argv[1:]
+
+ if len(argv) != 2:
+ print("Usage: skeleton_diff <path_to_Harmony> <path_to_project>")
+ return 1
+
+ harmony_dir = Path(argv[0]).resolve()
+ project_dir = Path(argv[1]).resolve()
+
+ if not harmony_dir.is_dir() or not project_dir.is_dir():
+ print("Error: Both arguments must be valid directories." ,file=sys.stderr)
+ return 1
+
+ project_name = get_project_name(project_dir)
+ harmony_branch = get_git_branch(harmony_dir)
+ project_branch = get_git_branch(project_dir)
+
+ # Data structures for the output sections
+ opus_warnings = []
+ missing_files = []
+
+ # Time-based modification buckets
+ mod_harmony_newer = []
+ mod_same_time = []
+ mod_project_newer = []
+
+ perms_only_changes = []
+ added_files = []
+
+ # --- Section 1: Opus File Structural Audit ---
+ project_opus = f"0pus_{project_name}"
+ harmony_opus = "0pus_Harmony"
+ found_opus = False
+
+ for TM_item in os.listdir(project_dir):
+ if TM_item.startswith("0pus_") and os.path.isfile(project_dir / TM_item):
+ if TM_item == project_opus:
+ found_opus = True
+ else:
+ opus_warnings.append(f"Incorrectly named Opus file found: '{TM_item}'. Expected '{project_opus}'.")
+
+ if not found_opus:
+ opus_warnings.append(f"Expected Opus file '{project_opus}' is MISSING from project root.")
+
+ # Semantic boundaries: isolate the skeleton by ignoring user zones
+ IGNORED_DIRS = {".git" ,"scratchpad" ,"consumer" ,"authored" ,"__pycache__"}
+ IGNORED_EXTS = {".pyc" ,".pyo" ,".swp" ,".bak"}
+
+ def is_ignored(path_obj: Path) -> bool:
+ if path_obj.name in IGNORED_DIRS: return True
+ if path_obj.suffix in IGNORED_EXTS: return True
+ if path_obj.name.endswith("~"): return True
+ return False
+
+ harmony_files = set()
+ for TM_root ,TM_dirs ,TM_files in os.walk(harmony_dir):
+ TM_dirs[:] = [TM_d for TM_d in TM_dirs if not is_ignored(Path(TM_d))]
+ for TM_f in TM_files:
+ f_path = Path(TM_root) / TM_f
+ if not is_ignored(f_path):
+ rel_path = f_path.relative_to(harmony_dir)
+ harmony_files.add(str(rel_path))
+
+ project_files = set()
+ for TM_root ,TM_dirs ,TM_files in os.walk(project_dir):
+ TM_dirs[:] = [TM_d for TM_d in TM_dirs if not is_ignored(Path(TM_d))]
+ for TM_f in TM_files:
+ f_path = Path(TM_root) / TM_f
+ if not is_ignored(f_path):
+ rel_path = f_path.relative_to(project_dir)
+ project_files.add(str(rel_path))
+
+ # Align the Opus file names for comparison
+ if project_opus in project_files:
+ project_files.remove(project_opus)
+ project_files.add(harmony_opus)
+ else:
+ for TM_item in list(project_files):
+ if TM_item.startswith("0pus_") and "/" not in TM_item:
+ project_files.remove(TM_item)
+
+ all_files = sorted(list(harmony_files | project_files))
+
+ for TM_rel in all_files:
+ if TM_rel not in project_files:
+ if TM_rel == harmony_opus and not found_opus:
+ continue
+ missing_files.append(TM_rel)
+ elif TM_rel not in harmony_files:
+ added_files.append(TM_rel)
+ else:
+ h_abs = harmony_dir / TM_rel
+ p_abs = project_dir / TM_rel
+
+ if TM_rel == harmony_opus:
+ p_abs = project_dir / project_opus
+
+ if p_abs.exists():
+ h_stat = h_abs.stat()
+ p_stat = p_abs.stat()
+
+ h_perms = format_perms(h_stat.st_mode)
+ p_perms = format_perms(p_stat.st_mode)
+ perms_differ = (h_perms != p_perms)
+
+ display_str = f"({h_perms}, {p_perms}) {TM_rel}" if perms_differ else TM_rel
+ content_differs = not filecmp.cmp(h_abs ,p_abs ,shallow=False)
+
+ if content_differs:
+ h_time = h_stat.st_mtime
+ p_time = p_stat.st_mtime
+
+ if h_time > p_time:
+ mod_harmony_newer.append(display_str)
+ elif h_time < p_time:
+ mod_project_newer.append(display_str)
+ else:
+ mod_same_time.append(display_str)
+ elif perms_differ:
+ perms_only_changes.append(display_str)
+
+ # --- Output Generation ---
+ print("Skeleton Audit:")
+ print(f" Harmony {harmony_branch}")
+ print(" vs")
+ print(f" {project_name} {project_branch}")
+ print("=" * 60)
+
+ print("\n1. 0pus File Audit")
+ print("-" * 20)
+ if not opus_warnings:
+ print(" [OK] Opus file is correctly named and present.")
+ else:
+ for TM_w in opus_warnings:
+ print(f" [WARNING] {TM_w}")
+
+ print("\n2. Missing from Project (In Harmony, missing locally)")
+ print("-" * 20)
+ if not missing_files:
+ print(" [OK] No Harmony skeleton files are missing.")
+ else:
+ for TM_f in missing_files:
+ print(f" - {TM_f}")
+
+ print("\n3. Modified Skeleton Files (Diverged from Harmony)")
+ print("-" * 20)
+ if not (mod_harmony_newer or mod_same_time or mod_project_newer):
+ print(" [OK] No shared skeleton files have been modified.")
+ else:
+ if mod_harmony_newer:
+ print(" [Harmony newer]")
+ for TM_f in mod_harmony_newer:
+ print(f" ~ {TM_f}")
+ if mod_same_time:
+ print(" [Same modification time]")
+ for TM_f in mod_same_time:
+ print(f" ~ {TM_f}")
+ if mod_project_newer:
+ print(" [Project newer]")
+ for TM_f in mod_project_newer:
+ print(f" ~ {TM_f}")
+
+ print("\n4. Permissions Only Changes")
+ print("-" * 20)
+ if not perms_only_changes:
+ print(" [OK] No permission-only changes detected.")
+ else:
+ for TM_f in perms_only_changes:
+ print(f" * {TM_f}")
+
+ print("\n5. Project-Specific Files (Not in Harmony)")
+ print("-" * 20)
+ if not added_files:
+ print(" [INFO] No additional files outside the standard skeleton.")
+ else:
+ for TM_f in added_files:
+ print(f" + {TM_f}")
+
+ print("\n" + "=" * 60)
+ drift_count = len(opus_warnings) + len(missing_files) + len(mod_harmony_newer) + len(mod_same_time) + len(mod_project_newer) + len(perms_only_changes)
+ if drift_count == 0:
+ print("Result: Skeleton is perfectly synchronized with Harmony.")
+ else:
+ print(f"Total Structural Drift (Sections 1-4): {drift_count} issues.")
+
+ return 0
+
+if __name__ == "__main__":
+ sys.exit(CLI())
+
--- /dev/null
+# Ignore everything in this directory
+*
+# Except this file
+!.gitignore
+
+++ /dev/null
-
-__pycache__
-stage/
-deprecated/
-scratchpad/
-
+++ /dev/null
-# write /etc/nftables.d/10-block-IPv6.nft — drop all IPv6
-def configure(prov, planner, WriteFileMeta):
- wfm = WriteFileMeta(
- dpath="/etc/nftables.d",
- fname="10-block-IPv6.nft",
- owner="root",
- mode=0o644,
- )
- planner.displace(wfm)
- planner.copy(wfm, content="""\
-table inet NO-IPV6 {
- chain input {
- type filter hook input priority -300; policy accept;
- meta nfproto ipv6 counter comment "drop all IPv6 inbound" drop;
- }
-
- chain output {
- type filter hook output priority -300; policy accept;
- meta nfproto ipv6 counter comment "drop all IPv6 outbound" drop;
- }
-
- chain forward {
- type filter hook forward priority -300; policy accept;
- meta nfproto ipv6 counter comment "drop all IPv6 forward" drop;
- }
-}
-""")
+++ /dev/null
-table inet SUBU-DNS-REDIRECT {
- chain output {
- type nat hook output priority -100; policy accept;
-
- # Redirect DNS for the subu UIDs to local Unbound listeners
- meta skuid 2017 udp dport 53 redirect to :5301
- meta skuid 2018 udp dport 53 redirect to :5302
- meta skuid 2017 tcp dport 53 redirect to :5301
- meta skuid 2018 tcp dport 53 redirect to :5302
- }
-}
-
-table inet SUBU-PORT-EGRESS {
- chain output {
- type filter hook output priority 0; policy accept;
-
- # Always allow loopback on egress
- oifname "lo" accept
-
- # No IPv6 for subu (until you reintroduce v6)
- meta skuid {2017,2018} meta nfproto ipv6 counter comment "no IPv6 for subu" drop
-
- ##### x6 (UID 2018)
- # Block some exfil channels regardless of iface
- meta skuid 2018 tcp dport {25,465,587} counter comment "block SMTP/Submission" drop
- meta skuid 2018 udp dport {3478,5349,19302-19309} counter comment "block STUN/TURN" drop
- meta skuid 2018 tcp dport 853 counter comment "block DoT (TCP/853)" drop
-
- # (Optional) allow ICMP echo out via x6
- meta skuid 2018 oifname "x6" ip protocol icmp icmp type echo-request accept
-
- # Enforce interface binding
- meta skuid 2018 oifname "x6" accept
- meta skuid 2018 oifname != "x6" counter comment "x6 must use wg x6" drop
-
- ##### US (UID 2017)
- meta skuid 2017 tcp dport {25,465,587} counter drop comment "block SMTP/Submission"
- meta skuid 2017 udp dport {3478,5349,19302-19309} counter drop comment "block STUN/TURN"
- meta skuid 2017 tcp dport 853 counter drop comment "block DoT (TCP/853)"
-
- # (Optional) ICMP via US
- meta skuid 2017 oifname "US" ip protocol icmp icmp type echo-request accept
-
- meta skuid 2017 oifname "US" accept
- meta skuid 2017 oifname != "US" counter comment "US must use wg US" drop
- }
-}
+++ /dev/null
-# Man_In_Grey acceptance filter (default template)
-# Return True to include a config file ,False to skip it.
-# You receive a PlanProvenance object named `prov`.
-#
-# Common fields:
-# prov.stage_root_dpath : Path
-# prov.config_abs_fpath : Path
-# prov.config_rel_fpath : Path
-# prov.read_dir_dpath : Path
-# prov.read_fname : str
-#
-# 1) Accept everything (default):
-# def accept(prov):
-# return True
-#
-# 2) Only a namespace:
-# def accept(prov):
-# return prov.config_rel_fpath.as_posix().startswith("dns/")
-#
-# 3) Exclude editor junk:
-# def accept(prov):
-# r = prov.config_rel_fpath.as_posix()
-# return not (r.endswith("~") or r.endswith(".swp"))
-#
-def accept(prov):
- return True
+++ /dev/null
-#+TITLE: DNS Bundle (Unbound + Per-subu Redirect) — RT-v2025.09.15.1
-#+AUTHOR: RT Toolkit
-#+OPTIONS: toc:2
-#+STARTUP: show2levels
-
-* Overview
-This bundle stages a *per-subu DNS* setup on the client:
-- Two Unbound instances (templated via ~unbound@.service~):
- - ~unbound@US~ listens on ~127.0.0.1:5301~, resolves *over the US tunnel* (outgoing interface = ~10.0.0.1~).
- - ~unbound@x6~ listens on ~127.0.0.1:5302~, resolves *over the x6 tunnel* (outgoing interface = ~10.8.0.2~).
-- nftables rules match the subu’s UID and *redirect TCP/UDP port 53* to the corresponding local Unbound port.
-- A small deploy helper (~deploy_DNS.py~) installs the staged tree and enables services.
-
-* Why this design?
-- When a subu (containerized user) does DNS, traffic is forced to the tunnel assigned to that subu.
-- If a tunnel is down, DNS for that subu fails closed (no silent leak), while your ~local~ subu can still use ISP DNS.
-- No changes to per-user resolv.conf are required: subu keep using ~nameserver 127.0.0.1~ (via redirect).
-
-* Layout
-#+begin_example
-DNS_bundle/
- README.org
- deploy_DNS.py
- stage/
- etc/
- nftables.d/
- DNS-redirect.nft
- systemd/
- system/
- DNS-redirect.service
- unbound@.service
- unbound/
- unbound-US.conf
- unbound-x6.conf
- usr/
- local/
- sbin/
- DNS_status.sh
-#+end_example
-
-* Assumptions / Customize
-- Client WG local addresses (from your earlier setup):
- - US: ~10.0.0.1/32~
- - x6: ~10.8.0.2/32~
-- Subu UIDs (adjust if different):
- - US → UID ~2017~
- - x6 → UID ~2018~
-- If these differ on your box, edit:
- - ~stage/etc/unbound/unbound-US.conf~ (~outgoing-interface~)
- - ~stage/etc/unbound/unbound-x6.conf~ (~outgoing-interface~)
- - ~stage/etc/nftables.d/DNS-redirect.nft~ (the ~meta skuid~ lines)
-
-* Deploy
-1. Review staged files:
- #+begin_src sh
- tar tzf DNS_bundle.tgz | sed 's/^/ /'
- #+end_src
-2. Extract and run deploy (root):
- #+begin_src sh
- tar xzf DNS_bundle.tgz
- cd DNS_bundle
- sudo ./deploy_DNS.py --instances US x6
- #+end_src
-3. Verify:
- #+begin_src sh
- systemctl status unbound@US unbound@x6 DNS-redirect
- sudo nft list table inet NAT-DNS-REDIRECT
- #+end_src
-
-* How it works
-- nftables (~DNS-redirect.nft~) in ~inet~ *nat output* hook rewrites subu DNS to the local listener ports:
- - US (UID 2017) → ~127.0.0.1:5301~
- - x6 (UID 2018) → ~127.0.0.1:5302~
-- Each Unbound instance binds to its port and *sources queries from the WG IP* using ~outgoing-interface~.
-- Unit ordering ties each instance to its tunnel: ~After=~ and ~Requires=~ ~wg-quick@%i.service~.
-
-* Notes
-- If a tunnel’s address is not present at Unbound start, the unit waits because of the dependency and restarts later.
-- For DoT/DoH upstream, you can switch to ~forward-tls-upstream: yes~ with providers that support TLS on 853.
-- The ~DNS_status.sh~ helper prints a quick status and the top of logs.
-
-* Rollback
-#+begin_src sh
-sudo systemctl disable --now unbound@US unbound@x6 DNS-redirect
-sudo nft flush table inet NAT-DNS-REDIRECT || true
-# Remove staged files if desired (be careful)
-# sudo rm -f /etc/unbound/unbound-US.conf /etc/unbound/unbound-x6.conf
-#+end_src
-
-* License
-This bundle is provided “as-is”. Use at your own discretion.
+++ /dev/null
-
-* 1. modify stage files
-
- The stage/ directory holds bespoke configuration files for host StanleyPark's configuration.
-
- Copy/Modify the sraged files for your site.
-
- Work on the stage is done in user space. The program `sudo install_staged_tree.py` copies the files on the stage into the root file system, `/', or optionally to another specified directory target. However, normally one will run `deploy.py` to do the install and to make the systemctl calls to restart services.
-
-* 2. edit /etc/nftables.conf
-
- requires root priv
-
- Strange, but Debian 12 nftables does not automatically include the scripts in its drop-in directory, so ..
-
- add this at the bottom of /etc/nftables.conf
-
- flush ruleset
- include "/etc/nftables.d/*.nft"
-
-* 3. run `deploy.py`
-
- requires root priv
-
-* 4. check
-
- requires root priv
-
- nft list ruleset | sed -n '/SUBU-/,/}/p'
- systemctl status nftables
- ss -ltnup 'sport = :5301' 'sport = :5302' # your Unbound listeners
-
+++ /dev/null
-# write /etc/nftables.d/10-block-IPv6.nft — drop all IPv6
-def configure(prov, planner, WriteFileMeta):
- wfm = WriteFileMeta(
- dpath="/etc/nftables.d",
- fname="10-block-IPv6.nft",
- owner="root",
- mode=0o644,
- )
- planner.displace(wfm)
- planner.copy(wfm, content="""\
-table inet NO-IPV6 {
- chain input {
- type filter hook input priority -300; policy accept;
- meta nfproto ipv6 counter comment "drop all IPv6 inbound" drop;
- }
-
- chain output {
- type filter hook output priority -300; policy accept;
- meta nfproto ipv6 counter comment "drop all IPv6 outbound" drop;
- }
-
- chain forward {
- type filter hook forward priority -300; policy accept;
- meta nfproto ipv6 counter comment "drop all IPv6 forward" drop;
- }
-}
-""")
+++ /dev/null
-# write /etc/nftables.d/20-SUBU-ports.nft — DNS redirect + strict egress
-def configure(prov, planner, WriteFileMeta):
- wfm = WriteFileMeta(
- dpath="/etc/nftables.d",
- fname="20-SUBU-ports.nft",
- owner="root",
- mode=0o644,
- )
- planner.displace(wfm)
- planner.copy(wfm, content="""\
-# DNS per-UID redirect to local Unbound
-table inet SUBU-DNS-REDIRECT {
- chain output {
- type nat hook output priority -100; policy accept;
-
- # US (uid 2017) -> 127.0.0.1:5301
- meta skuid 2017 udp dport 53 redirect to :5301
- meta skuid 2017 tcp dport 53 redirect to :5301
- # x6 (uid 2018) -> 127.0.0.1:5302
- meta skuid 2018 udp dport 53 redirect to :5302
- meta skuid 2018 tcp dport 53 redirect to :5302
- }
-}
-
-# Egress policy: subu UIDs must use their WireGuard iface; block exfil channels
-table inet SUBU-PORT-EGRESS {
- chain output {
- type filter hook output priority 0; policy accept;
-
- # Always allow loopback
- oifname "lo" accept;
-
- # No IPv6 for subu (until you reintroduce v6)
- meta skuid {2017,2018} meta nfproto ipv6 counter comment "no IPv6 for subu" drop;
-
- ##### x6 (UID 2018)
- meta skuid 2018 tcp dport {25,465,587} counter comment "block SMTP/Submission" drop;
- meta skuid 2018 udp dport {3478,5349,19302-19309} counter comment "block STUN/TURN" drop;
- meta skuid 2018 tcp dport 853 counter comment "block DoT (TCP/853)" drop;
- meta skuid 2018 oifname "x6" accept;
- meta skuid 2018 oifname != "x6" counter comment "x6 must use wg x6" drop;
-
- ##### US (UID 2017)
- meta skuid 2017 tcp dport {25,465,587} counter comment "block SMTP/Submission" drop;
- meta skuid 2017 udp dport {3478,5349,19302-19309} counter comment "block STUN/TURN" drop;
- meta skuid 2017 tcp dport 853 counter comment "block DoT (TCP/853)" drop;
- meta skuid 2017 oifname "US" accept;
- meta skuid 2017 oifname != "US" counter comment "US must use wg US" drop;
- }
-}
-""")
+++ /dev/null
-# write /etc/unbound/unbound-US.conf — local listener that egresses via US WG
-def configure(prov, planner, WriteFileMeta):
- wfm = WriteFileMeta(
- dpath="/etc/unbound",
- fname="unbound-US.conf",
- owner="root",
- mode=0o644,
- )
- planner.displace(wfm)
- planner.copy(wfm, content="""\
-server:
- verbosity: 1
- username: "unbound"
- directory: "/etc/unbound"
- chroot: ""
-
- do-ip6: no
- do-udp: yes
- do-tcp: yes
- prefer-ip6: no
-
- # Listen only on loopback (US instance)
- interface: 127.0.0.1@5301
- access-control: 127.0.0.0/8 allow
-
- # Egress via US tunnel address (policy rules ensure it leaves on wg US)
- outgoing-interface: 10.0.0.1
-
- # Hardening/cache
- hide-identity: yes
- hide-version: yes
- harden-referral-path: yes
- harden-dnssec-stripped: yes
- qname-minimisation: yes
- aggressive-nsec: yes
- prefetch: yes
- cache-min-ttl: 60
- cache-max-ttl: 86400
-
- # DNSSEC trust anchor
- auto-trust-anchor-file: "/var/lib/unbound/root.key"
-""")
+++ /dev/null
-# /etc/systemd/system/wg-policy-US.service — run after wg-quick@US to install policy rules
-def configure(prov, planner, WriteFileMeta):
- content = """[Unit]
-Description=Policy routing for Unbound egress (US)
-After=wg-quick@US.service
-Wants=wg-quick@US.service
-
-[Service]
-Type=oneshot
-ExecStart=/usr/local/sbin/wg-policy-US.sh
-RemainAfterExit=yes
-
-[Install]
-WantedBy=multi-user.target
-"""
- wfm = WriteFileMeta(dpath="/etc/systemd/system", fname="wg-policy-US.service", owner="root", mode="0644")
- planner.displace(wfm)
- planner.copy(wfm, content=content)
+++ /dev/null
-# /usr/local/sbin/wg-policy-US.sh — source-policy routing for Unbound's egress
-def configure(prov, planner, WriteFileMeta):
- # EDIT if your interface/IP differ:
- WG_IFACE = "US"
- WG_SRC_IP = "10.0.0.1"
- TABLE = 100
-
- content = f"""#!/usr/bin/env bash
-set -euo pipefail
-WG_IFACE="{WG_IFACE}"
-WG_SRC_IP="{WG_SRC_IP}"
-TABLE={TABLE}
-
-ip rule replace from "$WG_SRC_IP" lookup "$TABLE" priority 10010
-ip route replace default dev "$WG_IFACE" table "$TABLE"
-"""
- wfm = WriteFileMeta(dpath="/usr/local/sbin", fname="wg-policy-US.sh", owner="root", mode="0755")
- planner.displace(wfm)
- planner.copy(wfm, content=content)
+++ /dev/null
-# Redirect DNS traffic per-UID to local Unbound instances.
-# US (uid 2017) -> 127.0.0.1:5301
-# x6 (uid 2018) -> 127.0.0.1:5302
-table inet nat {
- chain output {
- type nat hook output priority -100;
- # US
- meta skuid 2017 udp dport 53 redirect to :5301
- meta skuid 2017 tcp dport 53 redirect to :5301
- # x6
- meta skuid 2018 udp dport 53 redirect to :5302
- meta skuid 2018 tcp dport 53 redirect to :5302
- }
-}
+++ /dev/null
-server:
- verbosity: 1
- username: "unbound"
- directory: "/etc/unbound"
- chroot: ""
-
- do-ip6: no
- do-udp: yes
- do-tcp: yes
- prefer-ip6: no
-
- # Listen only on loopback (US instance)
- interface: 127.0.0.1@5301
- access-control: 127.0.0.0/8 allow
-
- # Egress via US tunnel address (policy routing will carry it out the WG table)
- outgoing-interface: 10.0.0.1
-
- # Sensible hardening/cache
- hide-identity: yes
- hide-version: yes
- harden-referral-path: yes
- harden-dnssec-stripped: yes
- qname-minimisation: yes
- aggressive-nsec: yes
- prefetch: yes
- cache-min-ttl: 60
- cache-max-ttl: 86400
-
- # DNSSEC TA (create with unbound-anchor)
- auto-trust-anchor-file: "/var/lib/unbound/root.key"
- # Optional root hints (download separately)
- # root-hints: "/var/lib/unbound/root.hints"
-
-# To use forwarding instead of full recursion, uncomment and edit:
-# forward-zone:
-# name: "."
-# forward-tls-upstream: no
-# forward-addr: 9.9.9.9
-# forward-addr: 1.1.1.1
+++ /dev/null
-server:
- verbosity: 1
- username: "unbound"
- directory: "/etc/unbound"
- chroot: ""
-
- do-ip6: no
- do-udp: yes
- do-tcp: yes
- prefer-ip6: no
-
- # Listen only on loopback (x6 instance)
- interface: 127.0.0.1@5302
- access-control: 127.0.0.0/8 allow
-
- # Egress via x6 tunnel address (policy routing will carry it out the WG table)
- outgoing-interface: 10.8.0.2
-
- # Sensible hardening/cache
- hide-identity: yes
- hide-version: yes
- harden-referral-path: yes
- harden-dnssec-stripped: yes
- qname-minimisation: yes
- aggressive-nsec: yes
- prefetch: yes
- cache-min-ttl: 60
- cache-max-ttl: 86400
-
- # DNSSEC TA (create with unbound-anchor)
- auto-trust-anchor-file: "/var/lib/unbound/root.key"
- # Optional root hints (download separately)
- # root-hints: "/var/lib/unbound/root.hints"
-
-# To use forwarding instead of full recursion, uncomment and edit:
-# forward-zone:
-# name: "."
-# forward-tls-upstream: no
-# forward-addr: 9.9.9.9
-# forward-addr: 1.1.1.1
+++ /dev/null
-[Unit]
-Description=Unbound DNS (%i)
-Documentation=man:unbound(8)
-After=network-online.target wg-quick@%i.service
-Wants=network-online.target
-
-[Service]
-Type=simple
-ExecStart=/usr/sbin/unbound -d -p -c /etc/unbound/unbound-%i.conf
-Restart=on-failure
-# Lock down a bit
-CapabilityBoundingSet=CAP_NET_BIND_SERVICE CAP_SETGID CAP_SETUID
-AmbientCapabilities=CAP_NET_BIND_SERVICE
-NoNewPrivileges=true
-PrivateTmp=true
-ProtectSystem=full
-ProtectHome=true
-
-[Install]
-WantedBy=multi-user.target
+++ /dev/null
-# unbound/unbound_US.py
-def configure(prov, planner, WriteFileMeta):
- conf = """server:
- verbosity: 1
- username: "unbound"
- directory: "/etc/unbound"
- chroot: ""
-
- do-ip6: no
- do-udp: yes
- do-tcp: yes
- prefer-ip6: no
-
- interface: 127.0.0.1@5301
- access-control: 127.0.0.0/8 allow
-
- outgoing-interface: 10.0.0.1
-
- hide-identity: yes
- hide-version: yes
- harden-referral-path: yes
- harden-dnssec-stripped: yes
- qname-minimisation: yes
- aggressive-nsec: yes
- prefetch: yes
- cache-min-ttl: 60
- cache-max-ttl: 86400
-
- auto-trust-anchor-file: "/var/lib/unbound/root.key"
-"""
- wfm = WriteFileMeta(dpath="/etc/unbound", fname="unbound-US.conf",
- owner="root", mode="0644")
- planner.copy(wfm, content=conf)
+++ /dev/null
-# systemd/unbound_at_template.py
-def configure(prov, planner, WriteFileMeta):
- service = """[Unit]
-Description=Unbound DNS (%i)
-Documentation=man:unbound(8)
-After=network-online.target wg-quick@%i.service
-Wants=network-online.target
-
-[Service]
-Type=simple
-ExecStart=/usr/sbin/unbound -d -p -c /etc/unbound/unbound-%i.conf
-Restart=on-failure
-CapabilityBoundingSet=CAP_NET_BIND_SERVICE CAP_SETGID CAP_SETUID
-AmbientCapabilities=CAP_NET_BIND_SERVICE
-NoNewPrivileges=true
-PrivateTmp=true
-ProtectSystem=full
-ProtectHome=true
-
-[Install]
-WantedBy=multi-user.target
-"""
- wfm = WriteFileMeta(dpath="/etc/systemd/system", fname="unbound@.service",
- owner="root", mode="0644")
- planner.copy(wfm, content=service)
+++ /dev/null
-# unbound/unbound_x6.py
-def configure(prov, planner, WriteFileMeta):
- conf = """server:
- verbosity: 1
- username: "unbound"
- directory: "/etc/unbound"
- chroot: ""
-
- do-ip6: no
- do-udp: yes
- do-tcp: yes
- prefer-ip6: no
-
- interface: 127.0.0.1@5302
- access-control: 127.0.0.0/8 allow
-
- outgoing-interface: 10.8.0.2
-
- hide-identity: yes
- hide-version: yes
- harden-referral-path: yes
- harden-dnssec-stripped: yes
- qname-minimisation: yes
- aggressive-nsec: yes
- prefetch: yes
- cache-min-ttl: 60
- cache-max-ttl: 86400
-
- auto-trust-anchor-file: "/var/lib/unbound/root.key"
-"""
- wfm = WriteFileMeta(dpath="/etc/unbound", fname="unbound-x6.conf",
- owner="root", mode="0644")
- planner.copy(wfm, content=conf)
--- /dev/null
+window.RT_REPO_ROOT = "../../";
+document.write('<script src="' + window.RT_REPO_ROOT + 'shared/dictionary_style-directory.js"></script>');
+document.write('<script src="' + window.RT_REPO_ROOT + 'shared/linked-project/RT-style-JS_public/consumer/release/RT/core/loader.js"></script>');
+document.write('<script src="' + window.RT_REPO_ROOT + 'shared/linked-project/RT-style-JS_public/consumer/release/RT/core/body_visibility_hidden.js"></script>');
--- /dev/null
+
+__pycache__
+stage/
+deprecated/
+scratchpad/
+
--- /dev/null
+# write /etc/nftables.d/10-block-IPv6.nft — drop all IPv6
+def configure(prov, planner, WriteFileMeta):
+ wfm = WriteFileMeta(
+ dpath="/etc/nftables.d",
+ fname="10-block-IPv6.nft",
+ owner="root",
+ mode=0o644,
+ )
+ planner.displace(wfm)
+ planner.copy(wfm, content="""\
+table inet NO-IPV6 {
+ chain input {
+ type filter hook input priority -300; policy accept;
+ meta nfproto ipv6 counter comment "drop all IPv6 inbound" drop;
+ }
+
+ chain output {
+ type filter hook output priority -300; policy accept;
+ meta nfproto ipv6 counter comment "drop all IPv6 outbound" drop;
+ }
+
+ chain forward {
+ type filter hook forward priority -300; policy accept;
+ meta nfproto ipv6 counter comment "drop all IPv6 forward" drop;
+ }
+}
+""")
--- /dev/null
+table inet SUBU-DNS-REDIRECT {
+ chain output {
+ type nat hook output priority -100; policy accept;
+
+ # Redirect DNS for the subu UIDs to local Unbound listeners
+ meta skuid 2017 udp dport 53 redirect to :5301
+ meta skuid 2018 udp dport 53 redirect to :5302
+ meta skuid 2017 tcp dport 53 redirect to :5301
+ meta skuid 2018 tcp dport 53 redirect to :5302
+ }
+}
+
+table inet SUBU-PORT-EGRESS {
+ chain output {
+ type filter hook output priority 0; policy accept;
+
+ # Always allow loopback on egress
+ oifname "lo" accept
+
+ # No IPv6 for subu (until you reintroduce v6)
+ meta skuid {2017,2018} meta nfproto ipv6 counter comment "no IPv6 for subu" drop
+
+ ##### x6 (UID 2018)
+ # Block some exfil channels regardless of iface
+ meta skuid 2018 tcp dport {25,465,587} counter comment "block SMTP/Submission" drop
+ meta skuid 2018 udp dport {3478,5349,19302-19309} counter comment "block STUN/TURN" drop
+ meta skuid 2018 tcp dport 853 counter comment "block DoT (TCP/853)" drop
+
+ # (Optional) allow ICMP echo out via x6
+ meta skuid 2018 oifname "x6" ip protocol icmp icmp type echo-request accept
+
+ # Enforce interface binding
+ meta skuid 2018 oifname "x6" accept
+ meta skuid 2018 oifname != "x6" counter comment "x6 must use wg x6" drop
+
+ ##### US (UID 2017)
+ meta skuid 2017 tcp dport {25,465,587} counter drop comment "block SMTP/Submission"
+ meta skuid 2017 udp dport {3478,5349,19302-19309} counter drop comment "block STUN/TURN"
+ meta skuid 2017 tcp dport 853 counter drop comment "block DoT (TCP/853)"
+
+ # (Optional) ICMP via US
+ meta skuid 2017 oifname "US" ip protocol icmp icmp type echo-request accept
+
+ meta skuid 2017 oifname "US" accept
+ meta skuid 2017 oifname != "US" counter comment "US must use wg US" drop
+ }
+}
--- /dev/null
+# Man_In_Grey acceptance filter (default template)
+# Return True to include a config file ,False to skip it.
+# You receive a PlanProvenance object named `prov`.
+#
+# Common fields:
+# prov.stage_root_dpath : Path
+# prov.config_abs_fpath : Path
+# prov.config_rel_fpath : Path
+# prov.read_dir_dpath : Path
+# prov.read_fname : str
+#
+# 1) Accept everything (default):
+# def accept(prov):
+# return True
+#
+# 2) Only a namespace:
+# def accept(prov):
+# return prov.config_rel_fpath.as_posix().startswith("dns/")
+#
+# 3) Exclude editor junk:
+# def accept(prov):
+# r = prov.config_rel_fpath.as_posix()
+# return not (r.endswith("~") or r.endswith(".swp"))
+#
+def accept(prov):
+ return True
--- /dev/null
+#+TITLE: DNS Bundle (Unbound + Per-subu Redirect) — RT-v2025.09.15.1
+#+AUTHOR: RT Toolkit
+#+OPTIONS: toc:2
+#+STARTUP: show2levels
+
+* Overview
+This bundle stages a *per-subu DNS* setup on the client:
+- Two Unbound instances (templated via ~unbound@.service~):
+ - ~unbound@US~ listens on ~127.0.0.1:5301~, resolves *over the US tunnel* (outgoing interface = ~10.0.0.1~).
+ - ~unbound@x6~ listens on ~127.0.0.1:5302~, resolves *over the x6 tunnel* (outgoing interface = ~10.8.0.2~).
+- nftables rules match the subu’s UID and *redirect TCP/UDP port 53* to the corresponding local Unbound port.
+- A small deploy helper (~deploy_DNS.py~) installs the staged tree and enables services.
+
+* Why this design?
+- When a subu (containerized user) does DNS, traffic is forced to the tunnel assigned to that subu.
+- If a tunnel is down, DNS for that subu fails closed (no silent leak), while your ~local~ subu can still use ISP DNS.
+- No changes to per-user resolv.conf are required: subu keep using ~nameserver 127.0.0.1~ (via redirect).
+
+* Layout
+#+begin_example
+DNS_bundle/
+ README.org
+ deploy_DNS.py
+ stage/
+ etc/
+ nftables.d/
+ DNS-redirect.nft
+ systemd/
+ system/
+ DNS-redirect.service
+ unbound@.service
+ unbound/
+ unbound-US.conf
+ unbound-x6.conf
+ usr/
+ local/
+ sbin/
+ DNS_status.sh
+#+end_example
+
+* Assumptions / Customize
+- Client WG local addresses (from your earlier setup):
+ - US: ~10.0.0.1/32~
+ - x6: ~10.8.0.2/32~
+- Subu UIDs (adjust if different):
+ - US → UID ~2017~
+ - x6 → UID ~2018~
+- If these differ on your box, edit:
+ - ~stage/etc/unbound/unbound-US.conf~ (~outgoing-interface~)
+ - ~stage/etc/unbound/unbound-x6.conf~ (~outgoing-interface~)
+ - ~stage/etc/nftables.d/DNS-redirect.nft~ (the ~meta skuid~ lines)
+
+* Deploy
+1. Review staged files:
+ #+begin_src sh
+ tar tzf DNS_bundle.tgz | sed 's/^/ /'
+ #+end_src
+2. Extract and run deploy (root):
+ #+begin_src sh
+ tar xzf DNS_bundle.tgz
+ cd DNS_bundle
+ sudo ./deploy_DNS.py --instances US x6
+ #+end_src
+3. Verify:
+ #+begin_src sh
+ systemctl status unbound@US unbound@x6 DNS-redirect
+ sudo nft list table inet NAT-DNS-REDIRECT
+ #+end_src
+
+* How it works
+- nftables (~DNS-redirect.nft~) in ~inet~ *nat output* hook rewrites subu DNS to the local listener ports:
+ - US (UID 2017) → ~127.0.0.1:5301~
+ - x6 (UID 2018) → ~127.0.0.1:5302~
+- Each Unbound instance binds to its port and *sources queries from the WG IP* using ~outgoing-interface~.
+- Unit ordering ties each instance to its tunnel: ~After=~ and ~Requires=~ ~wg-quick@%i.service~.
+
+* Notes
+- If a tunnel’s address is not present at Unbound start, the unit waits because of the dependency and restarts later.
+- For DoT/DoH upstream, you can switch to ~forward-tls-upstream: yes~ with providers that support TLS on 853.
+- The ~DNS_status.sh~ helper prints a quick status and the top of logs.
+
+* Rollback
+#+begin_src sh
+sudo systemctl disable --now unbound@US unbound@x6 DNS-redirect
+sudo nft flush table inet NAT-DNS-REDIRECT || true
+# Remove staged files if desired (be careful)
+# sudo rm -f /etc/unbound/unbound-US.conf /etc/unbound/unbound-x6.conf
+#+end_src
+
+* License
+This bundle is provided “as-is”. Use at your own discretion.
--- /dev/null
+
+* 1. modify stage files
+
+ The stage/ directory holds bespoke configuration files for host StanleyPark's configuration.
+
+ Copy/Modify the sraged files for your site.
+
+ Work on the stage is done in user space. The program `sudo install_staged_tree.py` copies the files on the stage into the root file system, `/', or optionally to another specified directory target. However, normally one will run `deploy.py` to do the install and to make the systemctl calls to restart services.
+
+* 2. edit /etc/nftables.conf
+
+ requires root priv
+
+ Strange, but Debian 12 nftables does not automatically include the scripts in its drop-in directory, so ..
+
+ add this at the bottom of /etc/nftables.conf
+
+ flush ruleset
+ include "/etc/nftables.d/*.nft"
+
+* 3. run `deploy.py`
+
+ requires root priv
+
+* 4. check
+
+ requires root priv
+
+ nft list ruleset | sed -n '/SUBU-/,/}/p'
+ systemctl status nftables
+ ss -ltnup 'sport = :5301' 'sport = :5302' # your Unbound listeners
+
--- /dev/null
+# write /etc/nftables.d/10-block-IPv6.nft — drop all IPv6
+def configure(prov, planner, WriteFileMeta):
+ wfm = WriteFileMeta(
+ dpath="/etc/nftables.d",
+ fname="10-block-IPv6.nft",
+ owner="root",
+ mode=0o644,
+ )
+ planner.displace(wfm)
+ planner.copy(wfm, content="""\
+table inet NO-IPV6 {
+ chain input {
+ type filter hook input priority -300; policy accept;
+ meta nfproto ipv6 counter comment "drop all IPv6 inbound" drop;
+ }
+
+ chain output {
+ type filter hook output priority -300; policy accept;
+ meta nfproto ipv6 counter comment "drop all IPv6 outbound" drop;
+ }
+
+ chain forward {
+ type filter hook forward priority -300; policy accept;
+ meta nfproto ipv6 counter comment "drop all IPv6 forward" drop;
+ }
+}
+""")
--- /dev/null
+# write /etc/nftables.d/20-SUBU-ports.nft — DNS redirect + strict egress
+def configure(prov, planner, WriteFileMeta):
+ wfm = WriteFileMeta(
+ dpath="/etc/nftables.d",
+ fname="20-SUBU-ports.nft",
+ owner="root",
+ mode=0o644,
+ )
+ planner.displace(wfm)
+ planner.copy(wfm, content="""\
+# DNS per-UID redirect to local Unbound
+table inet SUBU-DNS-REDIRECT {
+ chain output {
+ type nat hook output priority -100; policy accept;
+
+ # US (uid 2017) -> 127.0.0.1:5301
+ meta skuid 2017 udp dport 53 redirect to :5301
+ meta skuid 2017 tcp dport 53 redirect to :5301
+ # x6 (uid 2018) -> 127.0.0.1:5302
+ meta skuid 2018 udp dport 53 redirect to :5302
+ meta skuid 2018 tcp dport 53 redirect to :5302
+ }
+}
+
+# Egress policy: subu UIDs must use their WireGuard iface; block exfil channels
+table inet SUBU-PORT-EGRESS {
+ chain output {
+ type filter hook output priority 0; policy accept;
+
+ # Always allow loopback
+ oifname "lo" accept;
+
+ # No IPv6 for subu (until you reintroduce v6)
+ meta skuid {2017,2018} meta nfproto ipv6 counter comment "no IPv6 for subu" drop;
+
+ ##### x6 (UID 2018)
+ meta skuid 2018 tcp dport {25,465,587} counter comment "block SMTP/Submission" drop;
+ meta skuid 2018 udp dport {3478,5349,19302-19309} counter comment "block STUN/TURN" drop;
+ meta skuid 2018 tcp dport 853 counter comment "block DoT (TCP/853)" drop;
+ meta skuid 2018 oifname "x6" accept;
+ meta skuid 2018 oifname != "x6" counter comment "x6 must use wg x6" drop;
+
+ ##### US (UID 2017)
+ meta skuid 2017 tcp dport {25,465,587} counter comment "block SMTP/Submission" drop;
+ meta skuid 2017 udp dport {3478,5349,19302-19309} counter comment "block STUN/TURN" drop;
+ meta skuid 2017 tcp dport 853 counter comment "block DoT (TCP/853)" drop;
+ meta skuid 2017 oifname "US" accept;
+ meta skuid 2017 oifname != "US" counter comment "US must use wg US" drop;
+ }
+}
+""")
--- /dev/null
+# write /etc/unbound/unbound-US.conf — local listener that egresses via US WG
+def configure(prov, planner, WriteFileMeta):
+ wfm = WriteFileMeta(
+ dpath="/etc/unbound",
+ fname="unbound-US.conf",
+ owner="root",
+ mode=0o644,
+ )
+ planner.displace(wfm)
+ planner.copy(wfm, content="""\
+server:
+ verbosity: 1
+ username: "unbound"
+ directory: "/etc/unbound"
+ chroot: ""
+
+ do-ip6: no
+ do-udp: yes
+ do-tcp: yes
+ prefer-ip6: no
+
+ # Listen only on loopback (US instance)
+ interface: 127.0.0.1@5301
+ access-control: 127.0.0.0/8 allow
+
+ # Egress via US tunnel address (policy rules ensure it leaves on wg US)
+ outgoing-interface: 10.0.0.1
+
+ # Hardening/cache
+ hide-identity: yes
+ hide-version: yes
+ harden-referral-path: yes
+ harden-dnssec-stripped: yes
+ qname-minimisation: yes
+ aggressive-nsec: yes
+ prefetch: yes
+ cache-min-ttl: 60
+ cache-max-ttl: 86400
+
+ # DNSSEC trust anchor
+ auto-trust-anchor-file: "/var/lib/unbound/root.key"
+""")
--- /dev/null
+# /etc/systemd/system/wg-policy-US.service — run after wg-quick@US to install policy rules
+def configure(prov, planner, WriteFileMeta):
+ content = """[Unit]
+Description=Policy routing for Unbound egress (US)
+After=wg-quick@US.service
+Wants=wg-quick@US.service
+
+[Service]
+Type=oneshot
+ExecStart=/usr/local/sbin/wg-policy-US.sh
+RemainAfterExit=yes
+
+[Install]
+WantedBy=multi-user.target
+"""
+ wfm = WriteFileMeta(dpath="/etc/systemd/system", fname="wg-policy-US.service", owner="root", mode="0644")
+ planner.displace(wfm)
+ planner.copy(wfm, content=content)
--- /dev/null
+# /usr/local/sbin/wg-policy-US.sh — source-policy routing for Unbound's egress
+def configure(prov, planner, WriteFileMeta):
+ # EDIT if your interface/IP differ:
+ WG_IFACE = "US"
+ WG_SRC_IP = "10.0.0.1"
+ TABLE = 100
+
+ content = f"""#!/usr/bin/env bash
+set -euo pipefail
+WG_IFACE="{WG_IFACE}"
+WG_SRC_IP="{WG_SRC_IP}"
+TABLE={TABLE}
+
+ip rule replace from "$WG_SRC_IP" lookup "$TABLE" priority 10010
+ip route replace default dev "$WG_IFACE" table "$TABLE"
+"""
+ wfm = WriteFileMeta(dpath="/usr/local/sbin", fname="wg-policy-US.sh", owner="root", mode="0755")
+ planner.displace(wfm)
+ planner.copy(wfm, content=content)
--- /dev/null
+# Redirect DNS traffic per-UID to local Unbound instances.
+# US (uid 2017) -> 127.0.0.1:5301
+# x6 (uid 2018) -> 127.0.0.1:5302
+table inet nat {
+ chain output {
+ type nat hook output priority -100;
+ # US
+ meta skuid 2017 udp dport 53 redirect to :5301
+ meta skuid 2017 tcp dport 53 redirect to :5301
+ # x6
+ meta skuid 2018 udp dport 53 redirect to :5302
+ meta skuid 2018 tcp dport 53 redirect to :5302
+ }
+}
--- /dev/null
+server:
+ verbosity: 1
+ username: "unbound"
+ directory: "/etc/unbound"
+ chroot: ""
+
+ do-ip6: no
+ do-udp: yes
+ do-tcp: yes
+ prefer-ip6: no
+
+ # Listen only on loopback (US instance)
+ interface: 127.0.0.1@5301
+ access-control: 127.0.0.0/8 allow
+
+ # Egress via US tunnel address (policy routing will carry it out the WG table)
+ outgoing-interface: 10.0.0.1
+
+ # Sensible hardening/cache
+ hide-identity: yes
+ hide-version: yes
+ harden-referral-path: yes
+ harden-dnssec-stripped: yes
+ qname-minimisation: yes
+ aggressive-nsec: yes
+ prefetch: yes
+ cache-min-ttl: 60
+ cache-max-ttl: 86400
+
+ # DNSSEC TA (create with unbound-anchor)
+ auto-trust-anchor-file: "/var/lib/unbound/root.key"
+ # Optional root hints (download separately)
+ # root-hints: "/var/lib/unbound/root.hints"
+
+# To use forwarding instead of full recursion, uncomment and edit:
+# forward-zone:
+# name: "."
+# forward-tls-upstream: no
+# forward-addr: 9.9.9.9
+# forward-addr: 1.1.1.1
--- /dev/null
+server:
+ verbosity: 1
+ username: "unbound"
+ directory: "/etc/unbound"
+ chroot: ""
+
+ do-ip6: no
+ do-udp: yes
+ do-tcp: yes
+ prefer-ip6: no
+
+ # Listen only on loopback (x6 instance)
+ interface: 127.0.0.1@5302
+ access-control: 127.0.0.0/8 allow
+
+ # Egress via x6 tunnel address (policy routing will carry it out the WG table)
+ outgoing-interface: 10.8.0.2
+
+ # Sensible hardening/cache
+ hide-identity: yes
+ hide-version: yes
+ harden-referral-path: yes
+ harden-dnssec-stripped: yes
+ qname-minimisation: yes
+ aggressive-nsec: yes
+ prefetch: yes
+ cache-min-ttl: 60
+ cache-max-ttl: 86400
+
+ # DNSSEC TA (create with unbound-anchor)
+ auto-trust-anchor-file: "/var/lib/unbound/root.key"
+ # Optional root hints (download separately)
+ # root-hints: "/var/lib/unbound/root.hints"
+
+# To use forwarding instead of full recursion, uncomment and edit:
+# forward-zone:
+# name: "."
+# forward-tls-upstream: no
+# forward-addr: 9.9.9.9
+# forward-addr: 1.1.1.1
--- /dev/null
+[Unit]
+Description=Unbound DNS (%i)
+Documentation=man:unbound(8)
+After=network-online.target wg-quick@%i.service
+Wants=network-online.target
+
+[Service]
+Type=simple
+ExecStart=/usr/sbin/unbound -d -p -c /etc/unbound/unbound-%i.conf
+Restart=on-failure
+# Lock down a bit
+CapabilityBoundingSet=CAP_NET_BIND_SERVICE CAP_SETGID CAP_SETUID
+AmbientCapabilities=CAP_NET_BIND_SERVICE
+NoNewPrivileges=true
+PrivateTmp=true
+ProtectSystem=full
+ProtectHome=true
+
+[Install]
+WantedBy=multi-user.target
--- /dev/null
+# unbound/unbound_US.py
+def configure(prov, planner, WriteFileMeta):
+ conf = """server:
+ verbosity: 1
+ username: "unbound"
+ directory: "/etc/unbound"
+ chroot: ""
+
+ do-ip6: no
+ do-udp: yes
+ do-tcp: yes
+ prefer-ip6: no
+
+ interface: 127.0.0.1@5301
+ access-control: 127.0.0.0/8 allow
+
+ outgoing-interface: 10.0.0.1
+
+ hide-identity: yes
+ hide-version: yes
+ harden-referral-path: yes
+ harden-dnssec-stripped: yes
+ qname-minimisation: yes
+ aggressive-nsec: yes
+ prefetch: yes
+ cache-min-ttl: 60
+ cache-max-ttl: 86400
+
+ auto-trust-anchor-file: "/var/lib/unbound/root.key"
+"""
+ wfm = WriteFileMeta(dpath="/etc/unbound", fname="unbound-US.conf",
+ owner="root", mode="0644")
+ planner.copy(wfm, content=conf)
--- /dev/null
+# systemd/unbound_at_template.py
+def configure(prov, planner, WriteFileMeta):
+ service = """[Unit]
+Description=Unbound DNS (%i)
+Documentation=man:unbound(8)
+After=network-online.target wg-quick@%i.service
+Wants=network-online.target
+
+[Service]
+Type=simple
+ExecStart=/usr/sbin/unbound -d -p -c /etc/unbound/unbound-%i.conf
+Restart=on-failure
+CapabilityBoundingSet=CAP_NET_BIND_SERVICE CAP_SETGID CAP_SETUID
+AmbientCapabilities=CAP_NET_BIND_SERVICE
+NoNewPrivileges=true
+PrivateTmp=true
+ProtectSystem=full
+ProtectHome=true
+
+[Install]
+WantedBy=multi-user.target
+"""
+ wfm = WriteFileMeta(dpath="/etc/systemd/system", fname="unbound@.service",
+ owner="root", mode="0644")
+ planner.copy(wfm, content=service)
--- /dev/null
+# unbound/unbound_x6.py
+def configure(prov, planner, WriteFileMeta):
+ conf = """server:
+ verbosity: 1
+ username: "unbound"
+ directory: "/etc/unbound"
+ chroot: ""
+
+ do-ip6: no
+ do-udp: yes
+ do-tcp: yes
+ prefer-ip6: no
+
+ interface: 127.0.0.1@5302
+ access-control: 127.0.0.0/8 allow
+
+ outgoing-interface: 10.8.0.2
+
+ hide-identity: yes
+ hide-version: yes
+ harden-referral-path: yes
+ harden-dnssec-stripped: yes
+ qname-minimisation: yes
+ aggressive-nsec: yes
+ prefetch: yes
+ cache-min-ttl: 60
+ cache-max-ttl: 86400
+
+ auto-trust-anchor-file: "/var/lib/unbound/root.key"
+"""
+ wfm = WriteFileMeta(dpath="/etc/unbound", fname="unbound-x6.conf",
+ owner="root", mode="0644")
+ planner.copy(wfm, content=conf)
--- /dev/null
+#ifndef IFACE
+#define Db·IMPLEMENTATION
+#define IFACE
+#endif
+
+#ifndef Db·IFACE
+#define Db·IFACE
+
+ #include <sqlite3.h>
+ #include <stdbool.h>
+
+ // Enum for exit codes
+ typedef enum {
+ Db·EXIT_SUCCESS = 0,
+ Db·EXIT_DB_OPEN_ERROR,
+ Db·EXIT_SCHEMA_LOAD_ERROR,
+ Db·EXIT_MEMORY_ALLOCATION_ERROR,
+ Db·EXIT_STATEMENT_PREPARE_ERROR,
+ Db·EXIT_STATEMENT_EXECUTE_ERROR
+ } Db·ExitCode;
+
+ // Interface prototypes
+ sqlite3* Db·open(const char *db_path ,bool create_if_not_exists);
+ Db·ExitCode Db·load_schema(sqlite3 *db, const char *schema_path);
+ Db·ExitCode Db·log_event(sqlite3 *db, int event_id, int user_id);
+ int Db·query(
+ sqlite3 *db
+ ,const char *sql
+ ,int (*callback)(void * ,int ,char ** ,char **)
+ ,void *callback_arg
+ );
+ void Db·close(sqlite3 *db);
+
+#endif // Db·IFACE
+
+#ifndef Db·IMPLEMENTATION
+
+ #include <sqlite3.h>
+ #include <stdio.h>
+ #include <stdlib.h>
+ #include <string.h>
+ #include <errno.h>
+ #include <time.h>
+ #include <string.h>
+
+ sqlite3* Db·open(const char *db_path ,bool create_if_not_exists){
+ sqlite3 *db;
+ FILE *file_check = fopen(db_path ,"r");
+
+ if(!file_check && create_if_not_exists){
+ file_check = fopen(db_path ,"w");
+ if(!file_check){
+ fprintf(
+ stderr,
+ "Db::open failed to create database file '%s': %s\n",
+ db_path,
+ strerror(errno)
+ );
+ return NULL;
+ }
+ fclose(file_check);
+ printf("Db::open created new database file '%s'\n", db_path);
+ }else if(!file_check){
+ fprintf(stderr ,"Db::open database file '%s' not found and create flag not set\n" ,db_path);
+ return NULL;
+ }else{
+ fclose(file_check);
+ }
+
+ if( sqlite3_open(db_path ,&db) != SQLITE_OK ){
+ fprintf(
+ stderr,
+ "Db::open failed to open database '%s': %s\n",
+ db_path,
+ sqlite3_errmsg(db)
+ );
+ return NULL;
+ }
+
+ printf("Db::open database '%s' opened successfully\n", db_path);
+ return db;
+ }
+
+ // Load schema from a file
+ Db·ExitCode Db·load_schema(sqlite3 *db ,const char *schema_path){
+ FILE *file = fopen(schema_path, "r");
+ if(!file){
+ fprintf
+ (
+ stderr
+ ,"Db::load_schema failed to open schema file '%s'\n"
+ ,schema_path
+ );
+ return Db·EXIT_SCHEMA_LOAD_ERROR;
+ }
+
+ fseek(file, 0, SEEK_END);
+ long file_size = ftell(file);
+ rewind(file);
+
+ char *schema = malloc(file_size + 1);
+ if(!schema){
+ fprintf(stderr, "Db::load_schema memory allocation failed\n");
+ fclose(file);
+ return Db·EXIT_MEMORY_ALLOCATION_ERROR;
+ }
+
+ fread(schema, 1, file_size, file);
+ schema[file_size] = '\0';
+ fclose(file);
+
+ char *err_msg = NULL;
+ if( sqlite3_exec(db, schema, NULL, NULL, &err_msg) != SQLITE_OK ){
+ fprintf
+ (
+ stderr
+ ,"Db::load_schema failed to execute schema: %s\n"
+ ,err_msg
+ );
+ sqlite3_free(err_msg);
+ free(schema);
+ return Db·EXIT_STATEMENT_EXECUTE_ERROR;
+ }
+
+ printf("Db::load_schema schema initialized successfully from '%s'\n", schema_path);
+ free(schema);
+ return Db·EXIT_SUCCESS;
+ }
+
+ // Log an event into the database
+ Db·ExitCode Db·log_event(sqlite3 *db ,int event_id ,int user_id){
+ const char *sql_template =
+ "INSERT INTO db_event (event_time ,event_id ,user_id) "
+ "VALUES (CURRENT_TIMESTAMP ,? ,?);";
+ sqlite3_stmt *stmt;
+
+ if( sqlite3_prepare_v2(db ,sql_template ,-1 ,&stmt ,NULL) != SQLITE_OK ){
+ fprintf
+ (
+ stderr
+ ,"Db::log_event failed to prepare statement: %s\n"
+ ,sqlite3_errmsg(db)
+ );
+ return Db·EXIT_STATEMENT_PREPARE_ERROR;
+ }
+
+ sqlite3_bind_int(stmt, 1, event_id);
+ sqlite3_bind_int(stmt, 2, user_id);
+
+ if( sqlite3_step(stmt) != SQLITE_DONE ){
+ fprintf
+ (
+ stderr
+ ,"Db::log_event failed to execute statement: %s\n"
+ ,sqlite3_errmsg(db)
+ );
+ sqlite3_finalize(stmt);
+ return Db·EXIT_STATEMENT_EXECUTE_ERROR;
+ }
+
+ sqlite3_finalize(stmt);
+ return Db·EXIT_SUCCESS;
+ }
+
+ // Query Execution Function
+ int Db·query(
+ sqlite3 *db
+ ,const char *sql
+ ,int (*callback)(void * ,int ,char ** ,char **)
+ ,void *callback_arg
+ ){
+ char *err_msg = NULL;
+ int rc = sqlite3_exec(db ,sql ,callback ,callback_arg ,&err_msg);
+
+ if( rc != SQLITE_OK ){
+ fprintf
+ (
+ stderr
+ ,"Db::query SQL error: %s\nQuery: %s\n"
+ ,err_msg
+ ,sql
+ );
+ sqlite3_free(err_msg);
+ return rc;
+ }
+
+ return SQLITE_OK;
+ }
+
+ // Close the database
+ void Db·close(sqlite3 *db){
+ if( db ){
+ sqlite3_close(db);
+ printf("Db::close database connection closed\n");
+ }
+ }
+
+#endif // Db·IMPLEMENTATION
+
+
--- /dev/null
+#ifndef IFACE
+#define DbSubu·IMPLEMENTATION
+#define IFACE
+#endif
+
+#ifndef DbSubu·IFACE
+#define DbSubu·IFACE
+
+ #include <sqlite3.h>
+
+ typedef struct DbSubu {
+ sqlite3 *db;
+ } DbSubu;
+
+
+ // db connection
+ DbSubu* DbSubu·open( const char *db_path );
+ void DbSubu·close( DbSubu *db );
+ int DbSubu·validate_schema( DbSubu *db );
+
+ // User Management
+ int DbSubu·add_user( DbSubu *db ,const char *name ,const char *home_directory ,int shell_id ,int parent_id ,int user_type_id );
+ int DbSubu·delete_user( DbSubu *db ,int user_id );
+ int DbSubu·get_user( DbSubu *db ,int user_id ,char **name ,char **home_directory ,int *shell_id ,int *parent_id ,int *user_type_id );
+
+ // Sharing Management
+ int DbSubu·add_share( DbSubu *db ,int user_id ,int other_user_id ,const char *permissions );
+ int DbSubu·delete_share( DbSubu *db ,int share_id );
+
+ // System Resource Management
+ int DbSubu·grant_resource( DbSubu *db ,int user_id ,int resource_id ,int granted_by );
+ int DbSubu·revoke_resource( DbSubu *db ,int user_id ,int resource_id );
+
+ // Event Logging
+ int DbSubu·log_event( DbSubu *db ,int event_id ,int user_id );
+
+#endif // DbSubu·IFACE
+
+#ifdef DbSubu·IMPLEMENTATION
+
+ #include <stdio.h>
+ #include <stdlib.h>
+ #include <string.h>
+ #include "Db.lib.c"
+
+ // Open the database
+ DbSubu* DbSubu·open( const char *db_path ){
+ DbSubu *db = malloc( sizeof(DbSubu) );
+ if( !db ){
+ fprintf( stderr ,"DbSubu·open:: failed to allocate memory for DbSubu\n" );
+ return NULL;
+ }
+ db->db = Db·open(db_path ,true);
+ if( !db->db ){
+ free( db );
+ return NULL;
+ }
+ return db;
+ }
+
+ // Close the database
+ void DbSubu·close( DbSubu *db ){
+ if( db ){
+ Db·close( db->db );
+ free( db );
+ }
+ }
+
+ // Validate the schema
+ int DbSubu·validate_schema( DbSubu *db ){
+ // Validation logic for ensuring the schema is correct
+ return 0; // Placeholder for schema validation implementation
+ }
+
+ // Add a user
+ int DbSubu·add_user( DbSubu *db ,const char *name ,const char *home_directory ,int shell_id ,int parent_id ,int user_type_id ){
+ char sql[256];
+ snprintf
+ (
+ sql
+ ,sizeof(sql)
+ ,"INSERT INTO user (name ,home_directory ,shell ,parent_id ,user_type_id) VALUES ('%s' ,'%s' ,%d ,%d ,%d);"
+ ,name
+ ,home_directory
+ ,shell_id
+ ,parent_id
+ ,user_type_id
+ );
+ return Db·query( db->db ,sql ,NULL ,NULL );
+ }
+
+ // Delete a user
+ int DbSubu·delete_user( DbSubu *db ,int user_id ){
+ char sql[128];
+ snprintf( sql ,sizeof(sql) ,"DELETE FROM user WHERE id = %d;" ,user_id );
+ return Db·query( db->db ,sql ,NULL ,NULL );
+ }
+
+ // Log an event
+ int DbSubu·log_event( DbSubu *db ,int event_id ,int user_id ){
+ char sql[128];
+ snprintf
+ (
+ sql
+ ,sizeof(sql)
+ ,"INSERT INTO db_event (event_id ,user_id) VALUES (%d ,%d);"
+ ,event_id
+ ,user_id
+ );
+ return Db·query( db->db ,sql ,NULL ,NULL );
+ }
+
+ // Add to a list (private function)
+ static int add_to_list( sqlite3 *db ,const char *list_name ,const char *entry_name ){
+ char sql[128];
+ snprintf
+ (
+ sql
+ ,sizeof(sql)
+ ,"INSERT INTO %s (name) VALUES ('%s');"
+ ,list_name
+ ,entry_name
+ );
+ return Db·query( db ,sql ,NULL ,NULL );
+ }
+
+ // Get list entries (private function)
+ static char** get_list( sqlite3 *db ,const char *list_name ,int *count ){
+ char sql[128];
+ snprintf( sql ,sizeof(sql) ,"SELECT name FROM %s;" ,list_name );
+
+ struct ListResult {
+ char **entries;
+ int count;
+ } result = { NULL ,0 };
+
+ int callback( void *arg ,int argc ,char **argv ,char **col_names ){
+ (void)argc; (void)col_names;
+ struct ListResult *res = arg;
+ res->entries = realloc( res->entries ,(res->count + 1) * sizeof(char *) );
+ res->entries[res->count++] = strdup( argv[0] );
+ return 0;
+ }
+
+ if( Db·query( db ,sql ,callback ,&result ) != SQLITE_OK ){
+ for( int i = 0; i < result.count; ++i ){
+ free( result.entries[i] );
+ }
+ free( result.entries );
+ return NULL;
+ }
+
+ *count = result.count;
+ return result.entries;
+ }
+
+#endif // DbSubu·IMPLEMENTATION
--- /dev/null
+#define IFACE
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <inttypes.h>
+#include "Db.lib.c"
+
+int main(int argc ,char *argv[]){
+ if( argc < 2 ){
+ fprintf(stderr ,"Usage: %s <sqlite_handle_as_hex>\n" ,argv[0]);
+ return EXIT_FAILURE;
+ }
+
+ // Parse the SQLite handle from the command-line argument
+ uintptr_t handle_as_int;
+ if( sscanf(argv[1] ,"%lx" ,&handle_as_int) != 1 ){
+ fprintf(stderr ,"%s::main failed to parse handle '%s'\n" ,argv[0] ,argv[1]);
+ return EXIT_FAILURE;
+ }
+
+ sqlite3 *db = (sqlite3 *)handle_as_int;
+
+ // Attempt to close the database
+ if( db ){
+ Db·close(db);
+ printf("Database handle %p closed successfully.\n" ,db);
+ return EXIT_SUCCESS;
+ } else {
+ fprintf(stderr ,"Invalid or NULL database handle: %p\n" ,db);
+ return EXIT_FAILURE;
+ }
+}
--- /dev/null
+
+/*
+ The subu server command line interface.
+
+ Usage:
+ server [-s <socket_path>] [-l <log_path>] [arguments...]
+
+ Options:
+ -s <socket_path> Specify the Unix socket file path. Default: ./socket
+ -l <log_path> Specify the log file path. Default: ./log.txt
+*/
+
+#define IFACE
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+#include "Hello.lib.c"
+
+// Define defaults
+#define DEFAULT_SOCKET_PATH "socket"
+
+int main( int argc ,char **argv ){
+ char *socket_path = DEFAULT_SOCKET_PATH;
+ int error_flag = 0;
+
+ int opt;
+ while( (opt = getopt(argc ,argv ,":s:l:")) != -1 ){
+ switch( opt ){
+ case 's':
+ socket_path = optarg;
+ break;
+ case '?': // Unknown option
+ fprintf( stderr ,"%s::main unknown option '-%c'\n" ,argv[0] ,optopt );
+ error_flag = 1;
+ break;
+ case ':': // Missing argument
+ fprintf( stderr ,"%s::main missing argument for option '-%c'\n" ,argv[0] ,optopt );
+ error_flag = 1;
+ break;
+ }
+ }
+
+ if( optind > argc ){
+ fprintf( stderr ,"%s::main optind(%d) > argc(%d), which indicates an option parsing bug\n" ,argv[0] ,optind ,argc );
+ error_flag = 1;
+ }
+
+ // Exit on error after processing all options
+ if( error_flag ){
+ fprintf( stderr ,"%s::main usage: %s [-s <socket_path>] [arguments...]\n" ,argv[0] ,argv[0] );
+ return EXIT_FAILURE;
+ }
+
+ // Rebase argv to prepare for run
+ if(optind > 0){
+ argv[optind - 1] = argv[0]; // Program name at the new base
+ argc -= (optind - 1);
+ argv += (optind - 1);
+ }
+
+ // Log parsed options
+ printf( "%s::main socket_path='%s'\n" ,argv[0] ,socket_path );
+
+ // Call the hello function
+ return Hello·run(argc ,argv ,socket_path);
+}
--- /dev/null
+#ifndef IFACE
+#define Hello·IMPLEMENTATION
+#define IFACE
+#endif
+
+#ifndef Hello·IFACE
+#define Hello·IFACE
+
+ // Necessary interface includes
+ // .. none
+
+ // Interface prototypes
+ int Hello·run(int argc ,char** argv ,char *socket_path);
+
+#endif // Hello·IFACE
+
+#ifdef Hello·IMPLEMENTATION
+
+ // Implementation-specific includes
+ #include <sys/socket.h>
+ #include <sys/un.h>
+ #include <stddef.h>
+ #include <stdio.h>
+ #include <stdlib.h>
+ #include <string.h>
+ #include <unistd.h>
+ #include <errno.h>
+
+ // Constants
+ #define Hello·SOCKET_PATH "/var/user_data/Thomas-developer/subu/developer/mockup/subu_server_home/subu_server.sock"
+ #define Hello·LOG_PATH "server_test.log"
+ #define Hello·BUFFER_SIZE 256
+
+ int Hello·run(int argc ,char** argv ,char *socket_path){
+ (void)argc; // Suppress unused variable warnings
+ (void)argv;
+
+ int client_fd;
+ struct sockaddr_un address;
+ char buffer[Hello·BUFFER_SIZE];
+
+ client_fd = socket(AF_UNIX ,SOCK_STREAM ,0);
+ if( client_fd == -1 ){
+ perror("Hello·run:: error opening socket");
+ return EXIT_FAILURE;
+ }
+
+ // Configure server socket address
+ memset(&address ,0 ,sizeof(address));
+ address.sun_family = AF_UNIX;
+ strncpy(address.sun_path ,socket_path ,sizeof(address.sun_path) - 1);
+
+ // Connect to the server
+ if( connect(client_fd ,(struct sockaddr *)&address ,sizeof(address)) == -1 ){
+ perror("Hello·run:: error connecting to server");
+ close(client_fd);
+ return EXIT_FAILURE;
+ }
+
+ // Send message to the server
+ char *out_buf = "hello\n";
+ if( write(client_fd ,out_buf ,strlen(out_buf)) == -1 ){
+ perror("Hello·run:: error writing to server");
+ return EXIT_FAILURE;
+ }
+
+ printf("Hello·run:: sent \"%s\"\n" ,out_buf);
+
+ // Clean up
+ close(client_fd);
+
+ return EXIT_SUCCESS;
+ }
+
+#endif // Hello·IMPLEMENTATION
--- /dev/null
+
+These are currently not used. Eventually the main subu commands will be C programs and setuid root so that master users (and only master users) can seamlessly manipulate sub users.
+
+I started on this, then decided to let the scripts stabilize first.
+
--- /dev/null
+/*
+ The subu server command line interface.
+
+ Usage:
+ server [-s <socket_path>] [-l <log_path>] [arguments...]
+
+ Options:
+ -s <socket_path> Specify the Unix socket file path. Default: ./socket
+ -l <log_path> Specify the log file path. Default: ./log.txt
+*/
+
+#define IFACE
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+#include "Server.lib.c"
+
+// Define defaults
+#define DEFAULT_SOCKET_PATH "socket"
+#define DEFAULT_LOG_PATH "log.txt"
+
+int main( int argc ,char **argv ){
+ char *socket_path = DEFAULT_SOCKET_PATH;
+ char *log_path = DEFAULT_LOG_PATH;
+ int error_flag = 0;
+
+ // Parse command-line options
+ int opt;
+ while( (opt = getopt(argc ,argv ,":s:l:")) != -1 ){
+ switch( opt ){
+ case 's':
+ socket_path = optarg;
+ break;
+ case 'l':
+ log_path = optarg;
+ break;
+ case '?': // Unknown option
+ fprintf( stderr ,"%s::main unknown option '-%c'\n" ,argv[0] ,optopt );
+ error_flag = 1;
+ break;
+ case ':': // Missing argument
+ fprintf( stderr ,"%s::main missing argument for option '-%c'\n" ,argv[0] ,optopt );
+ error_flag = 1;
+ break;
+ }
+ }
+
+ if( optind > argc ){
+ fprintf( stderr ,"%s::main optind(%d) > argc(%d), which indicates an option parsing bug\n" ,argv[0] ,optind ,argc );
+ error_flag = 1;
+ }
+
+ // Exit on error after processing all options
+ if( error_flag ){
+ fprintf( stderr ,"%s::main usage: %s [-s <socket_path>] [-l <log_path>] [arguments...]\n" ,argv[0] ,argv[0] );
+ return EXIT_FAILURE;
+ }
+
+ // Rebase argv to prepare for run
+ if(optind > 0){
+ argv[optind - 1] = argv[0]; // Program name at the new base
+ argc -= (optind - 1);
+ argv += (optind - 1);
+ }
+
+ // Open the log file
+ FILE *log_file = Server·open_log(log_path);
+ if( !log_file ){
+ fprintf( stderr ,"%s::main unable to open log file '%s'\n" ,argv[0] ,log_path );
+ return Server·EXIT_LOG_FILE_ERROR;
+ }
+
+ // Log parsed options
+ fprintf( log_file ,"%s::main socket_path='%s'\n" ,argv[0] ,socket_path );
+ fprintf( log_file ,"%s::main log_path='%s'\n" ,argv[0] ,log_path );
+ fflush(log_file);
+
+ // Prepare file descriptors for error reporting
+ int fds[] = { fileno(stderr), fileno(log_file), -1 };
+
+ // Call the core server function
+ int exit_code = Server·run(argc ,argv ,fds ,socket_path);
+
+ // Report return condition
+ Server·return_condition_report(exit_code ,fds);
+
+ // Clean up
+ fclose(log_file);
+
+ return exit_code;
+}
--- /dev/null
+#ifndef IFACE
+#define Server·IMPLEMENTATION
+#define IFACE
+#endif
+
+#ifndef Server·IFACE
+#define Server·IFACE
+
+ #include <stdio.h>
+ #include <time.h>
+
+ // Exit codes
+ typedef enum {
+ Server·EXIT_SUCCESS = 0,
+ Server·EXIT_LOG_FILE_ERROR,
+ Server·EXIT_SOCKET_CREATION_ERROR,
+ Server·EXIT_BIND_ERROR,
+ Server·EXIT_LISTEN_ERROR,
+ Server·EXIT_ACCEPT_ERROR
+ } Server·ExitCode;
+
+ // Interface prototypes
+ int Server·run( int argc ,char **argv ,int *fds ,char *socket_path );
+ void Server·return_condition_report( Server·ExitCode code ,int *fds );
+ void Server·report( int *fds ,const char *message );
+ FILE* Server·open_log( const char *log_path );
+
+#endif // Server·IFACE
+
+#ifdef Server·IMPLEMENTATION
+
+ // Implementation-specific includes
+ #include <sys/socket.h>
+ #include <sys/un.h>
+ #include <stddef.h>
+ #include <sys/types.h>
+ #include <bits/socket.h> // Ensure full definition of struct ucred
+ #include <stdlib.h>
+ #include <string.h>
+ #include <unistd.h>
+ #include <errno.h>
+
+ // Constants
+ #define Server·BUFFER_SIZE 256
+ #define MAX_ARGC 16
+
+ // Internal function prototypes
+ static void parse( int *fds ,struct ucred *client_cred ,char *input_line );
+ static void hello( int *fds ,int argc ,char *argv[] ,struct ucred *client_cred );
+
+ // Log a message with time and to multiple destinations
+ void Server·report( int *fds ,const char *message ){
+ time_t now = time(NULL);
+ char time_buffer[32];
+ strftime(time_buffer ,sizeof(time_buffer) ,"%Y-%m-%dT%H:%M:%SZ" ,gmtime(&now));
+
+ for( int i = 0; fds[i] != -1; ++i ){
+ dprintf( fds[i] ,"\n%s:: %s" ,time_buffer ,message );
+ }
+ }
+
+ int Server·run( int argc ,char **argv ,int *fds ,char *socket_path ){
+ (void)argc; // Suppress unused variable warnings
+ (void)argv;
+
+ int server_fd ,client_fd;
+ struct sockaddr_un address;
+
+ // Create socket
+ if( (server_fd = socket(AF_UNIX ,SOCK_STREAM ,0)) == -1 ){
+ Server·report(fds ,"Socket creation failed.");
+ return Server·EXIT_SOCKET_CREATION_ERROR;
+ }
+
+ // Configure socket address
+ memset(&address ,0 ,sizeof(address));
+ address.sun_family = AF_UNIX;
+ strncpy(address.sun_path ,socket_path ,sizeof(address.sun_path) - 1);
+
+ unlink(socket_path);
+ if( bind(server_fd ,(struct sockaddr *)&address ,sizeof(address)) == -1 ){
+ Server·report(fds ,"Binding socket failed.");
+ close(server_fd);
+ return Server·EXIT_BIND_ERROR;
+ }
+
+ if( listen(server_fd ,5) == -1 ){
+ Server·report(fds ,"Listening on socket failed.");
+ close(server_fd);
+ return Server·EXIT_LISTEN_ERROR;
+ }
+
+ char startup_message[Server·BUFFER_SIZE];
+ snprintf(startup_message ,Server·BUFFER_SIZE ,"Server running with socket '%s' ,awaiting connections..." ,socket_path);
+ Server·report(fds ,startup_message);
+
+ while( (client_fd = accept(server_fd ,NULL ,NULL)) != -1 ){
+ struct ucred client_cred;
+ socklen_t len = sizeof(client_cred);
+
+ if( getsockopt(client_fd ,SOL_SOCKET ,SO_PEERCRED ,&client_cred ,&len) == -1 ){
+ Server·report(fds ,"Failed to retrieve client credentials.");
+ close(client_fd);
+ continue;
+ }
+
+ char connection_message[Server·BUFFER_SIZE];
+ snprintf(connection_message ,Server·BUFFER_SIZE ,
+ "Connection from PID=%d ,UID=%d ,GID=%d" ,
+ client_cred.pid ,client_cred.uid ,client_cred.gid);
+ Server·report(fds ,connection_message);
+
+ char buffer[Server·BUFFER_SIZE];
+ memset(buffer ,0 ,Server·BUFFER_SIZE);
+ ssize_t bytes_read = read(client_fd ,buffer ,Server·BUFFER_SIZE - 1);
+ if(bytes_read > 0){
+ char *line = strtok(buffer ,"\n");
+ while(line != NULL){
+ parse(fds ,&client_cred ,line);
+ line = strtok(NULL ,"\n");
+ }
+ } else if(bytes_read == -1){
+ Server·report(fds ,"Error reading from client.");
+ }
+
+ close(client_fd);
+ }
+
+ Server·report(fds ,"Error accepting connection.");
+ close(server_fd);
+ unlink(socket_path);
+ return Server·EXIT_ACCEPT_ERROR;
+ }
+
+ // Parse a single input line and dispatch to the appropriate command
+ static void parse( int *fds ,struct ucred *client_cred ,char *input_line ){
+ char *argv[MAX_ARGC + 1] = {0};
+ int argc = 0;
+
+ char *line_copy = strdup(input_line);
+ if(!line_copy){
+ Server·report(fds ,"Failed to duplicate input line.");
+ return;
+ }
+
+ char *token = strtok(line_copy ," ");
+ while(token != NULL && argc < MAX_ARGC){
+ argv[argc++] = token;
+ token = strtok(NULL ," ");
+ }
+
+ if(argc > 0){
+ if( strcmp(argv[0] ,"hello") == 0 ){
+ hello(fds ,argc ,argv ,client_cred);
+ }else{
+ char unknown_command_message[Server·BUFFER_SIZE];
+ snprintf(unknown_command_message ,Server·BUFFER_SIZE ,"Unknown command '%s'" ,argv[0]);
+ Server·report(fds ,unknown_command_message);
+ }
+ }
+
+ free(line_copy);
+ }
+
+ // Example command: hello
+ static void hello( int *fds ,int argc ,char *argv[] ,struct ucred *client_cred ){
+ char hello_message[Server·BUFFER_SIZE];
+ snprintf(hello_message ,Server·BUFFER_SIZE ,
+ "hello:: invoked by PID=%d ,UID=%d ,GID=%d" ,
+ client_cred->pid ,client_cred->uid ,client_cred->gid);
+ Server·report(fds ,hello_message);
+
+ for( int i = 1; i < argc; ++i ){
+ char argument_message[Server·BUFFER_SIZE];
+ snprintf(argument_message ,Server·BUFFER_SIZE ," Arg %d: %s" ,i ,argv[i]);
+ Server·report(fds ,argument_message);
+ }
+ }
+
+ // Error reporting function
+ void Server·return_condition_report( Server·ExitCode code ,int *fds ){
+ const char *message;
+ switch( code ){
+ case Server·EXIT_SUCCESS:
+ message = "Operation completed successfully.";
+ break;
+ case Server·EXIT_LOG_FILE_ERROR:
+ message = "Failed to open log file.";
+ break;
+ case Server·EXIT_SOCKET_CREATION_ERROR:
+ message = "Socket creation failed.";
+ break;
+ case Server·EXIT_BIND_ERROR:
+ message = "Binding socket failed.";
+ break;
+ case Server·EXIT_LISTEN_ERROR:
+ message = "Listening on socket failed.";
+ break;
+ case Server·EXIT_ACCEPT_ERROR:
+ message = "Error accepting connection.";
+ break;
+ default:
+ message = "Unknown error occurred.";
+ break;
+ }
+
+ Server·report(fds ,message);
+ }
+
+ // Log file opener
+ FILE* Server·open_log( const char *log_path ){
+ FILE *log_file = fopen(log_path ,"a+");
+ if( log_file ){
+ Server·report( (int[]){fileno(log_file), -1} ,"Log file opened.");
+ }
+ return log_file;
+ }
+
+#endif // Server·IMPLEMENTATION
--- /dev/null
+#define IFACE
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include "DbSubu.lib.c"
+
+int main(int argc ,char *argv[]){
+ if( argc < 7 ){
+ fprintf(stderr, "Usage: %s <db_path> <name> <home_directory> <shell_id> <parent_id> <user_type_id>\n", argv[0]);
+ return 1;
+ }
+
+ const char *db_path = argv[1];
+ const char *name = argv[2];
+ const char *home_directory = argv[3];
+ int shell_id = atoi(argv[4]);
+ int parent_id = atoi(argv[5]);
+ int user_type_id = atoi(argv[6]);
+
+ DbSubu *db = DbSubu·open(db_path);
+ if( !db ){
+ fprintf(stderr, "Failed to open database: %s\n", db_path);
+ return 1;
+ }
+
+ int result = DbSubu·add_user(db, name, home_directory, shell_id, parent_id, user_type_id);
+ DbSubu·close(db);
+
+ if( result == 0 ){
+ printf("User added successfully.\n");
+ return 0;
+ } else {
+ fprintf(stderr, "Failed to add user.\n");
+ return 1;
+ }
+}
--- /dev/null
+#define IFACE
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include "DbSubu.lib.c"
+
+int main(int argc ,char *argv[]){
+ if( argc < 3 ){
+ fprintf(stderr, "Usage: %s <db_path> <user_id>\n", argv[0]);
+ return 1;
+ }
+
+ const char *db_path = argv[1];
+ int user_id = atoi(argv[2]);
+
+ DbSubu *db = DbSubu·open(db_path);
+ if( !db ){
+ fprintf(stderr, "Failed to open database: %s\n", db_path);
+ return 1;
+ }
+
+ int result = DbSubu·delete_user(db, user_id);
+ DbSubu·close(db);
+
+ if( result == 0 ){
+ printf("User deleted successfully.\n");
+ return 0;
+ } else {
+ fprintf(stderr, "Failed to delete user.\n");
+ return 1;
+ }
+}
--- /dev/null
+#define IFACE
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include "DbSubu.lib.c"
+
+int main(int argc ,char *argv[]){
+ if( argc < 3 ){
+ fprintf(stderr, "Usage: %s <db_path> <user_id>\n", argv[0]);
+ return 1;
+ }
+
+ const char *db_path = argv[1];
+ int user_id = atoi(argv[2]);
+
+ DbSubu *db = DbSubu·open(db_path);
+ if( !db ){
+ fprintf(stderr, "Failed to open database: %s\n", db_path);
+ return 1;
+ }
+
+ int result = DbSubu·delete_user(db, user_id);
+ DbSubu·close(db);
+
+ if( result == 0 ){
+ printf("User deleted successfully.\n");
+ return 0;
+ } else {
+ fprintf(stderr, "Failed to delete user.\n");
+ return 1;
+ }
+}
--- /dev/null
+#define IFACE
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include "Db.lib.c"
+
+// Define default database path
+#define DEFAULT_DB_PATH "db.sqlite"
+
+int main(int argc ,char *argv[]){
+ const char *db_path = (argc > 1) ? argv[1] : DEFAULT_DB_PATH;
+
+ // Open the database using Db·open
+ sqlite3 *db = Db·open(db_path ,true);
+ if( !db ){
+ fprintf(stderr ,"Failed to open or create database: %s\n" ,db_path);
+ return EXIT_FAILURE;
+ }
+
+ // Check if the file was created or already existed
+ printf("Database %s opened successfully\n" ,db_path);
+
+ // Attempt to close the database
+ if( db ){
+ Db·close(db);
+ printf("Database handle %p closed successfully.\n" ,db);
+ return EXIT_SUCCESS;
+ } else {
+ fprintf(stderr ,"Invalid or NULL database handle: %p\n" ,db);
+ return EXIT_FAILURE;
+ }
+
+ return EXIT_SUCCESS;
+}
--- /dev/null
+#define IFACE
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include "DbSubu.lib.c"
+
+int main(int argc ,char *argv[]){
+ const char *db_path = (argc > 1) ? argv[1] : "db.sqlite";
+ DbSubu *db = DbSubu·open(db_path);
+ if( !db ){
+ fprintf(stderr, "Failed to open database: %s\n", db_path);
+ return 1;
+ }
+
+ int result = DbSubu·validate_schema(db);
+ DbSubu·close(db);
+
+ if( result == 0 ){
+ printf("Schema validation passed.\n");
+ return 0;
+ } else {
+ fprintf(stderr, "Schema validation failed.\n");
+ return 1;
+ }
+}
--- /dev/null
+*
+!/.gitignore
--- /dev/null
+-- Schema for the subu server
+--
+
+-- List Tables
+-- SQLite does not support PSQL style types
+--
+-- CREATE TYPE List AS (
+-- id SERIAL, -- Integer ID
+-- name TEXT NOT NULL -- Name of the list entry
+-- );
+--
+-- so though these all have the same `List` form, they are declared independently
+--
+ CREATE TABLE db_property_list (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL UNIQUE
+ );
+
+ CREATE TABLE db_event_list (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL UNIQUE
+ );
+
+ CREATE TABLE shell_list (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL UNIQUE
+ );
+
+ CREATE TABLE system_resource_list (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL UNIQUE
+ );
+
+ CREATE TABLE user_type_list (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL UNIQUE
+ );
+
+-- Data Tables
+--
+ CREATE TABLE db_property (
+ id INTEGER PRIMARY KEY,
+ property_id INTEGER NOT NULL REFERENCES db_property_list(id),
+ type TEXT NOT NULL,
+ value TEXT,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
+ );
+
+ CREATE TABLE db_event (
+ id INTEGER PRIMARY KEY,
+ event_time DATETIME DEFAULT CURRENT_TIMESTAMP,
+ event_id INTEGER NOT NULL REFERENCES db_event_list(id),
+ user_id INTEGER REFERENCES user(id)
+ );
+
+ CREATE TABLE user (
+ id INTEGER PRIMARY KEY,
+ login_gid INTEGER NOT NULL UNIQUE,
+ name TEXT NOT NULL UNIQUE,
+ home_directory TEXT NOT NULL,
+ shell INTEGER NOT NULL REFERENCES shell_list(id),
+ parent_id INTEGER REFERENCES user(id),
+ user_type_id INTEGER NOT NULL REFERENCES user_type_list(id),
+ status TEXT DEFAULT 'active'
+ );
+
+ CREATE TABLE share (
+ id INTEGER PRIMARY KEY,
+ user_id INTEGER NOT NULL REFERENCES user(id),
+ other_user_id INTEGER NOT NULL REFERENCES user(id),
+ permissions TEXT NOT NULL
+ );
+
+ CREATE TABLE system_resource (
+ id INTEGER PRIMARY KEY,
+ user_id INTEGER NOT NULL REFERENCES user(id),
+ resource_id INTEGER NOT NULL REFERENCES system_resource_list(id),
+ granted_by INTEGER REFERENCES user(id)
+ );
--- /dev/null
+*
+!/.gitignore
--- /dev/null
+#!/bin/bash
+# give_audio.sh — run as master user "Thomas"
+# Usage: ./give_audio.sh <USER>
+# Example: ./give_audio.sh Thomas-US # give card to subuser
+# ./give_audio.sh Thomas # reclaim for master
+
+set -euo pipefail
+
+target="${1-}"
+if [[ -z "$target" ]]; then
+ echo "❌ usage: $0 <USER>"; exit 2
+fi
+
+master="Thomas"
+
+# don't use sudo -v as it dumps the password into the emacs shell
+sudo echo >& /dev/null
+
+run() { echo "+ $*"; eval "$*"; }
+
+# --- sanity checks ---
+if ! id "$target" &>/dev/null; then
+ echo "❌ user not found: $target"; exit 1
+fi
+if [[ "$(id -un)" != "$master" ]]; then
+ echo "❌ must be run as master user '$master'"; exit 1
+fi
+
+# Gather all subusers (Thomas-*)
+mapfile -t subusers < <(getent passwd | awk -F: '$1 ~ /^'"$master"'-/ {print $1}' | sort)
+
+stop_master_audio() {
+ run "systemctl --user stop pipewire pipewire-pulse wireplumber || true"
+}
+
+start_master_audio() {
+ # start services (not only sockets) to avoid lazy-activation races
+ run "systemctl --user start pipewire.service pipewire-pulse.service wireplumber.service"
+}
+
+stop_subu_audio() {
+ local u="$1"
+ run "sudo machinectl shell ${u}@ /bin/bash -lc 'systemctl --user stop pipewire pipewire-pulse wireplumber || true'"
+}
+
+start_subu_audio() {
+ local u="$1"
+ # Keep subuser from trying to bind to logind (not the active seat)
+ run "sudo machinectl shell ${u}@ /bin/bash -lc 'export WIREPLUMBER_DISABLE_PLUGINS=logind; systemctl --user import-environment WIREPLUMBER_DISABLE_PLUGINS; systemctl --user start pipewire.service pipewire-pulse.service wireplumber.service'"
+}
+
+# --- stop everyone first (to release ALSA cleanly) ---
+stop_master_audio
+for u in "${subusers[@]}"; do
+ stop_subu_audio "$u"
+done
+
+# Small settle time so ALSA reservation clears
+sleep 0.5
+
+# --- start only the target ---
+if [[ "$target" == "$master" ]]; then
+ start_master_audio
+else
+ # ensure linger for target so user services can run
+ run "sudo loginctl enable-linger '$target' || true"
+ start_subu_audio "$target"
+fi
+
+# --- quick verification (best-effort) ---
+if [[ "$target" == "$master" ]]; then
+ # Show default sink name (may require pipewire-pulse to be fully up)
+ run "pactl info | sed -n 's/^Default Sink: /Default Sink: /p'"
+else
+ run "sudo machinectl shell ${target}@ /bin/bash -lc 'pactl info | sed -n \\\"s/^Default Sink: /Default Sink: /p\\\"'"
+fi
--- /dev/null
+# As root:
+# loginctl terminate-user Thomas-US
+loginctl terminate-user $1
--- /dev/null
+#!/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
+
+
--- /dev/null
+#!/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 <masu> <subu> [<subu>]*
+ subu capture <masu> <subu> [<subu>]*
+ subu remove <Subu_ID> | <masu> <subu> [<subu>]*
+ subu list
+ subu info <Subu_ID> | <masu> <subu> [<subu>]*
+ subu option set|clear incommon <Subu_ID> | <masu> <subu> [<subu>]*
+ """
+ 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 <Subu_ID> <WG_ID>
+ detach WG <Subu_ID>
+ """
+ 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 <Subu_ID>
+ """
+ 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 <Subu_ID> -- <cmd> ...
+ """
+ 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 <Subu_ID>
+ """
+ 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 <masu> and one <subu> 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} <masu> <subu> [<subu> ...] 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 "
+ "<masu> <subu> [<subu> ...] 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: <masu> <subu> [.<subu>]*
+ return dispatch.subu_capture(ns.args)
+ if ns.subverb == "option":
+ # expected: set|clear incommon <masu> <subu> [.<subu>]*
+ 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 <Subu_ID> -- <cmd> ...", 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())
--- /dev/null
+# 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 <masu> <subu> [.<subu>]*", 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/<mapname>, 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/<mapname> 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()
--- /dev/null
+# 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/<mapname>).
+ 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
--- /dev/null
+"""
+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)
--- /dev/null
+"""
+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_<id>
+ run(["ip", "netns", "add", netns_name])
+ # ip netns exec ns-subu_<id> ip link set lo down
+ run(["ip", "netns", "exec", netns_name, "ip", "link", "set", "lo", "down"])
--- /dev/null
+"""
+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}")
+
--- /dev/null
+# 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/<parent>/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
--- /dev/null
+"""
+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 <CIDR>` 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)")
+
--- /dev/null
+# 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_<schema>.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())
--- /dev/null
+"""
+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}")
+
--- /dev/null
+// -*- 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 <linux/bpf.h>
+#include <bpf/bpf_helpers.h>
+#include <bpf/bpf_endian.h>
+
+
+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;
+}
--- /dev/null
+# -*- 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
--- /dev/null
+# 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()
--- /dev/null
+# 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/<mapname>
+ * On each device, user data lives under: /mnt/<mapname>/user_data/<masu>
+ * Subu home directories follow the pattern:
+
+ /mnt/<mapname>/user_data/<masu>/subu_data/<subu0>/subu_data/<subu1>/...
+
+ 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/<masu>/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/<mapname>':
+
+ * 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
--- /dev/null
+# 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)
--- /dev/null
+-- 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);
+
--- /dev/null
+# 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
+
--- /dev/null
+# 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 <mapname>\n"
+ f" {p} device detach <mapname>\n"
+ "\n"
+ f" {p} subu make <masu> <subu> [<subu> ...]\n"
+ f" {p} subu capture <masu> <subu> [<subu> ...]\n"
+ f" {p} subu list\n"
+ f" {p} subu info subu_<id>\n"
+ f" {p} subu info <masu> <subu> [<subu> ...]\n"
+ f" {p} subu remove subu_<id>\n"
+ f" {p} subu remove <masu> <subu> [<subu> ...]\n"
+ f" {p} subu option set incommon subu_<id>\n"
+ f" {p} subu option set incommon <masu> <subu> [<subu> ...]\n"
+ f" {p} subu option clear incommon subu_<id>\n"
+ f" {p} subu option clear incommon <masu> <subu> [<subu> ...]\n"
+ "\n"
+ f" {p} lo up|down <Subu_ID>\n"
+ "\n"
+ f" {p} WG global <BaseCIDR>\n"
+ f" {p} WG make <host:port>\n"
+ f" {p} WG server_provided_public_key <WG_ID> <Base64Key>\n"
+ f" {p} WG info|information <WG_ID>\n"
+ f" {p} WG up <WG_ID>\n"
+ f" {p} WG down <WG_ID>\n"
+ "\n"
+ f" {p} attach WG <Subu_ID> <WG_ID>\n"
+ f" {p} detach WG <Subu_ID>\n"
+ "\n"
+ f" {p} network up|down <Subu_ID>\n"
+ "\n"
+ f" {p} option set <Subu_ID> <name> <value>\n"
+ f" {p} option get <Subu_ID> <name>\n"
+ f" {p} option list <Subu_ID>\n"
+ "\n"
+ f" {p} exec <Subu_ID> -- <cmd> ...\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/<mapname>/user_data and captures all <masu>/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"
+ " <masu> <subu> [<subu> ...]\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 <masu> (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/<masu>/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)
--- /dev/null
+#!/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/<masu> will be bound to /home/<masu>,\n"
+ " the DB will be reconciled, and subu bindfs mounts reopened.")
+
+if __name__ == "__main__":
+ main()
--- /dev/null
+[Unit]
+Description=Attach /home/<masu> 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
--- /dev/null
+[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
--- /dev/null
+#!/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/<mapname>/user_data/<masu> as /home/<masu>
+ 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 <masu>
+ 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/<masu> is a bind from /mnt/*/user_data/<masu>
+ 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())
--- /dev/null
+
+When the master users are not remote mounted, but rather have static home directories on the local machine, these scripts are not needed.
+
+masu == master user
+subu == sub user
+
+These 'mount' scripts are for keeping master users on a remote, optionally encrypted, device.
+
+I have one example running, so there might be generalization issues with these scripts.
+
+The order and type of the command arguments are the first part of each
+command name. The command description follows. For example:
+
+`map_name__mounted_masu_list.sh`
+
+is given a `map_name` and provides a list of the masu found at the mount point with that name. (Note, that /dev/mapper/<map_name> is mounted at /mnt/<map_name>.)
+
+
+For mounting a remote device.
+
+The remote device has at the top level master user home directories. Each master user home directory has a sub directory called subu_data. Each directory in subu_data is owned by a sub-user, and is the home directory for that sub-user. Each master user also has a `subu` sub-directory. The contents of the subu directory will parallel that of the subu_data directory, but differ in that the contained files will be owned by the master user.
+
+1. the remote device is mounted under /mnt/map_name
+
+ `device_mapname__open_mount.sh` <device> <mapname>`
+
+ `mapname` is the name that appears under /dev/mapper, it is also used
+ as the /mnt/<name> mount point.
+
+ if <device> is not to be opened with cryptsetup, instead mount it to the mount pt directly.
+
+2. Each master user is mount --bind from the /mnt/<map_name> to /home
+
+ masu__to_home.sh <masu>
+
+ There is currently no script to map bind all the masu in one call.
+
+3. master users have subu home directories under /home/<masu>/subu_data. These
+ are owned by the subu users. Map the /home/<masu>/subu_data directories to
+ `/home/subu`n using
+
+ masu__map_own_all.sh <user>
+
+ The `/home/subu` directories are bindfs mapped to be be owned by the master
+ user, so that the master user may administer them.
+
+
+
+* bindfs problem
+
+ On my Fedora install once it was bound by root, it stayed there. On
+ Debian it keeps losing the mounts, for sure on a logout or login,
+ and maybe other times also --- the machinectl logins and logouts I
+ bet that is what is doing it. Hence very time I go to use a mount
+ in subu it is not there or filled with the dreaded question mark
+ garbage, has to be umount then remounted.
+
+ --------------------------------------------------------------------------------
+ You're exactly right in your diagnosis: Debian's behavior under
+ systemd with machinectl is unmounting user bind mounts on
+ login/logout cycles—especially when using enable-linger, systemd
+ --user, or machinectl shell.
+
+ This differs from Fedora, which preserves those mounts more consistently unless explicitly torn down. Let’s first address why it’s happening, then provide a robust, recoverable fix.
+ 🔍 Why This Happens
+
+ When machinectl or loginctl manages a user session:
+
+ systemd creates a private mount namespace per user.
+
+ Any mounts (especially bind mounts) not made within that
+ namespace or not managed by systemd itself (i.e. not via .mount
+ units) can be:
+
+ Torn down on logout,
+
+ Hidden due to namespace isolation,
+
+ Disconnected if user lingering is toggled.
+
+ If your bindfs mount is outside the user’s namespace (i.e. mounted by root), but then accessed inside a user-managed session, systemd may unmount it or lose visibility.
+ ✅ Recommended Fix
+
+ There are two paths:
+ A. Use a systemd .mount unit (most stable)
+
+ Create a file /etc/systemd/system/home-Thomas-subu-<subuname>.mount:
+
+ [Unit]
+ Description=Bind mount subu user home
+
+ [Mount]
+ What=/home/Thomas/subu_data/<subuname>
+ Where=/home/Thomas/subu/<subuname>
+ Type=none
+ Options=bind
+
+ [Install]
+ WantedBy=multi-user.target
+
+ Enable and start it:
+
+ sudo systemctl daemon-reexec
+ sudo systemctl enable --now home-Thomas-subu-<subuname>.mount
+
+ Repeat for each subu.
+
+ This ensures systemd manages the bind mount in its native way and it won’t get torn down.
+ B. Remount automatically on login via ~/.bash_profile or a systemd --user unit
+
+ You can modify your script to:
+
+ Skip sudo if already root
+
+ Be idempotent
+
+ Automatically remount on session start
+
+ Then in ~/.bash_profile or ~/.config/systemd/user/subu-mount.service:
+
+ [Unit]
+ Description=Re-mount subu directory for Thomas
+
+ [Service]
+ Type=oneshot
+ ExecStart=/home/Thomas/executable/subu/user_open_subu.sh Thomas US
+
+ [Install]
+ WantedBy=default.target
+
+ Enable with:
+
+ systemctl --user daemon-reexec
+ systemctl --user enable --now subu-mount.service
+
+ This guarantees it will come up as part of your login, even after a reboot.
+ 🔄 Temporary Workaround
+
+ If you want a quick way to check all subu are mounted and remount them if not, write a watchdog script:
+
+ #!/bin/bash
+ for subu in $(ls /home/Thomas/subu_data); do
+ mountpoint="/home/Thomas/subu/$subu"
+ if ! mountpoint -q "$mountpoint"; then
+ /home/Thomas/executable/subu/user_open_subu.sh Thomas "$subu"
+ fi
+ done
+
+ Would you like me to auto-generate the .mount or .service files for each subu? We can script the system-wide setup cleanly.
--- /dev/null
+#!/usr/bin/env bash
+
+if [[ $EUID -ne 0 ]]; then
+ echo "❌ This script must be run as root." >&2
+ exit 1
+fi
+
+# Function to unlock and mount the device
+device_mount() {
+ local device_node=$1 # e.g., /dev/sdb1
+ local device_name=$2 # e.g., Zathustra
+ local mount_point="/mnt/$device_name"
+
+ # Check if cryptsetup is installed
+ if ! command -v cryptsetup &> /dev/null; then
+ echo "Error: cryptsetup is not installed!"
+ return 1
+ fi
+
+ # Check if the device is already mounted
+ if mount | grep "on $mount_point" > /dev/null; then
+ echo "Device $device_name is already mounted at $mount_point."
+ return 0
+ fi
+
+ # Make sure the mount point exists
+ mkdir -p "$mount_point"
+
+ # Unlock the encrypted device
+ sudo cryptsetup luksOpen "$device_node" "$device_name-crypt"
+
+ # Mount the unlocked device
+ sudo mount "/dev/mapper/$device_name-crypt" "$mount_point"
+
+ echo "$device_name mounted at $mount_point"
+}
+
+# Run the function with the device node and device name as arguments
+device_mount "$1" "$2"
--- /dev/null
+#!/usr/bin/env bash
+
+if [[ $EUID -ne 0 ]]; then
+ echo "❌ This script must be run as root." >&2
+ exit 1
+fi
+
+umount /mnt/"$1"
+cryptsetup close "$1"
--- /dev/null
+#!/usr/bin/env bash
+# disable_linger_subu — turn off systemd --user lingering for all <masu>-* users
+# Usage: sudo disable_linger_subu --masu Thomas
+
+set -euo pipefail
+MASU=""
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --masu) MASU="${2:-}"; shift 2;;
+ *) echo "unknown arg: $1" >&2; exit 2;;
+ esac
+done
+[[ -n "$MASU" ]] || { echo "usage: sudo $0 --masu <name>"; exit 2; }
+[[ $EUID -eq 0 ]] || { echo "must run as root"; exit 1; }
+
+mapfile -t SUBU_USERS < <(getent passwd | awk -F: -v pfx="^${MASU}-" '$1 ~ pfx {print $1}' | sort)
+for u in "${SUBU_USERS[@]}"; do
+ echo "loginctl disable-linger $u"
+ loginctl disable-linger "$u" || true
+done
+
+echo "Current linger files (should be empty or only intentional users):"
+ls -1 /var/lib/systemd/linger 2>/dev/null || echo "(none)"
+echo "✅ linger disabled for ${#SUBU_USERS[@]} users"
--- /dev/null
+#!/usr/bin/env bash
+# logout_subu — cleanly stop subu users, tear down bindfs, unbind /home, unmount device, close LUKS
+# Usage:
+# sudo logout_subu --masu Thomas --device Eagle [--aggressive] [--dry-run]
+#
+# Notes:
+# - Run from a directory NOT under /home/<masu> (we'll auto 'cd /' if needed).
+# - --aggressive enables pkill -KILL fallback if user@ sessions don't exit.
+# - --device is the mapname mounted at /mnt/<device> and /dev/mapper/<device>-crypt.
+
+set -euo pipefail
+
+MASU=""
+DEVICE=""
+AGGR=0
+DRY=0
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --masu) MASU="${2:-}"; shift 2;;
+ --device) DEVICE="${2:-}"; shift 2;;
+ --aggressive) AGGR=1; shift;;
+ --dry-run) DRY=1; shift;;
+ -h|--help)
+ grep -E '^(# |#-)' "$0" | sed 's/^# \{0,1\}//'
+ exit 0;;
+ *) echo "unknown arg: $1" >&2; exit 2;;
+ esac
+done
+
+if [[ -z "$MASU" ]]; then
+ # best guess: current sudo user or login user
+ MASU="${SUDO_USER:-${USER:-}}"
+ [[ -n "$MASU" ]] || { echo "Set --masu <name>"; exit 2; }
+fi
+
+if [[ $EUID -ne 0 ]]; then
+ echo "❌ must run as root (sudo)"; exit 1
+fi
+
+# If we’re under /home/<masu>, move away so unmount can succeed
+if [[ "$(pwd -P)" == /home/${MASU}* ]]; then
+ echo "cd / (leaving $(pwd -P) so unmounts can proceed)"
+ [[ $DRY -eq 1 ]] || cd /
+fi
+
+say() { printf '%s\n' "$*"; }
+doit() { echo "+ $*"; [[ $DRY -eq 1 ]] || eval "$@"; }
+
+# --- enumerate subu users and mountpoints
+SUBU_ROOT="/home/${MASU}/subu"
+SUBU_DATA="/home/${MASU}/subu_data"
+
+# Users of the form MASU-something that actually exist
+mapfile -t SUBU_USERS < <(getent passwd | awk -F: -v pfx="^${MASU}-" '$1 ~ pfx {print $1}' | sort)
+
+# Bindfs targets (reverse depth for unmount)
+mapfile -t SUBU_MPS < <(findmnt -Rn -S fuse.* -T "$SUBU_ROOT" -o TARGET 2>/dev/null | \
+ awk -F/ '{print NF, $0}' | sort -rn | cut -d" " -f2-)
+
+say "== stop subu systemd user managers =="
+for u in "${SUBU_USERS[@]}"; do
+ say "terminating user@ for $u"
+ doit loginctl terminate-user "$u" || true
+done
+
+# wait a moment and optionally KILL leftovers
+sleep 0.5
+for u in "${SUBU_USERS[@]}"; do
+ if loginctl list-users --no-legend | awk '{print $2}' | grep -qx "$u"; then
+ if [[ $AGGR -eq 1 ]]; then
+ uid="$(id -u "$u" 2>/dev/null || echo "")"
+ if [[ -n "$uid" ]]; then
+ say "aggressive kill of UID $uid ($u)"
+ doit pkill -KILL -u "$uid" || true
+ fi
+ else
+ say "⚠︎ $u still has a user@ manager; rerun with --aggressive to force-kill"
+ fi
+ fi
+done
+
+say "== unmount bindfs subu mounts under $SUBU_ROOT =="
+for mp in "${SUBU_MPS[@]}"; do
+ say "umount $mp"
+ if [[ $DRY -eq 1 ]]; then
+ echo "+ umount '$mp'"
+ else
+ if ! umount "$mp" 2>/dev/null; then
+ echo " (busy) trying lazy umount"
+ umount -l "$mp" || true
+ fi
+ fi
+done
+
+# Unmount the MASU home if it is a bind of /mnt/<device>/user_data/<masu>
+say "== unmount MASU home bind (if any) =="
+if findmnt -n -T "/home/${MASU}" >/dev/null 2>&1; then
+ src="$(findmnt -no SOURCE -T "/home/${MASU}")"
+ say "/home/${MASU} source: ${src}"
+ say "umount /home/${MASU}"
+ doit umount "/home/${MASU}" || true
+fi
+
+# If a device mapname was provided, unmount and close it
+if [[ -n "$DEVICE" ]]; then
+ say "== unmount /mnt/${DEVICE} and close LUKS =="
+ if findmnt -n "/mnt/${DEVICE}" >/dev/null 2>&1; then
+ say "umount /mnt/${DEVICE}"
+ doit umount "/mnt/${DEVICE}" || true
+ fi
+ if cryptsetup status "${DEVICE}-crypt" >/dev/null 2>&1; then
+ say "cryptsetup close ${DEVICE}-crypt"
+ doit cryptsetup close "${DEVICE}-crypt" || true
+ else
+ say "crypt mapping ${DEVICE}-crypt not active"
+ fi
+fi
+
+say "sync disks"
+[[ $DRY -eq 1 ]] || sync
+say "✅ done"
--- /dev/null
+#!/usr/bin/env bash
+
+# Function to list users in the /mnt/<device>/user_data directory
+device_user_list() {
+ local device=$1
+ local user_data_dir="/mnt/$device/user_data"
+
+ if [ ! -d "$user_data_dir" ]; then
+ echo "Error: $user_data_dir does not exist!"
+ return 1
+ fi
+
+ # List all user directories in the user_data directory
+ find "$user_data_dir" -maxdepth 1 -mindepth 1 -type d -exec basename {} \;
+}
+
+# Run the function with the device name as an argument
+device_user_list "$1"
--- /dev/null
+#!/usr/bin/env bash
+
+# Function to bind mount a user's data to /home/<user>
+device_user_bind() {
+ local device=$1
+ local user=$2
+ local user_data_dir="/mnt/$device/user_data/$user"
+ local home_dir="/home/$user"
+
+ if [ ! -d "$user_data_dir" ]; then
+ echo "Error: $user_data_dir does not exist!"
+ return 1
+ fi
+
+ # Create the home directory if it doesn't exist
+ mkdir -p "$home_dir"
+
+ # Mount --bind the user data to the home directory
+ sudo mount --bind "$user_data_dir" "$home_dir"
+ echo "Mounted $user_data_dir -> $home_dir"
+}
+
+# Run the function with the device name and user as arguments
+device_user_bind "$1" "$2"
--- /dev/null
+#!/usr/bin/env bash
+# usage: sudo ./masu__map_own_all.sh <masu> [--suid=US,x6]
+set -euo pipefail
+masu="${1:?usage: $0 <masu> [--suid=a,b]}"
+suid_list="${2-}"
+
+want_suid_for() {
+ [[ "$suid_list" =~ ^--suid= ]] || return 1
+ IFS=',' read -r -a arr <<< "${suid_list#--suid=}"
+ for n in "${arr[@]}"; do [[ "$n" == "$1" ]] && return 0; done
+ return 1
+}
+
+subus="$(./masu__subu_data_dir_list.sh "$masu")"
+[[ -n "$subus" ]] || { echo "No sub-users found for $masu"; exit 1; }
+
+while IFS= read -r s; do
+ [[ -n "$s" ]] || continue
+ echo "Opening sub-user: $s"
+ if want_suid_for "$s"; then
+ sudo ./masu_subu__map_own.sh "$masu" "$s" --suid
+ else
+ sudo ./masu_subu__map_own.sh "$masu" "$s"
+ fi
+done <<< "$subus"
--- /dev/null
+#!/usr/bin/env bash
+
+# Function to list sub-users in /home/<user>/subu_data
+subu_home_dir_list() {
+ local user=$1
+ local subu_home_dir="/home/$user/subu_data"
+
+ if [ ! -d "/home/$user" ]; then
+ echo "Error: /home/$user does not exist!"
+ return 1
+ fi
+
+ if [ ! -d "$subu_home_dir" ]; then
+ echo "Error: $subu_home_dir does not exist!"
+ return 1
+ fi
+
+ # List all sub-users in the subu directory
+ find "$subu_home_dir" -maxdepth 1 -mindepth 1 -type d -exec basename {} \;
+}
+
+# Run the function with the user as an argument
+subu_home_dir_list "$1"
--- /dev/null
+#!/usr/bin/env bash
+# masu__subu_dir_list.sh
+
+set -euo pipefail
+user="${1:?usage: $0 <masu>}"
+
+# Prefer the /home/<masu>/subu view; if empty/nonexistent, fall back to subu_data.
+list_from_dir() { local d="$1"; [[ -d "$d" ]] && find "$d" -mindepth 1 -maxdepth 1 -type d -printf '%f\n' || true; }
+
+candidates="$(
+ list_from_dir "/home/$user/subu"
+ [[ -d "/home/$user/subu" && -n "$(ls -A /home/$user/subu 2>/dev/null || true)" ]] || list_from_dir "/home/$user/subu_data"
+)"
+
+# Unique, stable order
+printf '%s\n' "$candidates" | LC_ALL=C sort -u
--- /dev/null
+#!/usr/bin/env bash
+# usage: sudo ./masu_subu__map_own.sh <masu> <subu> [--suid]
+set -euo pipefail
+
+masu="${1:?usage: $0 <masu> <subu> [--suid]}"
+subu="${2:?usage: $0 <masu> <subu> [--suid]}"
+want_suid=0; [[ "${3-}" == "--suid" ]] && want_suid=1
+
+need(){ command -v "$1" >/dev/null 2>&1 || { echo "missing: $1" >&2; exit 1; }; }
+need bindfs; need findmnt; need umount
+
+src="/home/$masu/subu_data/$subu"
+mp="/home/$masu/subu/$subu"
+[[ -d "$src" ]] || { echo "❌ source not found: $src" >&2; exit 1; }
+mkdir -p "$mp"
+
+# mount options
+base_opts="allow_other,default_permissions,exec"
+opts="$base_opts,$([[ $want_suid -eq 1 ]] && echo suid || echo nosuid)"
+
+# fully unstack any prior bindfs at the target
+while findmnt -rn -T "$mp" -t fuse.bindfs >/dev/null 2>&1; do
+ umount "$mp" 2>/dev/null || umount -l "$mp" || break
+ sleep 0.1
+done
+
+echo "mounting $src -> $mp (opts: $opts)"
+bindfs -o "$opts" --map="${masu}-${subu}/${masu}:@${masu}-${subu}/@${masu}" "$src" "$mp"
+
+# verify (single line, kernel-only)
+findmnt -rn -T "$mp" -S "$src" -o TARGET,SOURCE,FSTYPE,OPTIONS | head -n1
+echo "OK"
+if [[ $want_suid -eq 1 ]]; then
+ echo "note: suid enabled at $mp"
+else
+ echo "note: nosuid (default) — setuid will NOT take effect at $mp"
+fi
--- /dev/null
+#!/usr/bin/env bash
+
+# Function to bind mount with UID/GID mapping
+subu_bind() {
+ local user=$1
+ local subu=$2
+
+ # Check if bindfs is installed
+ if ! command -v bindfs &> /dev/null; then
+ echo "Error: bindfs is not installed!"
+ return 1
+ fi
+
+ # Get the username and group name for the main user
+ master_user_name=$user
+ master_group=$user
+
+ # Get the username and group name for the sub-user
+ subu_user_name="${user}-${subu}"
+ subu_group="${user}-${subu}"
+
+ # Check if the user and sub-user exist
+ if ! id "$master_user_name" &>/dev/null; then
+ echo "Error: User '$master_user_name' not found!"
+ return 1
+ fi
+ if ! id "$subu_user_name" &>/dev/null; then
+ echo "Error: Sub-user '${master_user_name}-${subu}' not found!"
+ return 1
+ fi
+
+ # Directories to be bind-mounted
+ subu_data_path="/home/$user/subu_data/$subu"
+ subu_mount_point_path="/home/$user/subu/$subu"
+
+ # Check if sub-user directory exists
+ if [ ! -d "$subu_data_path" ]; then
+ echo "Error: Sub-user directory '$subu_data_path' does not exist!"
+ return 1
+ fi
+
+ # Create the mount point if it doesn't exist
+ mkdir -p "$subu_mount_point_path"
+
+ # Perform the bind mount using bindfs with UID/GID mapping
+ sudo bindfs\
+ --map="$subu_user_name/$master_user_name:@$subu_group/@$master_group" \
+ "$subu_data_path" \
+ "$subu_mount_point_path"
+
+ # Verify if the mount was successful
+ if [ $? -eq 0 ]; then
+ echo "Successfully bind-mounted $subu_data_path to $subu_mount_point_path with UID/GID mapping."
+ else
+ echo "Error: Failed to bind-mount $subu_data_path to $subu_mount_point_path, might already exist."
+ fi
+}
+
+# Call the function with user and subu as arguments
+subu_bind "$1" "$2"
--- /dev/null
+#!/usr/bin/env bash
+
+# Function to lookup the UID of the user and sub-user combination
+get_subu_uid() {
+ local user=$1
+ local subu=$2
+
+ # Concatenate user and sub-user name (no space around the = sign)
+ local subu_user="${user}-${subu}"
+
+ # Lookup the UID for the sub-user (user-subuser) combination
+ subu_uid=$(id -u "$subu_user" 2>/dev/null)
+
+ # If found, return only the UID, otherwise return nothing
+ if [ -n "$subu_uid" ]; then
+ echo "$subu_uid"
+ fi
+}
+
+# Call the function with user and subu as arguments
+get_subu_uid "$1" "$2"
--- /dev/null
+#!/usr/bin/env bash
+
+# Function to list available devices under /mnt, excluding /mnt itself
+mount_pt_list() {
+ # List all directories in /mnt that are potentially available for mounting
+ find /mnt -mindepth 1 -maxdepth 1 -type d -exec basename {} \;
+}
+
+# Call the function to display available devices
+mount_pt_list
--- /dev/null
+#!/usr/bin/env bash
+
+script_afp=$(realpath "${BASH_SOURCE[0]}")
+
+# input guards
+ env_must_be="developer/tool/env"
+ error=false
+ if [ "$ENV" != "$env_must_be" ]; then
+ echo "$(script_fp):: error: must be run in the $env_must_be environment"
+ error=true
+ fi
+ if $error; then exit 1; fi
+
+# remove files
+
+ cd "$REPO_HOME"/developer/
+ set -x
+ make dist-clean
+ cd mockup/subu_server_home
+ rm server hello socket
+ set +x
+
+echo "$(script_fn) done."
--- /dev/null
+#!/usr/bin/env bash
+script_afp=$(realpath "${BASH_SOURCE[0]}")
--- /dev/null
+#!/usr/bin/env bash
+script_afp=$(realpath "${BASH_SOURCE[0]}")
+
+# input guards
+ env_must_be="developer/tool/env"
+ error=false
+ if [ "$ENV" != "$env_must_be" ]; then
+ echo "$(script_fp):: error: must be run in the $env_must_be environment"
+ error=true
+ fi
+ if $error; then exit 1; fi
+
+set -e
+cd ${REPO_HOME}/developer
+/bin/make -f tool/makefile $@
--- /dev/null
+# after adding this file `make dependency` should be run again
+
+RESOURCE:=$(REPO_HOME)/tool_shared/third_party/resource/developer
+include $(RESOURCE)/make/environment_RT_0
+
+# override defaults
+LIBDIR=scratchpad
+
+# assign a compiler to use
+C=gcc
+
+# _GNU_SOURCE is needed to get ucred, the credentials of the pid that connects to a socket
+CFLAGS= -Werror -ggdb -D_GNU_SOURCE -DDEBUG -DDEBUGDB -include "$(RESOURCE)/make/RT_0.h" -I$(INCDIR)
+LINKFLAGS=-L$(LIBDIR) -L/usr/lib64 -L/usr/lib -l$(PROJECT) -lsqlite3
+
+# Import the rules. The dash prefix means to ignore include errors. This is
+# required because the DEPFILE might not exist yet.
+include $(RESOURCE)/make/targets
+-include $(DEPFILE)
--- /dev/null
+#!/usr/bin/env bash
+script_afp=$(realpath "${BASH_SOURCE[0]}")
+
+# input guards
+
+ env_must_be="developer/tool/env"
+ error=false
+ if [ "$ENV" != "$env_must_be" ]; then
+ echo "$(script_fp):: error: must be run in the $env_must_be environment"
+ error=true
+ fi
+ if $error; then exit 1; fi
+
+set -e
+cd ${REPO_HOME}/developer
+cp amd64/* mockup/subu_server_home
+
--- /dev/null
+#!/usr/bin/env -S python3 -B
+# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*-
+
+import os, sys, shutil, stat, pwd, grp, glob, tempfile, datetime
+
+HELP = """usage: release {write|clean|ls|help|dry write} [DIR]
+ write [DIR] Write released files.
+ - For DIR=manager: copy $REPO_HOME/developer/manager into $REPO_HOME/release/manager.
+ - For other DIR values: write from developer/scratchpad/DIR into $REPO_HOME/release/DIR.
+ clean [DIR] Remove the contents of the release directories.
+ - For DIR=manager: clean $REPO_HOME/release/manager.
+ - For other DIR values: clean only that subdirectory under the release root.
+ list List $REPO_HOME/release as an indented tree: PERMS OWNER DATE NAME.
+ ls List $REPO_HOME/release as an indented tree: PERMS OWNER DATE NAME.
+ help Show this message.
+ dry write [DIR]
+ Preview what write would do without modifying the filesystem.
+"""
+
+ENV_MUST_BE = "developer/tool/env"
+DEFAULT_DIR_MODE = 0o700 # 077-congruent dirs
+
+def exit_with_status(msg, code=1):
+ print(f"release: {msg}", file=sys.stderr)
+ sys.exit(code)
+
+def format_mtime_utc(ts: float) -> str:
+ dt = datetime.datetime.fromtimestamp(ts, datetime.timezone.utc)
+ return dt.strftime("%Y-%m-%d_%H:%M:%S_Z")
+
+def assert_env():
+ env = os.environ.get("ENV", "")
+ if env != ENV_MUST_BE:
+ hint = (
+ "ENV is not 'developer/tool/env'.\n"
+ "Enter the project with: source ./env_developer\n"
+ "That script exports: ROLE=developer; ENV=$ROLE/tool/env"
+ )
+ exit_with_status(f"bad environment: ENV='{env}'. {hint}")
+
+
+def repo_home():
+ rh = os.environ.get("REPO_HOME")
+ if not rh:
+ exit_with_status("REPO_HOME not set (did you 'source ./env_developer'?)")
+ return rh
+
+
+def dpath(*parts):
+ # Developer tree root: $REPO_HOME/developer/...
+ return os.path.join(repo_home(), "developer", *parts)
+
+
+def rpath(*parts):
+ """
+ Release root: $REPO_HOME/release/...
+
+ Manager releases go to $REPO_HOME/release/manager.
+ """
+ return os.path.join(repo_home(), "release", *parts)
+
+
+def dev_root():
+ return dpath()
+
+
+def rel_root():
+ return rpath()
+
+
+def _display_src(p_abs: str) -> str:
+ try:
+ if os.path.commonpath([dev_root()]) == os.path.commonpath([dev_root(), p_abs]):
+ return os.path.relpath(p_abs, dev_root())
+ except Exception:
+ pass
+ return p_abs
+
+
+def _display_dst(p_abs: str) -> str:
+ try:
+ rel = os.path.relpath(p_abs, rel_root())
+ rel = "" if rel == "." else rel
+ return "$REPO_HOME/release" + ("/" + rel if rel else "")
+ except Exception:
+ return p_abs
+
+
+def ensure_mode(path, mode):
+ try:
+ os.chmod(path, mode)
+ except Exception:
+ pass
+
+
+def ensure_dir(path, mode=DEFAULT_DIR_MODE, dry=False):
+ if dry:
+ if not os.path.isdir(path):
+ shown = _display_dst(path) if path.startswith(rel_root()) else (
+ os.path.relpath(path, dev_root()) if path.startswith(dev_root()) else path
+ )
+ print(f"(dry) mkdir -m {oct(mode)[2:]} '{shown}'")
+ return
+ os.makedirs(path, exist_ok=True)
+ ensure_mode(path, mode)
+
+def filemode(m):
+ try:
+ return stat.filemode(m)
+ except Exception:
+ return oct(m & 0o777)
+
+def owner_group(st):
+ try:
+ user = pwd.getpwuid(st.st_uid).pw_name
+ group = grp.getgrgid(st.st_gid).gr_name
+ except Exception:
+ user = str(st.st_uid)
+ group = str(st.st_gid)
+ return user if user == group else f"{user}:{group}"
+
+
+# ---------- LS (two-pass owner:group width) ----------
+# ---------- LS (two-pass owner:group width) ----------
+def list_tree(root):
+ if not os.path.isdir(root):
+ return
+ entries = []
+
+ def gather(path: str, depth: int, is_root: bool):
+ try:
+ it = list(os.scandir(path))
+ except FileNotFoundError:
+ return
+ dirs = [e for e in it if e.is_dir(follow_symlinks=False)]
+ files = [e for e in it if not e.is_dir(follow_symlinks=False)]
+ dirs.sort(key=lambda e: e.name)
+ files.sort(key=lambda e: e.name)
+
+ if is_root:
+ # dotfiles at root first
+ for f in (e for e in files if e.name.startswith(".")):
+ st = os.lstat(f.path)
+ perms = filemode(st.st_mode)
+ og = owner_group(st)
+ mtime = format_mtime_utc(st.st_mtime)
+ entries.append((False, depth, perms, og, mtime, f.name))
+ # dirs
+ for d in dirs:
+ st = os.lstat(d.path)
+ perms = filemode(st.st_mode)
+ og = owner_group(st)
+ mtime = format_mtime_utc(st.st_mtime)
+ entries.append((True, depth, perms, og, mtime, d.name + "/"))
+ gather(d.path, depth + 1, False)
+ # other files
+ for f in (e for e in files if not e.name.startswith(".")):
+ st = os.lstat(f.path)
+ perms = filemode(st.st_mode)
+ og = owner_group(st)
+ mtime = format_mtime_utc(st.st_mtime)
+ entries.append((False, depth, perms, og, mtime, f.name))
+ else:
+ # normal subdirs: dirs then files
+ for d in dirs:
+ st = os.lstat(d.path)
+ perms = filemode(st.st_mode)
+ og = owner_group(st)
+ mtime = format_mtime_utc(st.st_mtime)
+ entries.append((True, depth, perms, og, mtime, d.name + "/"))
+ gather(d.path, depth + 1, False)
+ for f in files:
+ st = os.lstat(f.path)
+ perms = filemode(st.st_mode)
+ og = owner_group(st)
+ mtime = format_mtime_utc(st.st_mtime)
+ entries.append((False, depth, perms, og, mtime, f.name))
+
+ gather(root, depth=1, is_root=True)
+
+ ogw = 0
+ for (_isdir, _depth, _perms, ownergrp, _mtime, _name) in entries:
+ if len(ownergrp) > ogw:
+ ogw = len(ownergrp)
+
+ print("release/")
+ for (_isdir, depth, perms, ownergrp, mtime, name) in entries:
+ indent = " " * depth
+ print(f"{perms} {ownergrp:<{ogw}} {mtime} {indent}{name}")
+# ---------- end LS ----------
+
+def iter_src_files(topdir, src_root):
+ """
+ Yield (src_abs, rel) pairs.
+
+ For manager:
+ - base is $REPO_HOME/developer/manager
+
+ For other topdirs (future reuse), preserves original Harmony behavior:
+ - base is src_root/topdir (usually developer/scratchpad/topdir)
+ """
+ if topdir == "manager":
+ base = dpath("manager")
+ else:
+ base = os.path.join(src_root, topdir)
+
+ if not os.path.isdir(base):
+ return
+ yield
+
+ if topdir == "kmod":
+ for p in sorted(glob.glob(os.path.join(base, "*.ko"))):
+ yield (p, os.path.basename(p))
+ else:
+ for root, dirs, files in os.walk(base):
+ dirs.sort()
+ files.sort()
+ for fn in files:
+ src = os.path.join(root, fn)
+ rel = os.path.relpath(src, base)
+ yield (src, rel)
+
+
+def _target_mode_from_source(src_abs: str) -> int:
+ """077 policy: files 0600; if source has owner-exec, make 0700."""
+ try:
+ sm = stat.S_IMODE(os.stat(src_abs).st_mode)
+ except FileNotFoundError:
+ return 0o600
+ return 0o700 if (sm & stat.S_IXUSR) else 0o600
+
+
+def copy_one(src_abs, dst_abs, dry=False):
+ src_show = _display_src(src_abs)
+ dst_show = _display_dst(dst_abs)
+ parent = os.path.dirname(dst_abs)
+ os.makedirs(parent, exist_ok=True)
+ target_mode = _target_mode_from_source(src_abs)
+
+ def _is_writable_dir(p):
+ return os.access(p, os.W_OK)
+
+ flip_needed = not _is_writable_dir(parent)
+ restore_mode = None
+ parent_show = _display_dst(parent)
+
+ if dry:
+ if flip_needed:
+ print(f"(dry) chmod u+w '{parent_show}'")
+ if os.path.exists(dst_abs):
+ print(f"(dry) unlink '{dst_show}'")
+ print(f"(dry) install -m {oct(target_mode)[2:]} -D '{src_show}' '{dst_show}'")
+ if flip_needed:
+ print(f"(dry) chmod u-w '{parent_show}'")
+ return
+
+ try:
+ if flip_needed:
+ try:
+ st_parent = os.stat(parent)
+ restore_mode = stat.S_IMODE(st_parent.st_mode)
+ os.chmod(parent, restore_mode | stat.S_IWUSR)
+ except PermissionError:
+ exit_with_status(
+ f"cannot write: parent dir not writable and chmod failed on {parent_show}"
+ )
+
+ fd, tmp_path = tempfile.mkstemp(prefix=".tmp.", dir=parent)
+ try:
+ with os.fdopen(fd, "wb") as tmpf, open(src_abs, "rb") as sf:
+ shutil.copyfileobj(sf, tmpf)
+ tmpf.flush()
+ os.chmod(tmp_path, target_mode)
+ os.replace(tmp_path, dst_abs)
+ finally:
+ try:
+ if os.path.exists(tmp_path):
+ os.unlink(tmp_path)
+ except Exception:
+ pass
+ finally:
+ if restore_mode is not None:
+ try:
+ os.chmod(parent, restore_mode)
+ except Exception:
+ pass
+
+ print(f"+ install -m {oct(target_mode)[2:]} '{src_show}' '{dst_show}'")
+
+
+def write_one_dir(topdir, dry):
+ """
+ For manager:
+
+ - Source: $REPO_HOME/developer/manager
+ - Dest: $REPO_HOME/release/manager
+
+ For other topdirs:
+
+ - Source: $REPO_HOME/developer/scratchpad/<topdir>
+ - Dest: $REPO_HOME/release/<topdir>
+ """
+ rel_root_dir = rpath()
+ src_root = dpath("scratchpad")
+
+ if topdir == "manager":
+ src_dir = dpath("manager")
+ dst_dir = rpath("manager")
+ else:
+ src_dir = os.path.join(src_root, topdir)
+ dst_dir = os.path.join(rel_root_dir, topdir)
+
+ if not os.path.isdir(src_dir):
+ shown = _display_src(src_dir)
+ if topdir == "manager":
+ exit_with_status(
+ f"cannot write: expected developer manager tree at '{shown}'. "
+ f"Expected directory: $REPO_HOME/developer/manager"
+ )
+ else:
+ exit_with_status(
+ f"cannot write: expected '{shown}' to exist. "
+ f"Create developer/scratchpad/{topdir} (Makefiles may need to populate it)."
+ )
+
+ ensure_dir(dst_dir, DEFAULT_DIR_MODE, dry=dry)
+
+ wrote = False
+ for src_abs, rel in iter_src_files(topdir, src_root):
+ dst_abs = os.path.join(dst_dir, rel)
+ copy_one(src_abs, dst_abs, dry=dry)
+ wrote = True
+ if not wrote:
+ msg = "no matching artifacts found"
+ if topdir == "kmod":
+ msg += " (looking for *.ko)"
+ print(f"(info) {msg} in {_display_src(src_dir)}")
+
+
+def cmd_write(dir_arg, dry=False):
+ assert_env()
+ ensure_dir(rpath(), DEFAULT_DIR_MODE, dry=dry)
+
+ if dir_arg:
+ write_one_dir(dir_arg, dry=dry)
+ else:
+ # Legacy behavior: auto-discover from scratchpad/
+ src_root = dpath("scratchpad")
+ if not os.path.isdir(src_root):
+ print(f"(info) nothing to release; no scratchpad/ directory at {_display_src(src_root)}")
+ return
+ subs = sorted(
+ [e.name for e in os.scandir(src_root) if e.is_dir(follow_symlinks=False)]
+ )
+ if not subs:
+ print(
+ f"(info) nothing to release; no subdirectories found under {_display_src(src_root)}"
+ )
+ return
+ for td in subs:
+ write_one_dir(td, dry=dry)
+
+
+def _clean_contents(dir_path):
+ if not os.path.isdir(dir_path):
+ return
+ for name in os.listdir(dir_path):
+ p = os.path.join(dir_path, name)
+ if os.path.isdir(p) and not os.path.islink(p):
+ shutil.rmtree(p, ignore_errors=True)
+ else:
+ try:
+ os.unlink(p)
+ except FileNotFoundError:
+ pass
+
+
+def cmd_clean(dir_arg):
+ assert_env()
+ rel_root_dir = rpath()
+ if not os.path.isdir(rel_root_dir):
+ return
+ if dir_arg:
+ _clean_contents(os.path.join(rel_root_dir, dir_arg))
+ else:
+ _clean_contents(rel_root_dir)
+
+
+def CLI():
+ if len(sys.argv) < 2:
+ print(HELP)
+ return
+ cmd, *args = sys.argv[1:]
+ if cmd == "write":
+ cmd_write(args[0] if args else None, dry=False)
+ elif cmd == "clean":
+ cmd_clean(args[0] if args else None)
+ elif cmd == "ls":
+ list_tree(rpath())
+ elif cmd == "list":
+ list_tree(rpath())
+ elif cmd == "help":
+ print(HELP)
+ elif cmd == "dry":
+ if args and args[0] == "write":
+ cmd_write(args[1] if len(args) >= 2 else None, dry=True)
+ else:
+ print(HELP)
+ else:
+ print(HELP)
+
+
+if __name__ == "__main__":
+ CLI()
--- /dev/null
+#!/usr/bin/env python3
+# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*-
+
+"""
+Remove all Python cache directories under the current working directory.
+
+Specifically:
+ * __pycache__
+ * __psycache__ (included in case of typo'd cache dirs)
+
+Usage:
+ python3 clean_pycache.py
+ ./clean_pycache.py
+"""
+
+import os, shutil, sys
+
+
+def is_cache_dir(name: str) -> bool:
+ """
+ Return True if the directory name should be treated as a cache directory.
+ """
+ return name in ("__pycache__", "__psycache__")
+
+
+def remove_cache_dir(path: str) -> None:
+ """
+ Remove a cache directory and print what we did.
+ """
+ try:
+ shutil.rmtree(path)
+ print(f"removed: {path}")
+ except FileNotFoundError:
+ # Someone else removed it; ignore.
+ pass
+ except PermissionError as e:
+ print(f"warning: cannot remove {path}: {e}", file =sys.stderr)
+ except OSError as e:
+ print(f"warning: error removing {path}: {e}", file =sys.stderr)
+
+
+def walk_and_clean(start: str) -> None:
+ """
+ Walk the tree from 'start' downward and remove all cache dirs.
+ """
+ for root, dirs, files in os.walk(start, topdown =True):
+ # Work on a copy so we can safely modify 'dirs' while iterating.
+ for d in list(dirs):
+ if is_cache_dir(d):
+ full = os.path.join(root, d)
+ remove_cache_dir(full)
+ # Prevent os.walk from descending into it.
+ try:
+ dirs.remove(d)
+ except ValueError:
+ pass
+
+
+def main(argv=None) -> int:
+ if argv is None:
+ argv = sys.argv[1:]
+
+ start_dir = os.getcwd()
+ walk_and_clean(start_dir)
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
--- /dev/null
+
+__pycache__
+
--- /dev/null
+
+*
+!.gitignore
+
--- /dev/null
+#!/usr/bin/env python3
+# db_bind_user_to_iface.py — bind ONE linux user to ONE interface in the DB (no schema writes)
+# Usage: ./db_bind_user_to_iface.py <username> <iface> # e.g. ./db_bind_user_to_iface.py Thomas-x6 x6
+
+from __future__ import annotations
+import sys, sqlite3, pwd
+from pathlib import Path
+from typing import Optional
+import incommon as ic # ROOT_DIR/DB_PATH, open_db()
+
+def system_uid_or_none(username: str) -> Optional[int]:
+ """Return the system UID for username, or None if the user doesn't exist locally."""
+ try:
+ return pwd.getpwnam(username).pw_uid
+ except KeyError:
+ return None
+
+def bind_user_to_iface(conn: sqlite3.Connection, iface: str, username: str) -> str:
+ """
+ Given (iface, username):
+ - Look up client.id by iface (table: client)
+ - Upsert into User(iface_id, username, uid)
+ - Update uid based on local /etc/passwd (None if user not found)
+ Returns a concise status string.
+ """
+ row = conn.execute("SELECT id FROM Iface WHERE iface=? LIMIT 1;", (iface,)).fetchone()
+ if not row:
+ raise RuntimeError(f"Interface '{iface}' not found in client")
+
+ iface_id = int(row[0])
+ uid_val = system_uid_or_none(username)
+
+ # Upsert binding
+ conn.execute("""
+ INSERT INTO User (iface_id, username, uid, created_at, updated_at)
+ VALUES (?, ?, ?, strftime('%Y-%m-%dT%H:%M:%SZ','now'), strftime('%Y-%m-%dT%H:%M:%SZ','now'))
+ ON CONFLICT(iface_id, username) DO UPDATE SET
+ uid = excluded.uid,
+ updated_at = strftime('%Y-%m-%dT%H:%M:%SZ','now');
+ """, (iface_id, username, uid_val))
+
+ if uid_val is None:
+ return f"bound {username} → {iface} (uid=NULL; user not present on this system)"
+ return f"bound {username} → {iface} (uid={uid_val})"
+
+def main(argv: list[str]) -> int:
+ if len(argv) != 2:
+ prog = Path(sys.argv[0]).name
+ print(f"Usage: {prog} <username> <iface>", file=sys.stderr)
+ return 2
+
+ username, iface = argv
+ try:
+ with ic.open_db() as conn:
+ msg = bind_user_to_iface(conn, iface, username)
+ conn.commit()
+ except FileNotFoundError as e:
+ print(f"❌ {e}", file=sys.stderr); return 1
+ except sqlite3.Error as e:
+ print(f"❌ sqlite error: {e}", file=sys.stderr); return 1
+ except RuntimeError as e:
+ print(f"❌ {e}", file=sys.stderr); return 1
+
+ print(f"✔ {msg}")
+ return 0
+
+if __name__ == "__main__":
+ sys.exit(main(sys.argv[1:]))
--- /dev/null
+#!/usr/bin/env python3
+# db_checks.py — quick audit for common misconfigurations
+
+from __future__ import annotations
+import sys, sqlite3, ipaddress
+import incommon as ic
+
+def audit(conn: sqlite3.Connection) -> int:
+ errs = 0
+
+ # 1) client present?
+ C = ic.rows(conn, """
+ SELECT id, iface, local_address_cidr, rt_table_name_eff
+ FROM v_client_effective
+ ORDER BY iface;
+ """)
+ if not C:
+ print("WARN: no client present"); return 1
+
+ # 2) CIDR sanity
+ for cid, iface, cidr, rtname in C:
+ try:
+ ipaddress.IPv4Interface(cidr)
+ except Exception as e:
+ print(f"ERR: client {iface} has invalid CIDR {cidr}: {e}")
+ errs += 1
+
+ # 3) server exist and map to client
+ S = ic.rows(conn, """
+ SELECT s.id, c.iface, s.name, s.public_key, s.endpoint_host, s.endpoint_port, s.allowed_ips
+ FROM server s
+ JOIN Iface c ON c.id = s.iface_id
+ ORDER BY c.iface, s.name;
+ """)
+ if not S:
+ print("WARN: no server present for any client")
+
+ # 4) user bindings exist? (not required, but useful)
+ UB = ic.rows(conn, """
+ SELECT c.iface, ub.username, ub.uid
+ FROM User ub
+ JOIN Iface c ON c.id = ub.iface_id
+ ORDER BY c.iface, ub.username;
+ """)
+ if not UB:
+ print("WARN: no User present")
+
+ # 5) duplicate tunnel IPs across client (/32 equality)
+ tunnel_hosts = {}
+ for _, iface, cidr, _ in C:
+ try:
+ host = str(ipaddress.IPv4Interface(cidr).ip)
+ if host in tunnel_hosts and tunnel_hosts[host] != iface:
+ print(f"ERR: duplicate tunnel host {host} on {tunnel_hosts[host]} and {iface}")
+ errs += 1
+ else:
+ tunnel_hosts[host] = iface
+ except Exception:
+ pass
+
+ # 6) Server AllowedIPs hygiene: warn when 0.0.0.0/0 appears in server table
+ for sid, iface, sname, pub, host, port, allow in S:
+ if allow.strip() == "0.0.0.0/0":
+ # client-side full-tunnel is fine; server-side peer should use /32 entries
+ print(f"NOTE: server(name={sname}, client={iface}) has AllowedIPs=0.0.0.0/0 (client-side full-tunnel). Ensure server peer uses /32(s).")
+
+ # 7) meta.subu_cidr present?
+ M = dict(ic.rows(conn, "SELECT key, value FROM meta;"))
+ if "subu_cidr" not in M:
+ print("WARN: meta.subu_cidr missing; default tooling may assume 10.0.0.0/24")
+
+ print("OK: audit complete" if errs == 0 else f"FAIL: {errs} error(s)")
+ return 1 if errs else 0
+
+def main(argv: list[str]) -> int:
+ try:
+ with ic.open_db() as conn:
+ return audit(conn)
+ except (sqlite3.Error, FileNotFoundError) as e:
+ print(f"❌ {e}", file=sys.stderr)
+ return 2
+
+if __name__ == "__main__":
+ sys.exit(main(sys.argv[1:]))
--- /dev/null
+#!/usr/bin/env python3
+# db_init_StanleyPark.py — initialize the DB for the StanleyPark client
+
+from __future__ import annotations
+import sys, subprocess, sqlite3
+from pathlib import Path
+import incommon as ic
+
+# Use existing business functions (no duplication)
+from db_init_iface_x6 import init_iface_x6
+from db_init_iface_US import init_iface_US
+from db_init_server_x6 import init_server_x6
+from db_init_server_US import init_server_US
+from db_bind_user_to_iface import bind_user_to_iface
+from db_init_ip_table_registration import assign_missing_rt_table_ids
+from db_init_ip_iface_addr_assign import reconcile_kernel_and_db_ipv4_addresses
+from db_init_route_defaults import seed_default_routes
+
+ROOT = Path(__file__).resolve().parent
+DB = ic.DB_PATH
+
+def msg_wrapped_call(title: str, fn=None, *args, **kwargs):
+ """Print a before/after status line around calling `fn(*args, **kwargs)`.
+ Returns the function’s return value."""
+ print(f"→ {title}", flush=True)
+ res = fn(*args, **kwargs) if fn else None
+ print(f"✔ {title}" + (f": {res}" if res not in (None, "") else ""), flush=True)
+ return res
+
+def _run_local(script: str, *argv: str):
+ subprocess.run([str(ROOT / script), *argv], check=True)
+
+def db_init_StanleyPark() -> int:
+ """
+ Given the local SQLite DB at ic.DB_PATH, this:
+ 1) loads schema
+ 2) upserts ifaces (x6, US)
+ 3) upserts servers (x6, US)
+ 4) binds users (Thomas-x6→x6, Thomas-US→US)
+ 5) seeds per-iface default routes into Route
+ 6) assigns missing rt_table_id values from /etc/iproute2/rt_tables
+ 7) reconciles/assigns interface IPv4 addresses (kernel→DB, then pool)
+ 8) commits and prints status
+ Returns 0 on success (raises on failure).
+ """
+ # 1) Schema
+ msg_wrapped_call("db_schema_load.sh", _run_local, "db_schema_load.sh")
+
+ # 2) DB work in one connection/commit
+ with ic.open_db(DB) as conn:
+ # ifaces + servers + user bindings
+ msg_wrapped_call("db_init_iface_x6.py (init_iface_x6)", init_iface_x6, conn)
+ msg_wrapped_call("db_init_server_x6.py (init_server_x6)", init_server_x6, conn)
+ msg_wrapped_call("bind_user_to_iface: Thomas-x6 → x6", bind_user_to_iface, conn, "x6", "Thomas-x6")
+
+ msg_wrapped_call("db_init_iface_US.py (init_iface_US)", init_iface_US, conn)
+ msg_wrapped_call("db_init_server_US.py (init_server_US)", init_server_US, conn)
+ msg_wrapped_call("bind_user_to_iface: Thomas-US → US", bind_user_to_iface, conn, "US", "Thomas-US")
+
+ # 5) seed default routes for the selected ifaces (no duplicates; idempotent)
+ msg_wrapped_call(
+ "db_init_route_defaults (x6,US)",
+ lambda: seed_default_routes(conn, iface_names=["x6","US"], overwrite=False)
+ )
+
+ # 6) assign rt_table_id from system tables (DB-only; no file writes)
+ msg_wrapped_call(
+ "db_init_ip_table_registration",
+ lambda: assign_missing_rt_table_ids(conn, low=20000, high=29999, dry_run=False)
+ )
+
+ # 7) reconcile/assign interface IPv4 addresses (kernel → DB; pool for missing)
+ msg_wrapped_call(
+ "db_init_ip_iface_addr_assign",
+ lambda: reconcile_kernel_and_db_ipv4_addresses(
+ conn,
+ pool_cidr="10.0.0.0/16",
+ assign_prefix=32,
+ reserve_first=0,
+ dry_run=False
+ )
+ )
+
+ # 8) commit
+ conn.commit()
+ print("✔ commit: database updated")
+
+ return 0
+
+def main(argv):
+ if argv:
+ print(f"Usage: {Path(sys.argv[0]).name}", file=sys.stderr)
+ return 2
+ try:
+ return db_init_StanleyPark()
+ except (subprocess.CalledProcessError, sqlite3.Error, FileNotFoundError) as e:
+ print(f"❌ {e}", file=sys.stderr)
+ return 1
+
+if __name__ == "__main__":
+ sys.exit(main(sys.argv[1:]))
--- /dev/null
+#!/usr/bin/env python3
+# Helpers to seed/update a row in client.
+
+from __future__ import annotations
+import sqlite3
+from typing import Any, Optional, Dict
+import incommon as ic # provides DB_PATH, open_db
+
+# Normally don't set the addr_cidr, the system will automically
+# assign a free address, or reuse one that is already set.
+
+def upsert_client(conn: sqlite3.Connection,
+ *,
+ iface: str,
+ addr_cidr: Optional[str] = None,
+ rt_table_name: Optional[str] = None,
+ rt_table_id: Optional[int] = None,
+ mtu: Optional[int] = None,
+ fwmark: Optional[int] = None,
+ dns_mode: Optional[str] = None, # 'none' or 'static'
+ dns_servers: Optional[str] = None,
+ autostart: Optional[int] = None, # 0 or 1
+ bound_user: Optional[str] = None,
+ bound_uid: Optional[int] = None
+ ) -> str:
+ row = conn.execute(
+ """SELECT id, iface, rt_table_id, rt_table_name, local_address_cidr,
+ mtu, fwmark, dns_mode, dns_servers, autostart,
+ bound_user, bound_uid
+ FROM Iface WHERE iface=? LIMIT 1;""",
+ (iface,)
+ ).fetchone()
+
+ defname = rt_table_name if rt_table_name is not None else iface
+ desired: Dict[str, Any] = {"iface": iface, "local_address_cidr": addr_cidr}
+ if rt_table_id is not None: desired["rt_table_id"] = rt_table_id
+ if rt_table_name is not None: desired["rt_table_name"] = rt_table_name
+ if mtu is not None: desired["mtu"] = mtu
+ if fwmark is not None: desired["fwmark"] = fwmark
+ if dns_mode is not None: desired["dns_mode"] = dns_mode
+ if dns_servers is not None: desired["dns_servers"] = dns_servers
+ if autostart is not None: desired["autostart"] = autostart
+ if bound_user is not None: desired["bound_user"] = bound_user
+ if bound_uid is not None: desired["bound_uid"] = bound_uid
+
+ if row is None:
+ fields = ["iface","local_address_cidr","rt_table_name"]
+ vals = [iface, addr_cidr, defname]
+ for k in ("rt_table_id","mtu","fwmark","dns_mode","dns_servers","autostart","bound_user","bound_uid"):
+ if k in desired: fields.append(k); vals.append(desired[k])
+ q = f"INSERT INTO Iface ({','.join(fields)}) VALUES ({','.join('?' for _ in vals)});"
+ cur = conn.execute(q, vals); conn.commit()
+ return f"seeded: client(iface={iface}) id={cur.lastrowid} addr={addr_cidr} rt={defname}"
+ else:
+ cid, _, rt_id, rt_name, cur_addr, cur_mtu, cur_fwm, cur_dns_mode, cur_dns_srv, cur_auto, cur_buser, cur_buid = row
+ current = {
+ "local_address_cidr": cur_addr, "rt_table_id": rt_id, "rt_table_name": rt_name,
+ "mtu": cur_mtu, "fwmark": cur_fwm, "dns_mode": cur_dns_mode, "dns_servers": cur_dns_srv,
+ "autostart": cur_auto, "bound_user": cur_buser, "bound_uid": cur_buid
+ }
+ changes: Dict[str, Any] = {}
+ for k, v in desired.items():
+ if k == "iface": continue
+ if current.get(k) != v: changes[k] = v
+ if rt_name is None and "rt_table_name" not in changes:
+ changes["rt_table_name"] = defname
+ if not changes:
+ return f"ok: client(iface={iface}) unchanged id={cid} addr={cur_addr} rt={rt_name or defname}"
+ sets = ", ".join(f"{k}=?" for k in changes)
+ vals = list(changes.values()) + [iface]
+ conn.execute(f"UPDATE Iface SET {sets} WHERE iface=?;", vals); conn.commit()
+ return f"updated: client(iface={iface}) id={cid} " + " ".join(f"{k}={changes[k]}" for k in changes)
--- /dev/null
+# db_init_iface_US.py
+from db_init_iface import upsert_client
+
+def init_iface_US(conn):
+ # iface US with dedicated table 'US' and a distinct host /32
+ return upsert_client(conn, iface="US", rt_table_name="US")
--- /dev/null
+# db_init_iface_x6.py
+from db_init_iface import upsert_client
+
+def init_iface_x6(conn):
+ # iface x6 with dedicated table 'x6' and host /32
+ return upsert_client(conn, iface="x6", rt_table_name="x6")
--- /dev/null
+#!/usr/bin/env python3
+"""
+db_init_ip_iface_addr_assign.py
+
+Business API:
+ reconcile_kernel_and_db_ipv4_addresses(conn ,pool_cidr="10.0.0.0/16" ,assign_prefix=32 ,reserve_first=0 ,dry_run=False)
+ -> (updated_count ,notes)
+"""
+
+from __future__ import annotations
+import argparse
+import ipaddress
+import json
+import sqlite3
+import subprocess
+from typing import Dict ,Iterable ,List ,Optional ,Sequence ,Tuple
+
+import incommon as ic
+
+
+def fetch_ifaces(conn: sqlite3.Connection) -> List[Tuple[int ,str ,Optional[str]]]:
+ sql = """
+ SELECT id,
+ iface,
+ NULLIF(TRIM(local_address_cidr),'') AS local_address_cidr
+ FROM Iface
+ ORDER BY id;
+ """
+ cur = conn.execute(sql)
+ rows = cur.fetchall()
+ return [
+ (int(r[0]) ,str(r[1]) ,(str(r[2]) if r[2] is not None else None))
+ for r in rows
+ ]
+
+
+def update_iface_addresses(conn: sqlite3.Connection ,updates: Dict[int ,str]) -> int:
+ if not updates:
+ return 0
+ with conn:
+ for iface_id ,cidr in updates.items():
+ conn.execute("UPDATE Iface SET local_address_cidr=? WHERE id=?" ,(cidr ,iface_id))
+ return len(updates)
+
+
+def kernel_ipv4_cidr_for(iface: str) -> Optional[str]:
+ try:
+ cp = subprocess.run(
+ ["ip","-j","addr","show","dev",iface]
+ ,check=False
+ ,capture_output=True
+ ,text=True
+ )
+ except Exception:
+ return None
+ if cp.returncode != 0 or not cp.stdout.strip():
+ return None
+ try:
+ data = json.loads(cp.stdout)
+ except json.JSONDecodeError:
+ return None
+ if not isinstance(data ,list) or not data:
+ return None
+ addr_info = data[0].get("addr_info") or []
+ for a in addr_info:
+ if a.get("family") == "inet" and a.get("scope") == "global":
+ local = a.get("local"); plen = a.get("prefixlen")
+ if local and isinstance(plen ,int):
+ return f"{local}/{plen}"
+ for a in addr_info:
+ if a.get("family") == "inet":
+ local = a.get("local"); plen = a.get("prefixlen")
+ if local and isinstance(plen ,int):
+ return f"{local}/{plen}"
+ return None
+
+
+def kernel_ipv4_map(ifaces: Sequence[str]) -> Dict[str ,Optional[str]]:
+ return {name: kernel_ipv4_cidr_for(name) for name in ifaces}
+
+
+def _host_ip_from_cidr(cidr: str):
+ try:
+ ipi = ipaddress.ip_interface(cidr)
+ except ValueError:
+ return None
+ if isinstance(ipi.ip ,ipaddress.IPv4Address):
+ return ipaddress.IPv4Address(int(ipi.ip))
+ return None
+
+
+def _collect_used_hosts_from(cidrs: Iterable[str] ,pool: ipaddress.IPv4Network) -> List[ipaddress.IPv4Address]:
+ used: List[ipaddress.IPv4Address] = []
+ for c in cidrs:
+ hip = _host_ip_from_cidr(c)
+ if hip is not None and hip in pool:
+ used.append(hip)
+ return used
+
+
+def _first_free_hosts(
+ count: int
+ ,used_hosts: Iterable[ipaddress.IPv4Address]
+ ,pool: ipaddress.IPv4Network
+ ,reserve_first: int = 0
+) -> List[ipaddress.IPv4Address]:
+ used_set = {int(h) for h in used_hosts}
+ result: List[ipaddress.IPv4Address] = []
+ start = int(pool.network_address) + 1 + max(0 ,reserve_first)
+ end = int(pool.broadcast_address) - 1
+ for val in range(start ,end+1):
+ if val not in used_set:
+ result.append(ipaddress.IPv4Address(val))
+ if len(result) >= count:
+ break
+ if len(result) < count:
+ raise RuntimeError(f"address pool exhausted in {pool} (needed {count} more)")
+ return result
+
+
+def plan_address_updates(
+ rows: Sequence[Tuple[int ,str ,Optional[str]]]
+ ,pool_cidr: str
+ ,assign_prefix: int
+ ,reserve_first: int
+ ,kmap: Dict[str ,Optional[str]]
+) -> Tuple[Dict[int ,str] ,List[str]]:
+ notes: List[str] = []
+ pool = ipaddress.IPv4Network(pool_cidr ,strict=False)
+ if pool.version != 4:
+ raise ValueError("only IPv4 pools supported")
+
+ kernel_present = [c for c in kmap.values() if c]
+ db_present = [c for (_i ,_n ,c) in rows if c]
+ used_hosts = (
+ _collect_used_hosts_from(kernel_present ,pool)
+ + _collect_used_hosts_from(db_present ,pool)
+ )
+
+ alloc_targets: List[Tuple[int ,str]] = []
+ updates: Dict[int ,str] = {}
+
+ for iface_id ,iface_name ,db_cidr in rows:
+ k_cidr = kmap.get(iface_name)
+
+ if k_cidr:
+ if db_cidr != k_cidr:
+ updates[iface_id] = k_cidr
+ if db_cidr:
+ notes.append(f"sync: iface '{iface_name}' DB {db_cidr} -> kernel {k_cidr}")
+ else:
+ notes.append(f"sync: iface '{iface_name}' set from kernel {k_cidr}")
+ continue
+
+ if db_cidr:
+ notes.append(f"note: iface '{iface_name}' has DB {db_cidr} but no kernel IPv4")
+ continue
+
+ alloc_targets.append((iface_id ,iface_name))
+
+ if alloc_targets:
+ free = _first_free_hosts(len(alloc_targets) ,used_hosts ,pool ,reserve_first=reserve_first)
+ for idx ,(iface_id ,iface_name) in enumerate(alloc_targets):
+ cidr = f"{free[idx]}/{assign_prefix}"
+ updates[iface_id] = cidr
+ notes.append(f"assign: iface '{iface_name}' -> {cidr} (from pool {pool_cidr})")
+
+ return (updates ,notes)
+
+
+def reconcile_kernel_and_db_ipv4_addresses(
+ conn: sqlite3.Connection
+ ,pool_cidr: str = "10.0.0.0/16"
+ ,assign_prefix: int = 32
+ ,reserve_first: int = 0
+ ,dry_run: bool = False
+) -> Tuple[int ,List[str]]:
+ rows = fetch_ifaces(conn)
+ iface_names = [n for (_i ,n ,_c) in rows]
+ kmap = kernel_ipv4_map(iface_names)
+
+ updates ,notes = plan_address_updates(
+ rows
+ ,pool_cidr
+ ,assign_prefix
+ ,reserve_first
+ ,kmap
+ )
+ if not updates:
+ return (0 ,notes or ["noop: nothing to change"])
+ if dry_run:
+ return (0 ,notes)
+
+ updated = update_iface_addresses(conn ,updates)
+ return (updated ,notes)
+
+
+# --- thin CLI ---
+
+def main(argv=None) -> int:
+ ap = argparse.ArgumentParser()
+ ap.add_argument("--pool" ,type=str ,default="10.0.0.0/16")
+ ap.add_argument("--assign-prefix" ,type=int ,default=32)
+ ap.add_argument("--reserve-first" ,type=int ,default=0)
+ ap.add_argument("--dry-run" ,action="store_true")
+ args = ap.parse_args(argv)
+ with ic.open_db() as conn:
+ updated ,notes = reconcile_kernel_and_db_ipv4_addresses(
+ conn
+ ,pool_cidr=args.pool
+ ,assign_prefix=args.assign_prefix
+ ,reserve_first=args.reserve_first
+ ,dry_run=args.dry_run
+ )
+ if notes:
+ print("\n".join(notes))
+ if not args.dry_run:
+ print(f"updated rows: {updated}")
+ return 0
+
+
+if __name__ == "__main__":
+ import sys
+ sys.exit(main())
--- /dev/null
+#!/usr/bin/env python3
+"""
+db_init_ip_table_registration.py
+
+Business API:
+ assign_missing_rt_table_ids(conn ,low=20000 ,high=29999 ,dry_run=False)
+ -> (updated_count ,planned_map ,notes)
+
+Policy:
+- Effective table name per iface is COALESCE(rt_table_name ,iface).
+- If that name exists in /etc/iproute2/rt_tables, reuse its number.
+- Else allocate first free number in [low ,high].
+- Writes DB only. Does NOT write rt_tables.
+"""
+
+from __future__ import annotations
+import argparse
+import sqlite3
+from pathlib import Path
+from typing import Dict ,Iterable ,List ,Optional ,Sequence ,Tuple
+
+import incommon as ic # for CLI path only
+
+RT_TABLES_PATH = Path("/etc/iproute2/rt_tables")
+
+
+def parse_rt_tables(path: Path) -> Tuple[List[str] ,Dict[str ,int] ,Dict[int ,str]]:
+ text = path.read_text() if path.exists() else ""
+ lines = text.splitlines()
+ name_to_num: Dict[str ,int] = {}
+ num_to_name: Dict[int ,str] = {}
+ for ln in lines:
+ s = ln.strip()
+ if not s or s.startswith("#"):
+ continue
+ parts = s.split()
+ if len(parts) >= 2 and parts[0].isdigit():
+ n = int(parts[0]); name = parts[1]
+ if name not in name_to_num and n not in num_to_name:
+ name_to_num[name] = n
+ num_to_name[n] = name
+ return (lines ,name_to_num ,num_to_name)
+
+
+def first_free_id(used: Iterable[int] ,low: int ,high: int) -> int:
+ used_set = set(u for u in used if low <= u <= high)
+ for n in range(low ,high+1):
+ if n not in used_set:
+ return n
+ raise RuntimeError(f"no free routing-table IDs in [{low},{high}]")
+
+
+def fetch_effective_ifaces(conn: sqlite3.Connection) -> List[Tuple[int ,str ,Optional[int]]]:
+ sql = """
+ SELECT i.id,
+ COALESCE(i.rt_table_name, i.iface) AS eff_name,
+ i.rt_table_id
+ FROM Iface i
+ ORDER BY i.id;
+ """
+ cur = conn.execute(sql)
+ rows = cur.fetchall()
+ return [
+ (int(r[0]) ,str(r[1]) ,(int(r[2]) if r[2] is not None else None))
+ for r in rows
+ ]
+
+
+def update_rt_ids(conn: sqlite3.Connection ,updates: Dict[int ,int]) -> int:
+ if not updates:
+ return 0
+ with conn:
+ for iface_id ,rt_id in updates.items():
+ conn.execute("UPDATE Iface SET rt_table_id=? WHERE id=?" ,(rt_id ,iface_id))
+ return len(updates)
+
+
+def plan_rt_id_assignments(
+ ifaces: Sequence[Tuple[int ,str ,Optional[int]]]
+ ,name_to_num_sys: Dict[str ,int]
+ ,existing_ids_in_db: Iterable[int]
+ ,low: int
+ ,high: int
+) -> Dict[int ,int]:
+ used_numbers = set(int(x) for x in existing_ids_in_db) | set(name_to_num_sys.values())
+ planned: Dict[int ,int] = {}
+
+ names_seen: Dict[str ,int] = {}
+ for iface_id ,eff_name ,_ in ifaces:
+ if eff_name in names_seen and names_seen[eff_name] != iface_id:
+ raise RuntimeError(
+ f"duplicate effective table name in DB: '{eff_name}' used by Iface.id {names_seen[eff_name]} and {iface_id}"
+ )
+ names_seen[eff_name] = iface_id
+
+ for iface_id ,eff_name ,current_id in ifaces:
+ if current_id is not None:
+ used_numbers.add(int(current_id))
+ continue
+ if eff_name in name_to_num_sys:
+ rt_id = int(name_to_num_sys[eff_name])
+ else:
+ rt_id = first_free_id(used_numbers ,low ,high)
+ planned[iface_id] = rt_id
+ used_numbers.add(rt_id)
+
+ return planned
+
+
+def assign_missing_rt_table_ids(
+ conn: sqlite3.Connection
+ ,low: int = 20000
+ ,high: int = 29999
+ ,dry_run: bool = False
+) -> Tuple[int ,Dict[int ,int] ,List[str]]:
+ _ ,name_to_num_sys ,_ = parse_rt_tables(RT_TABLES_PATH)
+ notes: List[str] = []
+
+ rows = fetch_effective_ifaces(conn)
+ existing_ids = [r[2] for r in rows if r[2] is not None]
+ planned = plan_rt_id_assignments(rows ,name_to_num_sys ,existing_ids ,low ,high)
+
+ if not planned:
+ return (0 ,{} ,["noop: all Iface.rt_table_id already set"])
+
+ for iface_id ,eff_name ,current in rows:
+ if iface_id in planned:
+ notes.append(f"Iface.id={iface_id} name='{eff_name}' rt_table_id: {current} -> {planned[iface_id]}")
+
+ if dry_run:
+ return (0 ,planned ,notes)
+
+ updated = update_rt_ids(conn ,planned)
+ return (updated ,planned ,notes)
+
+
+# --- thin CLI ---
+
+def main(argv=None) -> int:
+ ap = argparse.ArgumentParser()
+ ap.add_argument("--low" ,type=int ,default=20000)
+ ap.add_argument("--high" ,type=int ,default=29999)
+ ap.add_argument("--dry-run" ,action="store_true")
+ args = ap.parse_args(argv)
+ if args.low < 0 or args.high < args.low:
+ print(f"error: invalid range [{args.low},{args.high}]")
+ return 2
+ with ic.open_db() as conn:
+ updated ,_planned ,notes = assign_missing_rt_table_ids(conn ,low=args.low ,high=args.high ,dry_run=args.dry_run)
+ if notes:
+ print("\n".join(notes))
+ if not args.dry_run:
+ print(f"updated rows: {updated}")
+ return 0
+
+
+if __name__ == "__main__":
+ import sys
+ sys.exit(main())
--- /dev/null
+#!/usr/bin/env python3
+"""
+db_init_route_defaults.py
+
+Business API:
+ seed_default_routes(conn ,iface_names ,overwrite=False ,metric=None)
+ -> (inserted_count ,notes[list])
+
+What it does:
+- For each iface in iface_names, ensure a default route "0.0.0.0/0"
+ is present in the Route table (on_up=1, no via/metric/table override).
+- If overwrite=True, it first deletes existing Route rows for those ifaces,
+ then inserts the defaults.
+- Writes **DB only**. It does not touch the kernel or /etc/iproute2/rt_tables.
+
+Why:
+- Your apply script reads Route rows and emits `ip -4 route replace … table <rtname>`.
+ Seeding a per-iface default route makes policy-routed tables usable out of the box.
+"""
+
+from __future__ import annotations
+import argparse
+import sqlite3
+from typing import Dict ,Iterable ,List ,Optional ,Sequence ,Tuple
+
+# import helper to open DB when run as CLI; the business API accepts a conn
+try:
+ import incommon as ic # type: ignore
+except Exception:
+ ic = None # ok when used as a lib
+
+
+def _iface_map(conn: sqlite3.Connection ,iface_names: Sequence[str]) -> Dict[str ,int]:
+ """Return {iface_name -> iface_id} for provided names (must exist)."""
+ if not iface_names:
+ return {}
+ ph = ",".join("?" for _ in iface_names)
+ sql = f"""SELECT id ,iface FROM Iface WHERE iface IN ({ph}) ORDER BY id;"""
+ rows = conn.execute(sql ,tuple(iface_names)).fetchall()
+ found = {str(name): int(iid) for (iid ,name) in rows}
+ missing = [n for n in iface_names if n not in found]
+ if missing:
+ raise RuntimeError(f"iface(s) not found: {', '.join(missing)}")
+ return found
+
+
+def _existing_defaults(conn: sqlite3.Connection ,iface_ids: Iterable[int]) -> Dict[int ,bool]:
+ """Return {iface_id -> True/False} whether a default route row already exists (on_up=1)."""
+ ids = list(iface_ids)
+ if not ids:
+ return {}
+ ph = ",".join("?" for _ in ids)
+ sql = f"""
+ SELECT iface_id ,COUNT(1)
+ FROM Route
+ WHERE iface_id IN ({ph})
+ AND cidr='0.0.0.0/0'
+ AND on_up=1
+ GROUP BY iface_id;
+ """
+ out: Dict[int ,bool] = {i: False for i in ids}
+ for iid ,cnt in conn.execute(sql ,tuple(ids)).fetchall():
+ out[int(iid)] = int(cnt) > 0
+ return out
+
+
+def seed_default_routes(
+ conn: sqlite3.Connection
+ ,iface_names: Sequence[str]
+ ,overwrite: bool = False
+ ,metric: Optional[int] = None
+) -> Tuple[int ,List[str]]:
+ """
+ Upsert per-iface default routes into Route.
+
+ Inserts rows:
+ (iface_id ,cidr='0.0.0.0/0' ,via=NULL ,table_name=NULL ,metric=<metric or NULL> ,on_up=1 ,on_down=0)
+ """
+ if not iface_names:
+ raise RuntimeError("no interfaces provided")
+
+ id_map = _iface_map(conn ,iface_names)
+ iface_ids = list(id_map.values())
+ notes: List[str] = []
+ inserted = 0
+
+ with conn:
+ if overwrite:
+ ph = ",".join("?" for _ in iface_ids)
+ conn.execute(f"DELETE FROM Route WHERE iface_id IN ({ph});" ,tuple(iface_ids))
+ notes.append(f"cleared existing Route rows for: {', '.join(iface_names)}")
+
+ exists = _existing_defaults(conn ,iface_ids)
+
+ for name in iface_names:
+ iid = id_map[name]
+ if exists.get(iid):
+ notes.append(f"keep: default route already present for {name}")
+ continue
+ conn.execute(
+ """
+ INSERT INTO Route(iface_id ,cidr ,via ,table_name ,metric ,on_up ,on_down
+ ,created_at ,updated_at)
+ VALUES( ? ,'0.0.0.0/0' ,NULL ,NULL ,? ,1 ,0
+ ,strftime('%Y-%m-%dT%H:%M:%SZ','now') ,strftime('%Y-%m-%dT%H:%M:%SZ','now'))
+ """
+ ,(iid ,metric)
+ )
+ inserted += 1
+ notes.append(f"add: default route 0.0.0.0/0 for {name}")
+
+ return (inserted ,notes)
+
+
+# ---- thin CLI for ad-hoc use ----
+
+def main(argv: Optional[Sequence[str]] = None) -> int:
+ ap = argparse.ArgumentParser(description="Seed per-iface default Route rows.")
+ ap.add_argument("ifaces" ,nargs="+")
+ ap.add_argument("--overwrite" ,action="store_true")
+ ap.add_argument("--metric" ,type=int ,default=None)
+ args = ap.parse_args(argv)
+
+ if ic is None:
+ print("error: cannot locate incommon.open_db() for CLI use")
+ return 2
+
+ with ic.open_db() as conn:
+ n ,notes = seed_default_routes(conn ,args.ifaces ,overwrite=args.overwrite ,metric=args.metric)
+ if notes:
+ print("\n".join(notes))
+ print(f"inserted: {n}")
+ return 0
+
+
+if __name__ == "__main__":
+ import sys
+ sys.exit(main())
--- /dev/null
+# db_init_server_US.py
+from db_init_server_incommon import upsert_server
+
+def init_server_US(conn):
+ # Endpoint from the historical config; adjust if needed
+ return upsert_server(
+ conn,
+ client_iface="US",
+ server_name="US",
+ server_public_key="h8ZYEEVMForvv9p5Wx+9+eZ87t692hTN7sks5Noedw8=",
+ endpoint_host="35.194.71.194",
+ endpoint_port=443,
+ allowed_ips="0.0.0.0/0",
+ keepalive_s=25,
+ route_allowed_ips=0,
+ priority=100,
+ )
--- /dev/null
+#!/usr/bin/env python3
+# Helpers to upsert a row in server bound to a client iface.
+
+from __future__ import annotations
+import sqlite3
+from typing import Optional, Any, Dict
+import incommon as ic # provides open_db, get_client_id
+
+def upsert_server(conn: sqlite3.Connection,
+ *,
+ client_iface: str,
+ server_name: str,
+ server_public_key: str,
+ endpoint_host: str,
+ endpoint_port: int,
+ allowed_ips: str,
+ preshared_key: Optional[str] = None,
+ keepalive_s: Optional[int] = None,
+ route_allowed_ips: int = 0,
+ priority: int = 100) -> str:
+ cid = ic.get_client_id(conn, client_iface)
+
+ row = conn.execute(
+ "SELECT id, public_key, preshared_key, endpoint_host, endpoint_port, allowed_ips, "
+ " keepalive_s, route_allowed_ips, priority "
+ "FROM server WHERE iface_id=? AND name=? LIMIT 1;",
+ (cid, server_name),
+ ).fetchone()
+
+ desired = {
+ "public_key": server_public_key,
+ "preshared_key": preshared_key,
+ "endpoint_host": endpoint_host,
+ "endpoint_port": endpoint_port,
+ "allowed_ips": allowed_ips,
+ "keepalive_s": keepalive_s,
+ "route_allowed_ips": route_allowed_ips,
+ "priority": priority,
+ }
+
+ if row is None:
+ q = (
+ "INSERT INTO server (iface_id,name,public_key,preshared_key,"
+ " endpoint_host,endpoint_port,allowed_ips,keepalive_s,route_allowed_ips,priority,"
+ " created_at,updated_at) "
+ "VALUES (?,?,?,?,?,?,?,?,?,?, strftime('%Y-%m-%dT%H:%M:%SZ','now'), strftime('%Y-%m-%dT%H:%M:%SZ','now'));"
+ )
+ params = (cid, server_name, desired["public_key"], desired["preshared_key"],
+ desired["endpoint_host"], desired["endpoint_port"], desired["allowed_ips"],
+ desired["keepalive_s"], desired["route_allowed_ips"], desired["priority"])
+ cur = conn.execute(q, params); conn.commit()
+ return f"seeded: server(name={server_name}) client={client_iface} id={cur.lastrowid}"
+ else:
+ sid, pub, psk, host, port, allow, ka, route_ai, prio = row
+ current = {
+ "public_key": pub, "preshared_key": psk, "endpoint_host": host, "endpoint_port": port,
+ "allowed_ips": allow, "keepalive_s": ka, "route_allowed_ips": route_ai, "priority": prio
+ }
+ changes: Dict[str, Any] = {k: v for k, v in desired.items() if v != current.get(k)}
+ if not changes:
+ return f"ok: server(name={server_name}) client={client_iface} unchanged id={sid}"
+ sets = ", ".join(f"{k}=?" for k in changes)
+ params = list(changes.values()) + [cid, server_name]
+ conn.execute(
+ f"UPDATE server SET {sets}, updated_at=strftime('%Y-%m-%dT%H:%M:%SZ','now') "
+ "WHERE iface_id=? AND name=?;", params
+ )
+ conn.commit()
+ return f"updated: server(name={server_name}) client={client_iface} id={sid} " + " ".join(f"{k}={changes[k]}" for k in changes)
--- /dev/null
+# db_init_server_x6.py
+from db_init_server_incommon import upsert_server
+
+def init_server_x6(conn):
+ return upsert_server(
+ conn,
+ client_iface="x6",
+ server_name="x6",
+ server_public_key="pcbDlC1ZVoBYaN83/zAsvIvhgw0iQOL1YZKX5hcAqno=",
+ endpoint_host="66.248.243.113",
+ endpoint_port=51820,
+ allowed_ips="0.0.0.0/0",
+ keepalive_s=25,
+ route_allowed_ips=0,
+ priority=100,
+ )
--- /dev/null
+PRAGMA foreign_keys = ON;
+PRAGMA journal_mode = WAL;
+PRAGMA user_version = 300; -- v3.00: singular, capitalized tables; private_key removed
+
+-- meta first (so later INSERTs succeed)
+CREATE TABLE IF NOT EXISTS Meta (
+ key TEXT PRIMARY KEY
+ ,value TEXT NOT NULL
+);
+INSERT OR REPLACE INTO Meta(key,value) VALUES ('schema','wg-client-v3.00-Ifaces');
+INSERT OR IGNORE INTO Meta(key,value) VALUES ('subu_cidr','10.0.0.0/24');
+
+-- Iface, interface, device, netdevice, link — table of them
+CREATE TABLE IF NOT EXISTS Iface (
+ id INTEGER PRIMARY KEY
+ ,created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
+ ,updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
+ ,iface TEXT NOT NULL UNIQUE -- kernel interface name as shown by ip link (e.g., wg0, x6)
+ ,rt_table_id INTEGER -- e.g. 1002, unused
+ ,rt_table_name TEXT -- if NULL, default to iface (see view)
+ -- legacy caches (kept for compatibility; may be NULL)
+ ,bound_user TEXT
+ ,bound_uid INTEGER
+ ,local_address_cidr TEXT -- e.g. '10.8.0.2/32'
+ -- secrets: private key is NO LONGER stored in DB (lives under key/<machine>)
+ ,public_key TEXT CHECK (public_key IS NULL OR length(public_key) BETWEEN 43 AND 45)
+ ,mtu INTEGER
+ ,fwmark INTEGER
+ ,dns_mode TEXT NOT NULL DEFAULT 'none' CHECK (dns_mode IN ('none','static'))
+ ,dns_servers TEXT
+ ,autostart INTEGER NOT NULL DEFAULT 0
+);
+
+-- Server (one or more remote peers for an Iface)
+CREATE TABLE IF NOT EXISTS Server (
+ id INTEGER PRIMARY KEY
+ ,created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
+ ,updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
+ ,iface_id INTEGER NOT NULL REFERENCES Iface(id) ON DELETE CASCADE
+ ,name TEXT NOT NULL -- e.g. 'x6', 'US'
+ ,public_key TEXT NOT NULL CHECK (length(public_key) BETWEEN 43 AND 45)
+ ,preshared_key TEXT CHECK (preshared_key IS NULL OR length(preshared_key) BETWEEN 43 AND 45)
+ ,endpoint_host TEXT NOT NULL
+ ,endpoint_port INTEGER NOT NULL CHECK (endpoint_port BETWEEN 1 AND 65535)
+ ,allowed_ips TEXT NOT NULL -- typically '0.0.0.0/0'
+ ,keepalive_s INTEGER
+ ,route_allowed_ips INTEGER NOT NULL DEFAULT 1
+ ,priority INTEGER NOT NULL DEFAULT 100
+ ,UNIQUE(iface_id, name)
+);
+
+-- Route (optional extra routes applied by post-up script)
+CREATE TABLE IF NOT EXISTS Route (
+ id INTEGER PRIMARY KEY
+ ,created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
+ ,updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
+ ,iface_id INTEGER NOT NULL REFERENCES Iface(id) ON DELETE CASCADE
+ ,cidr TEXT NOT NULL
+ ,via TEXT
+ ,table_name TEXT
+ ,metric INTEGER
+ ,on_up INTEGER NOT NULL DEFAULT 1
+ ,on_down INTEGER NOT NULL DEFAULT 0
+);
+
+-- User (many linux users → one Iface)
+-- each user is bound to an iface via an 'ip rule add uidrange ..' command
+CREATE TABLE IF NOT EXISTS User (
+ id INTEGER PRIMARY KEY
+ ,created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
+ ,updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
+ ,iface_id INTEGER NOT NULL REFERENCES Iface(id) ON DELETE CASCADE
+ ,username TEXT NOT NULL
+ ,uid INTEGER -- cached UID if resolved
+ ,UNIQUE(iface_id, username)
+);
+
+-- Effective view (provides computed defaults like rt_table_name_eff)
+CREATE VIEW IF NOT EXISTS v_iface_effective AS
+SELECT
+ i.id
+ ,i.iface
+ ,COALESCE(i.rt_table_name, i.iface) AS rt_table_name_eff
+ ,i.local_address_cidr
+FROM Iface i;
+
+-- mtime triggers
+CREATE TRIGGER IF NOT EXISTS trg_iface_mtime
+AFTER UPDATE ON Iface FOR EACH ROW
+BEGIN
+ UPDATE Iface
+ SET updated_at=strftime('%Y-%m-%dT%H:%M:%SZ','now')
+ WHERE id=NEW.id;
+END;
+
+CREATE TRIGGER IF NOT EXISTS trg_server_mtime
+AFTER UPDATE ON Server FOR EACH ROW
+BEGIN
+ UPDATE Server
+ SET updated_at=strftime('%Y-%m-%dT%H:%M:%SZ','now')
+ WHERE id=NEW.id;
+END;
+
+CREATE TRIGGER IF NOT EXISTS trg_route_mtime
+AFTER UPDATE ON Route FOR EACH ROW
+BEGIN
+ UPDATE Route
+ SET updated_at=strftime('%Y-%m-%dT%H:%M:%SZ','now')
+ WHERE id=NEW.id;
+END;
+
+CREATE TRIGGER IF NOT EXISTS trg_user_binding_mtime
+AFTER UPDATE ON User FOR EACH ROW
+BEGIN
+ UPDATE User
+ SET updated_at=strftime('%Y-%m-%dT%H:%M:%SZ','now')
+ WHERE id=NEW.id;
+END;
--- /dev/null
+#!/usr/bin/env bash
+# db_init.sh — create/upgrade db/store by loading schema.sql (idempotent)
+
+set -euo pipefail
+DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
+DB="$DIR/db/store"
+SCHEMA="$DIR/db_schema.sql"
+
+command -v sqlite3 >/dev/null || { echo "❌ sqlite3 not found"; exit 1; }
+[[ -f "$SCHEMA" ]] || { echo "❌ schema file missing: $SCHEMA"; exit 1; }
+
+if [[ -f "$DB" ]]; then
+ ts="$(date -u +%Y%m%dT%H%M%SZ)"
+ cp -f -- "$DB" "$DB.bak-$ts"
+ echo "↩︎ Backed up existing DB to $DB.bak-$ts"
+fi
+
+sqlite3 -cmd '.bail on' "$DB" < "$SCHEMA"
+
+ver="$(sqlite3 "$DB" 'PRAGMA user_version;')"
+echo "✔ DB ready: $DB (user_version=$ver)"
+echo " Tables:"
+sqlite3 -noheader -list "$DB" "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name;"
--- /dev/null
+#!/usr/bin/env python3
+"""
+db_wipe.py
+
+Remove regular (non-directory) files in ./db, keeping the directory.
+
+Safety
+- Refuses to run if the target directory does not exist or its basename is not exactly "db".
+- Prints a plan, then asks "Are you sure? [y/N]" unless --force is used.
+- --dry-run prints what would be removed without deleting.
+- Hidden files (names starting with '.') are preserved by default; use --include-hidden to delete them too.
+
+Usage
+ ./db_wipe.py # plan + prompt, non-hidden files only, ./db next to this script
+ ./db_wipe.py --force # no prompt
+ ./db_wipe.py --dry-run # show what would be deleted
+ ./db_wipe.py --include-hidden
+ ./db_wipe.py --db /path/to/db
+"""
+
+from __future__ import annotations
+from pathlib import Path
+from typing import Iterable, List, Tuple
+import argparse
+import sys
+import os
+
+# ---------- business ----------
+
+def plan_db_wipe(db_dir: Path, include_hidden: bool = False) -> List[Path]:
+ """
+ Return a sorted list of file Paths (depth=1) to delete from db_dir.
+ """
+ if not db_dir.exists():
+ raise FileNotFoundError(f"not found: {db_dir}")
+ if not db_dir.is_dir():
+ raise NotADirectoryError(f"not a directory: {db_dir}")
+ if db_dir.name != "db":
+ raise RuntimeError(f"expected directory named 'db', got: {db_dir.name}")
+
+ def _is_hidden(p: Path) -> bool:
+ return p.name.startswith(".")
+
+ files = [p for p in db_dir.iterdir() if p.is_file()]
+ if not include_hidden:
+ files = [p for p in files if not _is_hidden(p)]
+
+ # Sort by name for stable output
+ return sorted(files, key=lambda p: p.name)
+
+
+def wipe_db(
+ db_dir: Path,
+ include_hidden: bool = False,
+ dry_run: bool = False,
+ assume_yes: bool = False,
+ _prompt_fn=input,
+) -> Tuple[int, List[str]]:
+ """
+ Delete planned files from db_dir. Returns (deleted_count, logs).
+ Does not prompt if assume_yes=True or dry_run=True.
+ """
+ targets = plan_db_wipe(db_dir, include_hidden=include_hidden)
+
+ logs: List[str] = []
+ script_dir = Path(__file__).resolve().parent
+
+ if not targets:
+ logs.append(f"db_wipe: no matching files in: {db_dir.relative_to(script_dir)}")
+ return (0, logs)
+
+ logs.append("db_wipe: plan")
+ for p in targets:
+ # Show path relative to script directory like the original
+ rel = p.resolve().relative_to(script_dir)
+ logs.append(f" delete: {rel}")
+
+ if dry_run:
+ logs.append("db_wipe: dry-run; no changes made")
+ return (0, logs)
+
+ if not assume_yes:
+ print("\n".join(logs))
+ try:
+ ans = _prompt_fn("Are you sure? [y/N] ").strip().lower()
+ except EOFError:
+ ans = ""
+ if ans not in ("y", "yes"):
+ logs.append("db_wipe: aborted")
+ return (0, logs)
+
+ deleted = 0
+ for p in targets:
+ try:
+ p.unlink(missing_ok=True) # py3.8+: if not available, catch FileNotFoundError
+ deleted += 1
+ except FileNotFoundError:
+ # Equivalent to rm -f
+ pass
+
+ rel_db = db_dir.resolve().relative_to(script_dir)
+ logs.append(f"db_wipe: deleted {deleted} file(s) from {rel_db}")
+ return (deleted, logs)
+
+
+# ---------- CLI wrapper ----------
+
+def _default_db_dir() -> Path:
+ return Path(__file__).resolve().parent / "db"
+
+def main(argv: list[str] | None = None) -> int:
+ ap = argparse.ArgumentParser(description="Remove regular files in ./db, keeping the directory.")
+ ap.add_argument("--db", default=str(_default_db_dir()), help="path to the db directory (default: ./db next to this script)")
+ ap.add_argument("--force", action="store_true", help="do not prompt for confirmation")
+ ap.add_argument("--dry-run", action="store_true", help="print what would be removed without deleting")
+ ap.add_argument("--include-hidden", action="store_true", help="include dotfiles (e.g., .gitignore)")
+ args = ap.parse_args(argv)
+
+ db_dir = Path(args.db)
+
+ try:
+ deleted, logs = wipe_db(
+ db_dir=db_dir,
+ include_hidden=args.include_hidden,
+ dry_run=args.dry_run,
+ assume_yes=args.force or args.dry_run,
+ )
+ if logs:
+ print("\n".join(logs))
+ return 0
+ except (FileNotFoundError, NotADirectoryError, RuntimeError) as e:
+ print(f"❌ {e}", file=sys.stderr)
+ return 1
+ except Exception as e:
+ print(f"❌ unexpected error: {e}", file=sys.stderr)
+ return 2
+
+if __name__ == "__main__":
+ sys.exit(main())
--- /dev/null
+#!/usr/bin/env python3
+"""
+deploy_StanleyPark.py — stop → install staged files → start (for selected ifaces)
+
+- Requires root. Exits after reporting *all* detected CLI/import errors.
+- Calls business functions directly:
+ * stop_clean_iface.stop_clean_ifaces(ifaces)
+ * install_staged_tree.install_staged_tree(stage_root, dest_root, create_dirs, skip_identical)
+ * start_iface.start_ifaces(ifaces)
+- If no ifaces provided on CLI, it discovers them from the stage tree.
+
+Usage:
+ sudo ./deploy_StanleyPark.py # discover ifaces from stage, stop→install→start
+ sudo ./deploy_StanleyPark.py x6 US # explicit iface list
+ sudo ./deploy_StanleyPark.py --no-stop # skip stop step
+ sudo ./deploy_StanleyPark.py --no-start # skip start step
+ sudo ./deploy_StanleyPark.py --stage ./stage --root / --create-dirs
+"""
+
+from __future__ import annotations
+from pathlib import Path
+from typing import List, Sequence, Tuple
+import argparse
+import os
+import sys
+import traceback
+
+ROOT = Path(__file__).resolve().parent
+sys.path.insert(0, str(ROOT)) # ensure sibling modules importable
+
+# --- lightweight staged-iface discovery (duplicated here to avoid importing internals) ---
+def _discover_ifaces_from_stage(stage_root: Path) -> List[str]:
+ names = set()
+ # from /etc/wireguard/<iface>.conf
+ wg_dir = stage_root / "etc" / "wireguard"
+ if wg_dir.is_dir():
+ for p in wg_dir.glob("*.conf"):
+ names.add(p.stem)
+ # from /etc/systemd/system/wg-quick@<iface>.service.d/
+ sysd = stage_root / "etc" / "systemd" / "system"
+ if sysd.is_dir():
+ for d in sysd.glob("wg-quick@*.service.d"):
+ nm = d.name # wg-quick@IFACE.service.d
+ at = nm.find("@")
+ dot = nm.find(".service.d")
+ if at != -1 and dot != -1 and dot > at:
+ names.add(nm[at+1:dot])
+ return sorted(names)
+
+def _is_root() -> bool:
+ try:
+ return os.geteuid() == 0
+ except AttributeError:
+ # Non-POSIX: best effort
+ return False
+
+def _validate_iface_name(n: str) -> bool:
+ # conservative: letters, digits, dash, underscore (WireGuard allows more, but keep it safe)
+ import re
+ return bool(re.fullmatch(r"[A-Za-z0-9_-]{1,32}", n))
+
+def _collect_errors(args) -> Tuple[List[str], List[str]]:
+ """
+ Return (errors, ifaces). Does *not* raise.
+ """
+ errors: List[str] = []
+
+ # Root required
+ if not _is_root():
+ errors.append("must be run as root (sudo)")
+
+ # Stage root
+ stage_root = Path(args.stage)
+ if not stage_root.exists():
+ errors.append(f"stage path does not exist: {stage_root}")
+
+ # Import modules
+ inst_mod = None
+ stop_mod = None
+ start_mod = None
+ try:
+ import install_staged_tree as inst_mod # type: ignore
+ except Exception as e:
+ errors.append(f"failed to import install_staged_tree: {e}")
+ try:
+ import stop_clean_iface as stop_mod # type: ignore
+ except Exception as e:
+ errors.append(f"failed to import stop_clean_iface: {e}")
+ try:
+ import start_iface as start_mod # type: ignore
+ except Exception as e:
+ errors.append(f"failed to import start_iface: {e}")
+
+ # Business functions existence (only if imports worked)
+ if inst_mod is not None and not hasattr(inst_mod, "install_staged_tree"):
+ errors.append("install_staged_tree module missing function: install_staged_tree")
+ if stop_mod is not None and not hasattr(stop_mod, "stop_clean_ifaces"):
+ errors.append("stop_clean_iface module missing function: stop_clean_ifaces")
+ if start_mod is not None and not hasattr(start_mod, "start_ifaces"):
+ errors.append("start_iface module missing function: start_ifaces")
+
+ # Ifaces
+ ifaces: List[str]
+ if args.ifaces:
+ ifaces = list(dict.fromkeys(args.ifaces)) # dedup preserve order
+ else:
+ ifaces = _discover_ifaces_from_stage(stage_root)
+ if not ifaces:
+ errors.append("no interfaces provided and none discovered from stage")
+ else:
+ bad = [n for n in ifaces if not _validate_iface_name(n)]
+ if bad:
+ errors.append(f"invalid iface name(s): {', '.join(bad)}")
+
+ return (errors, ifaces)
+
+def deploy_StanleyPark(
+ ifaces: Sequence[str],
+ stage_root: Path,
+ dest_root: Path,
+ create_dirs: bool,
+ skip_identical: bool,
+ do_stop: bool,
+ do_start: bool,
+) -> int:
+ """
+ Orchestration: stop (optional) → install → start (optional).
+ """
+ # Late imports so unit tests can monkeypatch easily
+ import install_staged_tree as inst
+ import stop_clean_iface as stopm
+ import start_iface as startm
+
+ print(f"Deploy plan:\n ifaces: {', '.join(ifaces)}\n stage: {stage_root}\n root: {dest_root}\n")
+
+ # Stop
+ if do_stop:
+ print(f"Stopping: {' '.join(ifaces)}")
+ try:
+ stop_logs = stopm.stop_clean_ifaces(ifaces)
+ if isinstance(stop_logs, (list, tuple)):
+ for line in stop_logs:
+ print(line)
+ except Exception:
+ print("warn: stop_clean_ifaces raised an exception (continuing):")
+ traceback.print_exc()
+
+ # Install
+ print("\nInstalling staged artifacts…")
+ try:
+ logs, detected = inst.install_staged_tree(
+ stage_root=stage_root,
+ dest_root=dest_root,
+ create_dirs=create_dirs,
+ skip_identical=skip_identical,
+ )
+ for line in logs:
+ print(line)
+ except Exception:
+ print("❌ install failed with exception:", file=sys.stderr)
+ traceback.print_exc()
+ return 2
+
+ # Start
+ if do_start:
+ # Prefer explicit ifaces; fall back to what installer detected
+ start_list = list(ifaces) if ifaces else list(detected)
+ if not start_list:
+ print("\nNo interfaces to start (none detected).")
+ else:
+ print(f"\nStarting: {' '.join(start_list)}")
+ try:
+ start_logs = startm.start_ifaces(start_list)
+ if isinstance(start_logs, (list, tuple)):
+ for line in start_logs:
+ print(line)
+ except Exception:
+ print("warn: start_ifaces raised an exception:", file=sys.stderr)
+ traceback.print_exc()
+ return 2
+
+ print("\n✓ Deploy complete.")
+ return 0
+
+def main(argv: List[str] | None = None) -> int:
+ ap = argparse.ArgumentParser(description="Deploy staged WG artifacts for StanleyPark (stop→install→start).")
+ ap.add_argument("ifaces", nargs="*", help="interfaces to manage (default: discover from stage)")
+ ap.add_argument("--stage", default=str(ROOT / "stage"), help="stage root (default: ./stage)")
+ ap.add_argument("--root", default="/", help="destination root (default: /)")
+ ap.add_argument("--create-dirs", action="store_true", help="create missing parent directories")
+ ap.add_argument("--no-skip-identical", action="store_true", help="always replace even if content identical")
+ ap.add_argument("--no-stop", action="store_true", help="do not stop interfaces before install")
+ ap.add_argument("--no-start", action="store_true", help="do not start interfaces after install")
+ args = ap.parse_args(argv)
+
+ # Collect all errors up front
+ errors, ifaces = _collect_errors(args)
+ if errors:
+ print("❌ deploy preflight found issue(s):", file=sys.stderr)
+ for e in errors:
+ print(f" - {e}", file=sys.stderr)
+ return 2
+
+ # Proceed
+ return deploy_StanleyPark(
+ ifaces=ifaces,
+ stage_root=Path(args.stage),
+ dest_root=Path(args.root),
+ create_dirs=args.create_dirs,
+ skip_identical=(not args.no_skip_identical),
+ do_stop=(not args.no_stop),
+ do_start=(not args.no_start),
+ )
+
+if __name__ == "__main__":
+ sys.exit(main())
--- /dev/null
+
+*
+!.gitignore
+
--- /dev/null
+#+TITLE: Interface vs Link vs Netdevice: a cynical field guide
+#+AUTHOR: Thomas & Nerith (session)
+#+LANGUAGE: en
+#+OPTIONS: toc:2 num:t
+
+* TL;DR
+In Linux networking (and in this doc), /interface/, /link/, and /netdevice/ can all refer to the same kernel object, e.g., wg0, x6, eth0. This conflation of terms came about because different tribes named the same thing differently.
+
+* What these words actually refer to
+- interface: common admin usage for referring to said kernel network object.
+- link: iproute2's vocabulary for said kernel network object (as in the command: `ip link show <name>` which gives information about said kernel network object).
+- netdevice: the kernel's term (struct net_device under the hood)
+
+* Where the words come from
+- Kernel engineers: /netdevice/ is the internal type that packets touch.
+- iproute2 authors: named their subcommands by subsystem; the L2-ish one is /link/. Hence ip link, ip addr, ip route, ip rule, ip neigh.
+- Humans: kept saying /interface/ because that was the word from ifconfig days and textbooks.
+
+* Cynic's guide to commands (map the terrain)
+- ip link show x6 → show properties of interface x6 (state, mtu, type, flags); not L3 addresses or routes (here /link/ == /interface/)
+- ip addr add A dev x6 → attach IP address A as a property of interface x6; this alone does not force source choice or egress path (here /dev/ = /interface/)
+- ip route add <prefix> dev x6 → write a route entry: map destination <prefix> → interface x6 (here /dev/ = /interface/)
+- ip rule add ... → write a policy rule that selects which routing table to consult
+- ip neigh ... → view/manage the neighbor cache (ARP/ND) per interface; maps L3 neighbor → L2 address; not routing
+
+
+* Device
+
+In computing, a /device/ is a piece of hardware. This includes printers, disk drives, memory cards, NIC cards, etc. An emulated device is software that is written to do the same thing as an actual device. This is sometimes done when compatibility with an old device is needed, but the old device is not available. A virtual device is software that is written to do the same thing as an imagined device. This is sometimes done to make available features that no physical device provides. A virtual device can also be state that is kept to support multiplexing a real device among many users, while giving each user the appearance of having sole ownership of said device. It is also common to call a device emulator a virtual device.
+
+In unix operating systems special files are used for interfacing to devices. Such an interface is often called a /device file/, which inevitably gets shortened to /device/.
+
+In networking, the kernel keeps state data for a device, and software drivers for shipping data to and from a device used for networking. Such software objects are often called /network devices/. The interface to the kernel used for talking to devices inevitably gets called a /device/.
+
+The terms, /physical device/, /device file/, and /netdevice/ are used to distinguish among the various possible meanings of /device/. We observe that generally terminology suffers due to a desire to flatten and thus simplify the discussion of the communication abstraction stack.
+
+* Interface
+
+An /interface/ is a shared surface between two systems. A user interface is the shared surface between a user and a system. E.g. the dashboard of a car is a user interface to the car.
+
+In software programming, an interface is a set of data and routines used for communication between software systems. For example, an API is a application programming interface.
+
+The OS provides named interfaces for communicating with network devices. Within the context of network programming, The literature will refer to such an interface as the /device/, /link/, or /interface/, the latter being the only term fitting the wider scope conventional definition.
+
+* Link
+
+A /link/ is a pathway that connects two systems. With an interface, there is no link, as the systems touch. A link has two interfaces, one on each end. Hence it was inevitable that a link interface would be called a /link/. And if the link connects to a device, then that link interface itself gets called a /device/.
+
+In iproute2 /link/ means the local endpoint object. Do not assume a remote counterpart exists just because you saw the word /link/.
+
+* Machine Peers
+
+- Client
+
+In these documents, the client machine is the local machine users are working on. Inevitably this gets shortened to /client/ in polite conversation. The example client used in this distribution is StanleyPark. That is a host name of a computer on our network.
+
+- Server
+
+In these document, the server machine is the remote machine that the write guard tunnels to. We have nicknames for machines. The example used here has the server nicknames of x6, and US.
+These nicknames are also used for the names of the client machine side interface that connects to the tunnel that leads to said server machine. The nickname is also used for the name of the routing table on the client that routes traffic go said wireguard tunnel.
+
+Hence, a nickname, like x6 or US, refers to a machine, an interface, and an IP route table.
+
+* Software Peers
+
+Programs that run as daemons while listening for connections, and once connected to,k they provide services, are server programs. The program that connects to said software server is called a client program. You guessed it, the terms 'server program' and 'client program' often get shortened to /server/ and /client/.
+
+
+* WireGuard mini-map
+We will use this terminology:
+
+- We will consider that WireGuard is conceptually a virtual device.
+- There can be many interfaces to said WireGuard device, taking names like wg0 or x6. Each has a keypair, a listen port, and a set of peers.
+- Config tools: "wg" (CLI, not a daemon), "wg-quick" (oneshot helper per interface).
+- Reality check:
+ - ip link show type wireguard → lists all WG interfaces
+ - ip -d link show x6 → detailed information about the x6 interface
+ - wg show x6 → peer/crypto state for the x6 interface
+
+* Sanity tests you can run
+#+begin_src sh
+# list all WireGuard interfaces
+ip link show type wireguard
+
+# detailed view of one interface
+ip -d link show x6
+
+# see handshake and byte counters
+wg show x6
+
+# show L3 addresses bound to an interface
+ip addr show dev x6
+
+# show routes in a named table (if you use policy routing)
+ip route show table x6
+#+end_src
+
+
--- /dev/null
+
+1. create/update the client configuration files.
+
+ These are the configuration files for the machine called StanleyPark, which is on
+ our local network. (Yes, we capitalize popper nouns, and thus have some "bad names".)
+
+ db_init_StanleyPark.py
+ stage_StanleyPark
+ deploy_StanleyPark
+
+ They are in Python.
+
+2. Wipe the database and the stage.
+
+ Wiping the db will erase keys and any other client configurations. This does not effect already installed configuration files. Also, the database can always be rebuilt by running the client configuration files again.
+
+ ./db_wipe.py
+ ./stage_wipe.py
+
+3. Setup the database
+
+ ./db_init_StanleyPark
+
+4. setup the keys
+
+ ./key_generate StanleyPark.py
+ ./key_server_set.py <sever's public key>
+
+ to see the keys in the database
+
+ ./ls_key.py
+
+ if the database was wiped, it will be necessary to key_generate again. Currently
+ there is one client machine key pair.
+
+5. stage the configuration files to be installed
+
+ ./stage_StanleyPark
+
+ check them make sure they are what you want
+
+6. install the staged files
+
+ ./deploy_StanlwayPark
+
+
+The goal here is work towards each subu as a container, with its networking tunneled
+to the specified interface. Perhaps the configuration scripts should be subu based instead of client machine based. Perhaps in the next version.
+
+
+
--- /dev/null
+-New interface:
+
+copy `db_init_iface_x6.py` to `db_init_iface_<name>.py`, replacing <name> with the name of the interface. Then edit `db_init_iface_<name>.py`
+
+-New Client
+
+-New User
+
+
--- /dev/null
+
+From the point of view of setting up the client (we are in the client setup directory after all):
+
+1. login to the server and get the server public key.
+
+ This public key is written into the db_init_iface_<server_name>>.py configuration file. Note the examples `db_init_iface_US.py` and `db_init_iface_x6`. `x6` and `US` are nicknames for two servers. These nicknames are also used for the interface names.
+
+ Note that the server private key remains on the server. The client has no knowledge of the server private key. It is not entered anywhere in the client configuration.
+
+2. run the program `key_client_generate1
+
+ This will print the client public key. It will also place a copy in the database.
+
+ This will write the client private key into a local directory called `key/`. The admin need not do anything concerning this key. Scripts that need it will find it in the 'key/' directory.
--- /dev/null
+
+stage programs write to the stage directory. Later install copies from the stage
+directory to a provided root, which if it is the local machine, will be '/'.
+
+
+* stage_IP_register_route_table.py
+
+ stages a replacement etc/iproute2/rt_tables file.
+
+* stage_wg_conf.py
+
+ stages etc/wireguard/ conf files for the configured interfaces
+
+* stage_IP_routes_script.py
+
+ 1. stages a shell script that when called writes the IP rule table. Said script binds UIDs to route tables.
+
+ 2. stages a priority 10 systemd guard systemd dropin that will call said shell script when
+ WireGuard is started or restarted.
+
+* stage_IP_rules_script.py
+
+ 1. stages a shell script that when called writes the required IP route tables
+
+ 2. stages a priority 20 systemd guard systemd dropin that will call said shell script when
+ WireGuard is started or restarted.
+
+* stage_client_StanleyPark.py
+
+ A local use client machine configuration file. Calls the other stage programs
+ while providing the correct parameters for configuring wireguard on the
+ machine StanleyPark. Typically these will be a database connection and a list of
+ users.
+
+ The admin will write such a file for each machine he/she/ai is configuring.
+
+* stage_incommon.py
+
+ Utility functions for stage programs.
+
+
+
--- /dev/null
+#!/usr/bin/env python3
+# iface_down.py — stop wg-quick@<iface> and remove uid→rt rules
+
+from __future__ import annotations
+import os, sys, sqlite3, subprocess
+import incommon as ic # provides open_db()
+
+def sh(args: list[str], check: bool=False) -> subprocess.CompletedProcess:
+ return subprocess.run(args, text=True, capture_output=True, check=check)
+
+def get_rt_table_name(conn: sqlite3.Connection, iface: str) -> str:
+ row = conn.execute(
+ "SELECT rt_table_name_eff FROM v_client_effective WHERE iface=? LIMIT 1;",
+ (iface,)
+ ).fetchone()
+ if not row:
+ raise RuntimeError(f"Interface not found in DB: {iface}")
+ return str(row[0])
+
+def get_bound_uids(conn: sqlite3.Connection, iface: str) -> list[int]:
+ rows = conn.execute(
+ """SELECT ub.uid
+ FROM User ub
+ JOIN Iface c ON c.id = ub.iface_id
+ WHERE c.iface=? AND ub.uid IS NOT NULL
+ ORDER BY ub.uid;""",
+ (iface,)
+ ).fetchall()
+ return [int(r[0]) for r in rows]
+
+def iface_down(iface: str) -> str:
+ if os.geteuid() != 0:
+ raise PermissionError("This script must be run as root.")
+
+ # Stop interface (ignore failure)
+ sh(["systemctl", "stop", f"wg-quick@{iface}"])
+
+ # DB lookups
+ with ic.open_db() as conn:
+ table = get_rt_table_name(conn, iface)
+ uids = get_bound_uids(conn, iface)
+
+ # Snapshot rules once for existence checks
+ rules = sh(["ip", "-4", "rule", "list"]).stdout
+
+ removed = 0
+ for uid in uids:
+ needle = f"uidrange {uid}-{uid} "
+ if needle in rules and f" lookup {table}" in rules:
+ # Try to delete; ignore failure to keep idempotence
+ sh(["ip", "-4", "rule", "del", "uidrange", f"{uid}-{uid}", "table", table])
+ sh(["logger", f"iface_down: removed uid {uid} rule for table {table}"])
+ removed += 1
+
+ return f"✅ {iface} stopped; removed {removed} uid rules from table {table}."
+
+def main(argv: list[str]) -> int:
+ if len(argv) != 1:
+ print(f"Usage: {os.path.basename(sys.argv[0])} <iface>", file=sys.stderr)
+ return 2
+ iface = argv[0]
+ try:
+ msg = iface_down(iface)
+ except (PermissionError, FileNotFoundError, sqlite3.Error, RuntimeError) as e:
+ print(f"❌ {e}", file=sys.stderr); return 1
+ print(msg); return 0
+
+if __name__ == "__main__":
+ sys.exit(main(sys.argv[1:]))
--- /dev/null
+#!/usr/bin/env python3
+# iface_status.py — show unit/wg/route/uid-rule status for <iface>
+
+from __future__ import annotations
+import os, sys, shutil, sqlite3, subprocess, time
+from pathlib import Path
+import incommon as ic # provides open_db()
+
+# --- small shell helpers -----------------------------------------------------
+
+def sh(args: list[str]) -> subprocess.CompletedProcess:
+ """Run command; never raise; text mode; capture stdout/stderr."""
+ return subprocess.run(args, text=True, capture_output=True)
+
+def which(cmd: str) -> bool:
+ return shutil.which(cmd) is not None
+
+def print_block(title: str, body: str | None = None) -> None:
+ print(f"=== {title} ===")
+ if body is not None and body != "":
+ print(body.rstrip())
+ print()
+
+# --- DB helpers ---------------------------------------------------------------
+
+def get_rt_table_name(conn: sqlite3.Connection, iface: str) -> str:
+ row = conn.execute(
+ "SELECT rt_table_name_eff FROM v_client_effective WHERE iface=? LIMIT 1;",
+ (iface,)
+ ).fetchone()
+ if not row:
+ raise RuntimeError(f"Interface not found in DB: {iface}")
+ return str(row[0])
+
+def get_bound_users(conn: sqlite3.Connection, iface: str) -> list[tuple[str, int | None]]:
+ rows = conn.execute(
+ """SELECT ub.username, ub.uid
+ FROM User ub
+ JOIN Iface c ON c.id = ub.iface_id
+ WHERE c.iface=?
+ ORDER BY ub.username;""",
+ (iface,)
+ ).fetchall()
+ return [(str(u), (None if v is None else int(v))) for (u, v) in rows]
+
+# --- core --------------------------------------------------------------------
+
+def iface_status(iface: str) -> int:
+ # DB open + resolve table name early for helpful errors
+ with ic.open_db() as conn:
+ table = get_rt_table_name(conn, iface)
+
+ # systemd status
+ en = sh(["systemctl", "is-enabled", f"wg-quick@{iface}"])
+ ac = sh(["systemctl", "is-active", f"wg-quick@{iface}"])
+ sys_body = "\n".join([
+ (en.stdout.strip() if en.stdout.strip() else "").strip(),
+ (ac.stdout.strip() if ac.stdout.strip() else "").strip(),
+ ]).strip()
+ print_block(f"systemd: wg-quick@{iface}", sys_body)
+
+ # wg presence + handshake age
+ wg_title = f"wg: {iface}"
+ if which("wg"):
+ if Path(f"/sys/class/net/{iface}").exists():
+ lines: list[str] = ["(present)"]
+ # Try sudo-less handshake read; if not permitted, show hint
+ hs_try = sh(["sudo", "-n", "wg", "show", iface, "latest-handshakes"])
+ if hs_try.returncode == 0 and hs_try.stdout.strip():
+ # expected format: "<pubkey> <epoch>"
+ epoch_part = hs_try.stdout.strip().split()[-1]
+ try:
+ hs = int(epoch_part)
+ if hs > 0:
+ age = int(time.time()) - hs
+ lines.append(f"latest-handshake: {age}s ago")
+ else:
+ lines.append("latest-handshake: none")
+ except ValueError:
+ lines.append("latest-handshake: unknown")
+ else:
+ prog = Path(sys.argv[0]).name or "iface_status.py"
+ lines.append(f"⚠ need sudo to read peers/handshake (try: sudo {prog} {iface})")
+ print_block(wg_title, "\n".join(lines))
+ else:
+ print_block(wg_title, "(interface down or not present)")
+ else:
+ print_block(wg_title, "wg tool not found.")
+
+ # route for table
+ rt = sh(["ip", "-4", "route", "show", "table", table])
+ print_block(f"route: table {table}", rt.stdout if rt.stdout else "")
+
+ # uid rules targeting table
+ rules = sh(["ip", "-4", "rule", "show"]).stdout.splitlines()
+ hits = [ln for ln in rules if f"lookup {table}" in ln]
+ print_block(f"uid rules → table {table}", "\n".join(hits) if hits else "(none)")
+
+ # DB: bound users
+ with ic.open_db() as conn:
+ bound = get_bound_users(conn, iface)
+
+ if not bound:
+ print_block(f"DB: bound users for {iface}", "(none)")
+ else:
+ # simple column render
+ header = ("username", "uid")
+ rows = [(u, ("" if v is None else str(v))) for (u, v) in bound]
+ w1 = max(len(header[0]), *(len(r[0]) for r in rows))
+ w2 = max(len(header[1]), *(len(r[1]) for r in rows))
+ body_lines = [f"{header[0]:<{w1}} {header[1]:<{w2}}",
+ f"{'-'*w1} {'-'*w2}"]
+ body_lines += [f"{u:<{w1}} {v:<{w2}}" for (u, v) in rows]
+ print_block(f"DB: bound users for {iface}", "\n".join(body_lines))
+
+ return 0
+
+# --- cli ---------------------------------------------------------------------
+
+def main(argv: list[str]) -> int:
+ if len(argv) != 1:
+ print(f"Usage: {Path(sys.argv[0]).name} <iface>", file=sys.stderr)
+ return 2
+ try:
+ return iface_status(argv[0])
+ except (sqlite3.Error, FileNotFoundError, RuntimeError) as e:
+ print(f"❌ {e}", file=sys.stderr)
+ return 1
+
+if __name__ == "__main__":
+ sys.exit(main(sys.argv[1:]))
--- /dev/null
+#!/usr/bin/env bash
+# iface_up.sh — enable/start wg-quick@<iface>
+set -euo pipefail
+
+(( $# == 1 )) || { echo "Usage: $0 <iface>"; exit 2; }
+IFACE="$1"
+
+# Require root because systemd + net ops
+if [[ $EUID -ne 0 ]]; then
+ echo "❌ This script must be run as root." >&2
+ exit 1
+fi
+
+# Sanity: config must exist
+[[ -r "/etc/wireguard/${IFACE}.conf" ]] || {
+ echo "❌ Missing: /etc/wireguard/${IFACE}.conf"; exit 1; }
+
+# Bring it up
+systemctl enable --now "wg-quick@${IFACE}"
+
+# Quick confirmation
+systemctl is-active --quiet "wg-quick@${IFACE}" \
+ && echo "✅ ${IFACE} is active." \
+ || { echo "⚠️ ${IFACE} failed to start."; exit 1; }
--- /dev/null
+#!/usr/bin/env python3
+# Shared helpers (DB path + small SQLite utilities). No side effects on import.
+
+from __future__ import annotations
+from pathlib import Path
+import sqlite3
+from typing import Iterable, Sequence, Any, List, Tuple, Optional
+
+# Base paths
+ROOT_DIR: Path = Path(__file__).resolve().parent
+DB_PATH: Path = ROOT_DIR / "db" / "store" # default location
+
+def open_db(path: Optional[Path]=None) -> sqlite3.Connection:
+ p = path or DB_PATH
+ if not p.exists():
+ raise FileNotFoundError(f"DB not found: {p}")
+ conn = sqlite3.connect(p.as_posix())
+ # enforce FK; journal mode is set by schema, but enabling FK here is harmless and desired
+ conn.execute("PRAGMA foreign_keys = ON;")
+ return conn
+
+def rows(conn: sqlite3.Connection, sql: str, params: Sequence[Any]=()) -> List[tuple]:
+ cur = conn.execute(sql, tuple(params))
+ out = cur.fetchall()
+ cur.close()
+ return out
+
+def get_client_id(conn: sqlite3.Connection, iface: str) -> int:
+ r = conn.execute("SELECT id FROM Iface WHERE iface=? LIMIT 1;", (iface,)).fetchone()
+ if not r: raise RuntimeError(f"client iface not found: {iface}")
+ return int(r[0])
+
+# Tx helpers (optional but nice)
+def begin_immediate(conn: sqlite3.Connection) -> None:
+ conn.execute("BEGIN IMMEDIATE;")
+
+def commit(conn: sqlite3.Connection) -> None:
+ conn.commit()
+
--- /dev/null
+#!/usr/bin/env bash
+# inspect.sh — prime sudo only if needed, then run inspect_1.py
+set -euo pipefail
+SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
+
+# If not primed, prompt via the tty (works inside Emacs shell without echoing)
+if ! sudo -n true 2>/dev/null; then
+ sudo echo -n
+fi
+
+sudo python3 "${SCRIPT_DIR}/inspect_1.py" "$@"
--- /dev/null
+#!/usr/bin/env python3
+# inspect.py — deep health: DB + systemd/drop-in + wg + route + uid rules + DNS plug
+
+from __future__ import annotations
+import os, sys, re, time, shutil, sqlite3, subprocess
+from pathlib import Path
+from typing import List, Tuple, Optional
+import incommon as ic # open_db()
+
+# ---------- small shell helpers ----------
+
+def sh(args: List[str]) -> subprocess.CompletedProcess:
+ """Run command; never raise; text mode; capture stdout/stderr."""
+ return subprocess.run(args, text=True, capture_output=True)
+
+def which(cmd: str) -> bool:
+ return shutil.which(cmd) is not None
+
+def print_block(title: str, body: str | None = None) -> None:
+ print(f"=== {title} ===")
+ if body: print(body.rstrip())
+ print()
+
+def format_table(headers: List[str], rows: List[Tuple]) -> str:
+ cols = list(zip(*([headers] + [[str(c) for c in r] for r in rows]))) if rows else [headers]
+ widths = [max(len(x) for x in col) for col in cols]
+ line = lambda r: " ".join(f"{str(c):<{w}}" for c, w in zip(r, widths))
+ out = [line(headers), line(tuple("-"*w for w in widths))]
+ for r in rows: out.append(line(tuple("" if c is None else str(c) for c in r)))
+ return "\n".join(out)
+
+# ---------- DB helpers ----------
+
+def client_row(conn: sqlite3.Connection, iface: str):
+ return conn.execute("""
+ SELECT c.iface,
+ v.rt_table_name_eff AS rt_table_name,
+ c.bound_user, c.bound_uid,
+ c.local_address_cidr,
+ substr(c.public_key,1,10)||'…' AS pub,
+ c.autostart, c.updated_at
+ FROM Iface c
+ JOIN v_client_effective v ON v.id=c.id
+ WHERE c.iface=? LIMIT 1;
+ """,(iface,)).fetchone()
+
+def server_rows(conn: sqlite3.Connection, iface: str) -> List[tuple]:
+ return conn.execute("""
+ SELECT s.name,
+ s.endpoint_host || ':' || s.endpoint_port AS endpoint,
+ substr(s.public_key,1,10)||'…' AS pub,
+ s.allowed_ips, s.keepalive_s, s.priority
+ FROM server s
+ JOIN Iface c ON c.id=s.iface_id
+ WHERE c.iface=?
+ ORDER BY s.priority, s.name;
+ """,(iface,)).fetchall()
+
+def rtname_and_cidr(conn: sqlite3.Connection, iface: str) -> Tuple[str, str]:
+ row = conn.execute("SELECT rt_table_name_eff, local_address_cidr FROM v_client_effective WHERE iface=? LIMIT 1;",(iface,)).fetchone()
+ if not row: raise RuntimeError(f"Interface not found in DB: {iface}")
+ return str(row[0]), str(row[1])
+
+def bound_uids(conn: sqlite3.Connection, iface: str) -> List[int]:
+ rows = conn.execute("""
+ SELECT ub.uid
+ FROM User ub
+ JOIN Iface c ON c.id=ub.iface_id
+ WHERE c.iface=? AND ub.uid IS NOT NULL AND ub.uid!=''
+ ORDER BY ub.uid;
+ """,(iface,)).fetchall()
+ return [int(r[0]) for r in rows]
+
+def legacy_bound_uid(conn: sqlite3.Connection, iface: str) -> Optional[int]:
+ r = conn.execute("SELECT bound_uid FROM Iface WHERE iface=? AND bound_uid IS NOT NULL AND bound_uid!='';",(iface,)).fetchone()
+ return (int(r[0]) if r and r[0] is not None and str(r[0])!="" else None)
+
+def primary_server_ep_and_allowed(conn: sqlite3.Connection, iface: str) -> Tuple[str,str]:
+ ep = conn.execute("""
+ SELECT s.endpoint_host||':'||s.endpoint_port
+ FROM server s JOIN Iface c ON c.id=s.iface_id
+ WHERE c.iface=? ORDER BY s.priority, s.name LIMIT 1;
+ """,(iface,)).fetchone()
+ allow = conn.execute("""
+ SELECT s.allowed_ips
+ FROM server s JOIN Iface c ON c.id=s.iface_id
+ WHERE c.iface=? ORDER BY s.priority, s.name LIMIT 1;
+ """,(iface,)).fetchone()
+ return (str(ep[0]) if ep and ep[0] else ""), (str(allow[0]) if allow and allow[0] else "")
+
+# ---------- file checks ----------
+
+def check_file(path: str, mode_oct: int, user: str, group: str) -> str:
+ p = Path(path)
+ if not p.exists(): return f"WARN: missing {path}"
+ try:
+ st = p.stat()
+ actual_mode = st.st_mode & 0o777
+ import pwd, grp
+ u = pwd.getpwuid(st.st_uid).pw_name
+ g = grp.getgrgid(st.st_gid).gr_name
+ want = f"{oct(mode_oct)[2:]} {user} {group}"
+ got = f"{oct(actual_mode)[2:]} {u} {g}"
+ if actual_mode==mode_oct and u==user and g==group:
+ return f"OK: {path} ({got})"
+ else:
+ return f"WARN: {path} perms/owner {got} (expected {want})"
+ except Exception as e:
+ return f"WARN: {path} stat error: {e}"
+
+def rt_tables_has(table: str) -> bool:
+ try:
+ txt = Path("/etc/iproute2/rt_tables").read_text()
+ except Exception:
+ return False
+ pat = re.compile(rf"^\s*\d+\s+{re.escape(table)}\s*$", re.M)
+ return pat.search(txt) is not None
+
+# ---------- wg helpers ----------
+
+def wg_present(iface: str) -> bool:
+ return Path(f"/sys/class/net/{iface}").exists()
+
+def wg_handshake_age_sec(iface: str) -> Optional[int]:
+ cp = sh(["sudo","-n","wg","show",iface,"latest-handshakes"])
+ if cp.returncode != 0 or not cp.stdout.strip(): return None
+ try:
+ epoch = int(cp.stdout.split()[-1])
+ if epoch<=0: return None
+ return int(time.time()) - epoch
+ except Exception:
+ return None
+
+def wg_endpoints_joined(iface: str) -> str:
+ cp = sh(["sudo","-n","wg","show",iface,"endpoints"])
+ if cp.returncode != 0: return ""
+ vals = []
+ for line in cp.stdout.splitlines():
+ parts = line.split()
+ if len(parts)>=2: vals.append(parts[1])
+ return "".join(vals)
+
+def wg_allowedips_csv(iface: str) -> str:
+ cp = sh(["sudo","-n","wg","show",iface,"allowed-ips"])
+ if cp.returncode != 0: return ""
+ vals=[]
+ for line in cp.stdout.splitlines():
+ parts = line.split()
+ if len(parts)>=2: vals.append(parts[1])
+ return ",".join(vals)
+
+# ---------- redact helpers ----------
+
+def redact_conf(text: str) -> str:
+ text = re.sub(r"^(PrivateKey\s*=\s*).+$", r"\1<redacted>", text, flags=re.M)
+ text = re.sub(r"^(PresharedKey\s*=\s*).+$", r"\1<redacted>", text, flags=re.M)
+ return text
+
+def sudo_cat(path: str) -> Optional[str]:
+ cp = sh(["sudo","-n","cat", path])
+ if cp.returncode != 0: return None
+ return cp.stdout
+
+# ---------- main inspect ----------
+
+def inspect_iface(iface: str) -> int:
+ # DB open
+ with ic.open_db() as conn:
+ crow = client_row(conn, iface)
+ if not crow:
+ print(f"❌ client row not found for iface={iface}", file=sys.stderr); return 1
+ srv_rows = server_rows(conn, iface)
+ rtname, local_cidr = rtname_and_cidr(conn, iface)
+ local_ip = local_cidr.split("/",1)[0]
+ db_ep, db_allowed = primary_server_ep_and_allowed(conn, iface)
+ uids = bound_uids(conn, iface)
+ leg = legacy_bound_uid(conn, iface)
+ if leg is not None: uids.append(leg)
+
+ # DB snapshot
+ print("=== DB: client '{}' ===".format(iface))
+ headers = ["iface","rt_table_name","bound_user","bound_uid","local_address_cidr","pub","autostart","updated_at"]
+ print(format_table(headers, [crow]))
+ print()
+ print(f"--- server for '{iface}' ---")
+ if srv_rows:
+ print(format_table(["name","endpoint","pub","allowed_ips","keepalive_s","priority"], srv_rows))
+ else:
+ print("(none)")
+ print()
+
+ # systemd + drop-in
+ print(f"=== systemd: wg-quick@{iface} ===")
+ if which("systemctl"):
+ en = sh(["systemctl","is-enabled",f"wg-quick@{iface}"]).stdout.strip()
+ ac = sh(["systemctl","is-active", f"wg-quick@{iface}"]).stdout.strip()
+ if en: print(en)
+ if ac: print(ac)
+ drop_dir = f"/etc/systemd/system/wg-quick@{iface}.service.d"
+ # common filenames: legacy 'restart.conf' or new '10-postup-IP-scripts.conf'
+ candidates = [f"{drop_dir}/restart.conf", f"{drop_dir}/10-postup-IP-scripts.conf"]
+ print(f"-- drop-in expected: {candidates[0]}")
+ found = [p for p in candidates if Path(p).is_file()]
+ if found:
+ print("OK: drop-in file exists")
+ else:
+ print("WARN: drop-in file missing or unreadable")
+ dpaths = sh(["systemctl","show",f"wg-quick@{iface}","-p","DropInPaths","--value"]).stdout.strip()
+ if dpaths and any(p in dpaths for p in candidates):
+ print("OK: drop-in is loaded by systemd")
+ else:
+ print("WARN: drop-in not reported by systemd (need daemon-reload?)")
+ else:
+ print("(systemctl not available)")
+ print()
+
+ # installed targets
+ print("=== installed targets ===")
+ print(check_file(f"/etc/wireguard/{iface}.conf", 0o600, "root", "root"))
+ # check both possible drop-in names
+ d1 = check_file(f"/etc/systemd/system/wg-quick@{iface}.service.d/restart.conf", 0o644, "root", "root")
+ d2 = check_file(f"/etc/systemd/system/wg-quick@{iface}.service.d/10-postup-IP-scripts.conf", 0o644, "root", "root")
+ # show OK if either exists
+ if d1.startswith("OK") or d2.startswith("OK"):
+ print(d1 if d1.startswith("OK") else d2)
+ else:
+ # print both warnings for clarity
+ print(d1); print(d2)
+ print(check_file("/usr/local/bin/IP_rule_add_UID.sh", 0o500, "root", "root"))
+ print(check_file(f"/usr/local/bin/route_init_{iface}.sh", 0o500, "root", "root"))
+ print("OK: rt_tables entry for '{}' present".format(rtname) if rt_tables_has(rtname)
+ else f"WARN: rt_tables entry for '{rtname}' missing")
+ print()
+
+ # wg + addr
+ print(f"=== wg + addr: {iface} ===")
+ present = wg_present(iface)
+ print("(present)" if present else "(interface down or not present)")
+ if present:
+ has_ip = sh(["ip","-4","addr","show","dev",iface]).stdout.find(f" {local_ip}/")>=0
+ print(f"OK: {iface} has {local_ip}" if has_ip else f"WARN: {iface} missing {local_ip}")
+ if which("wg"):
+ age = wg_handshake_age_sec(iface)
+ if age is None:
+ print("latest-handshake: none")
+ else:
+ print(f"latest-handshake: {age}s ago")
+ if age>600: print("WARN: handshake is stale (>600s)")
+ # endpoint and allowed-ips comparison (requires sudo)
+ wg_ep = wg_endpoints_joined(iface)
+ if db_ep:
+ if wg_ep == db_ep:
+ print(f"OK: endpoint matches DB ({wg_ep})")
+ else:
+ print(f"WARN: endpoint mismatch (wg={wg_ep or 'n/a'} db={db_ep})")
+ wg_allowed = wg_allowedips_csv(iface)
+ if db_allowed:
+ if wg_allowed == db_allowed:
+ print(f"OK: allowed-ips match DB ({wg_allowed})")
+ else:
+ print(f"WARN: allowed-ips mismatch (wg={wg_allowed or 'n/a'} db={db_allowed})")
+ else:
+ prog = Path(sys.argv[0]).name
+ print(f"⚠ need sudo for handshake/peer checks (try: sudo {prog} {iface})")
+ print()
+
+ # route table checks
+ print(f"=== route: table {rtname} ===")
+ rt = sh(["ip","-4","route","show","table",rtname]).stdout
+ print(rt or "")
+ def_ok = any(re.match(rf"^default\s+dev\s+{re.escape(iface)}\b", ln) for ln in rt.splitlines())
+ bh_ok = any(re.match(r"^blackhole\s+default\b", ln) for ln in rt.splitlines())
+ print("OK: default -> {}".format(iface) if def_ok else f"WARN: default route not on {iface}")
+ print("OK: blackhole guard present" if bh_ok else "WARN: blackhole guard missing")
+ print()
+
+ # uid rules
+ print(f"=== ip rules for bound UIDs → table {rtname} ===")
+ rules_txt = sh(["ip","-4","rule","show"]).stdout
+ if uids:
+ for u in uids:
+ if re.search(rf"uidrange {u}-{u}.*lookup {re.escape(rtname)}", rules_txt):
+ print(f"OK: uid {u} -> table {rtname}")
+ else:
+ print(f"WARN: missing rule for uid {u} -> table {rtname}")
+ else:
+ print("(no bound UIDs recorded)")
+ print()
+ print(f"=== ip rule lines targeting '{rtname}' (all) ===")
+ hit_lines = [ln for ln in rules_txt.splitlines() if f"lookup {rtname}" in ln]
+ print("\n".join(hit_lines) if hit_lines else "(none)")
+ print()
+
+ # DNS leak plug: iptables redirects
+ print("=== iptables nat OUTPUT DNS redirect (→ 127.0.0.1:53) ===")
+ if which("iptables"):
+ nat = sh(["iptables","-t","nat","-S","OUTPUT"]).stdout
+ r_udp = re.search(r"-A OUTPUT.*-p udp .* --dport 53 .* REDIRECT .*to-ports 53", nat or "")
+ r_tcp = re.search(r"-A OUTPUT.*-p tcp .* --dport 53 .* REDIRECT .*to-ports 53", nat or "")
+ print(r_udp.group(0) if r_udp else "WARN: no UDP:53 redirect")
+ print(r_tcp.group(0) if r_tcp else "WARN: no TCP:53 redirect")
+ else:
+ print("(iptables not available)")
+ print()
+
+ # on-disk configs (redacted)
+ conf = f"/etc/wireguard/{iface}.conf"
+ drop_restart = f"/etc/systemd/system/wg-quick@{iface}.service.d/restart.conf"
+ drop_postup = f"/etc/systemd/system/wg-quick@{iface}.service.d/10-postup-IP-scripts.conf"
+
+ print(f"=== file: {conf} (redacted) ===")
+ txt = sudo_cat(conf)
+ if txt is None:
+ print("(missing or unreadable; need sudo to view)")
+ else:
+ print(redact_conf(txt))
+ print()
+
+ pick_drop = drop_restart if Path(drop_restart).exists() else drop_postup
+ print(f"=== file: {pick_drop} (hooks) ===")
+ txt = sudo_cat(pick_drop)
+ if txt is None:
+ print("(missing or unreadable; need sudo to view)")
+ else:
+ # Show only interesting service lines if present
+ lines = [ln for ln in txt.splitlines()
+ if ln.startswith(("ExecStart","Restart","RestartSec","ExecStartPre","ExecStartPost"))]
+ print("\n".join(lines) if lines else txt)
+ print()
+
+ # summary verdict
+ print("=== summary ===")
+ ok = True
+ ok &= def_ok
+ ok &= bh_ok
+ if uids:
+ for u in uids:
+ if not re.search(rf"uidrange {u}-{u}.*lookup {re.escape(rtname)}", rules_txt): ok = False
+ ok &= rt_tables_has(rtname)
+ ok &= Path(f"/etc/wireguard/{iface}.conf").exists()
+ ok &= (Path(drop_restart).exists() or Path(drop_postup).exists())
+ ok &= wg_present(iface)
+ if db_ep and which("wg"):
+ # If wg is present and sudo works, compare endpoint; otherwise skip
+ wg_ep = wg_endpoints_joined(iface)
+ if wg_ep and wg_ep != db_ep: ok = False
+ print("✅ Looks consistent for '{}'.".format(iface) if ok else "⚠️ Something is off — check WARN lines above.")
+ return 0 if ok else 1
+
+# ---------- cli ----------
+
+def main(argv: List[str]) -> int:
+ if len(argv)!=1:
+ print(f"Usage: {Path(sys.argv[0]).name} <iface>", file=sys.stderr)
+ return 2
+ try:
+ return inspect_iface(argv[0])
+ except (sqlite3.Error, FileNotFoundError, RuntimeError) as e:
+ print(f"❌ {e}", file=sys.stderr); return 1
+
+if __name__ == "__main__":
+ sys.exit(main(sys.argv[1:]))
--- /dev/null
+#!/usr/bin/env python3
+# inspect_client_public_key.py — show the client's WireGuard public key for one iface
+# Sources checked (in this order): DB, staged conf, installed conf, kernel
+# The “client public key” is generated locally from the client’s PrivateKey and must be
+# copied to the **server** as the peer’s PublicKey in the server’s WireGuard config.
+
+from __future__ import annotations
+from pathlib import Path
+from typing import List, Optional, Tuple
+import argparse
+import os
+import subprocess
+import sqlite3
+import sys
+
+# Project helper providing DB_PATH and open_db()
+import incommon as ic
+
+ROOT = Path(__file__).resolve().parent
+DEFAULT_STAGE = ROOT / "stage"
+LIVE_WG_DIR = Path("/etc/wireguard")
+
+def _is_root() -> bool:
+ return os.geteuid() == 0
+
+def _format_table(headers: List[str], rows: List[Tuple]) -> str:
+ if not rows:
+ return "(none)"
+ cols = list(zip(*([headers] + [[("" if c is None else str(c)) for c in r] for r in rows])))
+ widths = [max(len(x) for x in col) for col in cols]
+ def line(r): return " ".join(f"{str(c):<{w}}" for c, w in zip(r, widths))
+ out = [line(headers), line(tuple("-"*w for w in widths))]
+ for r in rows:
+ out.append(line(r))
+ return "\n".join(out)
+
+def _read_conf_private_key(conf_path: Path) -> Optional[str]:
+ """Return the PrivateKey value from a wg conf (first [Interface] block), or None."""
+ try:
+ txt = conf_path.read_text()
+ except FileNotFoundError:
+ return None
+ section = None
+ for raw in txt.splitlines():
+ line = raw.strip()
+ if not line or line.startswith("#") or line.startswith(";"):
+ continue
+ if line.startswith("[") and line.endswith("]"):
+ section = line[1:-1].strip()
+ continue
+ if section == "Interface":
+ if line.lower().startswith("privatekey"):
+ parts = line.split("=", 1)
+ if len(parts) == 2:
+ val = parts[1].strip()
+ return val if val else None
+ return None
+
+def _pub_from_private_key(priv: str) -> Optional[str]:
+ """Compute public key from a WireGuard base64 private key using `wg pubkey`."""
+ if not priv:
+ return None
+ try:
+ cp = subprocess.run(
+ ["wg", "pubkey"],
+ input=(priv + "\n").encode("utf-8"),
+ stdout=subprocess.PIPE,
+ stderr=subprocess.DEVNULL,
+ check=True,
+ )
+ pub = cp.stdout.decode("utf-8", "replace").strip()
+ return pub or None
+ except (subprocess.CalledProcessError, FileNotFoundError):
+ return None
+
+def _kernel_iface_public_key(iface: str) -> Optional[str]:
+ try:
+ cp = subprocess.run(
+ ["wg", "show", iface, "public-key"],
+ stdout=subprocess.PIPE,
+ stderr=subprocess.DEVNULL,
+ check=True,
+ )
+ k = cp.stdout.decode("utf-8", "replace").strip()
+ return k or None
+ except (subprocess.CalledProcessError, FileNotFoundError):
+ return None
+
+def _db_client_public_key(conn: sqlite3.Connection, iface: str) -> Optional[str]:
+ row = conn.execute("SELECT public_key FROM Iface WHERE iface=? LIMIT 1;", (iface,)).fetchone()
+ if not row:
+ return None
+ k = row[0]
+ return k if k else None
+
+def _rel_from_stage(path: Path, stage_root: Path) -> str:
+ """Return a short, stage-relative display path when under stage_root."""
+ try:
+ rel = path.relative_to(stage_root)
+ return str(rel)
+ except ValueError:
+ return str(path)
+
+def _gather(iface: str, stage_root: Path) -> Tuple[List[Tuple[str, str, str]], List[str]]:
+ """
+ Return (rows, notes)
+ rows: list of (source, location, public_key or "(missing)")
+ """
+ notes: List[str] = []
+
+ # DB
+ db_pub: Optional[str] = None
+ if ic.DB_PATH.exists():
+ try:
+ with ic.open_db() as conn:
+ db_pub = _db_client_public_key(conn, iface)
+ except sqlite3.Error as e:
+ notes.append(f"DB error: {e}")
+ else:
+ notes.append(f"DB not found at {ic.DB_PATH}")
+
+ # staged conf -> derive pub from PrivateKey
+ staged_conf = stage_root / "etc" / "wireguard" / f"{iface}.conf"
+ staged_priv = _read_conf_private_key(staged_conf)
+ staged_pub = _pub_from_private_key(staged_priv) if staged_priv else None
+ if staged_priv is None and staged_conf.exists():
+ notes.append(f"staged conf present but PrivateKey missing: { _rel_from_stage(staged_conf, stage_root) }")
+
+ # live conf -> derive pub from PrivateKey
+ live_conf = LIVE_WG_DIR / f"{iface}.conf"
+ live_priv = _read_conf_private_key(live_conf)
+ live_pub = _pub_from_private_key(live_priv) if live_priv else None
+ if live_conf.exists() and live_priv is None:
+ notes.append(f"installed conf present but PrivateKey missing: {live_conf}")
+
+ # kernel
+ kern_pub = _kernel_iface_public_key(iface)
+
+ rows: List[Tuple[str, str, str]] = []
+ rows.append(("DB", f"Iface.public_key[{iface}]", db_pub or "(missing)"))
+ rows.append(("Stage", _rel_from_stage(staged_conf, stage_root),
+ staged_pub or ("(missing)" if not staged_conf.exists() else "(could not derive)")))
+ rows.append(("Installed", str(live_conf),
+ live_pub or ("(missing)" if not live_conf.exists() else "(could not derive)")))
+ rows.append(("Kernel", f"wg show {iface} public-key", kern_pub or "(missing)"))
+
+ # Quick consistency summary
+ present = [v for _s, _loc, v in rows if not v.startswith("(")]
+ if len(present) >= 2:
+ all_same = all(v == present[0] for v in present[1:])
+ if all_same:
+ notes.append("All present sources agree.")
+ else:
+ notes.append("Mismatch detected between sources.")
+ elif len(present) == 1:
+ notes.append("Only one source has a key (cannot check consistency).")
+ else:
+ notes.append("No source has a client public key.")
+
+ return (rows, notes)
+
+def inspect_client_public_key(iface: str, stage_root: Optional[Path] = None) -> str:
+ """
+ Business function: returns a formatted report string.
+ """
+ sr = stage_root or DEFAULT_STAGE
+ rows, notes = _gather(iface, sr)
+
+ header = (
+ f"Client public key inspection for iface '{iface}'\n"
+ "This public key is generated locally from the client’s PrivateKey and must be\n"
+ "installed on the *server* as the peer’s PublicKey in the server’s WireGuard config.\n"
+ )
+ table = _format_table(["source", "where", "public_key"], rows)
+ if notes:
+ note_block = "\nNotes:\n- " + "\n- ".join(notes)
+ else:
+ note_block = ""
+ return f"{header}\n{table}\n{note_block}\n"
+
+def main(argv: Optional[List[str]] = None) -> int:
+ ap = argparse.ArgumentParser(
+ description="Inspect the client’s WireGuard public key for a single interface."
+ )
+ # Make iface optional so we can aggregate errors ourselves
+ ap.add_argument("iface", nargs="?", help="interface name (e.g., x6)")
+ ap.add_argument("--stage-root", default=str(DEFAULT_STAGE), help="stage directory (default: ./stage)")
+ args = ap.parse_args(argv)
+
+ # Aggregate invocation errors
+ errors: List[str] = []
+ if not _is_root():
+ errors.append("must run as root (needs access to /etc/wireguard and wg)")
+ if not args.iface:
+ errors.append("missing required positional argument: iface")
+ if args.stage_root:
+ sr = Path(args.stage_root)
+ if not sr.exists():
+ errors.append(f"--stage-root does not exist: {sr}")
+ elif not sr.is_dir():
+ errors.append(f"--stage-root is not a directory: {sr}")
+
+ if errors:
+ ap.print_usage(sys.stderr)
+ print(f"{ap.prog}: error: " + "; ".join(errors), file=sys.stderr)
+ return 2
+
+ try:
+ report = inspect_client_public_key(args.iface, Path(args.stage_root))
+ print(report, end="")
+ return 0
+ except Exception as e:
+ print(f"❌ {e}", file=sys.stderr)
+ return 2
+
+if __name__ == "__main__":
+ sys.exit(main())
--- /dev/null
+#!/usr/bin/env python3
+"""
+install_staged_tree.py
+
+A dumb installer: copy staged files into the target root with backups and
+deterministic permissions. No systemd stop/start, no daemon-reload.
+
+Given:
+ - A staged tree (default: ./stage) containing any of:
+ /usr/local/bin/apply_ip_state.sh
+ /etc/wireguard/*.conf
+ /etc/systemd/system/wg-quick@IFACE.service.d/*.conf
+ /etc/iproute2/rt_tables
+ - A destination root (default: /). Parent dirs may be created with --create-dirs.
+
+Does:
+ - For each whitelisted staged file:
+ * if a target already exists, copy it back into the stage as a timestamped backup
+ * atomically replace target with staged version
+ * set root:root ownership (best-effort) and explicit permissions
+ - Prints a summary and suggests next steps (e.g., ./start_iface.py <ifaces>)
+
+Returns:
+ - Exit 0 on success; non-zero on error
+"""
+
+from __future__ import annotations
+from pathlib import Path
+from typing import Dict, Iterable, List, Optional, Sequence, Tuple
+import argparse
+import datetime as dt
+import hashlib
+import os
+import shutil
+import sys
+
+ROOT = Path(__file__).resolve().parent
+DEFAULT_STAGE = ROOT / "stage"
+
+# Whitelisted install targets → mode
+# (These are *relative* to the stage root)
+MODE_RULES: List[Tuple[str, int]] = [
+ ("usr/local/bin", 0o500), # files under here (scripts)
+ ("etc/wireguard", 0o600), # *.conf
+ ("etc/systemd/system", 0o644), # wg-quick@*.service.d/*.conf
+ ("etc/iproute2", 0o644), # rt_tables
+]
+
+def _sha256(path: Path) -> str:
+ h = hashlib.sha256()
+ with path.open("rb") as f:
+ for chunk in iter(lambda: f.read(1<<20), b""):
+ h.update(chunk)
+ return h.hexdigest()
+
+def _ensure_parents(dest_root: Path, rel: Path, create: bool) -> None:
+ parent = (dest_root / rel).parent
+ if parent.exists():
+ return
+ if not create:
+ raise RuntimeError(f"missing parent directory: {parent}")
+ parent.mkdir(parents=True, exist_ok=True)
+
+def _backup_existing_to_stage(stage_root: Path, dest_root: Path, rel: Path) -> Optional[Path]:
+ """If target exists, copy it back into stage/_backups/<ts>/<rel> and return backup path."""
+ target = dest_root / rel
+ if not target.exists():
+ return None
+ ts = dt.datetime.utcnow().strftime("%Y%m%dT%H%M%SZ")
+ backup = stage_root / "_backups" / ts / rel
+ backup.parent.mkdir(parents=True, exist_ok=True)
+ shutil.copy2(target, backup)
+ return backup
+
+def _atomic_install(src: Path, dst: Path, mode: int) -> None:
+ tmp = dst.with_suffix(dst.suffix + ".tmp")
+ shutil.copyfile(src, tmp)
+ os.chmod(tmp, mode)
+ try:
+ os.chown(tmp, 0, 0) # best-effort; may fail if not root
+ except PermissionError:
+ pass
+ os.replace(tmp, dst)
+
+def _mode_for_rel(rel: Path) -> Optional[int]:
+ """Choose a mode based on the relative path bucket."""
+ s = str(rel)
+ if s.startswith("usr/local/bin/"):
+ return 0o500
+ if s.startswith("etc/wireguard/") and rel.suffix == ".conf":
+ return 0o600
+ if s == "etc/iproute2/rt_tables":
+ return 0o644
+ if s.startswith("etc/systemd/system/") and s.endswith(".conf"):
+ return 0o644
+ return None
+
+def _iter_stage_targets(stage_root: Path) -> List[Path]:
+ """Return a list of *relative* paths under stage that match our whitelist."""
+ rels: List[Path] = []
+
+ # /usr/local/bin/*
+ bin_dir = stage_root / "usr" / "local" / "bin"
+ if bin_dir.is_dir():
+ for p in sorted(bin_dir.glob("*")):
+ if p.is_file():
+ rels.append(p.relative_to(stage_root))
+
+ # /etc/wireguard/*.conf
+ wg_dir = stage_root / "etc" / "wireguard"
+ if wg_dir.is_dir():
+ for p in sorted(wg_dir.glob("*.conf")):
+ rels.append(p.relative_to(stage_root))
+
+ # /etc/systemd/system/wg-quick@*.service.d/*.conf
+ sysd_dir = stage_root / "etc" / "systemd" / "system"
+ if sysd_dir.is_dir():
+ for p in sorted(sysd_dir.rglob("wg-quick@*.service.d/*.conf")):
+ rels.append(p.relative_to(stage_root))
+
+ # /etc/iproute2/rt_tables
+ rt = stage_root / "etc" / "iproute2" / "rt_tables"
+ if rt.is_file():
+ rels.append(rt.relative_to(stage_root))
+
+ return rels
+
+def _discover_ifaces_from_stage(stage_root: Path) -> List[str]:
+ """Peek into staged artifacts to guess iface names (for friendly next-steps)."""
+ names = set()
+
+ # from /etc/wireguard/<iface>.conf
+ wg_dir = stage_root / "etc" / "wireguard"
+ if wg_dir.is_dir():
+ for p in wg_dir.glob("*.conf"):
+ names.add(p.stem)
+
+ # from /etc/systemd/system/wg-quick@<iface>.service.d/
+ sysd = stage_root / "etc" / "systemd" / "system"
+ if sysd.is_dir():
+ for d in sysd.glob("wg-quick@*.service.d"):
+ name = d.name
+ # name looks like: wg-quick@X.service.d
+ at = name.find("@")
+ dot = name.find(".service.d")
+ if at != -1 and dot != -1 and dot > at:
+ names.add(name[at+1:dot])
+
+ return sorted(names)
+
+def install_staged_tree(
+ stage_root: Path,
+ dest_root: Path,
+ create_dirs: bool = False,
+ skip_identical: bool = True,
+) -> Tuple[List[str], List[str]]:
+ """
+ Copy files from stage_root to dest_root.
+ Returns (logs, detected_ifaces).
+ """
+ old_umask = os.umask(0o077)
+ logs: List[str] = []
+ try:
+ staged = _iter_stage_targets(stage_root)
+ if not staged:
+ raise RuntimeError("nothing to install (stage is empty or whitelist didn’t match)")
+
+ for rel in staged:
+ src = stage_root / rel
+ dst = dest_root / rel
+
+ mode = _mode_for_rel(rel)
+ if mode is None:
+ logs.append(f"skip (not whitelisted): {rel}")
+ continue
+
+ _ensure_parents(dest_root, rel, create_dirs)
+
+ backup = _backup_existing_to_stage(stage_root, dest_root, rel)
+ if backup:
+ logs.append(f"backup: {dst} -> {backup}")
+
+ if skip_identical and dst.exists():
+ try:
+ if _sha256(src) == _sha256(dst):
+ logs.append(f"identical: skip {rel}")
+ continue
+ except Exception:
+ pass
+
+ _atomic_install(src, dst, mode)
+ logs.append(f"install: {rel} (mode {oct(mode)})")
+
+ ifaces = _discover_ifaces_from_stage(stage_root)
+ return (logs, ifaces)
+ finally:
+ os.umask(old_umask)
+
+def _require_root(allow_nonroot: bool) -> None:
+ if not allow_nonroot and os.geteuid() != 0:
+ raise RuntimeError("must run as root (use --force-nonroot to override)")
+
+def main(argv: Optional[Sequence[str]] = None) -> int:
+ ap = argparse.ArgumentParser(description="Install staged artifacts into a target root. No service control.")
+ ap.add_argument("--stage", default=str(DEFAULT_STAGE))
+ ap.add_argument("--root", default="/")
+ ap.add_argument("--create-dirs", action="store_true", help="create missing parent directories")
+ ap.add_argument("--no-skip-identical", action="store_true", help="always replace even if content identical")
+ ap.add_argument("--force-nonroot", action="store_true", help="allow non-root install (ownership may be wrong)")
+ args = ap.parse_args(argv)
+
+ try:
+ _require_root(allow_nonroot=args.force_nonroot)
+ logs, ifaces = install_staged_tree(
+ stage_root=Path(args.stage),
+ dest_root=Path(args.root),
+ create_dirs=args.create_dirs,
+ skip_identical=(not args.no_skip_identical),
+ )
+ for line in logs:
+ print(line)
+
+ # Summary + suggested next steps
+ print("\n=== Summary ===")
+ print(f"Installed {sum(1 for l in logs if l.startswith('install:'))} file(s).")
+ if ifaces:
+ lst = " ".join(ifaces)
+ print(f"Detected interfaces from stage: {lst}")
+ print(f"\nNext steps:")
+ print(f" # (optional) verify configs")
+ print(f" sudo wg-quick strip /etc/wireguard/{ifaces[0]}.conf >/dev/null 2>&1 || true")
+ print(f"\n # start interfaces")
+ print(f" sudo ./start_iface.py {lst}")
+ else:
+ print("No interfaces detected in staged artifacts.")
+ print("\nNext steps:")
+ print(" # start your interface(s)")
+ print(" sudo ./start_iface.py <iface> [more ifaces]")
+ return 0
+ except Exception as e:
+ print(f"❌ install failed: {e}", file=sys.stderr)
+ return 2
+
+if __name__ == "__main__":
+ sys.exit(main())
--- /dev/null
+
+*
+!.gitignore
+
--- /dev/null
+#!/usr/bin/env python3
+# key_client_generate.py — generate a machine-wide WG keypair
+# Usage: ./key_client_generate.py <machine_name>
+# - Writes private key to: key/<machine_name>
+# - Updates ALL client.public_key in local DB (no private key stored in DB)
+
+from __future__ import annotations
+import sys, shutil, subprocess, sqlite3, os
+from pathlib import Path
+import incommon as ic # ROOT_DIR, DB_PATH, open_db()
+
+def generate_keypair() -> tuple[str, str]:
+ if not shutil.which("wg"):
+ raise RuntimeError("wg not found; install wireguard-tools")
+ priv = subprocess.run(["wg","genkey"], check=True, text=True, capture_output=True).stdout.strip()
+ pub = subprocess.run(["wg","pubkey"], check=True, input=priv.encode(), capture_output=True).stdout.decode().strip()
+ # quick sanity
+ if not (43 <= len(pub) <= 45):
+ raise RuntimeError(f"generated public key length looks wrong ({len(pub)})")
+ return priv, pub
+
+def write_private_key(machine: str, private_key: str) -> Path:
+ key_dir = ic.ROOT_DIR / "key"
+ key_dir.mkdir(parents=True, exist_ok=True)
+ out_path = key_dir / machine
+ if out_path.exists():
+ raise FileExistsError(f"refusing to overwrite existing private key file: {out_path}")
+ with open(out_path, "w", encoding="utf-8") as f:
+ f.write(private_key + "\n")
+ os.chmod(out_path, 0o600)
+ return out_path
+
+def update_client_public_keys(pub: str) -> int:
+ if not ic.DB_PATH.exists():
+ raise FileNotFoundError(f"DB not found: {ic.DB_PATH}")
+ with ic.open_db() as conn:
+ cur = conn.execute(
+ "UPDATE Iface "
+ " SET public_key=?, updated_at=strftime('%Y-%m-%dT%H:%M:%SZ','now');",
+ (pub,)
+ )
+ conn.commit()
+ return cur.rowcount or 0
+
+def main(argv: list[str]) -> int:
+ if len(argv) != 1:
+ print(f"Usage: {Path(sys.argv[0]).name} <machine_name>", file=sys.stderr)
+ return 2
+ machine = argv[0]
+ try:
+ priv, pub = generate_keypair()
+ out_path = write_private_key(machine, priv)
+ n = update_client_public_keys(pub)
+ print(f"wrote: {out_path.relative_to(ic.ROOT_DIR)} (600)")
+ print(f"updated client.public_key for {n} row(s)")
+ print(f"public_key: {pub}")
+ return 0
+ except (RuntimeError, FileExistsError, FileNotFoundError, sqlite3.Error, subprocess.CalledProcessError) as e:
+ print(f"❌ {e}", file=sys.stderr)
+ return 1
+
+if __name__ == "__main__":
+ sys.exit(main(sys.argv[1:]))
--- /dev/null
+#!/usr/bin/env python3
+# key_server_set.py — set a server's public key by nickname
+# Usage: ./key_server_set.py <server_name> <public_key>
+
+from __future__ import annotations
+import sys, sqlite3
+from pathlib import Path
+import incommon as ic # DB_PATH, open_db()
+
+def valid_pub(pub: str) -> bool:
+ # wg public keys are base64-like and typically 44 chars; allow 43–45 as used elsewhere
+ return isinstance(pub, str) and (43 <= len(pub.strip()) <= 45)
+
+def set_server_pubkey(server_name: str, pubkey: str) -> int:
+ if not ic.DB_PATH.exists():
+ raise FileNotFoundError(f"DB not found: {ic.DB_PATH}")
+ with ic.open_db() as conn:
+ cur = conn.execute(
+ "UPDATE server "
+ " SET public_key=?, updated_at=strftime('%Y-%m-%dT%H:%M:%SZ','now') "
+ " WHERE name=?;",
+ (pubkey.strip(), server_name)
+ )
+ conn.commit()
+ return cur.rowcount or 0
+
+def main(argv: list[str]) -> int:
+ if len(argv) != 2:
+ print(f"Usage: {Path(sys.argv[0]).name} <server_name> <public_key>", file=sys.stderr)
+ return 2
+ name, pub = argv
+ if not valid_pub(pub):
+ print(f"❌ public_key length looks wrong ({len(pub)})", file=sys.stderr)
+ return 1
+ try:
+ n = set_server_pubkey(name, pub)
+ if n == 0:
+ print(f"⚠️ no matching server rows for name='{name}'")
+ else:
+ print(f"updated server.public_key for {n} row(s) where name='{name}'")
+ return 0
+ except (sqlite3.Error, FileNotFoundError) as e:
+ print(f"❌ {e}", file=sys.stderr)
+ return 1
+
+if __name__ == "__main__":
+ sys.exit(main(sys.argv[1:]))
--- /dev/null
+#!/usr/bin/env python3
+"""
+ls_client.py — list client from the DB
+
+Default output: interface names, one per line.
+
+Options:
+ -i, --iface IFACE Filter to a single interface (exact match)
+ -l, --long Show a table with iface, rt_table_name, rt_table_id, addr, autostart, updated_at
+ -h, --help Show usage
+"""
+
+from __future__ import annotations
+import sys
+import argparse
+import sqlite3
+from typing import List, Tuple
+import incommon as ic # DB_PATH, open_db()
+
+def parse_args(argv: List[str]) -> argparse.Namespace:
+ ap = argparse.ArgumentParser(add_help=False, prog="ls_client.py", description="List client from the DB")
+ ap.add_argument("-i","--iface", help="Filter by interface (exact match)")
+ ap.add_argument("-l","--long", action="store_true", help="Long table output")
+ ap.add_argument("-h","--help", action="help", help="Show this help and exit")
+ return ap.parse_args(argv)
+
+def fmt_table(headers: List[str], rows: List[Tuple]) -> str:
+ if not rows: return ""
+ # normalize to strings; keep empty for None
+ rows = [[("" if c is None else str(c)) for c in r] for r in rows]
+ cols = list(zip(*([headers] + rows)))
+ widths = [max(len(x) for x in col) for col in cols]
+ line = lambda r: " ".join(f"{str(c):<{w}}" for c, w in zip(r, widths))
+ out = [line(headers), line(tuple("-"*w for w in widths))]
+ out += [line(r) for r in rows]
+ return "\n".join(out)
+
+def list_names(conn: sqlite3.Connection, iface: str | None) -> int:
+ if iface:
+ rows = conn.execute("SELECT iface FROM Iface WHERE iface=? ORDER BY iface;", (iface,)).fetchall()
+ else:
+ rows = conn.execute("SELECT iface FROM Iface ORDER BY iface;").fetchall()
+ for (name,) in rows:
+ print(name)
+ return 0
+
+def list_long(conn: sqlite3.Connection, iface: str | None) -> int:
+ if iface:
+ rows = conn.execute("""
+ SELECT c.iface,
+ v.rt_table_name_eff AS rt_table_name,
+ COALESCE(c.rt_table_id,'') AS rt_table_id,
+ c.local_address_cidr,
+ c.autostart,
+ c.updated_at
+ FROM Iface c
+ JOIN v_client_effective v ON v.id = c.id
+ WHERE c.iface = ?
+ ORDER BY c.iface;
+ """, (iface,)).fetchall()
+ else:
+ rows = conn.execute("""
+ SELECT c.iface,
+ v.rt_table_name_eff AS rt_table_name,
+ COALESCE(c.rt_table_id,'') AS rt_table_id,
+ c.local_address_cidr,
+ c.autostart,
+ c.updated_at
+ FROM Iface c
+ JOIN v_client_effective v ON v.id = c.id
+ ORDER BY c.iface;
+ """).fetchall()
+
+ hdr = ["iface","rt_table_name","rt_table_id","addr","autostart","updated_at"]
+ txt = fmt_table(hdr, rows)
+ if txt: print(txt)
+ return 0
+
+def main(argv: List[str]) -> int:
+ args = parse_args(argv)
+ try:
+ with ic.open_db() as conn:
+ return list_long(conn, args.iface) if args.long else list_names(conn, args.iface)
+ except (sqlite3.Error, FileNotFoundError) as e:
+ print(f"❌ {e}", file=sys.stderr)
+ return 2
+
+if __name__ == "__main__":
+ sys.exit(main(sys.argv[1:]))
--- /dev/null
+#!/usr/bin/env python3
+# ls_keys.py — list WireGuard public keys only
+# Usage:
+# ./ls_keys.py # all client/server
+# ./ls_keys.py -i x6 # only iface x6
+
+from __future__ import annotations
+import sys, argparse, sqlite3
+from pathlib import Path
+from typing import List, Tuple
+import incommon as ic # DB_PATH, open_db()
+
+def format_table(headers: List[str], rows: List[Tuple]) -> str:
+ if not rows:
+ return "(none)"
+ cols = list(zip(*([headers] + [[("" if c is None else str(c)) for c in r] for r in rows])))
+ widths = [max(len(x) for x in col) for col in cols]
+ def line(r): return " ".join(f"{str(c):<{w}}" for c, w in zip(r, widths))
+ out = [line(headers), line(tuple("-"*w for w in widths))]
+ for r in rows: out.append(line(r))
+ return "\n".join(out)
+
+def list_client_keys(conn: sqlite3.Connection, iface: str | None, banner=False) -> str:
+ if banner:
+ print("\n=== Public keys generated locally by client, probably by using `key_client_generate.py`===")
+ rows = conn.execute(
+ "SELECT iface, public_key AS client_public_key "
+ "FROM Iface "
+ + ("WHERE iface=? " if iface else "")
+ + "ORDER BY iface;",
+ ((iface,) if iface else tuple()),
+ ).fetchall()
+ return format_table(["iface","client_public_key"], rows)
+
+def list_server_keys(conn: sqlite3.Connection, iface: str | None ,banner=False) -> str:
+ if banner:
+ print("\n=== Public keys imported from remote server, probably edited into db_init_server_<name>.py ===")
+ rows = conn.execute(
+ "SELECT c.iface AS client, s.name AS server, s.public_key AS server_public_key "
+ "FROM server s JOIN Iface c ON c.id = s.iface_id "
+ + ("WHERE c.iface=? " if iface else "")
+ + "ORDER BY c.iface, s.name;",
+ ((iface,) if iface else tuple()),
+ ).fetchall()
+ return format_table(["client","server","server_public_key"], rows)
+
+def client_pub_for_iface(conn: sqlite3.Connection, iface: str) -> str | None:
+ r = conn.execute("SELECT public_key FROM Iface WHERE iface=? LIMIT 1;", (iface,)).fetchone()
+ return (r[0] if r and r[0] else None)
+
+def main(argv: List[str]) -> int:
+ ap = argparse.ArgumentParser(description="List WireGuard public keys from the local DB.")
+ ap.add_argument("-i","--iface", help="filter for one iface (e.g., x6)")
+ args = ap.parse_args(argv)
+
+ try:
+ # Ensure DB exists
+ if not ic.DB_PATH.exists():
+ print(f"❌ DB not found: {ic.DB_PATH}", file=sys.stderr)
+ return 1
+ with ic.open_db() as conn:
+ print(list_client_keys(conn, args.iface, banner=True))
+ print()
+ print(list_server_keys(conn, args.iface, banner=True))
+ if args.iface:
+ cpub = client_pub_for_iface(conn, args.iface)
+ if cpub:
+ print()
+ print("# Copy to server peer config if needed:")
+ print(f'CLIENT_PUB="{cpub}"')
+ return 0
+ except (sqlite3.Error, FileNotFoundError) as e:
+ print(f"❌ {e}", file=sys.stderr)
+ return 1
+
+if __name__ == "__main__":
+ sys.exit(main(sys.argv[1:]))
--- /dev/null
+#!/usr/bin/env python3
+"""
+ls_server.py — list server from the DB
+
+Default output: server names, one per line.
+
+Options:
+ -i, --iface IFACE Filter to a single client interface (e.g., x6, US)
+ -l, --long Show a table with client, name, endpoint, allowed_ips, priority
+ -h, --help Show usage
+"""
+
+from __future__ import annotations
+import sys
+import sqlite3
+import argparse
+from typing import List, Tuple
+import incommon as ic # DB_PATH, open_db()
+
+def parse_args(argv: List[str]) -> argparse.Namespace:
+ ap = argparse.ArgumentParser(add_help=False, prog="ls_server.py", description="List server from the DB")
+ ap.add_argument("-i","--iface", help="Filter by client interface")
+ ap.add_argument("-l","--long", action="store_true", help="Long table output")
+ ap.add_argument("-h","--help", action="help", help="Show this help and exit")
+ return ap.parse_args(argv)
+
+def fmt_table(headers: List[str], rows: List[Tuple]) -> str:
+ if not rows: return ""
+ cols = list(zip(*([headers] + [[("" if c is None else str(c)) for c in r] for r in rows])))
+ widths = [max(len(x) for x in col) for col in cols]
+ line = lambda r: " ".join(f"{str(c):<{w}}" for c, w in zip(r, widths))
+ out = [line(headers), line(tuple("-"*w for w in widths))]
+ for r in rows: out.append(line(r))
+ return "\n".join(out)
+
+def list_names(conn: sqlite3.Connection, iface: str | None) -> int:
+ if iface:
+ rows = conn.execute("""
+ SELECT s.name
+ FROM server s
+ JOIN Iface c ON c.id = s.iface_id
+ WHERE c.iface = ?
+ ORDER BY s.name
+ """, (iface,)).fetchall()
+ else:
+ rows = conn.execute("SELECT name FROM server ORDER BY name").fetchall()
+ for (name,) in rows:
+ print(name)
+ return 0
+
+def list_long(conn: sqlite3.Connection, iface: str | None) -> int:
+ if iface:
+ rows = conn.execute("""
+ SELECT c.iface,
+ s.name,
+ s.endpoint_host || ':' || CAST(s.endpoint_port AS TEXT) AS endpoint,
+ s.allowed_ips,
+ s.priority
+ FROM server s
+ JOIN Iface c ON c.id = s.iface_id
+ WHERE c.iface = ?
+ ORDER BY c.iface, s.priority, s.name
+ """, (iface,)).fetchall()
+ else:
+ rows = conn.execute("""
+ SELECT c.iface,
+ s.name,
+ s.endpoint_host || ':' || CAST(s.endpoint_port AS TEXT) AS endpoint,
+ s.allowed_ips,
+ s.priority
+ FROM server s
+ JOIN Iface c ON c.id = s.iface_id
+ ORDER BY c.iface, s.priority, s.name
+ """).fetchall()
+
+ hdr = ["client","name","endpoint","allowed_ips","priority"]
+ txt = fmt_table(hdr, rows)
+ if txt: print(txt)
+ return 0
+
+def main(argv: List[str]) -> int:
+ args = parse_args(argv)
+ try:
+ with ic.open_db() as conn:
+ return list_long(conn, args.iface) if args.long else list_names(conn, args.iface)
+ except (sqlite3.Error, FileNotFoundError) as e:
+ print(f"❌ {e}", file=sys.stderr)
+ return 2
+
+if __name__ == "__main__":
+ sys.exit(main(sys.argv[1:]))
--- /dev/null
+#!/usr/bin/env python3
+"""
+ls_server_settings.py — print server-side WireGuard [Peer] stanzas from the DB
+
+Purpose:
+ Emit configuration that belongs in a *server* wg conf (e.g., /etc/wireguard/wg0.conf).
+ One [Peer] block per (client, server) row.
+
+What is printed (per block):
+ - PublicKey = client's public key (from client.public_key)
+ - AllowedIPs = client's tunnel address(es) as seen by the server (from client.local_address_cidr)
+ (Use /32 per client. If multiple /32 per client are later added, enumerate them.)
+ - PresharedKey = server.preshared_key (only if present)
+
+Notes:
+ - Endpoint is NOT set on the server for client peers (client usually dials the server).
+ - PersistentKeepalive is generally set on the client; server may omit it.
+
+Usage:
+ ./ls_server_settings.py # all client and their server entries
+ ./ls_server_settings.py x6 us # only for these client ifaces
+ ./ls_server_settings.py --server x6 # filter by server.name
+"""
+
+from __future__ import annotations
+import sys, sqlite3
+from typing import Iterable, List, Optional, Sequence, Tuple
+from pathlib import Path
+
+# local helper import is optional; only used to locate db path if present
+try:
+ import incommon as ic
+ DB_PATH = ic.DB_PATH
+except Exception:
+ DB_PATH = Path(__file__).resolve().parent / "db" / "store"
+
+def die(msg: str, code: int = 1) -> None:
+ print(f"❌ {msg}", file=sys.stderr); sys.exit(code)
+
+def open_db(path: Path) -> sqlite3.Connection:
+ if not path.exists(): die(f"DB not found: {path}")
+ return sqlite3.connect(path.as_posix())
+
+def parse_args(argv: Sequence[str]) -> Tuple[List[str], Optional[str]]:
+ ifaces: List[str] = []
+ server_filter: Optional[str] = None
+ it = iter(argv)
+ for a in it:
+ if a == "--server":
+ try: server_filter = next(it)
+ except StopIteration: die("--server requires a value")
+ else:
+ ifaces.append(a)
+ return ifaces, server_filter
+
+def rows(conn: sqlite3.Connection, q: str, params: Iterable = ()) -> List[tuple]:
+ cur = conn.execute(q, tuple(params))
+ out = cur.fetchall()
+ cur.close()
+ return out
+
+def collect(conn: sqlite3.Connection, ifaces: List[str], server_filter: Optional[str]) -> List[dict]:
+ where = []
+ args: List = []
+ if ifaces:
+ ph = ",".join("?" for _ in ifaces)
+ where.append(f"c.iface IN ({ph})")
+ args.extend(ifaces)
+ if server_filter:
+ where.append("s.name = ?")
+ args.append(server_filter)
+ w = ("WHERE " + " AND ".join(where)) if where else ""
+ q = f"""
+ SELECT c.id, c.iface, c.public_key, c.local_address_cidr,
+ s.name, s.preshared_key, s.endpoint_host, s.endpoint_port
+ FROM Iface c
+ LEFT JOIN server s ON s.iface_id = c.id
+ {w}
+ ORDER BY s.name, c.iface, s.priority ASC, s.id ASC;
+ """
+ R = rows(conn, q, args)
+ out: List[dict] = []
+ for cid, iface, cpub, cidr, sname, psk, host, port in R:
+ out.append({
+ "iface_id": cid,
+ "iface": iface or "",
+ "client_pub": cpub or "",
+ "client_cidr": cidr or "",
+ "server_name": sname or "(unassigned)",
+ "server_host": host or "",
+ "server_port": port or None,
+ "psk": psk or None,
+ })
+ return out
+
+def print_header() -> None:
+ print("# === Server-side WireGuard peer stanzas ===")
+ print("# Place each [Peer] block into the server's wg conf (e.g., /etc/wireguard/wg0.conf).")
+ print("# Endpoint is not set for client peers on the server.")
+ print("# AllowedIPs must be /32 per client address; enumerate multiple /32 if a client uses several.")
+ print()
+
+def print_blocks(items: List[dict]) -> None:
+ if not items:
+ print("# (no rows matched)"); return
+ print_header()
+ # group by server_name for readability
+ cur_group = None
+ for r in items:
+ grp = r["server_name"]
+ if grp != cur_group:
+ cur_group = grp
+ ep = f" ({r['server_host']}:{r['server_port']})" if r["server_host"] and r["server_port"] else ""
+ print(f"## Server: {grp}{ep}")
+ # stanza
+ print("[Peer]")
+ print(f"# client iface={r['iface']} tunnel={r['client_cidr']}")
+ print(f"PublicKey = {r['client_pub']}")
+ # AllowedIPs: prefer the exact CIDR stored for the client (typically /32)
+ print(f"AllowedIPs = {r['client_cidr']}")
+ if r["psk"]:
+ print(f"PresharedKey = {r['psk']}")
+ print()
+ # end
+
+def main(argv: Sequence[str]) -> int:
+ ifaces, server_filter = parse_args(argv)
+ try:
+ with open_db(DB_PATH) as conn:
+ items = collect(conn, ifaces, server_filter)
+ except sqlite3.Error as e:
+ die(f"sqlite error: {e}")
+ print_blocks(items)
+ return 0
+
+if __name__ == "__main__":
+ sys.exit(main(sys.argv[1:]))
--- /dev/null
+
+# ls_server.sh
+#!/usr/bin/env bash
+set -euo pipefail
+DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
+DB="$DIR/db/store"
+sqlite3 -noheader -batch "$DB" "SELECT name FROM server ORDER BY name;"
--- /dev/null
+#!/usr/bin/env python3
+"""
+ls_users.py — print "<username> <iface>" from DB (names only)
+
+- Validates required tables exist (client, User)
+- No side effects; read-only
+"""
+
+from __future__ import annotations
+import sys
+import sqlite3
+import incommon as ic # DB_PATH, open_db()
+
+HELP = """Usage: ls_users.py
+Prints one line per user binding as: "<username> <iface>".
+"""
+
+def tables_ok(conn: sqlite3.Connection) -> bool:
+ row = conn.execute(
+ """
+ SELECT
+ (SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='client'),
+ (SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='User')
+ """
+ ).fetchone()
+ return row == (1, 1)
+
+def list_users(conn: sqlite3.Connection) -> None:
+ cur = conn.execute(
+ """
+ SELECT ub.username, c.iface
+ FROM User ub
+ JOIN Iface c ON c.id = ub.iface_id
+ ORDER BY c.iface, ub.username
+ """
+ )
+ for username, iface in cur.fetchall():
+ print(f"{username} {iface}")
+
+def main(argv: list[str]) -> int:
+ if argv and argv[0] in ("-h", "--help"):
+ print(HELP.strip()); return 0
+ try:
+ with ic.open_db() as conn:
+ if not tables_ok(conn):
+ print("❌ Missing tables (client/User). Initialize the database first.", file=sys.stderr)
+ return 1
+ list_users(conn)
+ return 0
+ except (sqlite3.Error, FileNotFoundError) as e:
+ print(f"❌ {e}", file=sys.stderr)
+ return 2
+
+if __name__ == "__main__":
+ sys.exit(main(sys.argv[1:]))
--- /dev/null
+#+title: WireGuard Client — Reference
+#+author: Thomas / Aerenis
+#+startup: showall
+
+* Directory layout (wg/)
+- =schema.sql= :: SQLite schema for clients/servers/routes/meta (keys stored in DB).
+- =wg_client.db= :: SQLite DB (created by =db_init.sh=).
+- =db_init.sh= :: Creates/initializes DB from =schema.sql= (user-space).
+- =client_create_keys.sh= :: Creates a fresh client keypair for an =iface= and stores into DB.
+- =config_client_StanleyPark.sh= :: Upserts the StanleyPark client row (iface, addr, mtu, dns_mode, autostart, etc.).
+- =config_server_x6.sh= :: Upserts the remote server (“x6”) row linked to the client.
+- =bind_user.sh= :: Binds a Linux username (and resolves UID) to a client interface in DB.
+- =ls_clients.sh= :: Lists interface names only (one per line).
+- =ls_servers.sh= :: Lists server names (optionally grouped by client).
+- =ls_users.sh= :: Lists =<linux-user> <iface>= pairs.
+- =inspect.sh= :: Shows effective config from DB and current system state for a given iface.
+- =IP_rule_add_UID.sh= :: Helper installed to =/usr/local/bin= (adds =ip rule uidrange= entries idempotently).
+- =stage_generate.sh= :: Builds staged artifacts from DB:
+ - =stage/wireguard/<iface>.conf=
+ - =stage/systemd/wg-quick@<iface>.d/restart.conf=
+ - =stage/usr/local/bin/routes_init_<iface>.sh=
+ - copies =IP_rule_add_UID.sh= into stage for install
+ - Offers to clean stage first; supports =--clean=, =--no-clean=, =--dry-clean=
+- =stage_install.sh= :: Copies staged files into:
+ - =/etc/wireguard/<iface>.conf=
+ - =/etc/systemd/system/wg-quick@<iface>.d/restart.conf=
+ - =/usr/local/bin/routes_init_<iface>.sh=
+ - =/usr/local/bin/IP_rule_add_UID.sh=
+ - Reloads systemd daemon and prints next steps.
+- =stage_clean.sh= :: Empties =./stage= safely (with confirmation).
+- =routes_init_x6.sh= :: (Legacy) Example per-iface route script; superseded by staged =routes_init_<iface>.sh=
+- =deprecated/= :: Old scripts retained for reference.
+- =stage/= :: Generated artifacts awaiting installation.
+- =scratchpad/= :: (Optional) Temporary workspace for ad-hoc edits before installation.
+
+* Schema (summary)
+- =clients=
+ - =iface= (TEXT UNIQUE): bare interface name (e.g., ‘x6’)
+ - =rt_table_id= (INTEGER): e.g., 1002
+ - =rt_table_name= (TEXT): defaults to iface if NULL (used by route scripts and =ip rule=)
+ - =bound_user= (TEXT), =bound_uid= (INTEGER): Linux user + UID that should egress via this iface
+ - =local_address_cidr=, =private_key=, =public_key=, =mtu=, =fwmark=
+ - =dns_mode= (‘none’ or ‘static’), =dns_servers= (if static)
+ - =autostart= (0/1)
+- =servers=
+ - Linked by =client_id= → =clients.id=
+ - =name= (‘x6’), =public_key=, optional =preshared_key=
+ - =endpoint_host=, =endpoint_port=, =allowed_ips=, =keepalive_s=
+ - =route_allowed_ips= (0/1): when 0, =Table= is set to =off= in wg conf and routing is handled by our scripts
+ - =priority= (lower preferred) — first by priority then id is staged
+- =routes=
+ - Linked by =client_id=
+ - =cidr=, optional =via=, optional =table_name= (else use client rt name), optional =metric=
+ - =on_up= (1/0), =on_down= (1/0) — generator emits only =on_up= routes in =routes_init_<iface>.sh=
+- =meta=
+ - =schema= key describing current schema version/string
+
+* Generated files (stage/)
+- wireguard/<iface>.conf ::
+ - =[Interface]= :: Address, PrivateKey, optional MTU/FwMark/DNS, optional =Table= off
+ - =[Peer]= :: Server public key, optional PSK, Endpoint, AllowedIPs, optional PersistentKeepalive
+- systemd/wg-quick@<iface>.d/restart.conf ::
+ - Restart policy; force fresh link; =ExecStartPost= hooks:
+ - routes init script
+ - =IP_rule_add_UID.sh <rt_table_name> <bound_uid>= (if bound)
+ - logger line
+- usr/local/bin/routes_init_<iface>.sh ::
+ - Installs default route to device in =rt_table_name= and a blackhole default guard
+ - Adds any DB =routes= with =on_up=1
+
+* Operational Notes
+- =iface= names are bare (not prefixed with =wg_=). Systemd unit is =wg-quick@<iface>.service=.
+- Unbound rides the tunnel; leave WireGuard DNS unset (=dns_mode=none=) unless you want static DNS in the conf.
+- Copy-based install preserves an audit trail in =./stage=. Clean explicitly when desired.
+
+* Security
+- The DB contains *private keys*. Restrict permissions:
+ #+begin_src bash
+ chmod 600 wg_client.db
+ #+end_src
+- Back up =wg_client.db= securely.
+
+* Troubleshooting
+- If unit fails to start: =journalctl -u wg-quick@<iface> -b=
+- Handshake age / peer state: =wg show=
+- Routing: =ip rule=, =ip route show table <rt_table_name>=
+- Regenerate & reinstall on mismatch:
+ #+begin_src bash
+ ./stage_generate.sh --clean && sudo ./stage_install.sh && sudo systemctl restart wg-quick@<iface>
+ #+end_src
--- /dev/null
+#+title: WireGuard Client — Admin User Guide
+#+author: Thomas / Aerenis
+#+startup: showall
+
+* Overview
+Authoritative state lives here:
+- ~/executable/setup/Debian12_client/wg/
+- Keys + config live in *SQLite* (./db/store).
+- You *stage* generated files in ./stage/, then *install* as root.
+- Interface names are *bare* (e.g., =x6=, =US=). Unit: =wg-quick@<iface>=; config: =/etc/wireguard/<iface>.conf=.
+- Unbound is used for DNS; typically =dns_mode= is =none= (no =DNS= line in WG conf).
+- Staging dirs are not auto-cleaned; each of =db/=, =stage/=, =scratchpad/= contains a =.gitignore= that ignores everything except itself.
+
+* Typical Workflow (example: x6)
+1) Initialize DB
+#+begin_src bash
+./db_init.sh
+#+end_src
+
+2) Create/Update *client* record for this host (inserts the =x6= row)
+#+begin_src bash
+./config_client_StanleyPark.sh
+#+end_src
+
+3) Create/rotate *client keys* (writes keys into DB for =x6=)
+#+begin_src bash
+./client_create_keys.sh x6
+#+end_src
+
+4) Configure the *remote server* record (x6)
+#+begin_src bash
+./config_server_x6.sh
+#+end_src
+
+5) Bind Linux user(s) to interface (traffic steering via uid rules)
+#+begin_src bash
+./user_to_iface.sh Thomas-x6 x6
+# or bulk:
+./user_all_to_iface.sh
+#+end_src
+Verify:
+#+begin_src bash
+./ls_users.sh
+#+end_src
+
+6) Generate staged files (will offer to clean ./stage first)
+#+begin_src bash
+./stage_generate.sh
+#+end_src
+Review contents of =./stage= (WG conf, systemd drop-in, route script).
+
+7) Install (as root) — copies staged files into the system
+#+begin_src bash
+sudo ./stage_install.sh
+#+end_src
+
+8) Enable & start the interface
+#+begin_src bash
+sudo systemctl enable wg-quick@x6
+sudo systemctl start wg-quick@x6
+#+end_src
+
+9) Inspect / validate
+#+begin_src bash
+./inspect.sh x6
+ip rule | grep x6
+ip route show table x6
+wg show
+#+end_src
+
+* Key Rotation (client)
+- Update keys in DB and redeploy:
+#+begin_src bash
+./client_create_keys.sh x6
+./stage_generate.sh --clean
+sudo ./stage_install.sh
+sudo systemctl restart wg-quick@x6
+#+end_src
+- Then update the *server’s* peer public key accordingly.
+
+* Listing helpers
+- Interfaces:
+#+begin_src bash
+./ls_clients.sh # prints: x6, US, ...
+#+end_src
+- Servers (per client):
+#+begin_src bash
+./ls_servers.sh # prints server names per client
+#+end_src
+- User bindings:
+#+begin_src bash
+./ls_users.sh # prints: <linux-user> <iface>
+#+end_src
+
+* Notes
+- =./stage= is not auto-cleaned. Use:
+#+begin_src bash
+./stage_clean.sh
+#+end_src
+- Protect your DB (contains private keys):
+#+begin_src bash
+chmod 700 db
+chmod 600 db/store
+#+end_src
--- /dev/null
+
+*
+!.gitignore
+
--- /dev/null
+
+*
+!.gitignore
+
--- /dev/null
+#!/usr/bin/env python3
+"""
+stage_IP_apply_script.py
+
+Given:
+ - A SQLite DB (schema you’ve defined), with:
+ * Iface(id, iface, local_address_cidr, rt_table_name, rt_table_id)
+ * v_iface_effective(id, rt_table_name_eff, local_address_cidr)
+ * Route(iface_id, cidr, via, table_name, metric, on_up, on_down)
+ * "User"(iface_id, username, uid) — table formerly User_Binding
+ * Meta(key='subu_cidr', value)
+ - A list of interface names to include (e.g., ["x6","US"]).
+
+Does:
+ - Reads DB once and *synthesizes a single* idempotent runtime script
+ that, for the selected interfaces, on each `wg-quick@IFACE` start:
+ 1) resets IPv4 addresses on the iface (delete-if-present, then add)
+ 2) ensures all configured routes exist (using `ip -4 route replace`)
+ 3) resets policy rules by preference number (delete-by-pref, then add)
+ with **per-iface prefs** to avoid collisions.
+ - Stages that script under: stage/usr/local/bin/<script_name>
+ - Stages per-iface systemd drop-ins:
+ stage/etc/systemd/system/wg-quick@IFACE.service.d/<prio>-postup-IP-state.conf
+ which call the script (default prio = 20).
+ - Stages a merged copy of rt_tables (does not write the live /etc/iproute2/rt_tables).
+
+Returns:
+ (script_path, notes[list of strings])
+
+Errors:
+ - Raises RuntimeError if no interfaces provided or there’s nothing to emit.
+ - Does not modify kernel state — this is staging only.
+
+Notes:
+ - Addresses: reset pattern (del → add) for deterministic convergence.
+ - Routes: `ip -4 route replace` (best-practice) with tolerant logging.
+ - Rules: reset by `pref` (del-by-pref → add). Prefs are unique per iface:
+ base = 17000 + Iface.id * 10
+ from_pref = base + 0
+ uid_pref = base + 1
+ - The runtime script accepts optional IFACE args to limit application.
+"""
+
+from __future__ import annotations
+from pathlib import Path
+from typing import Dict, Iterable, List, Optional, Sequence, Tuple
+import argparse
+import sqlite3
+import sys
+
+import incommon as ic # expected: open_db()
+
+ROOT = Path(__file__).resolve().parent
+STAGE_ROOT = ROOT / "stage"
+
+RT_TABLES_PATH = Path("/etc/iproute2/rt_tables")
+
+
+# ---------- helpers for notes ----------
+
+def _stage_note(path: Path, stage_root: Path) -> str:
+ """Return a short path like 'stage:/usr/local/bin/apply_IP_state.sh'."""
+ try:
+ rel = path.relative_to(stage_root)
+ return f"stage:/{rel.as_posix()}"
+ except ValueError:
+ return str(path)
+
+
+# ---------- rt_tables helpers ----------
+
+def _parse_rt_tables(path: Path) -> Tuple[List[str], Dict[str, int], set[int]]:
+ """
+ Returns (lines, name_to_num, used_nums).
+ Keeps original lines for a non-destructive merge.
+ """
+ text = path.read_text() if path.exists() else ""
+ lines = text.splitlines()
+ name_to_num: Dict[str, int] = {}
+ used_nums: set[int] = set()
+ for ln in lines:
+ s = ln.strip()
+ if not s or s.startswith("#"):
+ continue
+ parts = s.split()
+ if len(parts) >= 2 and parts[0].isdigit():
+ n = int(parts[0]); nm = parts[1]
+ if nm not in name_to_num and n not in used_nums:
+ name_to_num[nm] = n
+ used_nums.add(n)
+ return (lines, name_to_num, used_nums)
+
+
+def _first_free_id(used_nums: Iterable[int], low: int, high: int) -> int:
+ used = set(used_nums)
+ for n in range(low, high + 1):
+ if n not in used:
+ return n
+ raise RuntimeError(f"no free routing-table IDs in [{low},{high}]")
+
+
+def _stage_rt_tables(
+ stage_root: Path,
+ meta: Dict[str, Tuple[int, Optional[int], str, Optional[str]]],
+ low: int = 20000,
+ high: int = 29999
+) -> Tuple[Path, List[str]]:
+ """
+ Ensure entries for all effective table names present in `meta`.
+ Prefer DB rt_table_id when available and not conflicting.
+ Write merged file to stage/etc/iproute2/rt_tables.
+ Returns (staged_path, notes)
+ """
+ lines, name_to_num, used_nums = _parse_rt_tables(RT_TABLES_PATH)
+
+ # Build eff_name -> preferred_num mapping (first non-None rt_id wins)
+ eff_to_preferred: Dict[str, Optional[int]] = {}
+ for _n, (_iid, rtid, eff, _cidr) in meta.items():
+ if eff not in eff_to_preferred:
+ eff_to_preferred[eff] = rtid if rtid is not None else None
+
+ additions: List[Tuple[int, str]] = []
+ for eff_name, preferred_num in eff_to_preferred.items():
+ if eff_name in name_to_num:
+ continue # already present
+ if preferred_num is not None and preferred_num not in used_nums:
+ num = preferred_num
+ else:
+ num = _first_free_id(used_nums, low, high)
+ name_to_num[eff_name] = num
+ used_nums.add(num)
+ additions.append((num, eff_name))
+
+ out = stage_root / "etc" / "iproute2" / "rt_tables"
+ out.parent.mkdir(parents=True, exist_ok=True)
+
+ if not additions:
+ # still write a copy of current file so install step is uniform
+ out.write_text("\n".join(lines) + ("\n" if lines else ""))
+ return (out, ["rt_tables: no additions (kept existing map)"])
+
+ new_lines = list(lines)
+ for num, name in sorted(additions):
+ new_lines.append(f"{num} {name}")
+
+ out.write_text("\n".join(new_lines) + "\n")
+ notes = [f"rt_tables: add {num} {name}" for num, name in sorted(additions)]
+ return (out, notes)
+
+
+# ---------- DB access ----------
+
+def _fetch_meta_subu_cidr(conn: sqlite3.Connection, default="10.0.0.0/24") -> str:
+ row = conn.execute("SELECT value FROM Meta WHERE key='subu_cidr' LIMIT 1;").fetchone()
+ return str(row[0]) if row and row[0] else default
+
+
+def _fetch_iface_meta(conn: sqlite3.Connection, iface_names: Sequence[str]) -> Dict[str, Tuple[int, Optional[int], str, Optional[str]]]:
+ """
+ Return {iface_name -> (iface_id, rt_table_id, rt_table_name_eff, local_address_cidr_or_None)}.
+ """
+ if not iface_names:
+ return {}
+ ph = ",".join("?" for _ in iface_names)
+ sql = f"""
+ SELECT i.id,
+ i.iface,
+ i.rt_table_id,
+ v.rt_table_name_eff,
+ NULLIF(TRIM(v.local_address_cidr),'') AS cidr
+ FROM Iface i
+ JOIN v_iface_effective v ON v.id = i.id
+ WHERE i.iface IN ({ph})
+ ORDER BY i.id;
+ """
+ rows = conn.execute(sql, tuple(iface_names)).fetchall()
+ out: Dict[str, Tuple[int, Optional[int], str, Optional[str]]] = {}
+ for r in rows:
+ iface_id = int(r[0]); name = str(r[1])
+ rt_id = (int(r[2]) if r[2] is not None else None)
+ eff = str(r[3])
+ cidr = (str(r[4]) if r[4] is not None else None)
+ out[name] = (iface_id, rt_id, eff, cidr)
+ return out
+
+
+def _fetch_routes_by_iface_id(
+ conn: sqlite3.Connection,
+ iface_ids: Sequence[int],
+ only_on_up: bool = True
+) -> Dict[int, List[Tuple[str, Optional[str], Optional[str], Optional[int]]]]:
+ """
+ Return {iface_id -> [(cidr, via, table_name_or_None, metric_or_None), ...]}.
+ """
+ if not iface_ids:
+ return {}
+ ph = ",".join("?" for _ in iface_ids)
+ sql = f"""
+ SELECT iface_id,
+ cidr,
+ NULLIF(TRIM(via),'') AS via,
+ NULLIF(TRIM(table_name),'') AS table_name,
+ metric,
+ on_up
+ FROM Route
+ WHERE iface_id IN ({ph})
+ ORDER BY id;
+ """
+ rows = conn.execute(sql, tuple(iface_ids)).fetchall()
+ out: Dict[int, List[Tuple[str, Optional[str], Optional[str], Optional[int]]]] = {}
+ for iface_id, cidr, via, tname, metric, on_up in rows:
+ if only_on_up and int(on_up) != 1:
+ continue
+ out.setdefault(int(iface_id), []).append(
+ (str(cidr),
+ (str(via) if via is not None else None),
+ (str(tname) if tname is not None else None),
+ (int(metric) if metric is not None else None))
+ )
+ return out
+
+
+def _fetch_uids_by_iface_id(conn: sqlite3.Connection, iface_ids: Sequence[int]) -> Dict[int, List[int]]:
+ """
+ Return {iface_id -> [uid, ...]} using table "User".
+ """
+ if not iface_ids:
+ return {}
+ ph = ",".join("?" for _ in iface_ids)
+ sql = f"""
+ SELECT iface_id,
+ uid
+ FROM "User"
+ WHERE iface_id IN ({ph})
+ AND uid IS NOT NULL
+ AND CAST(uid AS TEXT) != ''
+ ORDER BY iface_id, uid;
+ """
+ rows = conn.execute(sql, tuple(iface_ids)).fetchall()
+ out: Dict[int, List[int]] = {}
+ for iface_id, uid in rows:
+ out.setdefault(int(iface_id), []).append(int(uid))
+ return out
+
+
+# ---------- rendering ----------
+
+def _render_composite_script(
+ plan_ifaces: List[str],
+ meta: Dict[str, Tuple[int, Optional[int], str, Optional[str]]],
+ routes_by_id: Dict[int, List[Tuple[str, Optional[str], Optional[str], Optional[int]]]],
+ uids_by_id: Dict[int, List[int]],
+ subu_cidr: str
+) -> str:
+ """
+ Build a single bash script that ensures addresses → routes → rules.
+ """
+ lines: List[str] = [
+ "#!/usr/bin/env bash",
+ "# apply IP state for selected interfaces (addresses, routes, rules) — idempotent",
+ "set -euo pipefail",
+ "",
+ "ALL_ARGS=(\"$@\")",
+ "",
+ "want_iface(){",
+ " local t=$1",
+ " if [ ${#ALL_ARGS[@]} -eq 0 ]; then return 0; fi",
+ " for a in \"${ALL_ARGS[@]}\"; do [ \"$a\" = \"$t\" ] && return 0; done",
+ " return 1",
+ "}",
+ "",
+ "exists_iface(){ ip -o link show dev \"$1\" >/dev/null 2>&1; }",
+ "",
+ "# Reset address: delete the exact CIDR if present, then add it back.",
+ "reset_addr(){",
+ " local iface=$1; local cidr=$2",
+ " ip -4 addr del \"$cidr\" dev \"$iface\" >/dev/null 2>&1 || true",
+ " if ip -4 addr add \"$cidr\" dev \"$iface\"; then",
+ " logger \"addr set: $iface $cidr\"",
+ " else",
+ " logger \"addr add failed (non-fatal): $iface $cidr\"",
+ " fi",
+ "}",
+ "",
+ "# Ensure route using replace; log but do not fail the unit if kernel says 'exists'.",
+ "ensure_route(){",
+ " local table=$1; local cidr=$2; local dev=$3; local via=${4:-}; local metric=${5:-}",
+ " if [ -n \"$via\" ] && [ -n \"$metric\" ]; then",
+ " if ip -4 route replace \"$cidr\" via \"$via\" dev \"$dev\" table \"$table\" metric \"$metric\" 2>/dev/null; then",
+ " logger \"route ensure: table=$table cidr=$cidr dev=$dev via=$via metric=$metric\"",
+ " else",
+ " logger \"route ensure (tolerated failure): table=$table cidr=$cidr dev=$dev via=$via metric=$metric\"",
+ " fi",
+ " elif [ -n \"$via\" ]; then",
+ " if ip -4 route replace \"$cidr\" via \"$via\" dev \"$dev\" table \"$table\" 2>/dev/null; then",
+ " logger \"route ensure: table=$table cidr=$cidr dev=$dev via=$via\"",
+ " else",
+ " logger \"route ensure (tolerated failure): table=$table cidr=$cidr dev=$dev via=$via\"",
+ " fi",
+ " elif [ -n \"$metric\" ]; then",
+ " if ip -4 route replace \"$cidr\" dev \"$dev\" table \"$table\" metric \"$metric\" 2>/dev/null; then",
+ " logger \"route ensure: table=$table cidr=$cidr dev=$dev metric=$metric\"",
+ " else",
+ " logger \"route ensure (tolerated failure): table=$table cidr=$cidr dev=$dev metric=$metric\"",
+ " fi",
+ " else",
+ " if ip -4 route replace \"$cidr\" dev \"$dev\" table \"$table\" 2>/dev/null; then",
+ " logger \"route ensure: table=$table cidr=$cidr dev=$dev\"",
+ " else",
+ " logger \"route ensure (tolerated failure): table=$table cidr=$cidr dev=$dev\"",
+ " fi",
+ " fi",
+ "}",
+ "",
+ "# Reset a policy rule by numeric preference: delete-by-pref, then add.",
+ "reset_IP_rule(){",
+ " # Usage: reset_IP_rule <pref> <rule-args...>",
+ " local pref=$1; shift",
+ " ip -4 rule del pref \"$pref\" >/dev/null 2>&1 || true",
+ " if ip -4 rule add \"$@\" pref \"$pref\"; then",
+ " logger \"rule set: pref=$pref $*\"",
+ " else",
+ " logger \"rule add failed (non-fatal): pref=$pref $*\"",
+ " fi",
+ "}",
+ "",
+ ]
+
+ any_action = False
+
+ # 1) Addresses (reset)
+ for name in plan_ifaces:
+ _iid, _rtid, rtname, cidr = meta[name]
+ if cidr:
+ lines += [
+ f'if want_iface {name}; then',
+ f' if exists_iface {name}; then reset_addr {name} {cidr}; else logger "skip: iface missing: {name}"; fi',
+ 'fi'
+ ]
+ any_action = True
+
+ # 2) Routes
+ for name in plan_ifaces:
+ iid, _rtid, rtname, _cidr = meta[name]
+ rows = routes_by_id.get(iid, [])
+ for cidr, via, t_override, metric in rows:
+ table_eff = t_override or rtname
+ viastr = (via if via is not None else "")
+ mstr = (str(metric) if metric is not None else "")
+ lines += [
+ f'if want_iface {name}; then',
+ f' if exists_iface {name}; then ensure_route "{table_eff}" "{cidr}" "{name}" "{viastr}" "{mstr}"; else logger "skip: iface missing: {name}"; fi',
+ 'fi'
+ ]
+ any_action = True
+
+ # 3) Rules (reset by pref: src-cidr, uids, and one global prohibit)
+ for name in plan_ifaces:
+ iid, _rtid, rtname, cidr = meta[name]
+
+ # Per-iface preference block (no collisions)
+ base_pref = 17000 + iid * 10
+ from_pref = base_pref + 0
+ uid_pref = base_pref + 1
+
+ if cidr:
+ lines += [
+ f'if want_iface {name}; then',
+ f' reset_IP_rule {from_pref} from "{cidr}" lookup "{rtname}"',
+ 'fi'
+ ]
+ any_action = True
+
+ uids = uids_by_id.get(iid, [])
+ for u in uids:
+ lines += [
+ f'if want_iface {name}; then',
+ f' reset_IP_rule {uid_pref} uidrange "{u}-{u}" lookup "{rtname}"',
+ 'fi'
+ ]
+ any_action = True
+
+ if subu_cidr:
+ lines += [
+ f'reset_IP_rule 18050 from "{subu_cidr}" prohibit'
+ ]
+ any_action = True
+
+ if not any_action:
+ raise RuntimeError("no IP state to emit for requested interfaces")
+
+ lines += [""]
+ return "\n".join(lines)
+
+
+def _write_dropin_for_iface(stage_root: Path ,iface: str ,script_name: str ,priority: int) -> Path:
+ # correct systemd path: /etc/systemd/system/wg-quick@IFACE.service.d/
+ d = stage_root / "etc" / "systemd" / "system" / f"wg-quick@{iface}.service.d"
+ d.mkdir(parents=True ,exist_ok=True)
+ p = d / f"{priority}-postup-IP-state.conf"
+ content = (
+ "[Service]\n"
+ f"ExecStartPost=+/usr/local/bin/{script_name} {iface}\n"
+ )
+ p.write_text(content)
+ return p
+
+
+# ---------- business ----------
+
+def stage_IP_apply_script(
+ conn: sqlite3.Connection,
+ iface_names: Sequence[str],
+ stage_root: Optional[Path] = None,
+ script_name: str = "apply_IP_state.sh",
+ dropin_priority: int = 20,
+ only_on_up: bool = True,
+ with_dropins: bool = True,
+ dry_run: bool = False
+) -> Tuple[Path, List[str]]:
+ """
+ Plan and stage the unified runtime script, a merged rt_tables, and per-iface drop-ins.
+ """
+ if not iface_names:
+ raise RuntimeError("no interfaces provided")
+
+ meta = _fetch_iface_meta(conn, iface_names)
+ if not meta:
+ raise RuntimeError("none of the requested interfaces exist in DB")
+
+ # preserve caller order but skip unknowns (already handled above)
+ ifaces_in_order = [n for n in iface_names if n in meta]
+ iface_ids = [meta[n][0] for n in ifaces_in_order]
+
+ routes_by_id = _fetch_routes_by_iface_id(conn, iface_ids, only_on_up=only_on_up)
+ uids_by_id = _fetch_uids_by_iface_id(conn, iface_ids)
+ subu_cidr = _fetch_meta_subu_cidr(conn, default="10.0.0.0/24")
+
+ sr = stage_root or STAGE_ROOT
+ out = sr / "usr" / "local" / "bin" / script_name
+ out.parent.mkdir(parents=True, exist_ok=True)
+
+ content = _render_composite_script(ifaces_in_order, meta, routes_by_id, uids_by_id, subu_cidr)
+
+ notes: List[str] = []
+ if dry_run:
+ notes.append(f"dry-run: would write {_stage_note(out, sr)}")
+ if with_dropins:
+ for n in ifaces_in_order:
+ notes.append(f"dry-run: would write {_stage_note(sr / 'etc' / 'systemd' / 'system' / f'wg-quick@{n}.service.d' / f'{dropin_priority}-postup-IP-state.conf', sr)}")
+ rt_out = sr / "etc" / "iproute2" / "rt_tables"
+ notes.append(f"dry-run: would write {_stage_note(rt_out, sr)}")
+ return (out, notes)
+
+ # ensure rt_tables entries for the effective names used by these ifaces
+ rt_path, rt_notes = _stage_rt_tables(sr, meta)
+ notes.extend(rt_notes)
+ notes.append(f"staged: {_stage_note(rt_path, sr)}")
+
+ out.write_text(content)
+ out.chmod(0o500)
+ notes.append(f"staged: {_stage_note(out, sr)}")
+
+ if with_dropins:
+ for n in ifaces_in_order:
+ dp = _write_dropin_for_iface(sr, n, script_name, dropin_priority)
+ notes.append(f"staged: {_stage_note(dp, sr)}")
+
+ return (out, notes)
+
+# Backwards-compatible alias for callers that still import the old name.
+stage_ip_apply_script = stage_IP_apply_script
+
+
+# ---------- CLI ----------
+
+def main(argv=None) -> int:
+ ap = argparse.ArgumentParser(description="Stage one script that applies IP addresses, routes, and rules for selected ifaces.")
+ ap.add_argument("ifaces", nargs="+", help="interface names to include")
+ ap.add_argument("--script-name", default="apply_IP_state.sh")
+ ap.add_argument("--dropin-priority", type=int, default=20)
+ ap.add_argument("--all", action="store_true", help="include routes where on_up=0 as well")
+ ap.add_argument("--no-dropins", action="store_true", help="do not stage systemd drop-ins")
+ ap.add_argument("--dry-run", action="store_true")
+ args = ap.parse_args(argv)
+
+ with ic.open_db() as conn:
+ try:
+ out, notes = stage_IP_apply_script(
+ conn,
+ args.ifaces,
+ script_name=args.script_name,
+ dropin_priority=args.dropin_priority,
+ only_on_up=(not args.all),
+ with_dropins=(not args.no_dropins),
+ dry_run=args.dry_run
+ )
+ except Exception as e:
+ print(f"error: {e}", file=sys.stderr)
+ return 2
+
+ if notes:
+ print("\n".join(notes))
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
--- /dev/null
+#!/usr/bin/env python3
+"""
+stage_StanleyPark.py
+
+Minimal config wrapper for client 'StanleyPark'.
+Calls the generic stage orchestrator with the chosen ifaces.
+"""
+
+from __future__ import annotations
+from stage_client import stage_client_artifacts
+
+CLIENT = "StanleyPark"
+IFACES = ["x6","US"] # keep this list minimal & declarative
+
+if __name__ == "__main__":
+ ok = stage_client_artifacts(
+ CLIENT
+ ,IFACES
+ )
+ raise SystemExit(0 if ok else 2)
--- /dev/null
+#!/usr/bin/env python3
+"""
+stage_client.py
+
+Given:
+ - A SQLite DB via incommon.open_db()
+ - A client machine name (for WG PrivateKey lookup under ./key/<client>)
+ - One or more interface names (e.g., x6, US)
+
+Does:
+ 1) Stage WireGuard confs for each iface
+ 2) Stage a unified IP apply script (addresses, routes, rules) + per-iface drop-ins
+
+Returns:
+ True on success, False on failure (prints progress)
+"""
+
+from __future__ import annotations
+from pathlib import Path
+from typing import Callable ,Optional ,Sequence ,Tuple
+import argparse
+import subprocess
+import sys
+
+import incommon as ic # open_db()
+
+ROOT = Path(__file__).resolve().parent
+STAGE_ROOT = ROOT / "stage"
+
+
+def _msg_wrapped_call(label: str ,fn: Callable[[], Tuple[Path ,Sequence[str]]]) -> bool:
+ print(f"→ {label}")
+ try:
+ path ,notes = fn()
+ for n in notes:
+ print(n)
+ if path:
+ print(f"✔ {label}: staged: {path}")
+ else:
+ print(f"✔ {label}")
+ return True
+ except Exception as e:
+ print(f"❌ {label}: {e}")
+ return False
+
+
+def _call_cli(argv: Sequence[str]) -> Tuple[Path ,Sequence[str]]:
+ cp = subprocess.run(list(argv) ,text=True ,capture_output=True)
+ if cp.returncode != 0:
+ raise RuntimeError(cp.stderr.strip() or f"exit {cp.returncode}")
+ notes = []
+ staged_path: Optional[Path] = None
+ for line in (cp.stdout or "").splitlines():
+ notes.append(line)
+ if line.startswith("staged: "):
+ try:
+ staged_path = Path(line.split("staged:",1)[1].strip())
+ except Exception:
+ pass
+ return (staged_path or STAGE_ROOT ,notes)
+
+
+def _stage_wg_conf_step(client_name: str ,ifaces: Sequence[str]) -> bool:
+ def _do():
+ try:
+ from stage_wg_conf import stage_wg_conf # type: ignore
+ with ic.open_db() as conn:
+ path ,notes = stage_wg_conf(
+ conn
+ ,ifaces
+ ,client_name
+ ,stage_root=STAGE_ROOT
+ ,dry_run=False
+ )
+ return (path ,notes)
+ except Exception:
+ return _call_cli([str(ROOT / "stage_wg_conf.py") ,client_name ,*ifaces])
+ return _msg_wrapped_call(f"stage_wg_conf ({client_name}; {','.join(ifaces)})" ,_do)
+
+
+def _stage_apply_ip_state_step(ifaces: Sequence[str]) -> bool:
+ def _do():
+ try:
+ from stage_IP_apply_script import stage_ip_apply_script # type: ignore
+ with ic.open_db() as conn:
+ path ,notes = stage_ip_apply_script(
+ conn
+ ,ifaces
+ ,stage_root=STAGE_ROOT
+ ,script_name="apply_ip_state.sh"
+ ,only_on_up=True
+ ,dry_run=False
+ )
+ return (path ,notes)
+ except Exception:
+ return _call_cli([str(ROOT / "stage_IP_apply_script.py") ,*ifaces])
+ return _msg_wrapped_call(f"stage_IP_apply_script ({','.join(ifaces)})" ,_do)
+
+
+def stage_client_artifacts(
+ client_name: str
+ ,iface_names: Sequence[str]
+ ,stage_root: Optional[Path] = None
+) -> bool:
+ if not iface_names:
+ raise ValueError("no interfaces provided")
+ if stage_root:
+ global STAGE_ROOT
+ STAGE_ROOT = stage_root
+
+ STAGE_ROOT.mkdir(parents=True ,exist_ok=True)
+
+ ok = True
+ ok = _stage_wg_conf_step(client_name ,iface_names) and ok
+ ok = _stage_apply_ip_state_step(iface_names) and ok
+ return ok
+
+
+def main(argv: Optional[Sequence[str]] = None) -> int:
+ ap = argparse.ArgumentParser(description="Stage all artifacts for a client.")
+ ap.add_argument("--client" ,required=True ,help="client machine name (for key lookup)")
+ ap.add_argument("ifaces" ,nargs="+")
+ args = ap.parse_args(argv)
+
+ ok = stage_client_artifacts(
+ args.client
+ ,args.ifaces
+ )
+ return 0 if ok else 2
+
+
+if __name__ == "__main__":
+ sys.exit(main())
--- /dev/null
+#!/usr/bin/env python3
+"""
+stage_wg_conf.py
+
+Given:
+ - SQLite DB reachable via incommon.open_db()
+ - A list of interface names (e.g., x6 ,US)
+ - client_machine_name used to locate the private key file under ./key/<client_machine_name>
+
+Does:
+ - For each iface, stage a minimal WireGuard config to stage/etc/wireguard/<iface>.conf:
+ [Interface]
+ PrivateKey = <from ./key/<client_machine_name>>
+ Table = off
+ ListenPort = <Iface.listen_port> (if the column exists and value is not NULL)
+ # ListenPort = 51820 (commented if value is absent)
+ [Peer] (one per Server row for that iface)
+ PublicKey = <Server.public_key>
+ PresharedKey = <Server.preshared_key> (only if present)
+ AllowedIPs = <Server.allowed_ips>
+ Endpoint = <Server.endpoint_host>:<Server.endpoint_port>
+ PersistentKeepalive = <Server.keepalive_s> (only if present)
+ - Omits Address ,PostUp ,SaveConfig (your systemd drop-in + script handle L3 state)
+
+Returns:
+ - (list_of_staged_paths ,notes)
+
+Errors:
+ - Missing private key file
+ - Iface not found
+ - Server rows missing required fields for that iface
+"""
+
+from __future__ import annotations
+from pathlib import Path
+from typing import Dict ,Iterable ,List ,Optional ,Sequence ,Tuple
+import argparse
+import sqlite3
+import sys
+
+import incommon as ic # expected: open_db()
+
+ROOT = Path(__file__).resolve().parent
+STAGE_ROOT = ROOT / "stage"
+
+
+# ---------- helpers ----------
+
+def _has_column(conn: sqlite3.Connection ,table: str ,col: str) -> bool:
+ cur = conn.execute(f"PRAGMA table_info({table});")
+ cols = [str(r[1]) for r in cur.fetchall()]
+ return col in cols
+
+
+def _read_private_key(client_machine_name: str ,key_root: Optional[Path] = None) -> str:
+ kr = key_root or (ROOT / "key")
+ path = kr / client_machine_name
+ if not path.exists():
+ raise RuntimeError(f"private key file missing: {path}")
+ text = path.read_text().strip()
+ if not text:
+ raise RuntimeError(f"private key file empty: {path}")
+ # WireGuard private keys are base64 (typically 44 chars), but don't over-validate here.
+ return text
+
+
+# ---------- DB ----------
+
+def _fetch_iface_ids_and_ports(
+ conn: sqlite3.Connection
+ ,iface_names: Sequence[str]
+) -> Dict[str ,Tuple[int ,Optional[int]]]:
+ """
+ Return {iface_name -> (iface_id ,listen_port_or_None)} for requested names.
+ If the listen_port column does not exist, value is None.
+ """
+ if not iface_names:
+ return {}
+ ph = ",".join("?" for _ in iface_names)
+ has_lp = _has_column(conn ,"Iface" ,"listen_port")
+ select_lp = ", i.listen_port" if has_lp else ", NULL as listen_port"
+ sql = f"""
+ SELECT i.id
+ , i.iface
+ {select_lp}
+ FROM Iface i
+ WHERE i.iface IN ({ph})
+ ORDER BY i.id;
+ """
+ rows = conn.execute(sql ,tuple(iface_names)).fetchall()
+ out: Dict[str ,Tuple[int ,Optional[int]]] = {}
+ for iid ,name ,lp in rows:
+ out[str(name)] = (int(iid) ,(int(lp) if lp is not None else None))
+ return out
+
+
+def _fetch_peers_for_iface(
+ conn: sqlite3.Connection
+ ,iface_id: int
+) -> List[Tuple[str ,Optional[str] ,str ,int ,str ,Optional[int] ,int ,int]]:
+ """
+ Return peers as tuples:
+ (public_key ,preshared_key ,endpoint_host ,endpoint_port ,allowed_ips ,keepalive_s ,priority ,id)
+ """
+ sql = """
+ SELECT public_key
+ , NULLIF(TRIM(preshared_key),'') as preshared_key
+ , endpoint_host
+ , endpoint_port
+ , allowed_ips
+ , keepalive_s
+ , priority
+ , id
+ FROM Server
+ WHERE iface_id = ?
+ ORDER BY priority ASC , id ASC;
+ """
+ rows = conn.execute(sql ,(iface_id,)).fetchall()
+ out: List[Tuple[str ,Optional[str] ,str ,int ,str ,Optional[int] ,int ,int]] = []
+ for pub ,psk ,host ,port ,alips ,ka ,prio ,sid in rows:
+ out.append((str(pub) ,(str(psk) if psk is not None else None) ,str(host) ,int(port) ,str(alips) ,(int(ka) if ka is not None else None) ,int(prio) ,int(sid)))
+ return out
+
+
+# ---------- rendering ----------
+
+def _render_conf(
+ iface_name: str
+ ,private_key: str
+ ,listen_port: Optional[int]
+ ,peers: Sequence[Tuple[str ,Optional[str] ,str ,int ,str ,Optional[int] ,int ,int]]
+) -> str:
+ lines: List[str] = []
+ lines += [
+ "[Interface]"
+ ,f"PrivateKey = {private_key}"
+ ,"Table = off"
+ ]
+ if listen_port is not None:
+ lines.append(f"ListenPort = {listen_port}")
+ else:
+ lines.append("# ListenPort = 51820")
+
+ lines.append("") # blank before peers
+
+ if not peers:
+ # You may choose to raise instead; keeping an empty peer set is valid but rarely useful.
+ lines.append("# (no peers found for this interface)")
+
+ for pub ,psk ,host ,port ,alips ,ka ,_prio ,_sid in peers:
+ lines += [
+ "[Peer]"
+ ,f"PublicKey = {pub}"
+ ]
+ if psk is not None:
+ lines.append(f"PresharedKey = {psk}")
+ lines += [
+ f"AllowedIPs = {alips}"
+ ,f"Endpoint = {host}:{port}"
+ ]
+ if ka is not None:
+ lines.append(f"PersistentKeepalive = {ka}")
+ lines.append("") # blank line between peers
+
+ return "\n".join(lines).rstrip() + "\n"
+
+
+# ---------- business ----------
+
+def stage_wg_conf(
+ conn: sqlite3.Connection
+ ,iface_names: Sequence[str]
+ ,client_machine_name: str
+ ,stage_root: Optional[Path] = None
+ ,dry_run: bool = False
+) -> Tuple[List[Path] ,List[str]]:
+ """
+ Stage /etc/wireguard/<iface>.conf for selected ifaces under stage root.
+ """
+ if not iface_names:
+ raise RuntimeError("no interfaces provided")
+ priv = _read_private_key(client_machine_name)
+
+ meta = _fetch_iface_ids_and_ports(conn ,iface_names)
+ if not meta:
+ raise RuntimeError("none of the requested interfaces exist in DB")
+
+ staged: List[Path] = []
+ notes: List[str] = []
+ sr = stage_root or STAGE_ROOT
+ outdir = sr / "etc" / "wireguard"
+ outdir.mkdir(parents=True ,exist_ok=True)
+
+ for name in iface_names:
+ if name not in meta:
+ notes.append(f"skip: iface '{name}' missing from DB")
+ continue
+
+ iface_id ,listen_port = meta[name]
+ peers = _fetch_peers_for_iface(conn ,iface_id)
+
+ # basic validation of required peer fields
+ bad = []
+ for pub ,_psk ,host ,port ,alips ,_ka ,_prio ,sid in peers:
+ if not pub or not host or not alips or not (1 <= int(port) <= 65535):
+ bad.append(sid)
+ if bad:
+ raise RuntimeError(f"iface '{name}': invalid peer rows id={bad}")
+
+ conf_text = _render_conf(name ,priv ,listen_port ,peers)
+
+ out = outdir / f"{name}.conf"
+ if dry_run:
+ notes.append(f"dry-run: would write {out}")
+ else:
+ out.write_text(conf_text)
+ out.chmod(0o600)
+ staged.append(out)
+ notes.append(f"staged: {out}")
+
+ if not staged and not dry_run:
+ raise RuntimeError("nothing staged (all missing or skipped)")
+
+ return (staged ,notes)
+
+
+# ---------- CLI ----------
+
+def main(argv=None) -> int:
+ ap = argparse.ArgumentParser(description="Stage minimal WireGuard configs with Table=off and no Address.")
+ ap.add_argument("client_machine_name" ,help="name used to read ./key/<client_machine_name>")
+ ap.add_argument("ifaces" ,nargs="+" ,help="interface names to stage")
+ ap.add_argument("--dry-run" ,action="store_true")
+ args = ap.parse_args(argv)
+
+ with ic.open_db() as conn:
+ try:
+ paths ,notes = stage_wg_conf(
+ conn
+ ,args.ifaces
+ ,args.client_machine_name
+ ,dry_run=args.dry_run
+ )
+ except Exception as e:
+ print(f"error: {e}" ,file=sys.stderr)
+ return 2
+
+ if notes:
+ print("\n".join(notes))
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
--- /dev/null
+#!/usr/bin/env python3
+# stage_wipe.py — safely wipe ./stage (keeps hidden files unless --hard)
+
+from __future__ import annotations
+import argparse, shutil, sys
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parent
+STAGE_ROOT = ROOT / "stage"
+
+def wipe_stage(*, yes: bool=False, dry_run: bool=False, hard: bool=False) -> int:
+ """Given flags, deletes staged output. Keeps dotfiles unless hard=True."""
+ st = STAGE_ROOT
+ if not st.exists():
+ print(f"Nothing to wipe: {st} does not exist.")
+ return 0
+
+ # safety: only operate on ./stage relative to this repo folder
+ if st.resolve() != (ROOT / "stage").resolve():
+ print(f"Refusing: unsafe STAGE path: {st}", file=sys.stderr)
+ return 1
+
+ # quick stats
+ try:
+ count = sum(1 for _ in st.rglob("*"))
+ except Exception:
+ count = 0
+
+ if dry_run:
+ print(f"DRY RUN — would wipe: {st} (items: {count})")
+ for p in sorted(st.iterdir()):
+ print(f" {p.name}")
+ return 0
+
+ if not yes:
+ try:
+ ans = input(f"Permanently delete contents of {st}? [y/N] ").strip()
+ except EOFError:
+ ans = ""
+ if ans.lower() not in ("y","yes"):
+ print("Aborted.")
+ return 0
+
+ if hard:
+ shutil.rmtree(st, ignore_errors=True)
+ print(f"Removed stage dir: {st}")
+ else:
+ # remove non-hidden entries only; keep dotfiles (e.g. .gitignore)
+ for p in st.iterdir():
+ if p.name.startswith("."):
+ continue # preserve hidden entries
+ try:
+ if p.is_dir():
+ shutil.rmtree(p, ignore_errors=True)
+ else:
+ p.unlink(missing_ok=True)
+ except Exception:
+ pass
+ print(f"Cleared contents of: {st} (hidden files preserved)")
+ return 0
+
+def main(argv):
+ ap = argparse.ArgumentParser()
+ ap.add_argument("--yes", action="store_true", help="do not prompt")
+ ap.add_argument("--dry-run", action="store_true", help="show what would be removed")
+ ap.add_argument("--hard", action="store_true", help="remove the stage dir itself")
+ args = ap.parse_args(argv)
+ return wipe_stage(yes=args.yes, dry_run=args.dry_run, hard=args.hard)
+
+if __name__ == "__main__":
+ sys.exit(main(sys.argv[1:]))
--- /dev/null
+#!/usr/bin/env python3
+"""
+start_iface.py
+
+Given:
+ - One or more WireGuard interface names (e.g., x6, US).
+ - Optional presence of systemd and wg-quick(8).
+ - Expected config at /etc/wireguard/<iface>.conf.
+ - Optional staged IP state script at /usr/local/bin/apply_ip_state.sh.
+
+Does:
+ - For each iface (best-effort, non-fatal steps):
+ 0) (optional) systemctl daemon-reload
+ 1) Start via systemd: systemctl start wg-quick@IFACE.service (unless --no-systemd)
+ else via wg-quick: wg-quick up IFACE (unless --no-wg-quick)
+ If the iface already exists and --force is given, it will attempt a
+ best-effort teardown then retry the start once.
+ 2) If started (or already present), optionally run IP state script:
+ /usr/local/bin/apply_ip_state.sh IFACE (unless --skip-ip-state)
+ - Logs each action taken or skipped.
+
+Returns:
+ - Exit 0 on success (even if some steps were no-ops); 2 on argument/privilege errors.
+ - Prints a concise, per-iface action log.
+
+Errors:
+ - If no ifaces are provided, or if not running as root (unless --force-nonroot).
+
+Notes:
+ - This does NOT edit config files or DB; it just brings the iface up cleanly.
+ - Safe to re-run: “already up/exist” conditions are handled. Use --force to
+ tear down and recreate if needed.
+"""
+
+from __future__ import annotations
+from pathlib import Path
+from typing import Iterable, List, Sequence
+import argparse
+import os
+import shutil
+import subprocess
+import sys
+
+
+# ---------- helpers ----------
+
+def _run(cmd: Sequence[str]) -> tuple[int, str, str]:
+ """Run a command, capture stdout/stderr, return (rc, out, err)."""
+ try:
+ cp = subprocess.run(cmd, check=False, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+ return (cp.returncode, cp.stdout.strip(), cp.stderr.strip())
+ except FileNotFoundError:
+ return (127, "", f"{cmd[0]}: not found")
+
+def _exists_iface(name: str) -> bool:
+ rc, _, _ = _run(["ip", "-o", "link", "show", "dev", name])
+ return rc == 0
+
+def _systemd_present() -> bool:
+ return shutil.which("systemctl") is not None
+
+def _wg_quick_present() -> bool:
+ return shutil.which("wg-quick") is not None
+
+def _conf_present(name: str) -> bool:
+ return Path(f"/etc/wireguard/{name}.conf").is_file()
+
+def _best_effort_teardown(name: str, logs: List[str]) -> None:
+ """Try to bring an iface down using systemd/wg-quick, then delete link; non-fatal."""
+ unit = f"wg-quick@{name}.service"
+ if _systemd_present():
+ rc, out, err = _run(["systemctl", "stop", unit])
+ if rc == 0:
+ logs.append(f"systemctl: stopped {unit}")
+ else:
+ logs.append(f"systemctl: stop {unit} (ignored): {err or out or f'rc={rc}'}")
+ if _wg_quick_present():
+ rc, out, err = _run(["wg-quick", "down", name])
+ if rc == 0:
+ logs.append("wg-quick: down ok")
+ else:
+ logs.append(f"wg-quick: down (ignored): {err or out or f'rc={rc}'}")
+ if _exists_iface(name):
+ rc, out, err = _run(["ip", "link", "del", "dev", name])
+ if rc == 0:
+ logs.append("ip link: deleted leftover device")
+ else:
+ logs.append(f"ip link: delete (ignored): {err or out or f'rc={rc}'}")
+
+
+# ---------- business ----------
+
+def start_ifaces(
+ ifaces: Sequence[str],
+ use_systemd: bool = True,
+ use_wg_quick: bool = True,
+ run_ip_state: bool = True,
+ ip_state_path: str = "/usr/local/bin/apply_ip_state.sh",
+ daemon_reload: bool = False,
+ force: bool = False,
+) -> List[str]:
+ """
+ Start the given WG ifaces and optionally apply IP state.
+ Returns a list of log lines.
+ """
+ logs: List[str] = []
+
+ if not ifaces:
+ raise RuntimeError("no interfaces provided")
+
+ have_systemd = _systemd_present()
+ have_wgquick = _wg_quick_present()
+ have_ipstate = Path(ip_state_path).is_file()
+
+ if use_systemd and daemon_reload and have_systemd:
+ rc, _out, err = _run(["systemctl", "daemon-reload"])
+ if rc == 0:
+ logs.append("systemctl: daemon-reload")
+ else:
+ logs.append(f"systemctl: daemon-reload (ignored): {err or f'rc={rc}'}")
+
+ for name in ifaces:
+ logs.append(f"== {name} ==")
+
+ # Ensure config exists
+ if not _conf_present(name):
+ logs.append(f"config missing: /etc/wireguard/{name}.conf (skip start)")
+ logs.append(f"status: absent")
+ logs.append("")
+ continue
+
+ started = False
+ already_present = _exists_iface(name)
+
+ # Optionally force recreate if device already around
+ if already_present and force:
+ logs.append("iface exists, --force given: tearing down before start")
+ _best_effort_teardown(name, logs)
+ already_present = _exists_iface(name)
+
+ # Start via systemd or wg-quick
+ if use_systemd and have_systemd:
+ unit = f"wg-quick@{name}.service"
+ rc, out, err = _run(["systemctl", "start", unit])
+ if rc == 0:
+ logs.append(f"systemctl: started {unit}")
+ started = True
+ else:
+ # If iface already exists, treat as running
+ if _exists_iface(name):
+ logs.append(f"systemctl: start {unit} reported error, but iface exists (continuing): {err or out or f'rc={rc}'}")
+ started = True
+ else:
+ logs.append(f"systemctl: start {unit} failed: {err or out or f'rc={rc}'}")
+ elif use_wg_quick and have_wgquick:
+ if already_present:
+ logs.append("wg-quick: iface already present")
+ started = True
+ else:
+ rc, out, err = _run(["wg-quick", "up", name])
+ if rc == 0:
+ logs.append("wg-quick: up ok")
+ started = True
+ else:
+ # If iface popped up anyway, continue
+ if _exists_iface(name):
+ logs.append(f"wg-quick: up reported error, but iface exists (continuing): {err or out or f'rc={rc}'}")
+ started = True
+ else:
+ logs.append(f"wg-quick: up failed: {err or out or f'rc={rc}'}")
+
+ else:
+ logs.append("no start method available (systemd/wg-quick disabled or not found)")
+
+ # If requested, apply IP state post-start (useful when not using systemd drop-ins)
+ if run_ip_state and have_ipstate:
+ if _exists_iface(name):
+ rc, out, err = _run([ip_state_path, name])
+ if rc == 0:
+ logs.append(f"ip-state: applied ({ip_state_path} {name})")
+ else:
+ logs.append(f"ip-state: apply failed: {err or out or f'rc={rc}'}")
+ else:
+ logs.append("ip-state: skipped (iface not present)")
+
+ # Final status
+ logs.append(f"status: {'up' if _exists_iface(name) else 'down'}")
+ logs.append("") # spacer
+
+ return logs
+
+
+# ---------- CLI (wrapper only) ----------
+
+def _require_root(allow_nonroot: bool) -> None:
+ if not allow_nonroot and os.geteuid() != 0:
+ raise RuntimeError("must run as root (use --force-nonroot to override)")
+
+def main(argv: Sequence[str] | None = None) -> int:
+ ap = argparse.ArgumentParser(description="Start one or more WireGuard interfaces safely.")
+ ap.add_argument("ifaces", nargs="+", help="interface names to start (e.g., x6 US)")
+ ap.add_argument("--no-systemd", action="store_true", help="do not call systemctl start wg-quick@IFACE")
+ ap.add_argument("--no-wg-quick", action="store_true", help="do not call wg-quick up IFACE")
+ ap.add_argument("--skip-ip-state", action="store_true", help="do not run apply_ip_state.sh after start")
+ ap.add_argument("--ip-state-path", default="/usr/local/bin/apply_ip_state.sh", help="path to the IP state script")
+ ap.add_argument("--daemon-reload", action="store_true", help="run systemctl daemon-reload before starts")
+ ap.add_argument("--force", action="store_true", help="if iface exists, tear down first and retry start")
+ ap.add_argument("--force-nonroot", action="store_true", help="allow running without root (best-effort)")
+ args = ap.parse_args(argv)
+
+ try:
+ _require_root(allow_nonroot=args.force_nonroot)
+ logs = start_ifaces(
+ args.ifaces,
+ use_systemd=(not args.no_systemd),
+ use_wg_quick=(not args.no_wg_quick),
+ run_ip_state=(not args.skip_ip_state),
+ ip_state_path=args.ip_state_path,
+ daemon_reload=args.daemon_reload,
+ force=args.force,
+ )
+ for line in logs:
+ print(line)
+ return 0
+ except Exception as e:
+ print(f"error: {e}", file=sys.stderr)
+ return 2
+
+if __name__ == "__main__":
+ sys.exit(main(sys.argv[1:]))
--- /dev/null
+#!/usr/bin/env python3
+"""
+stop_clean_iface.py
+
+Stop one or more WireGuard interfaces and clean IP state (rules/routes/addresses).
+"""
+
+from __future__ import annotations
+from pathlib import Path
+from typing import Iterable, List, Optional, Sequence, Tuple, Set
+import argparse
+import os
+import re
+import shutil
+import subprocess
+import sys
+
+__VERSION__ = "1.1-agg-errors"
+
+RT_TABLES_FILE = Path("/etc/iproute2/rt_tables")
+
+# ---------- helpers (shell) ----------
+
+def _run(cmd: Sequence[str], dry: bool=False) -> tuple[int, str, str]:
+ if dry:
+ return (0, "", "")
+ try:
+ cp = subprocess.run(cmd, check=False, text=True,
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+ return (cp.returncode, cp.stdout.strip(), cp.stderr.strip())
+ except FileNotFoundError:
+ return (127, "", f"{cmd[0]}: not found")
+
+def _exists_iface(name: str) -> bool:
+ rc, _, _ = _run(["ip", "-o", "link", "show", "dev", name])
+ return rc == 0
+
+def _systemd_present() -> bool:
+ return shutil.which("systemctl") is not None
+
+def _wg_quick_present() -> bool:
+ return shutil.which("wg-quick") is not None
+
+# ---------- helpers (routing tables & rules) ----------
+
+def _rt_table_num_for_name(name: str) -> Optional[int]:
+ if not RT_TABLES_FILE.exists():
+ return None
+ try:
+ text = RT_TABLES_FILE.read_text()
+ except Exception:
+ return None
+ for line in text.splitlines():
+ s = line.strip()
+ if not s or s.startswith("#"):
+ continue
+ parts = s.split()
+ if len(parts) >= 2 and parts[0].isdigit():
+ num = int(parts[0]); nm = parts[1]
+ if nm == name:
+ return num
+ return None
+
+_RULE_RE = re.compile(r"""^\s*(\d+):\s*(.+?)\s*$""")
+
+def _current_rule_lines() -> List[Tuple[int,str]]:
+ rc, out, _ = _run(["ip", "-4", "rule", "show"])
+ if rc != 0 or not out:
+ return []
+ rows: List[Tuple[int,str]] = []
+ for ln in out.splitlines():
+ m = _RULE_RE.match(ln)
+ if not m:
+ continue
+ pref = int(m.group(1))
+ rest = m.group(2)
+ rows.append((pref, rest))
+ return rows
+
+def _prefs_matching_lookups(lookups: Sequence[str]) -> Set[int]:
+ toks = [t for t in lookups if t]
+ prefs: Set[int] = set()
+ if not toks:
+ return prefs
+ for pref, rest in _current_rule_lines():
+ for t in toks:
+ if re.search(rf"\blookup\s+{re.escape(t)}\b", rest):
+ prefs.add(pref)
+ break
+ return prefs
+
+def _rule_del_by_pref(pref: int, logs: List[str], dry: bool) -> None:
+ rc, _out, err = _run(["ip", "-4", "rule", "del", "pref", str(pref)], dry=dry)
+ if rc == 0:
+ logs.append(f"ip rule: deleted pref {pref}")
+ else:
+ logs.append(f"ip rule: delete pref {pref} (ignored): {err or f'rc={rc}'}")
+
+def _flush_routes_for_table(table: str, logs: List[str], dry: bool) -> None:
+ rc, _out, err = _run(["ip", "-4", "route", "flush", "table", table], dry=dry)
+ if rc == 0:
+ logs.append(f"ip route: flushed table {table}")
+ else:
+ logs.append(f"ip route: flush table {table} (ignored): {err or f'rc={rc}'}")
+
+def _addr_del_all_v4_on_iface(iface: str, logs: List[str], dry: bool) -> None:
+ rc, out, err = _run(["ip", "-4", "-o", "addr", "show", "dev", iface], dry=dry)
+ if rc != 0:
+ logs.append(f"ip addr: list on {iface} (ignored): {err or f'rc={rc}'}")
+ return
+ cidrs: List[str] = []
+ for ln in out.splitlines():
+ parts = ln.split()
+ if len(parts) >= 4:
+ cidrs.append(parts[3])
+ if not cidrs:
+ logs.append("ip addr: none to remove")
+ return
+ for cidr in cidrs:
+ rc2, _o2, e2 = _run(["ip", "-4", "addr", "del", cidr, "dev", iface], dry=dry)
+ if rc2 == 0:
+ logs.append(f"ip addr: deleted {cidr}")
+ else:
+ logs.append(f"ip addr: delete {cidr} (ignored): {e2 or f'rc={rc2}'}")
+
+# ---------- business ----------
+
+def _clean_iface_ip_state(name: str, logs: List[str], *, dry: bool=False, aggressive: bool=False) -> None:
+ tokens: List[str] = [name]
+ num = _rt_table_num_for_name(name)
+ if num is not None:
+ tokens.append(str(num))
+
+ # Delete rules matching either numeric or named lookup tokens; loop to catch chains.
+ deleted_any = True
+ safety = 0
+ while deleted_any and safety < 10:
+ safety += 1
+ prefs = sorted(_prefs_matching_lookups(tokens))
+ if not prefs:
+ deleted_any = False
+ break
+ for p in prefs:
+ _rule_del_by_pref(p, logs, dry)
+ if aggressive:
+ for p in range(17000, 17060):
+ _rule_del_by_pref(p, logs, dry)
+
+ # Flush routes in the table by name and numeric (if known)
+ _flush_routes_for_table(name, logs, dry)
+ if num is not None:
+ _flush_routes_for_table(str(num), logs, dry)
+
+ # Remove all IPv4 addresses on the iface
+ _addr_del_all_v4_on_iface(name, logs, dry)
+
+def stop_clean_ifaces(
+ ifaces: Sequence[str],
+ use_systemd: bool = True,
+ use_wg_quick: bool = True,
+ do_clean: bool = True,
+ aggressive: bool = False,
+ dry_run: bool = False,
+) -> List[str]:
+ logs: List[str] = []
+ if not ifaces:
+ raise RuntimeError("no interfaces provided")
+
+ have_systemd = _systemd_present()
+ have_wgquick = _wg_quick_present()
+
+ for name in ifaces:
+ logs.append(f"== {name} ==")
+
+ if use_systemd and have_systemd:
+ unit = f"wg-quick@{name}.service"
+ rc, out, err = _run(["systemctl", "stop", unit], dry=dry_run)
+ if rc == 0:
+ logs.append(f"systemctl: stopped {unit}")
+ else:
+ msg = err or out or f"rc={rc}"
+ logs.append(f"systemctl: stop {unit} (ignored): {msg}")
+ elif use_systemd and not have_systemd:
+ logs.append("systemctl: not found; skipped")
+
+ if use_wg_quick and have_wgquick:
+ rc, out, err = _run(["wg-quick", "down", name], dry=dry_run)
+ if rc == 0:
+ logs.append("wg-quick: down ok")
+ else:
+ msg = err or out or f"rc={rc}"
+ logs.append(f"wg-quick: down (ignored): {msg}")
+ elif use_wg_quick and not have_wgquick:
+ logs.append("wg-quick: not found; skipped")
+
+ if do_clean:
+ _clean_iface_ip_state(name, logs, dry=dry_run, aggressive=aggressive)
+ else:
+ logs.append("clean: skipped (--no-clean)")
+
+ if _exists_iface(name):
+ rc, out, err = _run(["ip", "link", "del", "dev", name], dry=dry_run)
+ if rc == 0:
+ logs.append("ip link: deleted device")
+ else:
+ msg = err or out or f"rc={rc}"
+ logs.append(f"ip link: delete (ignored): {msg}")
+ else:
+ logs.append("ip link: device not present; nothing to delete")
+
+ final_present = _exists_iface(name)
+ logs.append(f"status: {'gone' if not final_present else 'still present'}")
+ logs.append("")
+
+ return logs
+
+# ---------- CLI (wrapper with aggregated errors) ----------
+
+def main(argv: Sequence[str] | None = None) -> int:
+ ap = argparse.ArgumentParser(
+ description="Stop one or more WireGuard interfaces and clean IP state.",
+ add_help=True)
+ ap.add_argument("ifaces", nargs="*", help="interface names to stop (e.g., x6 US)")
+ ap.add_argument("--no-systemd", action="store_true", help="do not call systemctl stop wg-quick@IFACE")
+ ap.add_argument("--no-wg-quick", action="store_true", help="do not call wg-quick down IFACE")
+ ap.add_argument("--no-clean", action="store_true", help="skip IP cleanup (rules/routes/addresses)")
+ ap.add_argument("--aggressive", action="store_true", help="also purge common rule pref window (17000-17059)")
+ ap.add_argument("--dry-run", action="store_true", help="print what would be done without changing state")
+ ap.add_argument("--force-nonroot", action="store_true", help="allow running without root (best-effort)")
+
+ args = ap.parse_args(argv)
+
+ # Aggregate invocation errors
+ errors: List[str] = []
+ if os.geteuid() != 0 and not args.force_nonroot:
+ errors.append("must run as root (use --force-nonroot to override)")
+ if not args.ifaces:
+ errors.append("no interfaces provided")
+
+ if errors:
+ sys.stderr.write(ap.format_usage())
+ prog = Path(sys.argv[0]).name or "stop_clean_iface.py"
+ sys.stderr.write(f"{prog}: error: " + "; ".join(errors) + "\n")
+ return 2
+
+ try:
+ logs = stop_clean_ifaces(
+ args.ifaces,
+ use_systemd=(not args.no_systemd),
+ use_wg_quick=(not args.no_wg_quick),
+ do_clean=(not args.no_clean),
+ aggressive=args.aggressive,
+ dry_run=args.dry_run,
+ )
+ for line in logs:
+ print(line)
+ return 0
+ except Exception as e:
+ print(f"error: {e}", file=sys.stderr)
+ return 2
+
+if __name__ == "__main__":
+ sys.exit(main(sys.argv[1:]))
--- /dev/null
+n#+TITLE: subu / WireGuard — TODO
+#+AUTHOR: Thomas & Nerith (session)
+#+LANGUAGE: en
+#+OPTIONS: toc:2 num:t
+#+TODO: TODO(t) NEXT(n) WAITING(w) BLOCKED(b) | DONE(d) CANCELED(c)
+
+- Your current DB schema (the one you pasted earlier) does not include a listen-port field on Iface. So if you want ListenPort = … to be driven from the DB, add a column like Iface.listen_port INTEGER CHECK(listen_port BETWEEN 1 AND 65535).
+
+- have the stage commands echo relative pathnames instead of absolute as they do now.
+
+- the one private key pair per client (instead of per interface), turns out to be a bad idea, as we can't manage tunnels individually, say, by revoking keys. We need to move to a key pair per interface instead.
+
+- db_wipe needs to delete the key directory contents also
+
+-------------------------------
+
+- Known gaps / open decisions
+ - Systemd drop-in to call staged scripts on ~wg-quick@IFACE~ up (IPv4 addrs + policy rules).
+ - Staged policy-rules script (source-based + uidrange rules) to replace the old global ~IP_rule_add.sh~ usage.
+ - Installer flow & atomic writes (copy staged files, set owner/perms; safe update of ~/etc/iproute2/rt_tables~).
+ - Pool size policy: default /16 with /32 hosts is implemented; decision pending on /8 vs /16.
+ - Style guardrails (RT commas / two-space indent) are manual; optional linter TBD.
+
+* NEXT wiring (high-level order)
+1) Stage: /etc/iproute2/rt_tables (merge) for selected ifaces.
+2) Stage: /usr/local/bin/set_iface_ipv4_addrs.sh for same ifaces.
+3) Stage: /usr/local/bin/set_policy_rules_for_ifaces.sh (new; replaces old global add tool).
+4) Stage: systemd drop-ins for ~wg-quick@IFACE.service.d/10-postup.conf~ to call (2) then (3).
+5) Install: copy staged files → system, set perms/owner; ~systemctl daemon-reload~.
+6) Bring-up: ~wg-quick up IFACE~; verify routes/rules; smoke tests.
+
+* TODO Add “missing-iface” guard to staged IPv4 script
+- When iface doesn’t exist yet, log and continue (no non-zero exit).
+
+* TODO Stage policy rules script (idempotent)
+- For each iface:
+ - Source-based rule: =from <local_address_cidr> lookup <rt_table_name_eff>=.
+ - UID rules: =uidrange U-U lookup <rt_table_name_eff>= for each bound UID.
+- Only for ifaces passed on the CLI; DB-driven; no kernel writes here.
+- Emit with checks (skip if grep finds the exact rule).
+
+* TODO Systemd drop-in generator
+- Emit to: ~stage/etc/systemd/wg-quick@IFACE.service.d/10-postup.conf~.
+- Include:
+ - =ExecStartPre=-/usr/sbin/ip link delete IFACE= (clean stale link).
+ - =ExecStartPost=+/usr/local/bin/set_iface_ipv4_addrs.sh=.
+ - =ExecStartPost=+/usr/local/bin/set_policy_rules_for_ifaces.sh=.
+ - =ExecStartPost=+/usr/bin/logger 'wg-quick@IFACE up: addrs+rules applied'=.
+
+* TODO Installer flow
+- Copy staged files with perms (0500 for scripts; 0644 for rt_tables; 0755 for dirs).
+- Atomic update for ~/etc/iproute2/rt_tables~ (write temp + move); keep timestamped backup.
+- ~systemctl daemon-reload~ after installing drop-ins.
+
+* WAITING Decide “no-op staging” policy for rt_tables
+- Option A: Always stage a copy (deterministic deployment).
+- Option B: Stage only when there are new entries (quieter diffs).
+
+* TODO Tests
+- Unit-ish: parse/plan functions for both staging scripts (dry-run cases, collisions, skip-missing cases).
+- Integration:
+ - Create temp WG iface: ~ip link add dev t0 type wireguard~ (and delete after).
+ - Run staged scripts; verify ~ip -4 addr show dev t0~, ~ip rule show~, ~ip route show table <name>~.
+ - Bring up real ~wg-quick up x6~; repeat verifications.
+
+* TODO Docs
+- Append “operational runbook” to the org manual (bring-up, verify, recover, teardown).
+
+* DONE What’s already proven by commands (from log)
+- all db_init is running, orchestrated by db_init_StanleyPark
+- =stage_rt_tables_merge.py --from-db x6 US= created staged rt_tables with merges.
+- =stage_iface_ipv4_script.py x6 US= staged ~set_iface_ipv4_addrs.sh~.
+
--- /dev/null
+#!/usr/bin/env python3
+# wg_keys_incommon.py — predicates + actuators for WG keypairs
+
+from __future__ import annotations
+import shutil, subprocess, sqlite3
+
+def wellformed_client_keypair(conn: sqlite3.Connection, iface: str) -> bool:
+ """Predicate: True iff client IFACE has a syntactically valid WG keypair."""
+ row = conn.execute(
+ "SELECT private_key, public_key FROM Iface WHERE iface=? LIMIT 1;", (iface,)
+ ).fetchone()
+ if not row: return False
+ priv, pub = (row[0] or ""), (row[1] or "")
+ return (43 <= len(priv.strip()) <= 45) and (43 <= len(pub.strip()) <= 45)
+
+def generate_client_keypair_if_missing(conn: sqlite3.Connection, iface: str) -> bool:
+ """
+ Actuator: if IFACE lacks a well-formed keypair, generate one with `wg`,
+ store it in the DB, and return True. Return False if nothing changed.
+ """
+ if wellformed_client_keypair(conn, iface):
+ return False
+ if not shutil.which("wg"):
+ raise RuntimeError("wg not found; cannot generate keys")
+ gen = subprocess.run(["wg","genkey"], capture_output=True, text=True, check=True)
+ priv = gen.stdout.strip()
+ pubp = subprocess.run(["wg","pubkey"], input=priv.encode(), capture_output=True, check=True)
+ pub = pubp.stdout.decode().strip()
+ conn.execute(
+ "UPDATE Iface SET private_key=?, public_key=?, "
+ "updated_at=strftime('%Y-%m-%dT%H:%M:%SZ','now') WHERE iface=?",
+ (priv, pub, iface),
+ )
+ return True
--- /dev/null
+#!/usr/bin/env bash
+# set_client_key.sh — replace/set a client's public key on the server
+# Usage: set_client_key.sh <client-public-key> [allowed-ips=10.8.0.2/32] [iface=wg0]
+# Example: set_client_key.sh 88gTdpESSwAc0iip6tVotc8/taZErY18n3lzrgAd+XY= 10.8.0.2/32 wg0
+
+set -euo pipefail
+
+PUB="${1:-}"
+ALLOWED="${2:-10.8.0.2/32}"
+IFACE="${3:-wg0}"
+CFG="/etc/wireguard/${IFACE}.conf"
+
+[[ $EUID -eq 0 ]] || { echo "❌ Must be run as root."; exit 1; }
+command -v wg >/dev/null || { echo "❌ wg not found."; exit 1; }
+command -v wg-quick >/dev/null || { echo "❌ wg-quick not found."; exit 1; }
+
+[[ -n "$PUB" ]] || { echo "Usage: $0 <client-public-key> [allowed-ips] [iface]"; exit 2; }
+# quick sanity on key length
+kl=${#PUB}; [[ $kl -ge 43 && $kl -le 45 ]] || { echo "❌ Public key length looks wrong."; exit 2; }
+[[ -f "$CFG" ]] || { echo "❌ Config not found: $CFG"; exit 1; }
+
+# Require the interface to be up (simplest, reliable path)
+if ! wg show "$IFACE" >/dev/null 2>&1; then
+ echo "❌ Interface $IFACE is not up. Start it first: wg-quick up $IFACE"
+ echo " Or stop it and edit $CFG manually (replace the peer that has AllowedIPs = $ALLOWED)."
+ exit 1
+fi
+
+# Remove any existing peer that currently owns the same AllowedIPs (typical /32 per client)
+while read -r oldkey oldips; do
+ if [[ "$oldips" == "$ALLOWED" ]]; then
+ echo "→ Removing existing peer $oldkey with AllowedIPs $ALLOWED"
+ wg set "$IFACE" peer "$oldkey" remove || true
+ fi
+done < <(wg show "$IFACE" allowed-ips | awk '{print $1, $2}')
+
+# Add the new peer
+wg set "$IFACE" peer "$PUB" allowed-ips "$ALLOWED"
+
+# Persist runtime state back to the config (works great even if SaveConfig=true)
+wg-quick save "$IFACE"
+
+echo "✔ Updated $IFACE: set peer $PUB with AllowedIPs $ALLOWED and saved to $CFG"
+wg show "$IFACE"
--- /dev/null
+#!/usr/bin/env bash
+# 2025-09-05
+# Debian 12 Setup: WireGuard egress server + one client (safe/idempotent)
+set -euo pipefail
+umask 0077
+[[ $EUID -eq 0 ]] || { echo "❌ run as root"; exit 1; }
+run(){ echo "+ $*"; eval "$@"; }
+
+WG_IF="wg0"
+WG_PORT="${WG_PORT:-51820}"
+WG_DIR="/etc/wireguard"
+CLIENT_DIR="/root/wireguard"
+CLIENT_NAME="${CLIENT_NAME:-client1}"
+
+SERVER_NET_V4="${SERVER_NET_V4:-10.8.0.0/24}"
+SERVER_ADDR_V4="${SERVER_ADDR_V4:-10.8.0.1/24}"
+CLIENT_ADDR_V4="${CLIENT_ADDR_V4:-10.8.0.2/32}"
+
+# --- Packages ---
+need_pkgs=()
+for p in wireguard qrencode iproute2; do command -v ${p%% *} >/dev/null 2>&1 || need_pkgs+=("$p"); done
+if ((${#need_pkgs[@]})); then
+ DEBIAN_FRONTEND=noninteractive run apt-get update
+ run apt-get install -y "${need_pkgs[@]}"
+fi
+
+install -d -m 0700 "$WG_DIR" "$CLIENT_DIR"
+
+# --- Detect WAN IF + public IPv4 ---
+WAN_IF=$(ip -o -4 route show to default | awk '{print $5; exit}')
+[[ -n "${WAN_IF:-}" ]] || { echo "❌ Could not detect WAN interface"; exit 1; }
+SERVER_IPv4=$(ip -o -4 addr show dev "$WAN_IF" | awk '{print $4}' | cut -d/ -f1 | head -n1)
+[[ -n "${SERVER_IPv4:-}" ]] || SERVER_IPv4="<PASTE_PUBLIC_IP>"
+
+# --- Keys (server) ---
+if [[ ! -f "$WG_DIR/server.key" ]]; then
+ (umask 077; wg genkey | tee "$WG_DIR/server.key" | wg pubkey > "$WG_DIR/server.pub")
+ chmod 600 "$WG_DIR/server.key"
+fi
+SERVER_PRIV=$(cat "$WG_DIR/server.key")
+SERVER_PUB=$(cat "$WG_DIR/server.pub")
+
+# --- Keys (client) ---
+if [[ ! -f "$CLIENT_DIR/${CLIENT_NAME}.key" ]]; then
+ (umask 077; wg genkey | tee "$CLIENT_DIR/${CLIENT_NAME}.key" | wg pubkey > "$CLIENT_DIR/${CLIENT_NAME}.pub")
+ chmod 600 "$CLIENT_DIR/${CLIENT_NAME}.key"
+fi
+CLIENT_PRIV=$(cat "$CLIENT_DIR/${CLIENT_NAME}.key")
+CLIENT_PUB=$(cat "$CLIENT_DIR/${CLIENT_NAME}.pub")
+
+# --- IPv4 forwarding ---
+install -d -m 0755 /etc/sysctl.d
+cat > /etc/sysctl.d/99-wireguard-forwarding.conf <<'EOF'
+net.ipv4.ip_forward=1
+# net.ipv6.conf.all.forwarding=1
+EOF
+sysctl --system >/dev/null
+
+# --- Write server config (backup if existing) ---
+CFG="$WG_DIR/${WG_IF}.conf"
+if [[ -f "$CFG" ]]; then
+ cp -a "$CFG" "$CFG.bak.$(date -u +%Y%m%dT%H%M%SZ)"
+fi
+cat > "$CFG" <<EOF
+[Interface]
+PrivateKey = $SERVER_PRIV
+Address = $SERVER_ADDR_V4
+ListenPort = $WG_PORT
+SaveConfig = true
+
+# NAT & forward for IPv4
+PostUp = iptables -A FORWARD -i %i -j ACCEPT; iptables -A FORWARD -o %i -m state --state RELATED,ESTABLISHED -j ACCEPT; iptables -t nat -A POSTROUTING -o $WAN_IF -j MASQUERADE
+PostDown = iptables -D FORWARD -i %i -j ACCEPT; iptables -D FORWARD -o %i -m state --state RELATED,ESTABLISHED -j ACCEPT; iptables -t nat -D POSTROUTING -o $WAN_IF -j MASQUERADE
+
+[Peer]
+# $CLIENT_NAME
+PublicKey = $CLIENT_PUB
+AllowedIPs = $CLIENT_ADDR_V4
+EOF
+chmod 600 "$CFG"
+
+# --- Client config ---
+CLIENT_CFG="$CLIENT_DIR/${CLIENT_NAME}.conf"
+cat > "$CLIENT_CFG" <<EOF
+[Interface]
+PrivateKey = $CLIENT_PRIV
+Address = ${CLIENT_ADDR_V4%/*}/24
+DNS = 1.1.1.1
+
+[Peer]
+PublicKey = $SERVER_PUB
+Endpoint = ${SERVER_IPv4}:${WG_PORT}
+AllowedIPs = 0.0.0.0/0
+PersistentKeepalive = 25
+EOF
+chmod 600 "$CLIENT_CFG"
+
+# --- UFW allow if active ---
+if command -v ufw >/dev/null 2>&1 && ufw status | grep -q "Status: active"; then
+ ufw status | grep -q "^${WG_PORT}/udp" || ufw allow "${WG_PORT}/udp" || true
+fi
+
+# --- Enable interface ---
+run systemctl enable --now wg-quick@"$WG_IF"
+
+# --- Status + QR ---
+echo
+wg show "$WG_IF" || true
+echo
+echo "Client file: $CLIENT_CFG"
+command -v qrencode >/dev/null 2>&1 && { echo "QR (WireGuard mobile import):"; qrencode -t ansiutf8 < "$CLIENT_CFG"; }
+echo
+echo "If Endpoint autodetection is wrong, edit it to your public IP or DNS."
--- /dev/null
+#!/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_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 <masu> <subu> [<subu>]*
+ subu capture <masu> <subu> [<subu>]*
+ subu remove <Subu_ID> | <masu> <subu> [<subu>]*
+ subu list
+ subu info <Subu_ID> | <masu> <subu> [<subu>]*
+ subu option set|clear incommon <Subu_ID> | <masu> <subu> [<subu>]*
+ """
+ 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 <Subu_ID> <WG_ID>
+ detach WG <Subu_ID>
+ """
+ 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 <Subu_ID>
+ """
+ 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 <Subu_ID> -- <cmd> ...
+ """
+ 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 <Subu_ID>
+ """
+ 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_lo_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 _collect_parse_errors(ns, program_name: str) -> list[str]:
+ """Check for semantic argument problems and collect error strings."""
+ errors: list[str] = []
+
+ 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 <masu> and one <subu> 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} <masu> <subu> [<subu> ...] 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 "
+ "<masu> <subu> [<subu> ...] 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 <Subu_ID> -- <cmd> ...", 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())
--- /dev/null
+# dispatch.py
+# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*-
+
+import os, sys, sqlite3, stat
+import env
+from domain import subu as subu_domain
+from infrastructure.db import open_db, ensure_schema
+from infrastructure.options_store import set_option, get_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 _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_<id>'",
+ 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 <masu> and one <subu> 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 <masu> <subu> [<subu> ...]
+
+ 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 <masu> and one <subu> 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 <masu> and one <subu> 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_ID>|<masu> <subu> [<subu> ...]
+ subu option clear incommon <Subu_ID>|<masu> <subu> [<subu> ...]
+ """
+ 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
--- /dev/null
+"""
+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)
--- /dev/null
+"""
+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_<id>
+ run(["ip", "netns", "add", netns_name])
+ # ip netns exec ns-subu_<id> ip link set lo down
+ run(["ip", "netns", "exec", netns_name, "ip", "link", "set", "lo", "down"])
--- /dev/null
+"""
+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}")
+
--- /dev/null
+# 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,
+)
+
+
+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 subu_username(masu: str, path_components: list[str]) -> str:
+ """
+ Build the Unix username for a subu.
+
+ Examples:
+ masu = "Thomas", path = ["S0"] -> "Thomas_S0"
+ masu = "Thomas", path = ["S0","S1"] -> "Thomas_S0_S1"
+
+ The path is:
+ masu subu subu ...
+ """
+ masu_s = _validate_token("masu", masu).replace(" ", "_")
+ subu_parts: list[str] = []
+ for s in path_components:
+ subu_parts.append(_validate_token("subu", s).replace(" ", "_"))
+ parts = [masu_s] + subu_parts
+ return "_".join(parts)
+
+
+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/<parent>/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
--- /dev/null
+"""
+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 <CIDR>` 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)")
+
--- /dev/null
+# 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_<schema>.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())
--- /dev/null
+"""
+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}")
+
--- /dev/null
+// -*- 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 <linux/bpf.h>
+#include <bpf/bpf_helpers.h>
+#include <bpf/bpf_endian.h>
+
+
+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;
+}
--- /dev/null
+# -*- 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
--- /dev/null
+# 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()
--- /dev/null
+# 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)
--- /dev/null
+-- 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
+);
--- /dev/null
+# 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])
--- /dev/null
+# 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 <masu> <subu> [<subu> ...]\n"
+ f" {p} subu capture <masu> <subu> [<subu> ...]\n"
+ f" {p} subu list\n"
+ f" {p} subu info subu_<id>\n"
+ f" {p} subu info <masu> <subu> [<subu> ...]\n"
+ f" {p} subu remove subu_<id>\n"
+ f" {p} subu remove <masu> <subu> [<subu> ...]\n"
+ f" {p} subu option set incommon subu_<id>\n"
+ f" {p} subu option set incommon <masu> <subu> [<subu> ...]\n"
+ f" {p} subu option clear incommon subu_<id>\n"
+ f" {p} subu option clear incommon <masu> <subu> [<subu> ...]\n"
+ "\n"
+
+ f" {p} lo up|down <Subu_ID>\n"
+ "\n"
+
+ f" {p} WG global <BaseCIDR>\n"
+ f" {p} WG make <host:port>\n"
+ f" {p} WG server_provided_public_key <WG_ID> <Base64Key>\n"
+ f" {p} WG info|information <WG_ID>\n"
+ f" {p} WG up <WG_ID>\n"
+ f" {p} WG down <WG_ID>\n"
+ "\n"
+
+ f" {p} attach WG <Subu_ID> <WG_ID>\n"
+ f" {p} detach WG <Subu_ID>\n"
+ "\n"
+
+ f" {p} network up|down <Subu_ID>\n"
+ "\n"
+
+ f" {p} option set <Subu_ID> <name> <value>\n"
+ f" {p} option get <Subu_ID> <name>\n"
+ f" {p} option list <Subu_ID>\n"
+ "\n"
+
+ f" {p} exec <Subu_ID> -- <cmd> ...\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"
+ " (<masu> <subu> [<subu> ...]) 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)
--- /dev/null
+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)
--- /dev/null
+window.StyleRT_namespaces = {
+ "RT": window.RT_REPO_ROOT + "shared/linked-project/RT-style-JS_public/consumer/release/RT"
+ ,"Project": window.RT_REPO_ROOT + "shared/authored/style"
+};
--- /dev/null
+#+TITLE: Installing Python in Harmony
+#+AUTHOR: Thomas Walker Lynch
+#+OPTIONS: toc:2 num:nil
+
+* Overview
+
+This document describes how to install a project-local Python environment under:
+
+#+begin_src bash
+tool_shared/third_party/Python
+#+end_src
+
+This environment is shared across the =developer= and =tester= roles and is automatically activated through their respective =env_<role>= scripts.
+
+* Precondition
+
+Ensure the following:
+
+- You are in a POSIX shell with =python3= installed.
+- The =python3-venv= package is available (on Debian: =sudo apt install python3-venv=).
+- You have sourced the Harmony environment via =env_toolsmith= to initialize =REPO_HOME= and related variables.
+
+* Step-by-Step Installation
+
+1. Source the Harmony environment:
+ #+begin_src bash
+ source env_toolsmith
+ #+end_src
+
+2. Create the virtual environment:
+ #+begin_src bash
+ python3 -m venv "$REPO_HOME/tool_shared/third_party/Python"
+ #+end_src
+
+3. Activate it temporarily to install required packages:
+ #+begin_src bash
+ source "$REPO_HOME/tool_shared/third_party/Python/bin/activate"
+ pip install --upgrade pip
+ pip install pytest # Add any shared packages here
+ deactivate
+ #+end_src
+
+4. Rename Python's default activate and deactivate:
+ Harmony provides its own role-aware environment management. Using Python’s default activation scripts may interfere with prompt logic, PATH order, and role-specific behavior.
+
+ Disable the default scripts by renaming them:
+ #+begin_src bash
+ mv "$REPO_HOME/tool_shared/third_party/Python/bin/activate" \
+ "$REPO_HOME/tool_shared/third_party/Python/bin/activate_deprecated"
+ #+end_src
+
+ This ensures that accidental sourcing of Python’s =activate= script won't override Harmony's environment setup.
+
+5. Verify installation:
+ #+begin_src bash
+ ls "$REPO_HOME/tool_shared/third_party/Python/bin/python3"
+ #+end_src
+
+ The binary should exist and report a working Python interpreter when run.
+
+* Notes
+
+- The virtual environment is deliberately named =Python=, not =venv=, to reflect its role as a shared system component.
+- Harmony environment scripts define and control =VIRTUAL_ENV=, =PYTHON_HOME=, and =PATH=, making Python activation seamless and uniform.
+- There is no need to use Python’s =bin/activate= directly — it is fully replaced by Harmony’s environment logic.
+
+* Related Files
+
+- =tool_shared/bespoke/env=
+- =tool_shared/bespoke/env_source=
+- =env_developer=, =env_tester=, =env_toolsmith=
+
+* Last Verified
+
+2025-05-19 :: Activate/deactivate renamed post-install. Requires Harmony environment sourcing prior to execution.
--- /dev/null
+
+This is the generic install.org doc that comes with the skeleton.
+
+1. $REPO_HOME/tool_shared/third_party/.gitignore:
+
+ *
+ !/.gitignore
+ !/patch
+
+ The only things from the third party directory that will be pushed to the repo origin is the .gitignore file and the patches.
+
+
+2. downloaded tar files etc. go into the directory `upstream`
+
+ $REPO_HOME/tool_shared/upstream
+
+ Typically the contents of upstream are deleted after the install.
+
+3. for the base install
+
+ cd $REPO_HOME/tool_shared/third_party
+ do whatever it takes to install tool, as examples:
+ git clone <tool_path>
+ tar -xzf ../upstream/tar
+ ...
+
+ Be sure to add the path to the tool executable(s) in the $REPO_HOME/env_$ROLE files for the $ROLE who uses the tool.
+
+ Assuming you are not also developing the tool, for safety
+ change each installed git project to a local branch:
+
+ b=<site>_<project>_local_$USER
+ git switch -c "$b"
+
+
+4. Define some variables to simplify our discussion. Lowercase variable names
+ are not exported from the shell.
+
+ # already set in the environment
+ # REPO_HOME
+ # PROJECT
+ # USER
+
+ # example tool names: 'RT_gcc' 'RT-project share` etc.
+ tool=<tool-name>
+ tool_dpath="$REPO_HOME/tool_shared/third_party/$tool"
+ patch_dpath="$REPO_HOME/tool_shared/patch/"
+
+
+5. create a patch series (from current vendor state → your local edits)
+
+ # this can be repeated and will create an encompassing diff file
+
+ # optionally crate a new branch after cloning the third party tool repo and work from there. You won't make any commits, but in case you plan to ever check the changes in, or have a the bad habit of doing ommits burned into your brain-stem, making a brnch will help.
+
+ # make changes
+
+ cd "$tool_dpath"
+
+ # do your edits
+
+ # Stage edits. Do not commit them!! Be sure you are in the third party
+ # tool directory when doing `git add -A` and `git diff` commands.
+ git add -A
+
+ # diff the stage from the current repo to create the patch file
+ git diff --staged > "$patch_dpath/$tool"
+
+ # the diff file can be added to the project and checked in at the project level.
+
+
+6. how to apply an existing patch
+
+ Get a fresh clone of the tool into $tool_dpath.
+
+ cd "$tool_dpath"
+ git apply "$patch_dpath/$tool"
+
+ You can see what `git apply` would do by running
+
+ git apply --check /path/to/your/patch_dpath/$tool
--- /dev/null
+#+TITLE: Installing Python in Harmony
+#+AUTHOR: Thomas Walker Lynch
+#+OPTIONS: toc:2 num:nil
+
+* Overview
+
+This document describes how to install a project-local Python environment under:
+
+#+begin_src bash
+shared/linked-project/Python
+#+end_src
+
+* Precondition
+
+Ensure the following:
+
+- You are in a POSIX shell with =python3= installed.
+- The =python3-venv= package is available (on Debian: =sudo apt install python3-venv=).
+- You have sourced the Harmony environment via =env_toolsmith= to initialize =REPO_HOME= and related variables.
+
+* Step-by-Step Installation
+
+1. Source the Harmony environment:
+ #+begin_src bash
+ source env_toolsmith
+ #+end_src
+
+2. Create the virtual environment:
+ #+begin_src bash
+ python3 -m venv "$REPO_HOME/shared/linked-project/Python"
+ #+end_src
+
+3. Activate it temporarily to install required packages:
+ #+begin_src bash
+ source "$REPO_HOME/shared/linked-project/Python/bin/activate"
+ pip install --upgrade pip
+ pip install pytest # Add any shared packages here
+ deactivate
+ #+end_src
+
+4. Rename Python's default activate and deactivate:
+ Harmony provides its own role-aware environment management. Using Python’s default activation scripts may interfere with prompt logic, PATH order, and role-specific behavior.
+
+ Disable the default scripts by renaming them:
+ #+begin_src bash
+ mv "$REPO_HOME/shared/linked-project/Python/bin/activate" \
+ "$REPO_HOME/shared/linked-project/Python/bin/activate_deprecated"
+ #+end_src
+
+ This ensures that accidental sourcing of Python’s =activate= script won't override Harmony's environment setup.
+
+5. Verify installation:
+ #+begin_src bash
+ ls "$REPO_HOME/shared/linked-project/Python/bin/python3"
+ #+end_src
+
+ The binary should exist and report a working Python interpreter when run.
+
+* Notes
+
+- The virtual environment is deliberately named =Python=, not =venv=, to reflect its role as a shared system component.
+- Harmony environment scripts define and control =VIRTUAL_ENV=, =PYTHON_HOME=, and =PATH=, making Python activation seamless and uniform.
+- There is no need to use Python’s =bin/activate= directly — it is fully replaced by Harmony’s environment logic.
+
+* Related Files
+
+- =shared/authored/env=
+- =shared/authored/env_source=
+- =env_developer=, =env_tester=, =env_toolsmith=
+
+* Last Verified
+
+2025-05-19 :: Activate/deactivate renamed post-install. Requires Harmony environment sourcing prior to execution.
--- /dev/null
+
+This is the generic install.org doc that comes with the skeleton.
+
+1. $REPO_HOME/shared/linked-project/.gitignore:
+
+ *
+ !/.gitignore
+ !/patch
+
+ The only things from the third party directory that will be pushed to the repo origin is the .gitignore file and the patches.
+
+
+2. downloaded tar files etc. go into the directory `upstream`
+
+ $REPO_HOME/shared/upstream
+
+ Typically the contents of upstream are deleted after the install.
+
+3. for the base install
+
+ cd $REPO_HOME/shared/linked-project
+ do whatever it takes to install tool, as examples:
+ git clone <tool_path>
+ tar -xzf ../upstream/tar
+ ...
+
+ Be sure to add the path to the tool executable(s) in the $REPO_HOME/env_$ROLE files for the $ROLE who uses the tool.
+
+ Assuming you are not also developing the tool, for safety
+ change each installed git project to a local branch:
+
+ b=<site>_<project>_local_$USER
+ git switch -c "$b"
+
+
+4. Define some variables to simplify our discussion. Lowercase variable names
+ are not exported from the shell.
+
+ # already set in the environment
+ # REPO_HOME
+ # PROJECT
+ # USER
+
+ # example tool names: 'RT_gcc' 'RT-project share` etc.
+ tool=<tool-name>
+ tool_dpath="$REPO_HOME/shared/linked-project/$tool"
+ patch_dpath="$REPO_HOME/shared/patch/"
+
+
+5. create a patch series (from current vendor state → your local edits)
+
+ # this can be repeated and will create an encompassing diff file
+
+ # optionally crate a new branch after cloning the third party tool repo and work from there. You won't make any commits, but in case you plan to ever check the changes in, or have a the bad habit of doing ommits burned into your brain-stem, making a brnch will help.
+
+ # make changes
+
+ cd "$tool_dpath"
+
+ # do your edits
+
+ # Stage edits. Do not commit them!! Be sure you are in the third party
+ # tool directory when doing `git add -A` and `git diff` commands.
+ git add -A
+
+ # diff the stage from the current repo to create the patch file
+ git diff --staged > "$patch_dpath/$tool"
+
+ # the diff file can be added to the project and checked in at the project level.
+
+
+6. how to apply an existing patch
+
+ Get a fresh clone of the tool into $tool_dpath.
+
+ cd "$tool_dpath"
+ git apply "$patch_dpath/$tool"
+
+ You can see what `git apply` would do by running
+
+ git apply --check /path/to/your/patch_dpath/$tool
--- /dev/null
+
+RT-share is used by a number of RT project. So I usually install RT-share in the projects directory along side of other projects, then symlink it into third_party.
+
+An alternative is to git clone RT-share under third_party.
--- /dev/null
+
+sudo apt-get install bindfs cryptsetup
--- /dev/null
+window.RT_REPO_ROOT = "../../";
+document.write('<script src="' + window.RT_REPO_ROOT + 'shared/dictionary_style-directory.js"></script>');
+document.write('<script src="' + window.RT_REPO_ROOT + 'shared/linked-project/RT-style-JS_public/consumer/release/RT/core/loader.js"></script>');
+document.write('<script src="' + window.RT_REPO_ROOT + 'shared/linked-project/RT-style-JS_public/consumer/release/RT/core/body_visibility_hidden.js"></script>');
--- /dev/null
+/home/Thomas/subu_data/developer/subu_data/RT-project-share/
\ No newline at end of file
--- /dev/null
+../../../RT-style-JS_public/
\ No newline at end of file
--- /dev/null
+#!/usr/bin/env -S python3 -B
+# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*-
+"""
+RTfmt — Reasoning Technology code formatter (Predicate Tokenizer)
+
+Commands:
+ RTfmt write [--lisp] <file ...> Format files in place (rewrite originals)
+ RTfmt copy [--lisp] <file ...> Save backups as <file>~ then format originals
+ RT-formatter pipe [--lisp] Read from stdin, write to stdout
+ RTfmt self_test Run built-in tests
+ RTfmt version Show tool version
+ RTfmt help | --help Show usage
+"""
+
+import sys ,re ,shutil ,os
+from typing import List ,Tuple ,Optional ,TextIO
+
+RTF_VERSION = "0.5.0-predicate"
+
+def get_usage() -> str:
+ prog_name = os.path.basename(sys.argv[0])
+ return f"""\
+Usage:
+ {prog_name} write [--lisp] <file ...>
+ {prog_name} copy [--lisp] <file ...>
+ {prog_name} pipe [--lisp]
+ {prog_name} self_test
+ {prog_name} version
+ {prog_name} help | --help
+"""
+
+# Removed < and > so they are treated as standard CODE operators
+BR_OPEN = "([{"
+BR_CLOSE = ")]}"
+PAIR = dict( zip(BR_OPEN ,BR_CLOSE) )
+REV = dict( zip(BR_CLOSE ,BR_OPEN) )
+
+# --------------- Lexer ----------------
+
+class RT_Token:
+ def __init__(self ,kind: str ,text: str):
+ self.kind = kind
+ self.text = text
+
+ def __repr__(self):
+ return f"<{self.kind}:{repr(self.text)}>"
+
+TOKEN_REGEX = re.compile(
+ r'(?P<COMMENT>//[^\n]*|#[^\n]*|(?s:/\*.*?\*/))'
+ r'|(?P<STRING>"""[\s\S]*?"""|\'\'\'[\s\S]*?\'\'\'|"(?:\\.|[^"\\])*"|\'(?:\\.|[^\'\\])*\')'
+ r'|(?P<SPACE>[ \t]+)'
+ r'|(?P<NEWLINE>\n)'
+ r'|(?P<COMMA>,)'
+ r'|(?P<BR_OPEN>[\[\(\{])'
+ r'|(?P<BR_CLOSE>[\]\)\}])'
+ r'|(?P<CODE>[^ \t\n,\[\(\{\]\)\}"\'#/]+|/)'
+)
+
+def tokenize(text: str) -> List[RT_Token]:
+ tokens = []
+ for TM_match in TOKEN_REGEX.finditer(text):
+ kind = TM_match.lastgroup
+ text_val = TM_match.group(kind)
+ tokens.append( RT_Token(kind ,text_val) )
+ return tokens
+
+# --------------- Intelligence API ----------------
+
+class TokenStream:
+ def __init__(self ,tokens: List[RT_Token]):
+ self.tokens = tokens
+
+ def get_token(self ,index: int) -> Optional[RT_Token]:
+ if 0 <= index < len(self.tokens):
+ return self.tokens[index]
+ return None
+
+ def next_sig_index(self ,index: int) -> Optional[int]:
+ for TM_i in range(index + 1 ,len(self.tokens)):
+ if self.tokens[TM_i].kind not in ("SPACE" ,"NEWLINE" ,"COMMENT"):
+ return TM_i
+ return None
+
+ def is_first_on_line(self ,index: int) -> bool:
+ for TM_i in range(index - 1 ,-1 ,-1):
+ k = self.tokens[TM_i].kind
+ if k == "NEWLINE":
+ return True
+ if k != "SPACE":
+ return False
+ return True # Start of file
+
+ def indent_of_line(self ,index: int) -> str:
+ for TM_i in range(index ,-1 ,-1):
+ if self.tokens[TM_i].kind == "NEWLINE":
+ if TM_i + 1 < len(self.tokens) and self.tokens[TM_i + 1].kind == "SPACE":
+ return self.tokens[TM_i + 1].text
+ return ""
+ if self.tokens and self.tokens[0].kind == "SPACE":
+ return self.tokens[0].text
+ return ""
+
+ def indent_of_left_match(self ,index: int) -> Optional[str]:
+ tok = self.get_token(index)
+ if not tok or tok.kind != "BR_CLOSE":
+ return None
+ target_opener = REV[tok.text]
+ depth = 0
+ for TM_i in range(index - 1 ,-1 ,-1):
+ t = self.tokens[TM_i]
+ if t.kind == "BR_CLOSE":
+ depth += 1
+ elif t.kind == "BR_OPEN":
+ if depth > 0:
+ depth -= 1
+ elif t.text == target_opener:
+ return self.indent_of_line(TM_i)
+ return None
+
+# --------------- Rule Engine ----------------
+
+def rule_migrate_vertical_commas(stream: TokenStream):
+ TM_i = 0
+ while TM_i < len(stream.tokens):
+ if stream.tokens[TM_i].kind == "COMMA":
+ is_trailing = False
+ next_sig = stream.next_sig_index(TM_i)
+ if next_sig is not None:
+ for TM_j in range(TM_i + 1 ,next_sig):
+ if stream.tokens[TM_j].kind == "NEWLINE":
+ is_trailing = True
+ break
+
+ if is_trailing:
+ comma_tok = stream.tokens.pop(TM_i)
+ next_sig -= 1 # Shifted because of pop
+ stream.tokens.insert(next_sig ,comma_tok)
+ continue
+ TM_i += 1
+
+def rule_format_horizontal_commas(stream: TokenStream):
+ for TM_i in range(len(stream.tokens) - 1 ,-1 ,-1):
+ if stream.tokens[TM_i].kind == "COMMA":
+ if stream.is_first_on_line(TM_i):
+ continue
+
+ next_tok = stream.get_token(TM_i + 1)
+ if next_tok and next_tok.kind == "SPACE":
+ stream.tokens.pop(TM_i + 1)
+
+ prev_tok = stream.get_token(TM_i - 1)
+ if prev_tok and prev_tok.kind == "SPACE":
+ if prev_tok.text != " ":
+ prev_tok.text = " "
+ else:
+ stream.tokens.insert(TM_i ,RT_Token("SPACE" ," "))
+
+def rule_fix_closing_indent(stream: TokenStream):
+ for TM_i in range(len(stream.tokens) - 1 ,-1 ,-1):
+ if stream.tokens[TM_i].kind == "BR_CLOSE" and stream.is_first_on_line(TM_i):
+ target_indent = stream.indent_of_left_match(TM_i)
+ if target_indent is not None:
+ prev = stream.get_token(TM_i - 1)
+ if prev and prev.kind == "SPACE":
+ prev.text = target_indent
+ else:
+ stream.tokens.insert(TM_i ,RT_Token("SPACE" ,target_indent))
+
+def rule_tighten_brackets(stream: TokenStream):
+ for TM_i in range(len(stream.tokens) - 1 ,-1 ,-1):
+ if stream.tokens[TM_i].kind == "SPACE" and not stream.is_first_on_line(TM_i):
+ prev_t = stream.get_token(TM_i - 1)
+ next_t = stream.get_token(TM_i + 1)
+ if (prev_t and prev_t.kind == "BR_OPEN") or (next_t and next_t.kind == "BR_CLOSE"):
+ stream.tokens.pop(TM_i)
+
+def get_bracket_spans(stream: TokenStream) -> List[Tuple[int ,int]]:
+ stack = []
+ spans = []
+ for TM_i ,tok in enumerate(stream.tokens):
+ if tok.kind == "BR_OPEN":
+ stack.append( (tok.text ,TM_i) )
+ elif tok.kind == "BR_CLOSE":
+ if stack and REV[tok.text] == stack[-1][0]:
+ _ ,pos = stack.pop()
+ if not stack:
+ spans.append( (pos ,TM_i) )
+ return spans
+
+def rule_pad_outermost(stream: TokenStream ,is_lisp: bool):
+ if is_lisp:
+ return
+ while True:
+ spans = get_bracket_spans(stream)
+ changed = False
+ for TM_start ,TM_end in reversed(spans):
+ has_inner = False
+ for TM_k in range(TM_start + 1 ,TM_end):
+ if stream.tokens[TM_k].kind in ("BR_OPEN" ,"BR_CLOSE"):
+ has_inner = True
+ break
+
+ if has_inner:
+ left_has = (TM_start + 1 < len(stream.tokens)) and stream.tokens[TM_start + 1].kind == "SPACE"
+ right_has = (TM_end - 1 >= 0) and stream.tokens[TM_end - 1].kind == "SPACE"
+ if not left_has or not right_has:
+ if not right_has:
+ stream.tokens.insert(TM_end ,RT_Token("SPACE" ," "))
+ if not left_has:
+ stream.tokens.insert(TM_start + 1 ,RT_Token("SPACE" ," "))
+ changed = True
+ break
+ if not changed:
+ break
+
+# --------------- Public API ----------------
+
+def format_tokens(tokens: List[RT_Token] ,is_lisp: bool) -> str:
+ stream = TokenStream(tokens)
+
+ rule_migrate_vertical_commas(stream)
+ rule_format_horizontal_commas(stream)
+ rule_tighten_brackets(stream)
+ rule_fix_closing_indent(stream)
+ rule_pad_outermost(stream ,is_lisp)
+
+ return "".join(t.text for t in stream.tokens)
+
+def rt_format_text(text: str ,is_lisp: bool) -> str:
+ tokens = tokenize(text)
+ return format_tokens(tokens ,is_lisp)
+
+def rt_format_stream(inp: TextIO ,out: TextIO ,is_lisp: bool) -> None:
+ text = inp.read()
+ out.write( rt_format_text(text ,is_lisp) )
+
+# --------------- Self-test ----------------
+
+def run_self_test() -> bool:
+ ok = True
+ def chk(src ,exp):
+ nonlocal ok
+ got = rt_format_text(src ,False)
+ if got != exp:
+ print("FAIL:\n" + src + "\n=>\n" + got + "\nexpected:\n" + exp)
+ ok = False
+
+ chk("a,b,c" ,"a ,b ,c")
+ chk("a , b , c" ,"a ,b ,c")
+ chk(" ,vertical_arg" ," ,vertical_arg")
+
+ chk("int a=0,\n b=1,\n c=2;" ,"int a=0\n ,b=1\n ,c=2;")
+
+ chk("f ( x )" ,"f(x)")
+ chk("f(x) + g(y)" ,"f(x) + g(y)")
+ chk(" {" ," {")
+
+ src = "int g(){int a=0,b=1,c=2; return h(a,b,c);}"
+ exp = "int g(){ int a=0 ,b=1 ,c=2; return h(a ,b ,c); }"
+ chk(src ,exp)
+
+ chk("outer( inner(a,b) )" ,"outer( inner(a ,b) )")
+
+ # Operator protection check
+ chk("for(int TM = 0; TM < count; ++TM)" ,"for(int TM = 0; TM < count; ++TM)")
+
+ print("SELFTEST OK" if ok else "SELFTEST FAILED")
+ return ok
+
+# --------------- CLI ----------------
+def write_files(paths: List[str] ,is_lisp: bool) -> int:
+ for TM_path in paths:
+ with open(TM_path ,"r" ,encoding="utf-8") as f:
+ data = f.read()
+ formatted = rt_format_text(data ,is_lisp)
+ with open(TM_path ,"w" ,encoding="utf-8") as f:
+ f.write(formatted)
+ return 0
+
+def copy_files(paths: List[str] ,is_lisp: bool) -> int:
+ for TM_path in paths:
+ shutil.copy2(TM_path ,TM_path + "~")
+ return write_files(paths ,is_lisp)
+
+def CLI(argv=None) -> int:
+ args = list(sys.argv[1:] if argv is None else argv)
+ usage_text = get_usage()
+
+ if not args or args[0] in {"help" ,"--help" ,"-h"}:
+ print(usage_text)
+ return 0
+
+ is_lisp = "--lisp" in args
+ args = [TM_a for TM_a in args if TM_a != "--lisp"]
+
+ if not args:
+ return 0
+
+ cmd = args[0]
+ rest = args[1:]
+
+ if cmd == "version":
+ print(RTF_VERSION)
+ return 0
+ if cmd == "self_test":
+ ok = run_self_test()
+ return 0 if ok else 1
+ if cmd == "pipe":
+ rt_format_stream(sys.stdin ,sys.stdout ,is_lisp)
+ return 0
+ if cmd == "write":
+ if not rest:
+ print("write: missing <file ...>\n" + usage_text)
+ return 2
+ return write_files(rest ,is_lisp)
+ if cmd == "copy":
+ if not rest:
+ print("copy: missing <file ...>\n" + usage_text)
+ return 2
+ return copy_files(rest ,is_lisp)
+
+ print(f"Unknown command: {cmd}\n" + usage_text)
+ return 2
+
+if __name__ == "__main__":
+ sys.exit( CLI() )
--- /dev/null
+(defun RT-formatter-buffer ()
+ "Format the current buffer using RTfmt0."
+ (interactive)
+ (if (not (executable-find "RT-formatter"))
+ (message "Error: RTfmt0 executable not found in PATH.")
+ (let ((temp-buffer (generate-new-buffer " *RTfmt0*"))
+ (args (list "pipe")))
+ (when (derived-mode-p 'emacs-lisp-mode 'lisp-mode)
+ (setq args (append args (list "--lisp"))))
+ (unwind-protect
+ (let ((exit-code (apply #'call-process-region
+ (point-min) (point-max)
+ "RT-formatter"
+ nil temp-buffer nil
+ args)))
+ (if (zerop exit-code)
+ (progn
+ ;; Applies a non-destructive diff, preserving point and markers natively
+ (replace-buffer-contents temp-buffer)
+ (message "RT-formatter formatting successful."))
+ (message "RT-formatter failed with exit code %s. Buffer unchanged." exit-code)))
+ (kill-buffer temp-buffer)))))
--- /dev/null
+*
+!/.gitignore
\ No newline at end of file
--- /dev/null
+/home/Thomas/subu_data/developer/subu_data/subu/release/manager/CLI.py
\ No newline at end of file
--- /dev/null
+#!/usr/bin/env bash
+
+{ . test_0_driver.sh 2> >(tee /dev/stderr >&3); } 3>test_0_out.sh 1>&3
+set -x
+diff -qr test_0_out.sh test_0_expected.sh
+set +x
--- /dev/null
+set -x
+CLI.py # -> USAGE (exit 0)
+CLI.py usage # -> USAGE
+CLI.py -h # -> HELP
+CLI.py --help # -> HELP
+CLI.py help # -> HELP
+CLI.py help WG # -> WG topic help (or full HELP if topic unknown)
+CLI.py example # -> EXAMPLE
+CLI.py version # -> 0.1.4
+CLI.py -V # -> 0.1.4
+set +x
--- /dev/null
+++ CLI.py
+Subu manager (v0.3.3)
+
+1) Init
+ CLI.py init <TOKEN>
+ Makes ./subu.db. Refuses to run if db exists.
+
+2) Subu
+ CLI.py make <masu> <subu> [_<subu>]*
+ CLI.py list
+ CLI.py info <Subu_ID>
+
+3) Loopback
+ CLI.py lo up|down <Subu_ID>
+
+4) WireGuard objects (independent of subu)
+ CLI.py WG global <BaseCIDR> # for example, 192.168.112.0/24
+ CLI.py WG make <host:port> # allocates next /32
+ CLI.py WG server_provided_public_key <WG_ID> <Base64Key>
+ CLI.py WG info <WG_ID>
+ CLI.py WG up <WG_ID> / CLI.py WG down <WG_ID> # administrative toggle after attached
+
+5) Attach or detach and eBPF steering
+ CLI.py attach WG <Subu_ID> <WG_ID>
+ - Makes WireGuard device as subu_<M> inside ns-subu_<N>, assigns /32, MTU 1420
+ - Installs per-subu cgroup and loads eBPF scaffold (user identifier check, metadata map)
+ - Keeps device administrative-down until `CLI.py network up`
+ CLI.py detach WG <Subu_ID>
+ - Deletes device, removes cgroup and eBPF program
+
+6) Network aggregate
+ CLI.py network up|down <Subu_ID>
+ - Ensures loopback is up on 'up', toggles attached WireGuard interfaces
+
+7) Options
+ CLI.py option set|get|list ...
+
+8) Exec
+ CLI.py exec <Subu_ID> -- <cmd> ...
+++ CLI.py usage
+CLI.py — Subu manager (v0.3.3)
+
+Usage:
+ CLI.py # usage
+ CLI.py help # detailed help
+ CLI.py example # example workflow
+ CLI.py version # print version
+
+ CLI.py init <TOKEN>
+ CLI.py make <masu> <subu> [_<subu>]*
+ CLI.py list
+ CLI.py info <Subu_ID> | CLI.py information <Subu_ID>
+
+ CLI.py lo up|down <Subu_ID>
+
+ CLI.py WG global <BaseCIDR>
+ CLI.py WG make <host:port>
+ CLI.py WG server_provided_public_key <WG_ID> <Base64Key>
+ CLI.py WG info|information <WG_ID>
+ CLI.py WG up <WG_ID>
+ CLI.py WG down <WG_ID>
+
+ CLI.py attach WG <Subu_ID> <WG_ID>
+ CLI.py detach WG <Subu_ID>
+
+ CLI.py network up|down <Subu_ID>
+
+ CLI.py option set <Subu_ID> <subu> <value>
+ CLI.py option get <Subu_ID> <subu>
+ CLI.py option list <Subu_ID>
+
+ CLI.py exec <Subu_ID> -- <cmd> ...
+++ CLI.py -h
+Subu manager (v0.3.3)
+
+1) Init
+ CLI.py init <TOKEN>
+ Makes ./subu.db. Refuses to run if db exists.
+
+2) Subu
+ CLI.py make <masu> <subu> [_<subu>]*
+ CLI.py list
+ CLI.py info <Subu_ID>
+
+3) Loopback
+ CLI.py lo up|down <Subu_ID>
+
+4) WireGuard objects (independent of subu)
+ CLI.py WG global <BaseCIDR> # for example, 192.168.112.0/24
+ CLI.py WG make <host:port> # allocates next /32
+ CLI.py WG server_provided_public_key <WG_ID> <Base64Key>
+ CLI.py WG info <WG_ID>
+ CLI.py WG up <WG_ID> / CLI.py WG down <WG_ID> # administrative toggle after attached
+
+5) Attach or detach and eBPF steering
+ CLI.py attach WG <Subu_ID> <WG_ID>
+ - Makes WireGuard device as subu_<M> inside ns-subu_<N>, assigns /32, MTU 1420
+ - Installs per-subu cgroup and loads eBPF scaffold (user identifier check, metadata map)
+ - Keeps device administrative-down until `CLI.py network up`
+ CLI.py detach WG <Subu_ID>
+ - Deletes device, removes cgroup and eBPF program
+
+6) Network aggregate
+ CLI.py network up|down <Subu_ID>
+ - Ensures loopback is up on 'up', toggles attached WireGuard interfaces
+
+7) Options
+ CLI.py option set|get|list ...
+
+8) Exec
+ CLI.py exec <Subu_ID> -- <cmd> ...
+++ CLI.py --help
+Subu manager (v0.3.3)
+
+1) Init
+ CLI.py init <TOKEN>
+ Makes ./subu.db. Refuses to run if db exists.
+
+2) Subu
+ CLI.py make <masu> <subu> [_<subu>]*
+ CLI.py list
+ CLI.py info <Subu_ID>
+
+3) Loopback
+ CLI.py lo up|down <Subu_ID>
+
+4) WireGuard objects (independent of subu)
+ CLI.py WG global <BaseCIDR> # for example, 192.168.112.0/24
+ CLI.py WG make <host:port> # allocates next /32
+ CLI.py WG server_provided_public_key <WG_ID> <Base64Key>
+ CLI.py WG info <WG_ID>
+ CLI.py WG up <WG_ID> / CLI.py WG down <WG_ID> # administrative toggle after attached
+
+5) Attach or detach and eBPF steering
+ CLI.py attach WG <Subu_ID> <WG_ID>
+ - Makes WireGuard device as subu_<M> inside ns-subu_<N>, assigns /32, MTU 1420
+ - Installs per-subu cgroup and loads eBPF scaffold (user identifier check, metadata map)
+ - Keeps device administrative-down until `CLI.py network up`
+ CLI.py detach WG <Subu_ID>
+ - Deletes device, removes cgroup and eBPF program
+
+6) Network aggregate
+ CLI.py network up|down <Subu_ID>
+ - Ensures loopback is up on 'up', toggles attached WireGuard interfaces
+
+7) Options
+ CLI.py option set|get|list ...
+
+8) Exec
+ CLI.py exec <Subu_ID> -- <cmd> ...
+++ CLI.py help
+Subu manager (v0.3.3)
+
+1) Init
+ CLI.py init <TOKEN>
+ Makes ./subu.db. Refuses to run if db exists.
+
+2) Subu
+ CLI.py make <masu> <subu> [_<subu>]*
+ CLI.py list
+ CLI.py info <Subu_ID>
+
+3) Loopback
+ CLI.py lo up|down <Subu_ID>
+
+4) WireGuard objects (independent of subu)
+ CLI.py WG global <BaseCIDR> # for example, 192.168.112.0/24
+ CLI.py WG make <host:port> # allocates next /32
+ CLI.py WG server_provided_public_key <WG_ID> <Base64Key>
+ CLI.py WG info <WG_ID>
+ CLI.py WG up <WG_ID> / CLI.py WG down <WG_ID> # administrative toggle after attached
+
+5) Attach or detach and eBPF steering
+ CLI.py attach WG <Subu_ID> <WG_ID>
+ - Makes WireGuard device as subu_<M> inside ns-subu_<N>, assigns /32, MTU 1420
+ - Installs per-subu cgroup and loads eBPF scaffold (user identifier check, metadata map)
+ - Keeps device administrative-down until `CLI.py network up`
+ CLI.py detach WG <Subu_ID>
+ - Deletes device, removes cgroup and eBPF program
+
+6) Network aggregate
+ CLI.py network up|down <Subu_ID>
+ - Ensures loopback is up on 'up', toggles attached WireGuard interfaces
+
+7) Options
+ CLI.py option set|get|list ...
+
+8) Exec
+ CLI.py exec <Subu_ID> -- <cmd> ...
+++ CLI.py help WG
+Subu manager (v0.3.3)
+
+1) Init
+ CLI.py init <TOKEN>
+ Makes ./subu.db. Refuses to run if db exists.
+
+2) Subu
+ CLI.py make <masu> <subu> [_<subu>]*
+ CLI.py list
+ CLI.py info <Subu_ID>
+
+3) Loopback
+ CLI.py lo up|down <Subu_ID>
+
+4) WireGuard objects (independent of subu)
+ CLI.py WG global <BaseCIDR> # for example, 192.168.112.0/24
+ CLI.py WG make <host:port> # allocates next /32
+ CLI.py WG server_provided_public_key <WG_ID> <Base64Key>
+ CLI.py WG info <WG_ID>
+ CLI.py WG up <WG_ID> / CLI.py WG down <WG_ID> # administrative toggle after attached
+
+5) Attach or detach and eBPF steering
+ CLI.py attach WG <Subu_ID> <WG_ID>
+ - Makes WireGuard device as subu_<M> inside ns-subu_<N>, assigns /32, MTU 1420
+ - Installs per-subu cgroup and loads eBPF scaffold (user identifier check, metadata map)
+ - Keeps device administrative-down until `CLI.py network up`
+ CLI.py detach WG <Subu_ID>
+ - Deletes device, removes cgroup and eBPF program
+
+6) Network aggregate
+ CLI.py network up|down <Subu_ID>
+ - Ensures loopback is up on 'up', toggles attached WireGuard interfaces
+
+7) Options
+ CLI.py option set|get|list ...
+
+8) Exec
+ CLI.py exec <Subu_ID> -- <cmd> ...
+++ CLI.py example
+# 0) Initialise the subu database (once per directory)
+CLI.py init dzkq7b
+
+# 1) Make Subu
+CLI.py make Thomas US
+# -> subu_1
+
+# 2) WireGuard pool once
+CLI.py WG global 192.168.112.0/24
+
+# 3) Make WireGuard object with endpoint
+CLI.py WG make ReasoningTechnology.com:51820
+# -> WG_1
+
+# 4) Server public key (placeholder)
+CLI.py WG server_provided_public_key WG_1 ABCDEFG...xyz=
+
+# 5) Attach device and install cgroup and eBPF steering
+CLI.py attach WG subu_1 WG_1
+
+# 6) Bring network up (loopback and WireGuard)
+CLI.py network up subu_1
+
+# 7) Test inside namespace
+CLI.py exec subu_1 -- curl -4v https://ifconfig.me
+++ CLI.py version
+0.3.3++ CLI.py -V
+0.3.3++ set +x
--- /dev/null
+++ CLI.py
+Subu manager (v0.3.3)
+
+1) Init
+ CLI.py init <TOKEN>
+ Makes ./subu.db. Refuses to run if db exists.
+
+2) Subu
+ CLI.py make <masu> <subu> [_<subu>]*
+ CLI.py list
+ CLI.py info <Subu_ID>
+
+3) Loopback
+ CLI.py lo up|down <Subu_ID>
+
+4) WireGuard objects (independent of subu)
+ CLI.py WG global <BaseCIDR> # for example, 192.168.112.0/24
+ CLI.py WG make <host:port> # allocates next /32
+ CLI.py WG server_provided_public_key <WG_ID> <Base64Key>
+ CLI.py WG info <WG_ID>
+ CLI.py WG up <WG_ID> / CLI.py WG down <WG_ID> # administrative toggle after attached
+
+5) Attach or detach and eBPF steering
+ CLI.py attach WG <Subu_ID> <WG_ID>
+ - Makes WireGuard device as subu_<M> inside ns-subu_<N>, assigns /32, MTU 1420
+ - Installs per-subu cgroup and loads eBPF scaffold (user identifier check, metadata map)
+ - Keeps device administrative-down until `CLI.py network up`
+ CLI.py detach WG <Subu_ID>
+ - Deletes device, removes cgroup and eBPF program
+
+6) Network aggregate
+ CLI.py network up|down <Subu_ID>
+ - Ensures loopback is up on 'up', toggles attached WireGuard interfaces
+
+7) Options
+ CLI.py option set|get|list ...
+
+8) Exec
+ CLI.py exec <Subu_ID> -- <cmd> ...
+++ CLI.py usage
+CLI.py — Subu manager (v0.3.3)
+
+Usage:
+ CLI.py # usage
+ CLI.py help # detailed help
+ CLI.py example # example workflow
+ CLI.py version # print version
+
+ CLI.py init <TOKEN>
+ CLI.py make <masu> <subu> [_<subu>]*
+ CLI.py list
+ CLI.py info <Subu_ID> | CLI.py information <Subu_ID>
+
+ CLI.py lo up|down <Subu_ID>
+
+ CLI.py WG global <BaseCIDR>
+ CLI.py WG make <host:port>
+ CLI.py WG server_provided_public_key <WG_ID> <Base64Key>
+ CLI.py WG info|information <WG_ID>
+ CLI.py WG up <WG_ID>
+ CLI.py WG down <WG_ID>
+
+ CLI.py attach WG <Subu_ID> <WG_ID>
+ CLI.py detach WG <Subu_ID>
+
+ CLI.py network up|down <Subu_ID>
+
+ CLI.py option set <Subu_ID> <subu> <value>
+ CLI.py option get <Subu_ID> <subu>
+ CLI.py option list <Subu_ID>
+
+ CLI.py exec <Subu_ID> -- <cmd> ...
+++ CLI.py -h
+Subu manager (v0.3.3)
+
+1) Init
+ CLI.py init <TOKEN>
+ Makes ./subu.db. Refuses to run if db exists.
+
+2) Subu
+ CLI.py make <masu> <subu> [_<subu>]*
+ CLI.py list
+ CLI.py info <Subu_ID>
+
+3) Loopback
+ CLI.py lo up|down <Subu_ID>
+
+4) WireGuard objects (independent of subu)
+ CLI.py WG global <BaseCIDR> # for example, 192.168.112.0/24
+ CLI.py WG make <host:port> # allocates next /32
+ CLI.py WG server_provided_public_key <WG_ID> <Base64Key>
+ CLI.py WG info <WG_ID>
+ CLI.py WG up <WG_ID> / CLI.py WG down <WG_ID> # administrative toggle after attached
+
+5) Attach or detach and eBPF steering
+ CLI.py attach WG <Subu_ID> <WG_ID>
+ - Makes WireGuard device as subu_<M> inside ns-subu_<N>, assigns /32, MTU 1420
+ - Installs per-subu cgroup and loads eBPF scaffold (user identifier check, metadata map)
+ - Keeps device administrative-down until `CLI.py network up`
+ CLI.py detach WG <Subu_ID>
+ - Deletes device, removes cgroup and eBPF program
+
+6) Network aggregate
+ CLI.py network up|down <Subu_ID>
+ - Ensures loopback is up on 'up', toggles attached WireGuard interfaces
+
+7) Options
+ CLI.py option set|get|list ...
+
+8) Exec
+ CLI.py exec <Subu_ID> -- <cmd> ...
+++ CLI.py --help
+Subu manager (v0.3.3)
+
+1) Init
+ CLI.py init <TOKEN>
+ Makes ./subu.db. Refuses to run if db exists.
+
+2) Subu
+ CLI.py make <masu> <subu> [_<subu>]*
+ CLI.py list
+ CLI.py info <Subu_ID>
+
+3) Loopback
+ CLI.py lo up|down <Subu_ID>
+
+4) WireGuard objects (independent of subu)
+ CLI.py WG global <BaseCIDR> # for example, 192.168.112.0/24
+ CLI.py WG make <host:port> # allocates next /32
+ CLI.py WG server_provided_public_key <WG_ID> <Base64Key>
+ CLI.py WG info <WG_ID>
+ CLI.py WG up <WG_ID> / CLI.py WG down <WG_ID> # administrative toggle after attached
+
+5) Attach or detach and eBPF steering
+ CLI.py attach WG <Subu_ID> <WG_ID>
+ - Makes WireGuard device as subu_<M> inside ns-subu_<N>, assigns /32, MTU 1420
+ - Installs per-subu cgroup and loads eBPF scaffold (user identifier check, metadata map)
+ - Keeps device administrative-down until `CLI.py network up`
+ CLI.py detach WG <Subu_ID>
+ - Deletes device, removes cgroup and eBPF program
+
+6) Network aggregate
+ CLI.py network up|down <Subu_ID>
+ - Ensures loopback is up on 'up', toggles attached WireGuard interfaces
+
+7) Options
+ CLI.py option set|get|list ...
+
+8) Exec
+ CLI.py exec <Subu_ID> -- <cmd> ...
+++ CLI.py help
+Subu manager (v0.3.3)
+
+1) Init
+ CLI.py init <TOKEN>
+ Makes ./subu.db. Refuses to run if db exists.
+
+2) Subu
+ CLI.py make <masu> <subu> [_<subu>]*
+ CLI.py list
+ CLI.py info <Subu_ID>
+
+3) Loopback
+ CLI.py lo up|down <Subu_ID>
+
+4) WireGuard objects (independent of subu)
+ CLI.py WG global <BaseCIDR> # for example, 192.168.112.0/24
+ CLI.py WG make <host:port> # allocates next /32
+ CLI.py WG server_provided_public_key <WG_ID> <Base64Key>
+ CLI.py WG info <WG_ID>
+ CLI.py WG up <WG_ID> / CLI.py WG down <WG_ID> # administrative toggle after attached
+
+5) Attach or detach and eBPF steering
+ CLI.py attach WG <Subu_ID> <WG_ID>
+ - Makes WireGuard device as subu_<M> inside ns-subu_<N>, assigns /32, MTU 1420
+ - Installs per-subu cgroup and loads eBPF scaffold (user identifier check, metadata map)
+ - Keeps device administrative-down until `CLI.py network up`
+ CLI.py detach WG <Subu_ID>
+ - Deletes device, removes cgroup and eBPF program
+
+6) Network aggregate
+ CLI.py network up|down <Subu_ID>
+ - Ensures loopback is up on 'up', toggles attached WireGuard interfaces
+
+7) Options
+ CLI.py option set|get|list ...
+
+8) Exec
+ CLI.py exec <Subu_ID> -- <cmd> ...
+++ CLI.py help WG
+Subu manager (v0.3.3)
+
+1) Init
+ CLI.py init <TOKEN>
+ Makes ./subu.db. Refuses to run if db exists.
+
+2) Subu
+ CLI.py make <masu> <subu> [_<subu>]*
+ CLI.py list
+ CLI.py info <Subu_ID>
+
+3) Loopback
+ CLI.py lo up|down <Subu_ID>
+
+4) WireGuard objects (independent of subu)
+ CLI.py WG global <BaseCIDR> # for example, 192.168.112.0/24
+ CLI.py WG make <host:port> # allocates next /32
+ CLI.py WG server_provided_public_key <WG_ID> <Base64Key>
+ CLI.py WG info <WG_ID>
+ CLI.py WG up <WG_ID> / CLI.py WG down <WG_ID> # administrative toggle after attached
+
+5) Attach or detach and eBPF steering
+ CLI.py attach WG <Subu_ID> <WG_ID>
+ - Makes WireGuard device as subu_<M> inside ns-subu_<N>, assigns /32, MTU 1420
+ - Installs per-subu cgroup and loads eBPF scaffold (user identifier check, metadata map)
+ - Keeps device administrative-down until `CLI.py network up`
+ CLI.py detach WG <Subu_ID>
+ - Deletes device, removes cgroup and eBPF program
+
+6) Network aggregate
+ CLI.py network up|down <Subu_ID>
+ - Ensures loopback is up on 'up', toggles attached WireGuard interfaces
+
+7) Options
+ CLI.py option set|get|list ...
+
+8) Exec
+ CLI.py exec <Subu_ID> -- <cmd> ...
+++ CLI.py example
+# 0) Initialise the subu database (once per directory)
+CLI.py init dzkq7b
+
+# 1) Make Subu
+CLI.py make Thomas US
+# -> subu_1
+
+# 2) WireGuard pool once
+CLI.py WG global 192.168.112.0/24
+
+# 3) Make WireGuard object with endpoint
+CLI.py WG make ReasoningTechnology.com:51820
+# -> WG_1
+
+# 4) Server public key (placeholder)
+CLI.py WG server_provided_public_key WG_1 ABCDEFG...xyz=
+
+# 5) Attach device and install cgroup and eBPF steering
+CLI.py attach WG subu_1 WG_1
+
+# 6) Bring network up (loopback and WireGuard)
+CLI.py network up subu_1
+
+# 7) Test inside namespace
+CLI.py exec subu_1 -- curl -4v https://ifconfig.me
+++ CLI.py version
+0.3.3++ CLI.py -V
+0.3.3++ set +x
--- /dev/null
+#!/usr/bin/env bash
+# test_1_driver.sh — smoke test: Unix dry mode + make
+
+set -x
+
+# 1) Put Unix handling into dry-run mode.
+# In this mode we expect *no real* user/group changes.
+CLI.py option Unix dry
+
+# 2) Make a first-level subu
+CLI.py make Thomas S0
+
+# 3) Make a second-level subu under the first
+CLI.py make Thomas S0 S1
+
+# 4) Return Unix handling to run mode (for future tests)
+CLI.py option Unix run
+
+set +x
--- /dev/null
+#!/bin/env bash
+
+set -x
+./CLI # -> USAGE (exit 0)
+./CLI usage # -> USAGE
+./CLI -h # -> HELP
+./CLI --help # -> HELP
+./CLI help # -> HELP
+./CLI help WG # -> WG topic help (or full HELP if topic unknown)
+./CLI example # -> EXAMPLE
+./CLI version # -> 0.1.4
+./CLI -V # -> 0.1.4
+
--- /dev/null
+set -x
+./subu.py # -> USAGE (exit 0)
+./subu.py usage # -> USAGE
+./subu.py -h # -> HELP
+./subu.py --help # -> HELP
+./subu.py help # -> HELP
+./subu.py help WG # -> WG topic help (or full HELP if topic unknown)
+./subu.py example # -> EXAMPLE
+./subu.py version # -> 0.1.4
+./subu.py -V # -> 0.1.4
+set +x
--- /dev/null
+++ ./subu.py
+usage: subu [-V] <verb> [<args>]
+
+Quick verbs:
+ usage Show this usage summary
+ help [topic] Detailed help; same as -h / --help
+ example End-to-end example session
+ version Print version
+
+Main verbs:
+ init Initialize a new subu database (refuses if it exists)
+ create Create a minimal subu record (defaults only)
+ info | information Show details for a subu
+ WG WireGuard object operations
+ attach Attach a WG object to a subu (netns + cgroup/eBPF)
+ detach Detach WG from a subu
+ network Bring all attached ifaces up/down inside the subu netns
+ lo Bring loopback up/down inside the subu netns
+ option Persisted options (list/set/get for future policy)
+ exec Run a command inside the subu netns
+
+Tip: `subu help` (or `subu --help`) shows detailed help; `subu help WG` shows topic help.
+++ ./subu.py usage
+usage: subu [-V] <verb> [<args>]
+
+Quick verbs:
+ usage Show this usage summary
+ help [topic] Detailed help; same as -h / --help
+ example End-to-end example session
+ version Print version
+
+Main verbs:
+ init Initialize a new subu database (refuses if it exists)
+ create Create a minimal subu record (defaults only)
+ info | information Show details for a subu
+ WG WireGuard object operations
+ attach Attach a WG object to a subu (netns + cgroup/eBPF)
+ detach Detach WG from a subu
+ network Bring all attached ifaces up/down inside the subu netns
+ lo Bring loopback up/down inside the subu netns
+ option Persisted options (list/set/get for future policy)
+ exec Run a command inside the subu netns
+
+Tip: `subu help` (or `subu --help`) shows detailed help; `subu help WG` shows topic help.
+++ ./subu.py -h
+subu — manage subu containers, namespaces, and WG attachments
+
+2.1 Core
+
+ subu init <TOKEN>
+ Create ./subu.db (tables: subu, wg, links, options, state).
+ Requires a 6-char token (e.g., dzkq7b). Refuses if DB already exists.
+
+ subu create <masu> <subu>
+ Make a default subu with netns ns-<Subu_ID> containing lo only (down).
+ Returns subu_N.
+
+ subu list
+ Columns: Subu_ID, Owner, Name, NetNS, WG_Attached?, Up/Down, Steer?
+
+ subu info <Subu_ID> | subu information <Subu_ID>
+ Full record + attached WG(s) + options + iface states.
+
+2.2 Loopback
+
+ subu lo up <Subu_ID> | subu lo down <Subu_ID>
+ Toggle loopback inside the subu’s netns.
+
+2.3 WireGuard objects (independent)
+
+ subu WG global <BaseCIDR>
+ e.g., 192.168.112.0/24; allocator hands out /32 peers sequentially.
+ Shows current base and next free on success.
+
+ subu WG create <host:port>
+ Creates WG object; allocates next /32 local IP; AllowedIPs=0.0.0.0/0.
+ Returns WG_M.
+
+ subu WG server_provided_public_key <WG_ID> <Base64Key>
+ Stores server’s pubkey.
+
+ subu WG info <WG_ID> | subu WG information <WG_ID>
+ Endpoint, allocated IP, pubkey set?, link state (admin/oper).
+
+2.4 Link WG ↔ subu, bring up/down
+
+ subu attach WG <Subu_ID> <WG_ID>
+ Creates/configures WG device inside ns-<Subu_ID>:
+ - device name: subu_<M> (M from WG_ID)
+ - set local /32, MTU 1420, accept_local=1
+ - (no default route is added — steering uses eBPF)
+ - v1: enforce one WG per Subu; error if another attached
+
+ subu detach WG <Subu_ID>
+ Remove WG device/config from the subu’s netns; keep WG object.
+
+ subu WG up <WG_ID> | subu WG down <WG_ID>
+ Toggle interface admin state in the subu’s netns (must be attached).
+
+ subu network up <Subu_ID> | subu network down <Subu_ID>
+ Only toggles admin state for all attached ifaces. On “up”, loopback
+ is brought up first automatically. No route manipulation.
+
+2.5 Execution & (future) steering
+
+ subu exec <Subu_ID> -- <cmd> …
+ Run a process inside the subu’s netns.
+
+ subu steer enable <Subu_ID> | subu steer disable <Subu_ID>
+ (Future) Attach/detach eBPF cgroup programs to force SO_BINDTOIFINDEX=subu_<M>
+ for TCP/UDP. Default: disabled.
+
+2.6 Options (persist only, for future policy)
+
+ subu option list <Subu_ID>
+ subu option get <Subu_ID> [name]
+ subu option set <Subu_ID> <name> <value>
+
+2.7 Meta
+
+ subu usage
+ Short usage summary (also printed when no args are given).
+
+ subu help [topic]
+ This help (or per-topic help such as `subu help WG`).
+
+ subu example
+ A concrete end-to-end scenario.
+
+ subu version
+ Print version (same as -V / --version).
+++ ./subu.py --help
+subu — manage subu containers, namespaces, and WG attachments
+
+2.1 Core
+
+ subu init <TOKEN>
+ Create ./subu.db (tables: subu, wg, links, options, state).
+ Requires a 6-char token (e.g., dzkq7b). Refuses if DB already exists.
+
+ subu create <masu> <subu>
+ Make a default subu with netns ns-<Subu_ID> containing lo only (down).
+ Returns subu_N.
+
+ subu list
+ Columns: Subu_ID, Owner, Name, NetNS, WG_Attached?, Up/Down, Steer?
+
+ subu info <Subu_ID> | subu information <Subu_ID>
+ Full record + attached WG(s) + options + iface states.
+
+2.2 Loopback
+
+ subu lo up <Subu_ID> | subu lo down <Subu_ID>
+ Toggle loopback inside the subu’s netns.
+
+2.3 WireGuard objects (independent)
+
+ subu WG global <BaseCIDR>
+ e.g., 192.168.112.0/24; allocator hands out /32 peers sequentially.
+ Shows current base and next free on success.
+
+ subu WG create <host:port>
+ Creates WG object; allocates next /32 local IP; AllowedIPs=0.0.0.0/0.
+ Returns WG_M.
+
+ subu WG server_provided_public_key <WG_ID> <Base64Key>
+ Stores server’s pubkey.
+
+ subu WG info <WG_ID> | subu WG information <WG_ID>
+ Endpoint, allocated IP, pubkey set?, link state (admin/oper).
+
+2.4 Link WG ↔ subu, bring up/down
+
+ subu attach WG <Subu_ID> <WG_ID>
+ Creates/configures WG device inside ns-<Subu_ID>:
+ - device name: subu_<M> (M from WG_ID)
+ - set local /32, MTU 1420, accept_local=1
+ - (no default route is added — steering uses eBPF)
+ - v1: enforce one WG per Subu; error if another attached
+
+ subu detach WG <Subu_ID>
+ Remove WG device/config from the subu’s netns; keep WG object.
+
+ subu WG up <WG_ID> | subu WG down <WG_ID>
+ Toggle interface admin state in the subu’s netns (must be attached).
+
+ subu network up <Subu_ID> | subu network down <Subu_ID>
+ Only toggles admin state for all attached ifaces. On “up”, loopback
+ is brought up first automatically. No route manipulation.
+
+2.5 Execution & (future) steering
+
+ subu exec <Subu_ID> -- <cmd> …
+ Run a process inside the subu’s netns.
+
+ subu steer enable <Subu_ID> | subu steer disable <Subu_ID>
+ (Future) Attach/detach eBPF cgroup programs to force SO_BINDTOIFINDEX=subu_<M>
+ for TCP/UDP. Default: disabled.
+
+2.6 Options (persist only, for future policy)
+
+ subu option list <Subu_ID>
+ subu option get <Subu_ID> [name]
+ subu option set <Subu_ID> <name> <value>
+
+2.7 Meta
+
+ subu usage
+ Short usage summary (also printed when no args are given).
+
+ subu help [topic]
+ This help (or per-topic help such as `subu help WG`).
+
+ subu example
+ A concrete end-to-end scenario.
+
+ subu version
+ Print version (same as -V / --version).
+++ ./subu.py help
+subu — manage subu containers, namespaces, and WG attachments
+
+2.1 Core
+
+ subu init <TOKEN>
+ Create ./subu.db (tables: subu, wg, links, options, state).
+ Requires a 6-char token (e.g., dzkq7b). Refuses if DB already exists.
+
+ subu create <masu> <subu>
+ Make a default subu with netns ns-<Subu_ID> containing lo only (down).
+ Returns subu_N.
+
+ subu list
+ Columns: Subu_ID, Owner, Name, NetNS, WG_Attached?, Up/Down, Steer?
+
+ subu info <Subu_ID> | subu information <Subu_ID>
+ Full record + attached WG(s) + options + iface states.
+
+2.2 Loopback
+
+ subu lo up <Subu_ID> | subu lo down <Subu_ID>
+ Toggle loopback inside the subu’s netns.
+
+2.3 WireGuard objects (independent)
+
+ subu WG global <BaseCIDR>
+ e.g., 192.168.112.0/24; allocator hands out /32 peers sequentially.
+ Shows current base and next free on success.
+
+ subu WG create <host:port>
+ Creates WG object; allocates next /32 local IP; AllowedIPs=0.0.0.0/0.
+ Returns WG_M.
+
+ subu WG server_provided_public_key <WG_ID> <Base64Key>
+ Stores server’s pubkey.
+
+ subu WG info <WG_ID> | subu WG information <WG_ID>
+ Endpoint, allocated IP, pubkey set?, link state (admin/oper).
+
+2.4 Link WG ↔ subu, bring up/down
+
+ subu attach WG <Subu_ID> <WG_ID>
+ Creates/configures WG device inside ns-<Subu_ID>:
+ - device name: subu_<M> (M from WG_ID)
+ - set local /32, MTU 1420, accept_local=1
+ - (no default route is added — steering uses eBPF)
+ - v1: enforce one WG per Subu; error if another attached
+
+ subu detach WG <Subu_ID>
+ Remove WG device/config from the subu’s netns; keep WG object.
+
+ subu WG up <WG_ID> | subu WG down <WG_ID>
+ Toggle interface admin state in the subu’s netns (must be attached).
+
+ subu network up <Subu_ID> | subu network down <Subu_ID>
+ Only toggles admin state for all attached ifaces. On “up”, loopback
+ is brought up first automatically. No route manipulation.
+
+2.5 Execution & (future) steering
+
+ subu exec <Subu_ID> -- <cmd> …
+ Run a process inside the subu’s netns.
+
+ subu steer enable <Subu_ID> | subu steer disable <Subu_ID>
+ (Future) Attach/detach eBPF cgroup programs to force SO_BINDTOIFINDEX=subu_<M>
+ for TCP/UDP. Default: disabled.
+
+2.6 Options (persist only, for future policy)
+
+ subu option list <Subu_ID>
+ subu option get <Subu_ID> [name]
+ subu option set <Subu_ID> <name> <value>
+
+2.7 Meta
+
+ subu usage
+ Short usage summary (also printed when no args are given).
+
+ subu help [topic]
+ This help (or per-topic help such as `subu help WG`).
+
+ subu example
+ A concrete end-to-end scenario.
+
+ subu version
+ Print version (same as -V / --version).
+++ ./subu.py help WG
+usage: subu WG [-h]
+
+options:
+ -h, --help show this help message and exit
+++ ./subu.py example
+# 0) Safe init (refuses if ./subu.db exists)
+subu init dzkq7b
+# -> created ./subu.db
+
+# 1) Create Subu
+subu create Thomas US
+# -> Subu_ID: subu_7
+# -> netns: ns-subu_7 with lo (down)
+
+# 2) Define WG pool (once per host)
+subu WG global 192.168.112.0/24
+# -> base set; next free: 192.168.112.2/32
+
+# 3) Create WG object with endpoint
+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
+subu WG server_provided_public_key WG_0 ABCDEFG...xyz=
+# -> saved
+
+# 5) Attach WG to Subu (device created/configured in ns)
+subu attach WG subu_7 WG_0
+# -> device ns-subu_7/subu_0 configured (no default route)
+
+# 6) Bring network up (lo first, then attached ifaces)
+subu network up subu_7
+# -> lo up; subu_0 admin up
+
+# 7) Start the WG engine inside the netns
+subu WG up WG_0
+# -> up, handshakes should start
+
+# 8) Test from inside the subu
+subu exec subu_7 -- curl -4v https://ifconfig.me
+++ ./subu.py version
+0.1.3
+++ ./subu.py -V
+0.1.3
+++ set +x
--- /dev/null
+#!/usr/bin/env -S python3 -B
+# developer — operations that compare/merge against developer/release branches:
+# developer branch list-new
+# developer branch pull
+
+import sys, subprocess
+from tester_lib import (
+ chdir_repo_root,
+ ensure_on_testing_branch_or_die,
+ get_developer_ref_for_merge,
+ git_diff_name_status,
+ print_changes_with_exec_marker,
+ fetch_remote,
+ changes_outside_tester,
+ prompt_yes_no,
+ RELEASE_ROOT_REL,
+ TESTER_ROOT_REL,
+)
+
+
+def _run_git(args):
+ return subprocess.run(["git"] + list(args), check=True)
+
+
+def usage() -> int:
+ print("""developer — tester-side view of developer/release branches
+
+Usage:
+ developer help
+ Show this message.
+
+ developer branch list-new
+ List changes under release/ between the current tester branch (HEAD)
+ and its corresponding developer/release branch.
+
+ developer branch pull
+ Merge from the corresponding developer/release branch into the current
+ tester branch, listing:
+ - changes in release/
+ - any changes under tester/ (policy warning)
+ - any other changed paths and executables
+ and asking for confirmation before the merge.
+""".rstrip())
+ return 0
+
+
+def cmd_branch_list_new() -> int:
+ repo_root = chdir_repo_root()
+ info = ensure_on_testing_branch_or_die()
+ dev_ref = get_developer_ref_for_merge(info.name)
+ if not dev_ref:
+ print(f"developer branch list-new: no corresponding developer branch for '{info.name}'.", file=sys.stderr)
+ return 1
+
+ fetch_remote("origin")
+ changes = git_diff_name_status("HEAD", dev_ref, paths=[RELEASE_ROOT_REL])
+
+ if not changes:
+ print(f"release/ is up to date with {dev_ref}.")
+ return 0
+
+ print(f"Changes in release/ between HEAD ({info.name}) and {dev_ref}:")
+ print_changes_with_exec_marker(changes, repo_root)
+ return 0
+
+
+def cmd_branch_pull() -> int:
+ repo_root = chdir_repo_root()
+ info = ensure_on_testing_branch_or_die()
+ dev_ref = get_developer_ref_for_merge(info.name)
+ if not dev_ref:
+ print(f"developer branch pull: no corresponding developer branch for '{info.name}'.", file=sys.stderr)
+ return 1
+
+ fetch_remote("origin")
+
+ # release/ changes
+ rel_changes = git_diff_name_status("HEAD", dev_ref, paths=[RELEASE_ROOT_REL])
+ print(f"Planned release/ changes from {info.name} -> {dev_ref}:")
+ print_changes_with_exec_marker(rel_changes, repo_root)
+
+ # tester/ changes (should be rare)
+ tester_changes = git_diff_name_status("HEAD", dev_ref, paths=[TESTER_ROOT_REL])
+ if tester_changes:
+ print("\nWARNING: these files under tester/ would change when merging from developer:")
+ print_changes_with_exec_marker(tester_changes, repo_root)
+ if not prompt_yes_no("These changes are against the usual policy. Proceed with merge?", default=False):
+ print("Aborting merge.")
+ return 1
+
+ # all other paths (shared tools etc.)
+ all_changes = git_diff_name_status("HEAD", dev_ref, paths=None)
+ non_tester = changes_outside_tester(all_changes)
+ other_non_release = [(s, p) for (s, p) in non_tester if not p.startswith(RELEASE_ROOT_REL + "/")]
+ if other_non_release:
+ print("\nOther files outside tester/ that will change (shared tools, etc.):")
+ print_changes_with_exec_marker(other_non_release, repo_root)
+
+ if not prompt_yes_no(f"Proceed with merge from {dev_ref} into {info.name}?", default=False):
+ print("Aborting merge.")
+ return 1
+
+ _run_git(["merge", dev_ref])
+ print(f"Merged {dev_ref} into {info.name}.")
+ return 0
+
+
+def CLI(argv=None) -> int:
+ if argv is None:
+ argv = sys.argv[1:]
+
+ if not argv or argv[0] in ("help", "-h", "--help"):
+ return usage()
+
+ if argv[0] != "branch":
+ print(f"developer: unknown top-level command '{argv[0]}' (expected 'branch' or 'help').", file=sys.stderr)
+ return 1
+
+ subargs = argv[1:]
+ if not subargs or subargs[0] in ("help", "-h", "--help"):
+ print("developer branch commands:")
+ print(" developer branch list-new")
+ print(" developer branch pull")
+ return 0
+
+ action = subargs[0]
+
+ if action == "list-new":
+ return cmd_branch_list_new()
+ if action == "pull":
+ return cmd_branch_pull()
+
+ print(f"developer branch: unknown action '{action}'.", file=sys.stderr)
+ return 1
+
+
+if __name__ == "__main__":
+ raise SystemExit(CLI())
--- /dev/null
+#!/usr/bin/env bash
+script_afp=$(realpath "${BASH_SOURCE[0]}")
+
+ manager="$REPO_HOME/release/manager"
+ if [[ ":$PATH:" != *":$manager:"* ]]; then
+ export PATH="$manager:$PATH"
+ fi
--- /dev/null
+#!/usr/bin/env -S python3 -B
+# publish — create / bump release_<major>[.<minor>] branches
+#
+# Usage:
+# publish help
+# publish minor
+# publish major [<major>] [<minor>]
+
+import sys, subprocess
+from typing import List, Tuple
+from tester_lib import (
+ chdir_repo_root,
+ ensure_on_testing_branch_or_die,
+ RELEASE_PREFIX,
+ RELEASE_TEST_PREFIX, # kept if you later want to auto-create tester branches
+ get_release_branches,
+ choose_latest_release,
+)
+
+
+def _run_git(args):
+ return subprocess.run(["git"] + list(args), check=True)
+
+
+def usage() -> int:
+ print("""publish — create or bump release_<major>[.<minor>] branches
+
+Usage:
+ publish help
+ Show this message.
+
+ publish minor
+ Create a new minor release branch for the highest existing major.
+ - Finds all release_<major>[.<minor>] branches.
+ - Picks the highest major M.
+ - Computes next minor as (max_minor_for_M + 1) (minor=0 when absent).
+ - Creates release_<M>.<next_minor> from the current tester branch.
+ - Checks out the new release_<...> branch.
+
+ publish major [<major>] [<minor>]
+ Create a new major release branch.
+ - If no major is given:
+ use next integer after the highest existing major (or 0 if none).
+ - If no minor is given:
+ start at minor 1 → release_<major>.1
+ - If minor==0 is explicitly given:
+ create release_<major> (no .0 in the name).
+ - Branch is created from the current tester branch.
+ - Checks out the new release_<...> branch.
+""".rstrip())
+ return 0
+
+
+def _list_release_branches() -> List[Tuple[str, int, int]]:
+ branches = get_release_branches()
+ out: List[Tuple[str, int, int]] = []
+ for b in branches:
+ if b.major is None:
+ continue
+ out.append((b.name, b.major, b.minor or 0))
+ return out
+
+
+def _next_minor_for_major(major: int) -> int:
+ branches = _list_release_branches()
+ max_minor = 0
+ found = False
+ for _name, maj, minor in branches:
+ if maj == major:
+ found = True
+ if minor > max_minor:
+ max_minor = minor
+ if not found:
+ return 1 # first minor is 1
+ return max_minor + 1
+
+
+def publish_minor() -> int:
+ info = ensure_on_testing_branch_or_die()
+ branches = _list_release_branches()
+ if not branches:
+ print("publish minor: no existing release_<major> branches. Use 'publish major' first.", file=sys.stderr)
+ return 1
+
+ branches_sorted = sorted(branches, key=lambda t: (t[1], t[2]))
+ highest_major = branches_sorted[-1][1]
+ next_minor = _next_minor_for_major(highest_major)
+
+ rel_name = f"{RELEASE_PREFIX}{highest_major}.{next_minor}"
+
+ print(f"Creating new minor release branch {rel_name} from {info.name}...")
+ _run_git(["branch", rel_name])
+ print(f"Checking out {rel_name}...")
+ _run_git(["checkout", rel_name])
+ return 0
+
+
+def publish_major(major_arg: str = None, minor_arg: str = None) -> int:
+ info = ensure_on_testing_branch_or_die()
+ branches = _list_release_branches()
+
+ if major_arg is None:
+ if branches:
+ highest_major = max(maj for (_n, maj, _m) in branches)
+ new_major = highest_major + 1
+ else:
+ new_major = 0
+ else:
+ new_major = int(major_arg)
+
+ if minor_arg is None:
+ new_minor = 1
+ else:
+ new_minor = int(minor_arg)
+
+ if new_minor == 0:
+ rel_name = f"{RELEASE_PREFIX}{new_major}"
+ else:
+ rel_name = f"{RELEASE_PREFIX}{new_major}.{new_minor}"
+
+ cp = subprocess.run(["git", "branch", "--list", rel_name], text=True, stdout=subprocess.PIPE, check=True)
+ if cp.stdout.strip():
+ print(f"publish major: branch {rel_name} already exists.", file=sys.stderr)
+ else:
+ print(f"Creating new major release branch {rel_name} from {info.name}...")
+ _run_git(["branch", rel_name])
+
+ print(f"Checking out {rel_name}...")
+ _run_git(["checkout", rel_name])
+ return 0
+
+
+def CLI(argv=None) -> int:
+ chdir_repo_root()
+ if argv is None:
+ argv = sys.argv[1:]
+
+ if not argv or argv[0] in ("help", "-h", "--help"):
+ return usage()
+
+ sub = argv[0]
+ rest = argv[1:]
+
+ if sub == "minor":
+ if rest:
+ print("publish minor: unexpected extra arguments.", file=sys.stderr)
+ return 1
+ return publish_minor()
+
+ if sub == "major":
+ major = rest[0] if len(rest) >= 1 else None
+ minor = rest[1] if len(rest) >= 2 else None
+ if len(rest) > 2:
+ print("publish major: too many arguments.", file=sys.stderr)
+ return 1
+ return publish_major(major, minor)
+
+ print(f"publish: unknown subcommand '{sub}' (expected 'help', 'minor', or 'major').", file=sys.stderr)
+ return 1
+
+
+if __name__ == "__main__":
+ raise SystemExit(CLI())
--- /dev/null
+#!/usr/bin/env -S python3 -B
+# tester — tester branch utilities:
+# tester branch select core
+# tester branch select release [<major>[.<minor>]]
+# tester branch show-name
+# tester branch pull
+# tester branch push
+
+import sys, subprocess
+from tester_lib import (
+ chdir_repo_root,
+ TEST_CORE_BRANCH,
+ get_current_branch,
+ is_testing_branch,
+ get_upstream_ref_for_current_branch,
+ git_diff_name_status,
+ print_changes_with_exec_marker,
+ changes_outside_tester,
+ prompt_yes_no,
+ get_release_branches,
+ choose_latest_release,
+ RELEASE_PREFIX,
+ RELEASE_TEST_PREFIX,
+)
+
+
+def _run_git(args):
+ return subprocess.run(["git"] + list(args), check=True)
+
+
+def usage() -> int:
+ print("""tester — tester-side branch commands
+
+Usage:
+ tester help
+ Show this message.
+
+ tester branch show-name
+ Print current branch and error if not a tester branch.
+
+ tester branch select core
+ Checkout core_tester_branch.
+
+ tester branch select release [<major>[.<minor>]]
+ Select a release_tester_<major>[.<minor>] branch.
+ - If no version is given: pick the highest existing release_<major>[.<minor>] by version.
+ - If the corresponding release_tester_* branch does not exist yet:
+ it is created from release_<major>[.<minor>] and checked out.
+
+ tester branch pull
+ Pull from the current branch's upstream (same tester branch on remote),
+ listing incoming changes and executables and asking for confirmation.
+
+ tester branch push
+ Push the current tester branch to its upstream, listing outgoing changes.
+ If any changes are outside $REPO_HOME/tester/, a policy warning and
+ confirmation prompt is shown.
+""".rstrip())
+ return 0
+
+
+# ----- branch show-name -----
+
+def cmd_branch_show_name() -> int:
+ chdir_repo_root()
+ name = get_current_branch()
+ print(name)
+ if not is_testing_branch(name):
+ print(f"tester: '{name}' is not a tester branch (core_tester_branch or release_tester_*).", file=sys.stderr)
+ return 1
+ return 0
+
+
+# ----- branch select -----
+
+def _parse_version_arg(version: str):
+ if "." in version:
+ major_s, minor_s = version.split(".", 1)
+ else:
+ major_s, minor_s = version, None
+ return major_s, minor_s
+
+
+def _select_release_specific(major_s: str, minor_s: str) -> int:
+ # construct release_<...> name
+ if minor_s is None:
+ rel_name = f"{RELEASE_PREFIX}{major_s}"
+ tester_name = f"{RELEASE_TEST_PREFIX}{major_s}"
+ else:
+ rel_name = f"{RELEASE_PREFIX}{major_s}.{minor_s}"
+ tester_name = f"{RELEASE_TEST_PREFIX}{major_s}.{minor_s}"
+
+ # ensure base release branch exists
+ cp = subprocess.run(
+ ["git", "branch", "--list", rel_name],
+ text=True, stdout=subprocess.PIPE, check=True
+ )
+ if not cp.stdout.strip():
+ print(f"tester: release branch '{rel_name}' does not exist.", file=sys.stderr)
+ return 1
+
+ # if release_tester branch does not exist, create it from release_<...>
+ cp2 = subprocess.run(
+ ["git", "branch", "--list", tester_name],
+ text=True, stdout=subprocess.PIPE, check=True
+ )
+ if not cp2.stdout.strip():
+ print(f"Creating tester branch {tester_name} from {rel_name}...")
+ _run_git(["branch", tester_name, rel_name])
+
+ # checkout tester branch
+ _run_git(["checkout", tester_name])
+ return 0
+
+
+def cmd_branch_select(args: list[str]) -> int:
+ chdir_repo_root()
+ if not args:
+ print("tester branch select: missing sub-argument (core|release).", file=sys.stderr)
+ return 1
+ sub = args[0]
+ if sub == "core":
+ _run_git(["checkout", TEST_CORE_BRANCH])
+ return 0
+ if sub == "release":
+ if len(args) == 1:
+ # pick latest release_<...> by version
+ rel_branches = get_release_branches()
+ info = choose_latest_release(rel_branches)
+ if info is None:
+ print("tester: no release_<major>[.<minor>] branches found.", file=sys.stderr)
+ return 1
+ major = info.major
+ minor = info.minor
+ major_s = str(major)
+ minor_s = None if minor is None or minor == 0 else str(minor)
+ return _select_release_specific(major_s, minor_s)
+ else:
+ major_s, minor_s = _parse_version_arg(args[1])
+ return _select_release_specific(major_s, minor_s or None)
+
+ print(f"tester branch select: unknown mode '{sub}' (expected 'core' or 'release').", file=sys.stderr)
+ return 1
+
+
+# ----- branch pull -----
+
+def cmd_branch_pull() -> int:
+ repo_root = chdir_repo_root()
+ branch = get_current_branch()
+ upstream = get_upstream_ref_for_current_branch()
+ if not upstream:
+ print(f"tester pull: no upstream configured for branch '{branch}'.", file=sys.stderr)
+ return 1
+
+ # HEAD..upstream
+ changes = git_diff_name_status("HEAD", upstream, paths=None)
+ if not changes:
+ print(f"{branch} is up to date with {upstream}.")
+ return 0
+
+ print(f"Incoming changes from {upstream} into {branch}:")
+ print_changes_with_exec_marker(changes, repo_root)
+
+ if not prompt_yes_no("Apply these changes with git pull?", default=False):
+ print("Aborting pull.")
+ return 1
+
+ _run_git(["pull"])
+ return 0
+
+
+# ----- branch push -----
+
+def cmd_branch_push() -> int:
+ repo_root = chdir_repo_root()
+ branch = get_current_branch()
+ upstream = get_upstream_ref_for_current_branch()
+ if not upstream:
+ print(f"tester push: no upstream configured for branch '{branch}'.", file=sys.stderr)
+ print("You may want to set it with: git branch --set-upstream-to origin/<branch>", file=sys.stderr)
+ return 1
+
+ # upstream..HEAD
+ changes = git_diff_name_status(upstream, "HEAD", paths=None)
+ if not changes:
+ print(f"Nothing to push (HEAD is same as {upstream}).")
+ return 0
+
+ print(f"Changes to be pushed from {branch} to {upstream}:")
+ print_changes_with_exec_marker(changes, repo_root)
+
+ outside = changes_outside_tester(changes)
+ if outside:
+ print("\nWARNING: the following paths are outside $REPO_HOME/tester/ and are against policy:")
+ print_changes_with_exec_marker(outside, repo_root)
+ if not prompt_yes_no("These are against policy. Are you sure you want to push?", default=False):
+ print("Aborting push.")
+ return 1
+
+ if not prompt_yes_no(f"Proceed with git push to {upstream}?", default=True):
+ print("Aborting push.")
+ return 1
+
+ _run_git(["push"])
+ return 0
+
+
+def CLI(argv=None) -> int:
+ if argv is None:
+ argv = sys.argv[1:]
+
+ if not argv or argv[0] in ("help", "-h", "--help"):
+ return usage()
+
+ if argv[0] != "branch":
+ print(f"tester: unknown top-level command '{argv[0]}' (expected 'branch' or 'help').", file=sys.stderr)
+ return 1
+
+ subargs = argv[1:]
+ if not subargs or subargs[0] in ("help", "-h", "--help"):
+ print("tester branch commands:")
+ print(" tester branch show-name")
+ print(" tester branch select core")
+ print(" tester branch select release [<major>[.<minor>]]")
+ print(" tester branch pull")
+ print(" tester branch push")
+ return 0
+
+ action = subargs[0]
+ rest = subargs[1:]
+
+ if action == "show-name":
+ return cmd_branch_show_name()
+ if action == "select":
+ return cmd_branch_select(rest)
+ if action == "pull":
+ return cmd_branch_pull()
+ if action == "push":
+ return cmd_branch_push()
+
+ print(f"tester branch: unknown action '{action}'.", file=sys.stderr)
+ return 1
+
+
+if __name__ == "__main__":
+ raise SystemExit(CLI())
--- /dev/null
+#!/usr/bin/env -S python3 -B
+# tester_lib.py — shared helpers for tester tools
+
+import os, sys, subprocess
+from dataclasses import dataclass
+from typing import List, Tuple, Optional
+
+
+DEV_BRANCH = "core_developer_branch"
+TEST_CORE_BRANCH = "core_tester_branch"
+RELEASE_PREFIX = "release_"
+RELEASE_TEST_PREFIX = "release_tester_"
+
+TESTER_ROOT_REL = "tester"
+DEVELOPER_ROOT_REL = "developer"
+RELEASE_ROOT_REL = "release"
+
+
+class BranchKind:
+ CORE_DEV = "core_developer"
+ CORE_TEST = "core_tester"
+ RELEASE = "release"
+ RELEASE_TEST = "release_tester"
+ OTHER = "other"
+
+
+@dataclass
+class BranchInfo:
+ name: str
+ kind: str
+ major: Optional[int] = None
+ minor: Optional[int] = None
+
+
+def _run_git(args, capture_output=True, check=True) -> subprocess.CompletedProcess:
+ cmd = ["git"] + list(args)
+ if capture_output:
+ return subprocess.run(cmd, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=check)
+ return subprocess.run(cmd, text=True, check=check)
+
+
+def get_repo_root() -> str:
+ rh = os.environ.get("REPO_HOME")
+ if rh:
+ return os.path.abspath(rh)
+ try:
+ cp = _run_git(["rev-parse", "--show-toplevel"])
+ return cp.stdout.strip()
+ except Exception as e:
+ print(f"tester_lib: cannot determine repo root: {e}", file=sys.stderr)
+ sys.exit(1)
+
+
+def chdir_repo_root() -> str:
+ root = get_repo_root()
+ os.chdir(root)
+ return root
+
+
+def get_current_branch() -> str:
+ cp = _run_git(["rev-parse", "--abbrev-ref", "HEAD"])
+ return cp.stdout.strip()
+
+
+def _parse_version_from_name(name: str, prefix: str) -> Optional[Tuple[int, int]]:
+ """
+ Parse branch names like:
+ prefix + "<major>" or prefix + "<major>.<minor>"
+
+ Returns (major, minor) with minor defaulting to 0 if absent.
+ """
+ if not name.startswith(prefix):
+ return None
+ tail = name[len(prefix):]
+ if not tail:
+ return None
+ if "." in tail:
+ major_s, minor_s = tail.split(".", 1)
+ else:
+ major_s, minor_s = tail, "0"
+ try:
+ major = int(major_s)
+ minor = int(minor_s)
+ except ValueError:
+ return None
+ return (major, minor)
+
+
+def classify_branch(name: str) -> BranchInfo:
+ if name == DEV_BRANCH:
+ return BranchInfo(name=name, kind=BranchKind.CORE_DEV)
+ if name == TEST_CORE_BRANCH:
+ return BranchInfo(name=name, kind=BranchKind.CORE_TEST)
+
+ v = _parse_version_from_name(name, RELEASE_PREFIX)
+ if v is not None:
+ major, minor = v
+ return BranchInfo(name=name, kind=BranchKind.RELEASE, major=major, minor=minor)
+
+ v = _parse_version_from_name(name, RELEASE_TEST_PREFIX)
+ if v is not None:
+ major, minor = v
+ return BranchInfo(name=name, kind=BranchKind.RELEASE_TEST, major=major, minor=minor)
+
+ return BranchInfo(name=name, kind=BranchKind.OTHER)
+
+
+def is_testing_branch(name: str) -> bool:
+ info = classify_branch(name)
+ return info.kind in (BranchKind.CORE_TEST, BranchKind.RELEASE_TEST)
+
+
+def get_release_tester_branches() -> List[BranchInfo]:
+ cp = _run_git(["branch", "--list", f"{RELEASE_TEST_PREFIX}*"])
+ branches: List[BranchInfo] = []
+ for line in cp.stdout.splitlines():
+ line = line.strip()
+ if not line:
+ continue
+ if line.startswith("* "):
+ line = line[2:]
+ info = classify_branch(line)
+ if info.kind == BranchKind.RELEASE_TEST and info.major is not None:
+ branches.append(info)
+ return branches
+
+
+def get_release_branches() -> List[BranchInfo]:
+ cp = _run_git(["branch", "--list", f"{RELEASE_PREFIX}*"])
+ branches: List[BranchInfo] = []
+ for line in cp.stdout.splitlines():
+ line = line.strip()
+ if not line:
+ continue
+ if line.startswith("* "):
+ line = line[2:]
+ info = classify_branch(line)
+ if info.kind == BranchKind.RELEASE and info.major is not None:
+ branches.append(info)
+ return branches
+
+
+def choose_latest_release(branches: List[BranchInfo]) -> Optional[BranchInfo]:
+ if not branches:
+ return None
+ sorted_br = sorted(
+ branches,
+ key=lambda b: (b.major if b.major is not None else -1,
+ b.minor if b.minor is not None else -1)
+ )
+ return sorted_br[-1]
+
+
+def choose_latest_release_tester(branches: List[BranchInfo]) -> Optional[BranchInfo]:
+ return choose_latest_release(branches)
+
+
+def corresponding_developer_branch(test_branch: str) -> Optional[str]:
+ info = classify_branch(test_branch)
+ if info.kind == BranchKind.CORE_TEST:
+ return DEV_BRANCH
+ if info.kind == BranchKind.RELEASE_TEST and info.major is not None:
+ if info.minor and info.minor != 0:
+ return f"{RELEASE_PREFIX}{info.major}.{info.minor}"
+ return f"{RELEASE_PREFIX}{info.major}"
+ return None
+
+
+def get_developer_ref_for_merge(test_branch: str) -> Optional[str]:
+ dev_branch = corresponding_developer_branch(test_branch)
+ if not dev_branch:
+ return None
+ return f"origin/{dev_branch}"
+
+
+def get_upstream_ref_for_current_branch() -> Optional[str]:
+ try:
+ cp = _run_git(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"])
+ except subprocess.CalledProcessError:
+ return None
+ return cp.stdout.strip()
+
+
+def git_diff_name_status(from_ref: str, to_ref: str, paths: Optional[List[str]] = None) -> List[Tuple[str, str]]:
+ args = ["diff", "--name-status", f"{from_ref}..{to_ref}"]
+ if paths:
+ args.append("--")
+ args.extend(paths)
+ cp = _run_git(args)
+ out: List[Tuple[str, str]] = []
+ for line in cp.stdout.splitlines():
+ if not line.strip():
+ continue
+ parts = line.split("\t", 1)
+ if len(parts) != 2:
+ continue
+ status, path = parts
+ out.append((status.strip(), path.strip()))
+ return out
+
+
+def list_executable_flags(paths: List[str], repo_root: str) -> List[str]:
+ exec_paths: List[str] = []
+ for p in paths:
+ fs_path = os.path.join(repo_root, p)
+ if os.path.exists(fs_path) and os.access(fs_path, os.X_OK):
+ exec_paths.append(p)
+ return exec_paths
+
+
+def print_changes_with_exec_marker(changes: List[Tuple[str, str]], repo_root: str) -> None:
+ if not changes:
+ print(" (no changes)")
+ return
+ paths = [p for _s, p in changes]
+ execs = set(list_executable_flags(paths, repo_root))
+ for status, path in changes:
+ mark = " [EXEC]" if path in execs else ""
+ print(f" {status}\t{path}{mark}")
+
+
+def prompt_yes_no(msg: str, default: bool = False) -> bool:
+ suffix = "[y/N]" if not default else "[Y/n]"
+ while True:
+ ans = input(f"{msg} {suffix} ").strip().lower()
+ if not ans:
+ return default
+ if ans in ("y", "yes"):
+ return True
+ if ans in ("n", "no"):
+ return False
+ print("Please answer y or n.")
+
+
+def is_under_tester_tree(rel_path: str) -> bool:
+ parts = rel_path.split(os.sep)
+ return bool(parts) and parts[0] == TESTER_ROOT_REL
+
+
+def changes_outside_tester(changes: List[Tuple[str, str]]) -> List[Tuple[str, str]]:
+ return [(s, p) for (s, p) in changes if not is_under_tester_tree(p)]
+
+
+def fetch_remote(remote: str = "origin") -> None:
+ _run_git(["fetch", remote], capture_output=False, check=True)
+
+
+def ensure_on_testing_branch_or_die() -> BranchInfo:
+ name = get_current_branch()
+ info = classify_branch(name)
+ if info.kind not in (BranchKind.CORE_TEST, BranchKind.RELEASE_TEST):
+ print(f"Error: current branch '{name}' is not a testing branch (core_tester or release_tester_*).", file=sys.stderr)
+ sys.exit(1)
+ return info
+++ /dev/null
-#ifndef IFACE
-#define Db·IMPLEMENTATION
-#define IFACE
-#endif
-
-#ifndef Db·IFACE
-#define Db·IFACE
-
- #include <sqlite3.h>
- #include <stdbool.h>
-
- // Enum for exit codes
- typedef enum {
- Db·EXIT_SUCCESS = 0,
- Db·EXIT_DB_OPEN_ERROR,
- Db·EXIT_SCHEMA_LOAD_ERROR,
- Db·EXIT_MEMORY_ALLOCATION_ERROR,
- Db·EXIT_STATEMENT_PREPARE_ERROR,
- Db·EXIT_STATEMENT_EXECUTE_ERROR
- } Db·ExitCode;
-
- // Interface prototypes
- sqlite3* Db·open(const char *db_path ,bool create_if_not_exists);
- Db·ExitCode Db·load_schema(sqlite3 *db, const char *schema_path);
- Db·ExitCode Db·log_event(sqlite3 *db, int event_id, int user_id);
- int Db·query(
- sqlite3 *db
- ,const char *sql
- ,int (*callback)(void * ,int ,char ** ,char **)
- ,void *callback_arg
- );
- void Db·close(sqlite3 *db);
-
-#endif // Db·IFACE
-
-#ifndef Db·IMPLEMENTATION
-
- #include <sqlite3.h>
- #include <stdio.h>
- #include <stdlib.h>
- #include <string.h>
- #include <errno.h>
- #include <time.h>
- #include <string.h>
-
- sqlite3* Db·open(const char *db_path ,bool create_if_not_exists){
- sqlite3 *db;
- FILE *file_check = fopen(db_path ,"r");
-
- if(!file_check && create_if_not_exists){
- file_check = fopen(db_path ,"w");
- if(!file_check){
- fprintf(
- stderr,
- "Db::open failed to create database file '%s': %s\n",
- db_path,
- strerror(errno)
- );
- return NULL;
- }
- fclose(file_check);
- printf("Db::open created new database file '%s'\n", db_path);
- }else if(!file_check){
- fprintf(stderr ,"Db::open database file '%s' not found and create flag not set\n" ,db_path);
- return NULL;
- }else{
- fclose(file_check);
- }
-
- if( sqlite3_open(db_path ,&db) != SQLITE_OK ){
- fprintf(
- stderr,
- "Db::open failed to open database '%s': %s\n",
- db_path,
- sqlite3_errmsg(db)
- );
- return NULL;
- }
-
- printf("Db::open database '%s' opened successfully\n", db_path);
- return db;
- }
-
- // Load schema from a file
- Db·ExitCode Db·load_schema(sqlite3 *db ,const char *schema_path){
- FILE *file = fopen(schema_path, "r");
- if(!file){
- fprintf
- (
- stderr
- ,"Db::load_schema failed to open schema file '%s'\n"
- ,schema_path
- );
- return Db·EXIT_SCHEMA_LOAD_ERROR;
- }
-
- fseek(file, 0, SEEK_END);
- long file_size = ftell(file);
- rewind(file);
-
- char *schema = malloc(file_size + 1);
- if(!schema){
- fprintf(stderr, "Db::load_schema memory allocation failed\n");
- fclose(file);
- return Db·EXIT_MEMORY_ALLOCATION_ERROR;
- }
-
- fread(schema, 1, file_size, file);
- schema[file_size] = '\0';
- fclose(file);
-
- char *err_msg = NULL;
- if( sqlite3_exec(db, schema, NULL, NULL, &err_msg) != SQLITE_OK ){
- fprintf
- (
- stderr
- ,"Db::load_schema failed to execute schema: %s\n"
- ,err_msg
- );
- sqlite3_free(err_msg);
- free(schema);
- return Db·EXIT_STATEMENT_EXECUTE_ERROR;
- }
-
- printf("Db::load_schema schema initialized successfully from '%s'\n", schema_path);
- free(schema);
- return Db·EXIT_SUCCESS;
- }
-
- // Log an event into the database
- Db·ExitCode Db·log_event(sqlite3 *db ,int event_id ,int user_id){
- const char *sql_template =
- "INSERT INTO db_event (event_time ,event_id ,user_id) "
- "VALUES (CURRENT_TIMESTAMP ,? ,?);";
- sqlite3_stmt *stmt;
-
- if( sqlite3_prepare_v2(db ,sql_template ,-1 ,&stmt ,NULL) != SQLITE_OK ){
- fprintf
- (
- stderr
- ,"Db::log_event failed to prepare statement: %s\n"
- ,sqlite3_errmsg(db)
- );
- return Db·EXIT_STATEMENT_PREPARE_ERROR;
- }
-
- sqlite3_bind_int(stmt, 1, event_id);
- sqlite3_bind_int(stmt, 2, user_id);
-
- if( sqlite3_step(stmt) != SQLITE_DONE ){
- fprintf
- (
- stderr
- ,"Db::log_event failed to execute statement: %s\n"
- ,sqlite3_errmsg(db)
- );
- sqlite3_finalize(stmt);
- return Db·EXIT_STATEMENT_EXECUTE_ERROR;
- }
-
- sqlite3_finalize(stmt);
- return Db·EXIT_SUCCESS;
- }
-
- // Query Execution Function
- int Db·query(
- sqlite3 *db
- ,const char *sql
- ,int (*callback)(void * ,int ,char ** ,char **)
- ,void *callback_arg
- ){
- char *err_msg = NULL;
- int rc = sqlite3_exec(db ,sql ,callback ,callback_arg ,&err_msg);
-
- if( rc != SQLITE_OK ){
- fprintf
- (
- stderr
- ,"Db::query SQL error: %s\nQuery: %s\n"
- ,err_msg
- ,sql
- );
- sqlite3_free(err_msg);
- return rc;
- }
-
- return SQLITE_OK;
- }
-
- // Close the database
- void Db·close(sqlite3 *db){
- if( db ){
- sqlite3_close(db);
- printf("Db::close database connection closed\n");
- }
- }
-
-#endif // Db·IMPLEMENTATION
-
-
+++ /dev/null
-#ifndef IFACE
-#define DbSubu·IMPLEMENTATION
-#define IFACE
-#endif
-
-#ifndef DbSubu·IFACE
-#define DbSubu·IFACE
-
- #include <sqlite3.h>
-
- typedef struct DbSubu {
- sqlite3 *db;
- } DbSubu;
-
-
- // db connection
- DbSubu* DbSubu·open( const char *db_path );
- void DbSubu·close( DbSubu *db );
- int DbSubu·validate_schema( DbSubu *db );
-
- // User Management
- int DbSubu·add_user( DbSubu *db ,const char *name ,const char *home_directory ,int shell_id ,int parent_id ,int user_type_id );
- int DbSubu·delete_user( DbSubu *db ,int user_id );
- int DbSubu·get_user( DbSubu *db ,int user_id ,char **name ,char **home_directory ,int *shell_id ,int *parent_id ,int *user_type_id );
-
- // Sharing Management
- int DbSubu·add_share( DbSubu *db ,int user_id ,int other_user_id ,const char *permissions );
- int DbSubu·delete_share( DbSubu *db ,int share_id );
-
- // System Resource Management
- int DbSubu·grant_resource( DbSubu *db ,int user_id ,int resource_id ,int granted_by );
- int DbSubu·revoke_resource( DbSubu *db ,int user_id ,int resource_id );
-
- // Event Logging
- int DbSubu·log_event( DbSubu *db ,int event_id ,int user_id );
-
-#endif // DbSubu·IFACE
-
-#ifdef DbSubu·IMPLEMENTATION
-
- #include <stdio.h>
- #include <stdlib.h>
- #include <string.h>
- #include "Db.lib.c"
-
- // Open the database
- DbSubu* DbSubu·open( const char *db_path ){
- DbSubu *db = malloc( sizeof(DbSubu) );
- if( !db ){
- fprintf( stderr ,"DbSubu·open:: failed to allocate memory for DbSubu\n" );
- return NULL;
- }
- db->db = Db·open(db_path ,true);
- if( !db->db ){
- free( db );
- return NULL;
- }
- return db;
- }
-
- // Close the database
- void DbSubu·close( DbSubu *db ){
- if( db ){
- Db·close( db->db );
- free( db );
- }
- }
-
- // Validate the schema
- int DbSubu·validate_schema( DbSubu *db ){
- // Validation logic for ensuring the schema is correct
- return 0; // Placeholder for schema validation implementation
- }
-
- // Add a user
- int DbSubu·add_user( DbSubu *db ,const char *name ,const char *home_directory ,int shell_id ,int parent_id ,int user_type_id ){
- char sql[256];
- snprintf
- (
- sql
- ,sizeof(sql)
- ,"INSERT INTO user (name ,home_directory ,shell ,parent_id ,user_type_id) VALUES ('%s' ,'%s' ,%d ,%d ,%d);"
- ,name
- ,home_directory
- ,shell_id
- ,parent_id
- ,user_type_id
- );
- return Db·query( db->db ,sql ,NULL ,NULL );
- }
-
- // Delete a user
- int DbSubu·delete_user( DbSubu *db ,int user_id ){
- char sql[128];
- snprintf( sql ,sizeof(sql) ,"DELETE FROM user WHERE id = %d;" ,user_id );
- return Db·query( db->db ,sql ,NULL ,NULL );
- }
-
- // Log an event
- int DbSubu·log_event( DbSubu *db ,int event_id ,int user_id ){
- char sql[128];
- snprintf
- (
- sql
- ,sizeof(sql)
- ,"INSERT INTO db_event (event_id ,user_id) VALUES (%d ,%d);"
- ,event_id
- ,user_id
- );
- return Db·query( db->db ,sql ,NULL ,NULL );
- }
-
- // Add to a list (private function)
- static int add_to_list( sqlite3 *db ,const char *list_name ,const char *entry_name ){
- char sql[128];
- snprintf
- (
- sql
- ,sizeof(sql)
- ,"INSERT INTO %s (name) VALUES ('%s');"
- ,list_name
- ,entry_name
- );
- return Db·query( db ,sql ,NULL ,NULL );
- }
-
- // Get list entries (private function)
- static char** get_list( sqlite3 *db ,const char *list_name ,int *count ){
- char sql[128];
- snprintf( sql ,sizeof(sql) ,"SELECT name FROM %s;" ,list_name );
-
- struct ListResult {
- char **entries;
- int count;
- } result = { NULL ,0 };
-
- int callback( void *arg ,int argc ,char **argv ,char **col_names ){
- (void)argc; (void)col_names;
- struct ListResult *res = arg;
- res->entries = realloc( res->entries ,(res->count + 1) * sizeof(char *) );
- res->entries[res->count++] = strdup( argv[0] );
- return 0;
- }
-
- if( Db·query( db ,sql ,callback ,&result ) != SQLITE_OK ){
- for( int i = 0; i < result.count; ++i ){
- free( result.entries[i] );
- }
- free( result.entries );
- return NULL;
- }
-
- *count = result.count;
- return result.entries;
- }
-
-#endif // DbSubu·IMPLEMENTATION
+++ /dev/null
-#define IFACE
-#include <stdio.h>
-#include <stdlib.h>
-#include <string.h>
-#include <inttypes.h>
-#include "Db.lib.c"
-
-int main(int argc ,char *argv[]){
- if( argc < 2 ){
- fprintf(stderr ,"Usage: %s <sqlite_handle_as_hex>\n" ,argv[0]);
- return EXIT_FAILURE;
- }
-
- // Parse the SQLite handle from the command-line argument
- uintptr_t handle_as_int;
- if( sscanf(argv[1] ,"%lx" ,&handle_as_int) != 1 ){
- fprintf(stderr ,"%s::main failed to parse handle '%s'\n" ,argv[0] ,argv[1]);
- return EXIT_FAILURE;
- }
-
- sqlite3 *db = (sqlite3 *)handle_as_int;
-
- // Attempt to close the database
- if( db ){
- Db·close(db);
- printf("Database handle %p closed successfully.\n" ,db);
- return EXIT_SUCCESS;
- } else {
- fprintf(stderr ,"Invalid or NULL database handle: %p\n" ,db);
- return EXIT_FAILURE;
- }
-}
+++ /dev/null
-
-/*
- The subu server command line interface.
-
- Usage:
- server [-s <socket_path>] [-l <log_path>] [arguments...]
-
- Options:
- -s <socket_path> Specify the Unix socket file path. Default: ./socket
- -l <log_path> Specify the log file path. Default: ./log.txt
-*/
-
-#define IFACE
-#include <stdio.h>
-#include <stdlib.h>
-#include <string.h>
-#include <unistd.h>
-#include "Hello.lib.c"
-
-// Define defaults
-#define DEFAULT_SOCKET_PATH "socket"
-
-int main( int argc ,char **argv ){
- char *socket_path = DEFAULT_SOCKET_PATH;
- int error_flag = 0;
-
- int opt;
- while( (opt = getopt(argc ,argv ,":s:l:")) != -1 ){
- switch( opt ){
- case 's':
- socket_path = optarg;
- break;
- case '?': // Unknown option
- fprintf( stderr ,"%s::main unknown option '-%c'\n" ,argv[0] ,optopt );
- error_flag = 1;
- break;
- case ':': // Missing argument
- fprintf( stderr ,"%s::main missing argument for option '-%c'\n" ,argv[0] ,optopt );
- error_flag = 1;
- break;
- }
- }
-
- if( optind > argc ){
- fprintf( stderr ,"%s::main optind(%d) > argc(%d), which indicates an option parsing bug\n" ,argv[0] ,optind ,argc );
- error_flag = 1;
- }
-
- // Exit on error after processing all options
- if( error_flag ){
- fprintf( stderr ,"%s::main usage: %s [-s <socket_path>] [arguments...]\n" ,argv[0] ,argv[0] );
- return EXIT_FAILURE;
- }
-
- // Rebase argv to prepare for run
- if(optind > 0){
- argv[optind - 1] = argv[0]; // Program name at the new base
- argc -= (optind - 1);
- argv += (optind - 1);
- }
-
- // Log parsed options
- printf( "%s::main socket_path='%s'\n" ,argv[0] ,socket_path );
-
- // Call the hello function
- return Hello·run(argc ,argv ,socket_path);
-}
+++ /dev/null
-#ifndef IFACE
-#define Hello·IMPLEMENTATION
-#define IFACE
-#endif
-
-#ifndef Hello·IFACE
-#define Hello·IFACE
-
- // Necessary interface includes
- // .. none
-
- // Interface prototypes
- int Hello·run(int argc ,char** argv ,char *socket_path);
-
-#endif // Hello·IFACE
-
-#ifdef Hello·IMPLEMENTATION
-
- // Implementation-specific includes
- #include <sys/socket.h>
- #include <sys/un.h>
- #include <stddef.h>
- #include <stdio.h>
- #include <stdlib.h>
- #include <string.h>
- #include <unistd.h>
- #include <errno.h>
-
- // Constants
- #define Hello·SOCKET_PATH "/var/user_data/Thomas-developer/subu/developer/mockup/subu_server_home/subu_server.sock"
- #define Hello·LOG_PATH "server_test.log"
- #define Hello·BUFFER_SIZE 256
-
- int Hello·run(int argc ,char** argv ,char *socket_path){
- (void)argc; // Suppress unused variable warnings
- (void)argv;
-
- int client_fd;
- struct sockaddr_un address;
- char buffer[Hello·BUFFER_SIZE];
-
- client_fd = socket(AF_UNIX ,SOCK_STREAM ,0);
- if( client_fd == -1 ){
- perror("Hello·run:: error opening socket");
- return EXIT_FAILURE;
- }
-
- // Configure server socket address
- memset(&address ,0 ,sizeof(address));
- address.sun_family = AF_UNIX;
- strncpy(address.sun_path ,socket_path ,sizeof(address.sun_path) - 1);
-
- // Connect to the server
- if( connect(client_fd ,(struct sockaddr *)&address ,sizeof(address)) == -1 ){
- perror("Hello·run:: error connecting to server");
- close(client_fd);
- return EXIT_FAILURE;
- }
-
- // Send message to the server
- char *out_buf = "hello\n";
- if( write(client_fd ,out_buf ,strlen(out_buf)) == -1 ){
- perror("Hello·run:: error writing to server");
- return EXIT_FAILURE;
- }
-
- printf("Hello·run:: sent \"%s\"\n" ,out_buf);
-
- // Clean up
- close(client_fd);
-
- return EXIT_SUCCESS;
- }
-
-#endif // Hello·IMPLEMENTATION
+++ /dev/null
-
-These are currently not used. Eventually the main subu commands will be C programs and setuid root so that master users (and only master users) can seamlessly manipulate sub users.
-
-I started on this, then decided to let the scripts stabilize first.
-
+++ /dev/null
-/*
- The subu server command line interface.
-
- Usage:
- server [-s <socket_path>] [-l <log_path>] [arguments...]
-
- Options:
- -s <socket_path> Specify the Unix socket file path. Default: ./socket
- -l <log_path> Specify the log file path. Default: ./log.txt
-*/
-
-#define IFACE
-#include <stdio.h>
-#include <stdlib.h>
-#include <string.h>
-#include <unistd.h>
-#include "Server.lib.c"
-
-// Define defaults
-#define DEFAULT_SOCKET_PATH "socket"
-#define DEFAULT_LOG_PATH "log.txt"
-
-int main( int argc ,char **argv ){
- char *socket_path = DEFAULT_SOCKET_PATH;
- char *log_path = DEFAULT_LOG_PATH;
- int error_flag = 0;
-
- // Parse command-line options
- int opt;
- while( (opt = getopt(argc ,argv ,":s:l:")) != -1 ){
- switch( opt ){
- case 's':
- socket_path = optarg;
- break;
- case 'l':
- log_path = optarg;
- break;
- case '?': // Unknown option
- fprintf( stderr ,"%s::main unknown option '-%c'\n" ,argv[0] ,optopt );
- error_flag = 1;
- break;
- case ':': // Missing argument
- fprintf( stderr ,"%s::main missing argument for option '-%c'\n" ,argv[0] ,optopt );
- error_flag = 1;
- break;
- }
- }
-
- if( optind > argc ){
- fprintf( stderr ,"%s::main optind(%d) > argc(%d), which indicates an option parsing bug\n" ,argv[0] ,optind ,argc );
- error_flag = 1;
- }
-
- // Exit on error after processing all options
- if( error_flag ){
- fprintf( stderr ,"%s::main usage: %s [-s <socket_path>] [-l <log_path>] [arguments...]\n" ,argv[0] ,argv[0] );
- return EXIT_FAILURE;
- }
-
- // Rebase argv to prepare for run
- if(optind > 0){
- argv[optind - 1] = argv[0]; // Program name at the new base
- argc -= (optind - 1);
- argv += (optind - 1);
- }
-
- // Open the log file
- FILE *log_file = Server·open_log(log_path);
- if( !log_file ){
- fprintf( stderr ,"%s::main unable to open log file '%s'\n" ,argv[0] ,log_path );
- return Server·EXIT_LOG_FILE_ERROR;
- }
-
- // Log parsed options
- fprintf( log_file ,"%s::main socket_path='%s'\n" ,argv[0] ,socket_path );
- fprintf( log_file ,"%s::main log_path='%s'\n" ,argv[0] ,log_path );
- fflush(log_file);
-
- // Prepare file descriptors for error reporting
- int fds[] = { fileno(stderr), fileno(log_file), -1 };
-
- // Call the core server function
- int exit_code = Server·run(argc ,argv ,fds ,socket_path);
-
- // Report return condition
- Server·return_condition_report(exit_code ,fds);
-
- // Clean up
- fclose(log_file);
-
- return exit_code;
-}
+++ /dev/null
-#ifndef IFACE
-#define Server·IMPLEMENTATION
-#define IFACE
-#endif
-
-#ifndef Server·IFACE
-#define Server·IFACE
-
- #include <stdio.h>
- #include <time.h>
-
- // Exit codes
- typedef enum {
- Server·EXIT_SUCCESS = 0,
- Server·EXIT_LOG_FILE_ERROR,
- Server·EXIT_SOCKET_CREATION_ERROR,
- Server·EXIT_BIND_ERROR,
- Server·EXIT_LISTEN_ERROR,
- Server·EXIT_ACCEPT_ERROR
- } Server·ExitCode;
-
- // Interface prototypes
- int Server·run( int argc ,char **argv ,int *fds ,char *socket_path );
- void Server·return_condition_report( Server·ExitCode code ,int *fds );
- void Server·report( int *fds ,const char *message );
- FILE* Server·open_log( const char *log_path );
-
-#endif // Server·IFACE
-
-#ifdef Server·IMPLEMENTATION
-
- // Implementation-specific includes
- #include <sys/socket.h>
- #include <sys/un.h>
- #include <stddef.h>
- #include <sys/types.h>
- #include <bits/socket.h> // Ensure full definition of struct ucred
- #include <stdlib.h>
- #include <string.h>
- #include <unistd.h>
- #include <errno.h>
-
- // Constants
- #define Server·BUFFER_SIZE 256
- #define MAX_ARGC 16
-
- // Internal function prototypes
- static void parse( int *fds ,struct ucred *client_cred ,char *input_line );
- static void hello( int *fds ,int argc ,char *argv[] ,struct ucred *client_cred );
-
- // Log a message with time and to multiple destinations
- void Server·report( int *fds ,const char *message ){
- time_t now = time(NULL);
- char time_buffer[32];
- strftime(time_buffer ,sizeof(time_buffer) ,"%Y-%m-%dT%H:%M:%SZ" ,gmtime(&now));
-
- for( int i = 0; fds[i] != -1; ++i ){
- dprintf( fds[i] ,"\n%s:: %s" ,time_buffer ,message );
- }
- }
-
- int Server·run( int argc ,char **argv ,int *fds ,char *socket_path ){
- (void)argc; // Suppress unused variable warnings
- (void)argv;
-
- int server_fd ,client_fd;
- struct sockaddr_un address;
-
- // Create socket
- if( (server_fd = socket(AF_UNIX ,SOCK_STREAM ,0)) == -1 ){
- Server·report(fds ,"Socket creation failed.");
- return Server·EXIT_SOCKET_CREATION_ERROR;
- }
-
- // Configure socket address
- memset(&address ,0 ,sizeof(address));
- address.sun_family = AF_UNIX;
- strncpy(address.sun_path ,socket_path ,sizeof(address.sun_path) - 1);
-
- unlink(socket_path);
- if( bind(server_fd ,(struct sockaddr *)&address ,sizeof(address)) == -1 ){
- Server·report(fds ,"Binding socket failed.");
- close(server_fd);
- return Server·EXIT_BIND_ERROR;
- }
-
- if( listen(server_fd ,5) == -1 ){
- Server·report(fds ,"Listening on socket failed.");
- close(server_fd);
- return Server·EXIT_LISTEN_ERROR;
- }
-
- char startup_message[Server·BUFFER_SIZE];
- snprintf(startup_message ,Server·BUFFER_SIZE ,"Server running with socket '%s' ,awaiting connections..." ,socket_path);
- Server·report(fds ,startup_message);
-
- while( (client_fd = accept(server_fd ,NULL ,NULL)) != -1 ){
- struct ucred client_cred;
- socklen_t len = sizeof(client_cred);
-
- if( getsockopt(client_fd ,SOL_SOCKET ,SO_PEERCRED ,&client_cred ,&len) == -1 ){
- Server·report(fds ,"Failed to retrieve client credentials.");
- close(client_fd);
- continue;
- }
-
- char connection_message[Server·BUFFER_SIZE];
- snprintf(connection_message ,Server·BUFFER_SIZE ,
- "Connection from PID=%d ,UID=%d ,GID=%d" ,
- client_cred.pid ,client_cred.uid ,client_cred.gid);
- Server·report(fds ,connection_message);
-
- char buffer[Server·BUFFER_SIZE];
- memset(buffer ,0 ,Server·BUFFER_SIZE);
- ssize_t bytes_read = read(client_fd ,buffer ,Server·BUFFER_SIZE - 1);
- if(bytes_read > 0){
- char *line = strtok(buffer ,"\n");
- while(line != NULL){
- parse(fds ,&client_cred ,line);
- line = strtok(NULL ,"\n");
- }
- } else if(bytes_read == -1){
- Server·report(fds ,"Error reading from client.");
- }
-
- close(client_fd);
- }
-
- Server·report(fds ,"Error accepting connection.");
- close(server_fd);
- unlink(socket_path);
- return Server·EXIT_ACCEPT_ERROR;
- }
-
- // Parse a single input line and dispatch to the appropriate command
- static void parse( int *fds ,struct ucred *client_cred ,char *input_line ){
- char *argv[MAX_ARGC + 1] = {0};
- int argc = 0;
-
- char *line_copy = strdup(input_line);
- if(!line_copy){
- Server·report(fds ,"Failed to duplicate input line.");
- return;
- }
-
- char *token = strtok(line_copy ," ");
- while(token != NULL && argc < MAX_ARGC){
- argv[argc++] = token;
- token = strtok(NULL ," ");
- }
-
- if(argc > 0){
- if( strcmp(argv[0] ,"hello") == 0 ){
- hello(fds ,argc ,argv ,client_cred);
- }else{
- char unknown_command_message[Server·BUFFER_SIZE];
- snprintf(unknown_command_message ,Server·BUFFER_SIZE ,"Unknown command '%s'" ,argv[0]);
- Server·report(fds ,unknown_command_message);
- }
- }
-
- free(line_copy);
- }
-
- // Example command: hello
- static void hello( int *fds ,int argc ,char *argv[] ,struct ucred *client_cred ){
- char hello_message[Server·BUFFER_SIZE];
- snprintf(hello_message ,Server·BUFFER_SIZE ,
- "hello:: invoked by PID=%d ,UID=%d ,GID=%d" ,
- client_cred->pid ,client_cred->uid ,client_cred->gid);
- Server·report(fds ,hello_message);
-
- for( int i = 1; i < argc; ++i ){
- char argument_message[Server·BUFFER_SIZE];
- snprintf(argument_message ,Server·BUFFER_SIZE ," Arg %d: %s" ,i ,argv[i]);
- Server·report(fds ,argument_message);
- }
- }
-
- // Error reporting function
- void Server·return_condition_report( Server·ExitCode code ,int *fds ){
- const char *message;
- switch( code ){
- case Server·EXIT_SUCCESS:
- message = "Operation completed successfully.";
- break;
- case Server·EXIT_LOG_FILE_ERROR:
- message = "Failed to open log file.";
- break;
- case Server·EXIT_SOCKET_CREATION_ERROR:
- message = "Socket creation failed.";
- break;
- case Server·EXIT_BIND_ERROR:
- message = "Binding socket failed.";
- break;
- case Server·EXIT_LISTEN_ERROR:
- message = "Listening on socket failed.";
- break;
- case Server·EXIT_ACCEPT_ERROR:
- message = "Error accepting connection.";
- break;
- default:
- message = "Unknown error occurred.";
- break;
- }
-
- Server·report(fds ,message);
- }
-
- // Log file opener
- FILE* Server·open_log( const char *log_path ){
- FILE *log_file = fopen(log_path ,"a+");
- if( log_file ){
- Server·report( (int[]){fileno(log_file), -1} ,"Log file opened.");
- }
- return log_file;
- }
-
-#endif // Server·IMPLEMENTATION
+++ /dev/null
-#define IFACE
-#include <stdio.h>
-#include <stdlib.h>
-#include <string.h>
-#include "DbSubu.lib.c"
-
-int main(int argc ,char *argv[]){
- if( argc < 7 ){
- fprintf(stderr, "Usage: %s <db_path> <name> <home_directory> <shell_id> <parent_id> <user_type_id>\n", argv[0]);
- return 1;
- }
-
- const char *db_path = argv[1];
- const char *name = argv[2];
- const char *home_directory = argv[3];
- int shell_id = atoi(argv[4]);
- int parent_id = atoi(argv[5]);
- int user_type_id = atoi(argv[6]);
-
- DbSubu *db = DbSubu·open(db_path);
- if( !db ){
- fprintf(stderr, "Failed to open database: %s\n", db_path);
- return 1;
- }
-
- int result = DbSubu·add_user(db, name, home_directory, shell_id, parent_id, user_type_id);
- DbSubu·close(db);
-
- if( result == 0 ){
- printf("User added successfully.\n");
- return 0;
- } else {
- fprintf(stderr, "Failed to add user.\n");
- return 1;
- }
-}
+++ /dev/null
-#define IFACE
-#include <stdio.h>
-#include <stdlib.h>
-#include <string.h>
-#include "DbSubu.lib.c"
-
-int main(int argc ,char *argv[]){
- if( argc < 3 ){
- fprintf(stderr, "Usage: %s <db_path> <user_id>\n", argv[0]);
- return 1;
- }
-
- const char *db_path = argv[1];
- int user_id = atoi(argv[2]);
-
- DbSubu *db = DbSubu·open(db_path);
- if( !db ){
- fprintf(stderr, "Failed to open database: %s\n", db_path);
- return 1;
- }
-
- int result = DbSubu·delete_user(db, user_id);
- DbSubu·close(db);
-
- if( result == 0 ){
- printf("User deleted successfully.\n");
- return 0;
- } else {
- fprintf(stderr, "Failed to delete user.\n");
- return 1;
- }
-}
+++ /dev/null
-#define IFACE
-#include <stdio.h>
-#include <stdlib.h>
-#include <string.h>
-#include "DbSubu.lib.c"
-
-int main(int argc ,char *argv[]){
- if( argc < 3 ){
- fprintf(stderr, "Usage: %s <db_path> <user_id>\n", argv[0]);
- return 1;
- }
-
- const char *db_path = argv[1];
- int user_id = atoi(argv[2]);
-
- DbSubu *db = DbSubu·open(db_path);
- if( !db ){
- fprintf(stderr, "Failed to open database: %s\n", db_path);
- return 1;
- }
-
- int result = DbSubu·delete_user(db, user_id);
- DbSubu·close(db);
-
- if( result == 0 ){
- printf("User deleted successfully.\n");
- return 0;
- } else {
- fprintf(stderr, "Failed to delete user.\n");
- return 1;
- }
-}
+++ /dev/null
-#define IFACE
-#include <stdio.h>
-#include <stdlib.h>
-#include <string.h>
-#include "Db.lib.c"
-
-// Define default database path
-#define DEFAULT_DB_PATH "db.sqlite"
-
-int main(int argc ,char *argv[]){
- const char *db_path = (argc > 1) ? argv[1] : DEFAULT_DB_PATH;
-
- // Open the database using Db·open
- sqlite3 *db = Db·open(db_path ,true);
- if( !db ){
- fprintf(stderr ,"Failed to open or create database: %s\n" ,db_path);
- return EXIT_FAILURE;
- }
-
- // Check if the file was created or already existed
- printf("Database %s opened successfully\n" ,db_path);
-
- // Attempt to close the database
- if( db ){
- Db·close(db);
- printf("Database handle %p closed successfully.\n" ,db);
- return EXIT_SUCCESS;
- } else {
- fprintf(stderr ,"Invalid or NULL database handle: %p\n" ,db);
- return EXIT_FAILURE;
- }
-
- return EXIT_SUCCESS;
-}
+++ /dev/null
-#define IFACE
-#include <stdio.h>
-#include <stdlib.h>
-#include <string.h>
-#include "DbSubu.lib.c"
-
-int main(int argc ,char *argv[]){
- const char *db_path = (argc > 1) ? argv[1] : "db.sqlite";
- DbSubu *db = DbSubu·open(db_path);
- if( !db ){
- fprintf(stderr, "Failed to open database: %s\n", db_path);
- return 1;
- }
-
- int result = DbSubu·validate_schema(db);
- DbSubu·close(db);
-
- if( result == 0 ){
- printf("Schema validation passed.\n");
- return 0;
- } else {
- fprintf(stderr, "Schema validation failed.\n");
- return 1;
- }
-}
+++ /dev/null
-*
-!/.gitignore
+++ /dev/null
--- Schema for the subu server
---
-
--- List Tables
--- SQLite does not support PSQL style types
---
--- CREATE TYPE List AS (
--- id SERIAL, -- Integer ID
--- name TEXT NOT NULL -- Name of the list entry
--- );
---
--- so though these all have the same `List` form, they are declared independently
---
- CREATE TABLE db_property_list (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- name TEXT NOT NULL UNIQUE
- );
-
- CREATE TABLE db_event_list (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- name TEXT NOT NULL UNIQUE
- );
-
- CREATE TABLE shell_list (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- name TEXT NOT NULL UNIQUE
- );
-
- CREATE TABLE system_resource_list (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- name TEXT NOT NULL UNIQUE
- );
-
- CREATE TABLE user_type_list (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- name TEXT NOT NULL UNIQUE
- );
-
--- Data Tables
---
- CREATE TABLE db_property (
- id INTEGER PRIMARY KEY,
- property_id INTEGER NOT NULL REFERENCES db_property_list(id),
- type TEXT NOT NULL,
- value TEXT,
- created_at DATETIME DEFAULT CURRENT_TIMESTAMP
- );
-
- CREATE TABLE db_event (
- id INTEGER PRIMARY KEY,
- event_time DATETIME DEFAULT CURRENT_TIMESTAMP,
- event_id INTEGER NOT NULL REFERENCES db_event_list(id),
- user_id INTEGER REFERENCES user(id)
- );
-
- CREATE TABLE user (
- id INTEGER PRIMARY KEY,
- login_gid INTEGER NOT NULL UNIQUE,
- name TEXT NOT NULL UNIQUE,
- home_directory TEXT NOT NULL,
- shell INTEGER NOT NULL REFERENCES shell_list(id),
- parent_id INTEGER REFERENCES user(id),
- user_type_id INTEGER NOT NULL REFERENCES user_type_list(id),
- status TEXT DEFAULT 'active'
- );
-
- CREATE TABLE share (
- id INTEGER PRIMARY KEY,
- user_id INTEGER NOT NULL REFERENCES user(id),
- other_user_id INTEGER NOT NULL REFERENCES user(id),
- permissions TEXT NOT NULL
- );
-
- CREATE TABLE system_resource (
- id INTEGER PRIMARY KEY,
- user_id INTEGER NOT NULL REFERENCES user(id),
- resource_id INTEGER NOT NULL REFERENCES system_resource_list(id),
- granted_by INTEGER REFERENCES user(id)
- );
+++ /dev/null
-*
-!/.gitignore
+++ /dev/null
-#ifndef IFACE
-#define Server·IMPLEMENTATION
-#define IFACE
-#endif
-
-#ifndef Server·IFACE
-#define Server·IFACE
-
- // Necessary interface includes
- #include <sys/socket.h>
- #include <sys/un.h>
- #include <stddef.h>
-
- // Interface prototypes
- int Server·run();
-
-#endif // Server·IFACE
-
-#ifdef Server·IMPLEMENTATION
-
- // Implementation-specific includes
- #include <stdio.h>
- #include <stdlib.h>
- #include <string.h>
- #include <unistd.h>
- #include <errno.h>
-
- // Constants
- #define Server·SOCKET_PATH "/var/user_data/Thomas-developer/subu/developer/mockup/subu_server_home/subu_server.sock"
- #define Server·LOG_PATH "server.log"
- #define Server·BUFFER_SIZE 256
-
- int Server·run(){
- int server_fd ,client_fd;
- struct sockaddr_un address;
- char buffer[Server·BUFFER_SIZE];
- FILE *log_file;
-
- // Open the log file
- log_file = fopen(Server·LOG_PATH ,"a+");
- if( log_file == NULL ){
- perror("Server·run:: error opening log file");
- return EXIT_FAILURE;
- }
-
- // Create the socket
- if( (server_fd = socket(AF_UNIX ,SOCK_STREAM ,0)) == -1 ){
- perror("Server·run:: error creating socket");
- fclose(log_file);
- return EXIT_FAILURE;
- }
-
- // Configure socket address
- memset(&address ,0 ,sizeof(address));
- address.sun_family = AF_UNIX;
- strncpy(address.sun_path ,Server·SOCKET_PATH ,sizeof(address.sun_path) - 1);
-
- // Bind the socket
- unlink(Server·SOCKET_PATH); // Remove existing file if present
- if( bind(server_fd ,(struct sockaddr *)&address ,sizeof(address)) == -1 ){
- perror("Server·run:: error binding socket");
- fclose(log_file);
- close(server_fd);
- return EXIT_FAILURE;
- }
-
- // Listen for connections
- if( listen(server_fd ,5) == -1 ){
- perror("Server·run:: error listening on socket");
- fclose(log_file);
- close(server_fd);
- return EXIT_FAILURE;
- }
-
- printf("Server·run:: server running, waiting for connections...\n");
-
- // Accept and handle client connections
- while( (client_fd = accept(server_fd ,NULL ,NULL)) != -1 ){
- ssize_t bytes_read;
- printf("Server·run:: connection made!\n");
-
- memset(buffer ,0 ,Server·BUFFER_SIZE);
- bytes_read = read(client_fd ,buffer ,Server·BUFFER_SIZE - 1);
- if( bytes_read > 0 ){
- printf("Server·run:: connection said: %s\n" ,buffer);
- fprintf(log_file ,"Received: %s\n" ,buffer);
- fflush(log_file);
- } else if( bytes_read == -1 ){
- perror("Server·run:: error reading from client");
- }
-
- close(client_fd);
- }
-
- // Clean up
- perror("Server·run:: error accepting connection");
- fclose(log_file);
- close(server_fd);
- unlink(Server·SOCKET_PATH);
-
- return EXIT_FAILURE;
- }
-
-#endif // Server·IMPLEMENTATION
+++ /dev/null
-#!/bin/bash
-# give_audio.sh — run as master user "Thomas"
-# Usage: ./give_audio.sh <USER>
-# Example: ./give_audio.sh Thomas-US # give card to subuser
-# ./give_audio.sh Thomas # reclaim for master
-
-set -euo pipefail
-
-target="${1-}"
-if [[ -z "$target" ]]; then
- echo "❌ usage: $0 <USER>"; exit 2
-fi
-
-master="Thomas"
-
-# don't use sudo -v as it dumps the password into the emacs shell
-sudo echo >& /dev/null
-
-run() { echo "+ $*"; eval "$*"; }
-
-# --- sanity checks ---
-if ! id "$target" &>/dev/null; then
- echo "❌ user not found: $target"; exit 1
-fi
-if [[ "$(id -un)" != "$master" ]]; then
- echo "❌ must be run as master user '$master'"; exit 1
-fi
-
-# Gather all subusers (Thomas-*)
-mapfile -t subusers < <(getent passwd | awk -F: '$1 ~ /^'"$master"'-/ {print $1}' | sort)
-
-stop_master_audio() {
- run "systemctl --user stop pipewire pipewire-pulse wireplumber || true"
-}
-
-start_master_audio() {
- # start services (not only sockets) to avoid lazy-activation races
- run "systemctl --user start pipewire.service pipewire-pulse.service wireplumber.service"
-}
-
-stop_subu_audio() {
- local u="$1"
- run "sudo machinectl shell ${u}@ /bin/bash -lc 'systemctl --user stop pipewire pipewire-pulse wireplumber || true'"
-}
-
-start_subu_audio() {
- local u="$1"
- # Keep subuser from trying to bind to logind (not the active seat)
- run "sudo machinectl shell ${u}@ /bin/bash -lc 'export WIREPLUMBER_DISABLE_PLUGINS=logind; systemctl --user import-environment WIREPLUMBER_DISABLE_PLUGINS; systemctl --user start pipewire.service pipewire-pulse.service wireplumber.service'"
-}
-
-# --- stop everyone first (to release ALSA cleanly) ---
-stop_master_audio
-for u in "${subusers[@]}"; do
- stop_subu_audio "$u"
-done
-
-# Small settle time so ALSA reservation clears
-sleep 0.5
-
-# --- start only the target ---
-if [[ "$target" == "$master" ]]; then
- start_master_audio
-else
- # ensure linger for target so user services can run
- run "sudo loginctl enable-linger '$target' || true"
- start_subu_audio "$target"
-fi
-
-# --- quick verification (best-effort) ---
-if [[ "$target" == "$master" ]]; then
- # Show default sink name (may require pipewire-pulse to be fully up)
- run "pactl info | sed -n 's/^Default Sink: /Default Sink: /p'"
-else
- run "sudo machinectl shell ${target}@ /bin/bash -lc 'pactl info | sed -n \\\"s/^Default Sink: /Default Sink: /p\\\"'"
-fi
--- /dev/null
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta charset="UTF-8">
+ <title>File and directory naming conventions</title>
+ <script>
+ {
+ let dirpr_library = null;
+ try{ dirpr_library = localStorage.getItem("RT.dirpr_library"); }catch(e){}
+ window.RT = window.RT || {};
+ RT.dirpr_library = RT.dirpr_library || dirpr_library || "https://style.ReasoningTechnology.com/Manuscript";
+ document.write('<script src="' + RT.dirpr_library + '/Core/RT-Manuscript_make.js"><\/script>');
+ }
+ </script>
+ <script>
+ window.RT.theme_preference('inverse_wheat');
+ window.RT.load('Layout/paginate');
+ window.RT.load('Layout/article_tech_ref');
+ window.RT.load('Element/theme_selector');
+ </script>
+ </head>
+ <body>
+ <RT·theme-selector></RT·theme-selector>
+ <RT·article>
+ <RT·title
+ author="RT"
+ date="2026-03-09"
+ title="File and directory naming conventions">
+ </RT·title>
+
+ <RT·TOC level="1"></RT·TOC>
+
+ <h1>Program file system objects</h1>
+
+ <p>
+ Harmony files and directories are considered to be program file systems objects.
+ </p>
+
+ <h2>Files</h2>
+ <p>
+ Files that hold a single class, have he same base name as that class. If a file holds multiple classes, but there is a class of primary importance and auxiliary classes, the file carries the name of the primary class.
+ </p>
+
+ <p>
+ If a file represents a module or name space, follow the same naming conventions specified for classes and name spaces in the <RT·code>format_RT·code.html</RT·code> document.
+ </p>
+
+ <p>
+ Other program files follow the same naming conventions specified in the <RT·code>format_RT·code.html</RT·code> document for an identifier of the
+ contained or related language.
+ </p>
+
+ <h2>Hyphens and spaces</h2>
+
+ <p>In a C language culture, file names use underscores between words. In a Lisp language culture use snake-kebab_case. Spaces are generally not used in program file names. Though the Harmony skeleton is language agnostic, it was first used with C, hence file names use primarily underscores.
+ </p>
+
+
+ <h1>Program Directories</h1>
+ <p>
+ A directory that is a package, follows the convention for name spaces.
+ </p>
+
+ <p>
+ A directory that is used to give the contained files a property is an identifier.
+ </p>
+
+ <p>
+ What is a reasonable primary property for a set of files? Perhaps:
+ </p>
+ <ul>
+ <li>Who uses each file with this property. Home directories are named like this.</li>
+ <li>The role of the people using the file. The <RT·code>developer</RT·code> and <RT·code>tester</RT·code> directories were named in this manner.</li>
+ <li>What program are the files for.</li>
+ <li>The generic category of program said files are for. We prefer the names <RT·code>authored</RT·code> and <RT·code>made</RT·code>. <RT·code>authored</RT·code> files are those written by humans or AI, while <RT·code>made</RT·code> files are products of tools.</li>
+ </ul>
+
+ <h1>Property indicators on files</h1>
+ <p>
+ We add a second property to a file using dot extensions to the file's name. We can extend the dot suffix model by using multiple dot suffixes. File name extensions are used to signal to the build tools how the file is to be processed:
+ </p>
+ <ul>
+ <li><RT·code>.cli.c</RT·code> : Compiler fodder made into a stand-alone executable.</li>
+ <li><RT·code>.lib.c</RT·code> : Library code source, compiled as an object file and added to the project archive.</li>
+ <li><RT·code>.mod.c</RT·code> : Kernel module sources.</li>
+ </ul>
+
+ <h1>Exercises</h1>
+ <h2>Exercise 1: Program file names</h2>
+ <p>
+ Rename the following poorly named files to strictly adhere to the RT naming conventions. Pay close attention to the language culture, the capitalization of acronyms, and the rule for spelling out abbreviations in outer scope identifiers.
+ </p>
+ <ul>
+ <li>A C-culture makefile that builds a target library and command line interface: <RT·code>target-lib-cli.mk</RT·code></li>
+ <li>A Lisp-culture source file for an HTML parsing module: <RT·code>html_parser_mod.el</RT·code></li>
+ <li>A C language source file that initializes an HTTP server: <RT·code>init-http-srv.c</RT·code></li>
+ </ul>
+
+ </RT·article>
+ </body>
+</html>
--- /dev/null
+<!DOCTYPE html>
+<html lang="en">
+ <!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta charset="UTF-8">
+ <title>C modules, namespaces, and the build lifecycle</title>
+ <script>
+ {
+ let dirpr_library = null;
+ try{ dirpr_library = localStorage.getItem("RT.dirpr_library"); }catch(e){}
+ window.RT = window.RT || {};
+ RT.dirpr_library = RT.dirpr_library || dirpr_library || "https://style.ReasoningTechnology.com/Manuscript";
+ document.write('<script src="' + RT.dirpr_library + '/Core/RT-Manuscript_make.js"><\/script>');
+ }
+ </script>
+ <script>
+ window.RT.theme_preference('inverse_wheat');
+ window.RT.load('Layout/paginate');
+ window.RT.load('Layout/article_tech_ref');
+ window.RT.load('Element/theme_selector');
+ </script>
+ </head>
+ <body>
+ <RT·theme-selector></RT·theme-selector>
+ <RT·article>
+ <RT·title
+ author="Thomas Walker Lynch"
+ date="2026-03-11"
+ title="C modules, namespaces, and the build lifecycle">
+ </RT·title>
+
+ <RT·TOC level="1"></RT·TOC>
+
+ <h1>Single-file C source</h1>
+ <p>
+ The RT C language culture abandons the traditional separation of declarations into <RT·code>.h</RT·code> header files and definitions into <RT·code>.c</RT·code> source files. A developer integrates the interface and the implementation into a single file.
+ </p>
+ <p>
+ When a person includes the file using an <RT·code>#include</RT·code> directive, the file exposes only its interface. When the build system compiles the file into an object, a preprocessor macro acts as an implementation gate to compile the definitions. This keeps the source tree clean and ensures the interface and implementation never fall out of synchronization.
+ </p>
+
+ <h1>Ad hoc namespaces</h1>
+ <p>
+ The C language lacks native namespaces. To prevent symbol collisions in large projects, RT code format uses a center dot (<RT·code>·</RT·code>) to denote ad hoc namespaces within identifiers.
+ </p>
+ <p>
+ A programmer maps the directory structure directly to these namespaces. For example, a file located at <RT·code>authored/ExampleGreet/Math.lib.c</RT·code> belongs to the <RT·code>ExampleGreet</RT·code> namespace and defines the <RT·code>Math</RT·code> module.
+ </p>
+ <ul>
+ <li>Types and modules use <RT·code>PascalCase</RT·code>.</li>
+ <li>Functions and variables use <RT·code>snake_case</RT·code>.</li>
+ </ul>
+ <p>
+ An exported function from this module carries the full namespace and module prefix, such as <RT·code>ExampleGreet·Math·add</RT·code>.
+ </p>
+
+ <h1>The implementation gate</h1>
+ <p>
+ To achieve the single-file source pattern, the code relies on two preprocessor constructs:
+ </p>
+ <ul>
+ <li><strong>Include Guard:</strong> The entire file is wrapped in an include guard using the <RT·code>·ONCE</RT·code> suffix. This prevents redeclaration errors if the file is included multiple times.</li>
+ <li><strong>Implementation Block:</strong> The definitions are wrapped in a single <RT·code>#ifdef</RT·code> block using the exact namespace and module name. The build system dynamically injects this macro via a <RT·code>-D</RT·code> compiler flag when building the module's specific object file.</li>
+ </ul>
+
+ <h2>Build system mechanics</h2>
+ <p>
+ When a consumer file, such as <RT·code>hello.CLI.c</RT·code>, contains <RT·code>#include "Math.lib.c"</RT·code>, the compiler processes the file without the <RT·code>ExampleGreet·Math</RT·code> macro defined. It skips the implementation and reads only the function prototype.
+ </p>
+ <p>
+ When the orchestrator compiles the library object, it evaluates the target name and explicitly passes <RT·code>-DExampleGreet·Math</RT·code> to the compiler. This unlocks the gate, compiling the machine code for the definitions into <RT·code>Math.lib.o</RT·code>.
+ </p>
+
+ <h2>Example: Math.lib.c</h2>
+ <p>
+ The following example demonstrates the complete structure.
+ </p>
+
+ <RT·code>
+ #ifndef ExampleGreet·Math·ONCE
+ #define ExampleGreet·Math·ONCE
+
+ int ExampleGreet·Math·add(int a ,int b);
+
+ #ifdef ExampleGreet·Math
+
+ int ExampleGreet·Math·add(int a ,int b){
+ return a + b;
+ }
+
+ #endif // ExampleGreet·Math
+ #endif // ExampleGreet·Math·ONCE
+ </RT·code>
+
+ <h2>Cross-module dependencies</h2>
+ <p>
+ When one module depends on another, the developer directly includes the library source file. For example, if the Greeter module requires the Math module, the file <RT·code>Greeter.lib.c</RT·code> will contain:
+ </p>
+ <RT·code>
+ #include "Math.lib.c"
+ </RT·code>
+ <p>
+ Because every file is protected by a <RT·code>·ONCE</RT·code> include guard, it is safe for multiple modules to include the same dependency. The preprocessor will expand the interface once per translation unit.
+ </p>
+
+ <h2>Information hiding</h2>
+ <p>
+ To define internal helper functions or private data that should not be exposed in the module's public interface, a programmer places them strictly inside the <RT·code>#ifdef</RT·code> implementation block.
+ </p>
+ <p>
+ To prevent these internal symbols from leaking into the global namespace during linking, they must be given internal linkage. The RT skeleton provides the <RT·code>Local</RT·code> macro (defined as <RT·code>static</RT·code> in <RT·code>RT_global.h</RT·code>) for this exact purpose.
+ </p>
+ <RT·code>
+ #ifdef ExampleGreet·Math
+
+ Local int internal_helper(int val){
+ return val * 2;
+ }
+
+ int ExampleGreet·Math·add(int a ,int b){
+ return internal_helper(a) + b;
+ }
+
+ #endif // ExampleGreet·Math
+ </RT·code>
+
+ <h2>Executable entry points</h2>
+ <p>
+ Programs intended to be compiled into standalone executables use the <RT·code>.CLI.c</RT·code> suffix. These files consume the library modules but do not define their own namespaces or include guards, as they are never included by other files.
+ </p>
+ <p>
+ A <RT·code>.CLI.c</RT·code> file includes the necessary <RT·code>.lib.c</RT·code> files, parses command-line arguments in the <RT·code>main</RT·code> function, and passes native data types to a dedicated <RT·code>CLI()</RT·code> function to orchestrate the core logic.
+ </p>
+
+ <h1>Directory structure and namespaces</h1>
+ <p>
+ The physical layout of the <RT·code>authored</RT·code> directory dictates how the build orchestrator finds the source code.
+ </p>
+ <ul>
+ <li><strong>Flat Structure:</strong> If a project does not utilize namespaces, the <RT·code>.lib.c</RT·code> and <RT·code>.CLI.c</RT·code> files reside directly inside the <RT·code>developer/authored/</RT·code> directory.</li>
+ <li><strong>Namespaced Structure:</strong> If a project utilizes namespaces, the files are grouped into subdirectories matching the namespace name, such as <RT·code>developer/authored/ExampleGreet/</RT·code>.</li>
+ </ul>
+
+ <h1>Making the code</h1>
+ <p>
+ The developer compiles the code using the <RT·code>make</RT·code> wrapper script located in <RT·code>developer/tool/make</RT·code>. This script accepts two primary arguments: the build command and the target namespace.
+ </p>
+
+ <RT·code>
+ > make <command> [namespace]
+ </RT·code>
+
+ <p>
+ To build the <RT·code>ExampleGreet</RT·code> code, a person issues the following command from the developer workspace:
+ </p>
+
+ <RT·code>
+ > make all ExampleGreet
+ </RT·code>
+
+ <p>
+ Behind the scenes, the <RT·code>make</RT·code> wrapper assigns the namespace to an environment variable and calls the master orchestrator makefile. The orchestrator detects the namespace, updates the source directory search path to <RT·code>authored/ExampleGreet</RT·code>, and injects the appropriate namespace macros during compilation.
+ </p>
+ <p>
+ The orchestrator compiles the <RT·code>.lib.c</RT·code> files into object files under <RT·code>scratchpad/build/object/</RT·code>, archives them into a library file, and links the <RT·code>.CLI.c</RT·code> objects into standalone executables. The final artifacts are placed in the <RT·code>scratchpad/made/</RT·code> directory.
+ </p>
+
+ <h1>Promoting the artifacts</h1>
+ <p>
+ Artifacts resting in the scratchpad are volatile and private to the developer. To share the executables and libraries with the rest of the project (such as the tester or consumer roles), the developer must promote them.
+ </p>
+
+ <RT·code>
+ > promote write
+ </RT·code>
+
+ <p>
+ The <RT·code>promote</RT·code> tool compares the contents of <RT·code>developer/scratchpad/made/</RT·code> against <RT·code>consumer/made/</RT·code>. It atomically copies any new or updated files to the consumer workspace, stripping write permissions to enforce the immutability of the deployment target. Once promoted, the <RT·code>hello</RT·code> executable is ready for testing.
+ </p>
+
+ </RT·article>
+ </body>
+</html>
+++ /dev/null
-# As root:
-# loginctl terminate-user Thomas-US
-loginctl terminate-user $1
+++ /dev/null
-#!/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
-
-
+++ /dev/null
-#!/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 <masu> <subu> [<subu>]*
- subu capture <masu> <subu> [<subu>]*
- subu remove <Subu_ID> | <masu> <subu> [<subu>]*
- subu list
- subu info <Subu_ID> | <masu> <subu> [<subu>]*
- subu option set|clear incommon <Subu_ID> | <masu> <subu> [<subu>]*
- """
- 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 <Subu_ID> <WG_ID>
- detach WG <Subu_ID>
- """
- 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 <Subu_ID>
- """
- 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 <Subu_ID> -- <cmd> ...
- """
- 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 <Subu_ID>
- """
- 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 <masu> and one <subu> 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} <masu> <subu> [<subu> ...] 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 "
- "<masu> <subu> [<subu> ...] 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: <masu> <subu> [.<subu>]*
- return dispatch.subu_capture(ns.args)
- if ns.subverb == "option":
- # expected: set|clear incommon <masu> <subu> [.<subu>]*
- 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 <Subu_ID> -- <cmd> ...", 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())
+++ /dev/null
-# 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 <masu> <subu> [.<subu>]*", 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/<mapname>, 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/<mapname> 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()
+++ /dev/null
-# 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/<mapname>).
- 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
+++ /dev/null
-"""
-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)
+++ /dev/null
-"""
-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_<id>
- run(["ip", "netns", "add", netns_name])
- # ip netns exec ns-subu_<id> ip link set lo down
- run(["ip", "netns", "exec", netns_name, "ip", "link", "set", "lo", "down"])
+++ /dev/null
-"""
-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}")
-
+++ /dev/null
-# 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/<parent>/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
+++ /dev/null
-"""
-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 <CIDR>` 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)")
-
+++ /dev/null
-# 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_<schema>.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())
+++ /dev/null
-"""
-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}")
-
+++ /dev/null
-// -*- 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 <linux/bpf.h>
-#include <bpf/bpf_helpers.h>
-#include <bpf/bpf_endian.h>
-
-
-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;
-}
+++ /dev/null
-# -*- 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
+++ /dev/null
-# 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()
+++ /dev/null
-# 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/<mapname>
- * On each device, user data lives under: /mnt/<mapname>/user_data/<masu>
- * Subu home directories follow the pattern:
-
- /mnt/<mapname>/user_data/<masu>/subu_data/<subu0>/subu_data/<subu1>/...
-
- 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/<masu>/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/<mapname>':
-
- * 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
+++ /dev/null
-# 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)
+++ /dev/null
--- 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);
-
+++ /dev/null
-# 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
-
+++ /dev/null
-# 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 <mapname>\n"
- f" {p} device detach <mapname>\n"
- "\n"
- f" {p} subu make <masu> <subu> [<subu> ...]\n"
- f" {p} subu capture <masu> <subu> [<subu> ...]\n"
- f" {p} subu list\n"
- f" {p} subu info subu_<id>\n"
- f" {p} subu info <masu> <subu> [<subu> ...]\n"
- f" {p} subu remove subu_<id>\n"
- f" {p} subu remove <masu> <subu> [<subu> ...]\n"
- f" {p} subu option set incommon subu_<id>\n"
- f" {p} subu option set incommon <masu> <subu> [<subu> ...]\n"
- f" {p} subu option clear incommon subu_<id>\n"
- f" {p} subu option clear incommon <masu> <subu> [<subu> ...]\n"
- "\n"
- f" {p} lo up|down <Subu_ID>\n"
- "\n"
- f" {p} WG global <BaseCIDR>\n"
- f" {p} WG make <host:port>\n"
- f" {p} WG server_provided_public_key <WG_ID> <Base64Key>\n"
- f" {p} WG info|information <WG_ID>\n"
- f" {p} WG up <WG_ID>\n"
- f" {p} WG down <WG_ID>\n"
- "\n"
- f" {p} attach WG <Subu_ID> <WG_ID>\n"
- f" {p} detach WG <Subu_ID>\n"
- "\n"
- f" {p} network up|down <Subu_ID>\n"
- "\n"
- f" {p} option set <Subu_ID> <name> <value>\n"
- f" {p} option get <Subu_ID> <name>\n"
- f" {p} option list <Subu_ID>\n"
- "\n"
- f" {p} exec <Subu_ID> -- <cmd> ...\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/<mapname>/user_data and captures all <masu>/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"
- " <masu> <subu> [<subu> ...]\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 <masu> (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/<masu>/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)
+++ /dev/null
-#!/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/<masu> will be bound to /home/<masu>,\n"
- " the DB will be reconciled, and subu bindfs mounts reopened.")
-
-if __name__ == "__main__":
- main()
+++ /dev/null
-[Unit]
-Description=Attach /home/<masu> 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
+++ /dev/null
-[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
+++ /dev/null
-#!/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/<mapname>/user_data/<masu> as /home/<masu>
- 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 <masu>
- 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/<masu> is a bind from /mnt/*/user_data/<masu>
- 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())
+++ /dev/null
-
-When the master users are not remote mounted, but rather have static home directories on the local machine, these scripts are not needed.
-
-masu == master user
-subu == sub user
-
-These 'mount' scripts are for keeping master users on a remote, optionally encrypted, device.
-
-I have one example running, so there might be generalization issues with these scripts.
-
-The order and type of the command arguments are the first part of each
-command name. The command description follows. For example:
-
-`map_name__mounted_masu_list.sh`
-
-is given a `map_name` and provides a list of the masu found at the mount point with that name. (Note, that /dev/mapper/<map_name> is mounted at /mnt/<map_name>.)
-
-
-For mounting a remote device.
-
-The remote device has at the top level master user home directories. Each master user home directory has a sub directory called subu_data. Each directory in subu_data is owned by a sub-user, and is the home directory for that sub-user. Each master user also has a `subu` sub-directory. The contents of the subu directory will parallel that of the subu_data directory, but differ in that the contained files will be owned by the master user.
-
-1. the remote device is mounted under /mnt/map_name
-
- `device_mapname__open_mount.sh` <device> <mapname>`
-
- `mapname` is the name that appears under /dev/mapper, it is also used
- as the /mnt/<name> mount point.
-
- if <device> is not to be opened with cryptsetup, instead mount it to the mount pt directly.
-
-2. Each master user is mount --bind from the /mnt/<map_name> to /home
-
- masu__to_home.sh <masu>
-
- There is currently no script to map bind all the masu in one call.
-
-3. master users have subu home directories under /home/<masu>/subu_data. These
- are owned by the subu users. Map the /home/<masu>/subu_data directories to
- `/home/subu`n using
-
- masu__map_own_all.sh <user>
-
- The `/home/subu` directories are bindfs mapped to be be owned by the master
- user, so that the master user may administer them.
-
-
-
-* bindfs problem
-
- On my Fedora install once it was bound by root, it stayed there. On
- Debian it keeps losing the mounts, for sure on a logout or login,
- and maybe other times also --- the machinectl logins and logouts I
- bet that is what is doing it. Hence very time I go to use a mount
- in subu it is not there or filled with the dreaded question mark
- garbage, has to be umount then remounted.
-
- --------------------------------------------------------------------------------
- You're exactly right in your diagnosis: Debian's behavior under
- systemd with machinectl is unmounting user bind mounts on
- login/logout cycles—especially when using enable-linger, systemd
- --user, or machinectl shell.
-
- This differs from Fedora, which preserves those mounts more consistently unless explicitly torn down. Let’s first address why it’s happening, then provide a robust, recoverable fix.
- 🔍 Why This Happens
-
- When machinectl or loginctl manages a user session:
-
- systemd creates a private mount namespace per user.
-
- Any mounts (especially bind mounts) not made within that
- namespace or not managed by systemd itself (i.e. not via .mount
- units) can be:
-
- Torn down on logout,
-
- Hidden due to namespace isolation,
-
- Disconnected if user lingering is toggled.
-
- If your bindfs mount is outside the user’s namespace (i.e. mounted by root), but then accessed inside a user-managed session, systemd may unmount it or lose visibility.
- ✅ Recommended Fix
-
- There are two paths:
- A. Use a systemd .mount unit (most stable)
-
- Create a file /etc/systemd/system/home-Thomas-subu-<subuname>.mount:
-
- [Unit]
- Description=Bind mount subu user home
-
- [Mount]
- What=/home/Thomas/subu_data/<subuname>
- Where=/home/Thomas/subu/<subuname>
- Type=none
- Options=bind
-
- [Install]
- WantedBy=multi-user.target
-
- Enable and start it:
-
- sudo systemctl daemon-reexec
- sudo systemctl enable --now home-Thomas-subu-<subuname>.mount
-
- Repeat for each subu.
-
- This ensures systemd manages the bind mount in its native way and it won’t get torn down.
- B. Remount automatically on login via ~/.bash_profile or a systemd --user unit
-
- You can modify your script to:
-
- Skip sudo if already root
-
- Be idempotent
-
- Automatically remount on session start
-
- Then in ~/.bash_profile or ~/.config/systemd/user/subu-mount.service:
-
- [Unit]
- Description=Re-mount subu directory for Thomas
-
- [Service]
- Type=oneshot
- ExecStart=/home/Thomas/executable/subu/user_open_subu.sh Thomas US
-
- [Install]
- WantedBy=default.target
-
- Enable with:
-
- systemctl --user daemon-reexec
- systemctl --user enable --now subu-mount.service
-
- This guarantees it will come up as part of your login, even after a reboot.
- 🔄 Temporary Workaround
-
- If you want a quick way to check all subu are mounted and remount them if not, write a watchdog script:
-
- #!/bin/bash
- for subu in $(ls /home/Thomas/subu_data); do
- mountpoint="/home/Thomas/subu/$subu"
- if ! mountpoint -q "$mountpoint"; then
- /home/Thomas/executable/subu/user_open_subu.sh Thomas "$subu"
- fi
- done
-
- Would you like me to auto-generate the .mount or .service files for each subu? We can script the system-wide setup cleanly.
+++ /dev/null
-#!/usr/bin/env bash
-
-if [[ $EUID -ne 0 ]]; then
- echo "❌ This script must be run as root." >&2
- exit 1
-fi
-
-# Function to unlock and mount the device
-device_mount() {
- local device_node=$1 # e.g., /dev/sdb1
- local device_name=$2 # e.g., Zathustra
- local mount_point="/mnt/$device_name"
-
- # Check if cryptsetup is installed
- if ! command -v cryptsetup &> /dev/null; then
- echo "Error: cryptsetup is not installed!"
- return 1
- fi
-
- # Check if the device is already mounted
- if mount | grep "on $mount_point" > /dev/null; then
- echo "Device $device_name is already mounted at $mount_point."
- return 0
- fi
-
- # Make sure the mount point exists
- mkdir -p "$mount_point"
-
- # Unlock the encrypted device
- sudo cryptsetup luksOpen "$device_node" "$device_name-crypt"
-
- # Mount the unlocked device
- sudo mount "/dev/mapper/$device_name-crypt" "$mount_point"
-
- echo "$device_name mounted at $mount_point"
-}
-
-# Run the function with the device node and device name as arguments
-device_mount "$1" "$2"
+++ /dev/null
-#!/usr/bin/env bash
-
-if [[ $EUID -ne 0 ]]; then
- echo "❌ This script must be run as root." >&2
- exit 1
-fi
-
-umount /mnt/"$1"
-cryptsetup close "$1"
+++ /dev/null
-#!/usr/bin/env bash
-# disable_linger_subu — turn off systemd --user lingering for all <masu>-* users
-# Usage: sudo disable_linger_subu --masu Thomas
-
-set -euo pipefail
-MASU=""
-while [[ $# -gt 0 ]]; do
- case "$1" in
- --masu) MASU="${2:-}"; shift 2;;
- *) echo "unknown arg: $1" >&2; exit 2;;
- esac
-done
-[[ -n "$MASU" ]] || { echo "usage: sudo $0 --masu <name>"; exit 2; }
-[[ $EUID -eq 0 ]] || { echo "must run as root"; exit 1; }
-
-mapfile -t SUBU_USERS < <(getent passwd | awk -F: -v pfx="^${MASU}-" '$1 ~ pfx {print $1}' | sort)
-for u in "${SUBU_USERS[@]}"; do
- echo "loginctl disable-linger $u"
- loginctl disable-linger "$u" || true
-done
-
-echo "Current linger files (should be empty or only intentional users):"
-ls -1 /var/lib/systemd/linger 2>/dev/null || echo "(none)"
-echo "✅ linger disabled for ${#SUBU_USERS[@]} users"
+++ /dev/null
-#!/usr/bin/env bash
-# logout_subu — cleanly stop subu users, tear down bindfs, unbind /home, unmount device, close LUKS
-# Usage:
-# sudo logout_subu --masu Thomas --device Eagle [--aggressive] [--dry-run]
-#
-# Notes:
-# - Run from a directory NOT under /home/<masu> (we'll auto 'cd /' if needed).
-# - --aggressive enables pkill -KILL fallback if user@ sessions don't exit.
-# - --device is the mapname mounted at /mnt/<device> and /dev/mapper/<device>-crypt.
-
-set -euo pipefail
-
-MASU=""
-DEVICE=""
-AGGR=0
-DRY=0
-
-while [[ $# -gt 0 ]]; do
- case "$1" in
- --masu) MASU="${2:-}"; shift 2;;
- --device) DEVICE="${2:-}"; shift 2;;
- --aggressive) AGGR=1; shift;;
- --dry-run) DRY=1; shift;;
- -h|--help)
- grep -E '^(# |#-)' "$0" | sed 's/^# \{0,1\}//'
- exit 0;;
- *) echo "unknown arg: $1" >&2; exit 2;;
- esac
-done
-
-if [[ -z "$MASU" ]]; then
- # best guess: current sudo user or login user
- MASU="${SUDO_USER:-${USER:-}}"
- [[ -n "$MASU" ]] || { echo "Set --masu <name>"; exit 2; }
-fi
-
-if [[ $EUID -ne 0 ]]; then
- echo "❌ must run as root (sudo)"; exit 1
-fi
-
-# If we’re under /home/<masu>, move away so unmount can succeed
-if [[ "$(pwd -P)" == /home/${MASU}* ]]; then
- echo "cd / (leaving $(pwd -P) so unmounts can proceed)"
- [[ $DRY -eq 1 ]] || cd /
-fi
-
-say() { printf '%s\n' "$*"; }
-doit() { echo "+ $*"; [[ $DRY -eq 1 ]] || eval "$@"; }
-
-# --- enumerate subu users and mountpoints
-SUBU_ROOT="/home/${MASU}/subu"
-SUBU_DATA="/home/${MASU}/subu_data"
-
-# Users of the form MASU-something that actually exist
-mapfile -t SUBU_USERS < <(getent passwd | awk -F: -v pfx="^${MASU}-" '$1 ~ pfx {print $1}' | sort)
-
-# Bindfs targets (reverse depth for unmount)
-mapfile -t SUBU_MPS < <(findmnt -Rn -S fuse.* -T "$SUBU_ROOT" -o TARGET 2>/dev/null | \
- awk -F/ '{print NF, $0}' | sort -rn | cut -d" " -f2-)
-
-say "== stop subu systemd user managers =="
-for u in "${SUBU_USERS[@]}"; do
- say "terminating user@ for $u"
- doit loginctl terminate-user "$u" || true
-done
-
-# wait a moment and optionally KILL leftovers
-sleep 0.5
-for u in "${SUBU_USERS[@]}"; do
- if loginctl list-users --no-legend | awk '{print $2}' | grep -qx "$u"; then
- if [[ $AGGR -eq 1 ]]; then
- uid="$(id -u "$u" 2>/dev/null || echo "")"
- if [[ -n "$uid" ]]; then
- say "aggressive kill of UID $uid ($u)"
- doit pkill -KILL -u "$uid" || true
- fi
- else
- say "⚠︎ $u still has a user@ manager; rerun with --aggressive to force-kill"
- fi
- fi
-done
-
-say "== unmount bindfs subu mounts under $SUBU_ROOT =="
-for mp in "${SUBU_MPS[@]}"; do
- say "umount $mp"
- if [[ $DRY -eq 1 ]]; then
- echo "+ umount '$mp'"
- else
- if ! umount "$mp" 2>/dev/null; then
- echo " (busy) trying lazy umount"
- umount -l "$mp" || true
- fi
- fi
-done
-
-# Unmount the MASU home if it is a bind of /mnt/<device>/user_data/<masu>
-say "== unmount MASU home bind (if any) =="
-if findmnt -n -T "/home/${MASU}" >/dev/null 2>&1; then
- src="$(findmnt -no SOURCE -T "/home/${MASU}")"
- say "/home/${MASU} source: ${src}"
- say "umount /home/${MASU}"
- doit umount "/home/${MASU}" || true
-fi
-
-# If a device mapname was provided, unmount and close it
-if [[ -n "$DEVICE" ]]; then
- say "== unmount /mnt/${DEVICE} and close LUKS =="
- if findmnt -n "/mnt/${DEVICE}" >/dev/null 2>&1; then
- say "umount /mnt/${DEVICE}"
- doit umount "/mnt/${DEVICE}" || true
- fi
- if cryptsetup status "${DEVICE}-crypt" >/dev/null 2>&1; then
- say "cryptsetup close ${DEVICE}-crypt"
- doit cryptsetup close "${DEVICE}-crypt" || true
- else
- say "crypt mapping ${DEVICE}-crypt not active"
- fi
-fi
-
-say "sync disks"
-[[ $DRY -eq 1 ]] || sync
-say "✅ done"
+++ /dev/null
-#!/usr/bin/env bash
-
-# Function to list users in the /mnt/<device>/user_data directory
-device_user_list() {
- local device=$1
- local user_data_dir="/mnt/$device/user_data"
-
- if [ ! -d "$user_data_dir" ]; then
- echo "Error: $user_data_dir does not exist!"
- return 1
- fi
-
- # List all user directories in the user_data directory
- find "$user_data_dir" -maxdepth 1 -mindepth 1 -type d -exec basename {} \;
-}
-
-# Run the function with the device name as an argument
-device_user_list "$1"
+++ /dev/null
-#!/usr/bin/env bash
-
-# Function to bind mount a user's data to /home/<user>
-device_user_bind() {
- local device=$1
- local user=$2
- local user_data_dir="/mnt/$device/user_data/$user"
- local home_dir="/home/$user"
-
- if [ ! -d "$user_data_dir" ]; then
- echo "Error: $user_data_dir does not exist!"
- return 1
- fi
-
- # Create the home directory if it doesn't exist
- mkdir -p "$home_dir"
-
- # Mount --bind the user data to the home directory
- sudo mount --bind "$user_data_dir" "$home_dir"
- echo "Mounted $user_data_dir -> $home_dir"
-}
-
-# Run the function with the device name and user as arguments
-device_user_bind "$1" "$2"
+++ /dev/null
-#!/usr/bin/env bash
-# usage: sudo ./masu__map_own_all.sh <masu> [--suid=US,x6]
-set -euo pipefail
-masu="${1:?usage: $0 <masu> [--suid=a,b]}"
-suid_list="${2-}"
-
-want_suid_for() {
- [[ "$suid_list" =~ ^--suid= ]] || return 1
- IFS=',' read -r -a arr <<< "${suid_list#--suid=}"
- for n in "${arr[@]}"; do [[ "$n" == "$1" ]] && return 0; done
- return 1
-}
-
-subus="$(./masu__subu_data_dir_list.sh "$masu")"
-[[ -n "$subus" ]] || { echo "No sub-users found for $masu"; exit 1; }
-
-while IFS= read -r s; do
- [[ -n "$s" ]] || continue
- echo "Opening sub-user: $s"
- if want_suid_for "$s"; then
- sudo ./masu_subu__map_own.sh "$masu" "$s" --suid
- else
- sudo ./masu_subu__map_own.sh "$masu" "$s"
- fi
-done <<< "$subus"
+++ /dev/null
-#!/usr/bin/env bash
-
-# Function to list sub-users in /home/<user>/subu_data
-subu_home_dir_list() {
- local user=$1
- local subu_home_dir="/home/$user/subu_data"
-
- if [ ! -d "/home/$user" ]; then
- echo "Error: /home/$user does not exist!"
- return 1
- fi
-
- if [ ! -d "$subu_home_dir" ]; then
- echo "Error: $subu_home_dir does not exist!"
- return 1
- fi
-
- # List all sub-users in the subu directory
- find "$subu_home_dir" -maxdepth 1 -mindepth 1 -type d -exec basename {} \;
-}
-
-# Run the function with the user as an argument
-subu_home_dir_list "$1"
+++ /dev/null
-#!/usr/bin/env bash
-# masu__subu_dir_list.sh
-
-set -euo pipefail
-user="${1:?usage: $0 <masu>}"
-
-# Prefer the /home/<masu>/subu view; if empty/nonexistent, fall back to subu_data.
-list_from_dir() { local d="$1"; [[ -d "$d" ]] && find "$d" -mindepth 1 -maxdepth 1 -type d -printf '%f\n' || true; }
-
-candidates="$(
- list_from_dir "/home/$user/subu"
- [[ -d "/home/$user/subu" && -n "$(ls -A /home/$user/subu 2>/dev/null || true)" ]] || list_from_dir "/home/$user/subu_data"
-)"
-
-# Unique, stable order
-printf '%s\n' "$candidates" | LC_ALL=C sort -u
+++ /dev/null
-#!/usr/bin/env bash
-# usage: sudo ./masu_subu__map_own.sh <masu> <subu> [--suid]
-set -euo pipefail
-
-masu="${1:?usage: $0 <masu> <subu> [--suid]}"
-subu="${2:?usage: $0 <masu> <subu> [--suid]}"
-want_suid=0; [[ "${3-}" == "--suid" ]] && want_suid=1
-
-need(){ command -v "$1" >/dev/null 2>&1 || { echo "missing: $1" >&2; exit 1; }; }
-need bindfs; need findmnt; need umount
-
-src="/home/$masu/subu_data/$subu"
-mp="/home/$masu/subu/$subu"
-[[ -d "$src" ]] || { echo "❌ source not found: $src" >&2; exit 1; }
-mkdir -p "$mp"
-
-# mount options
-base_opts="allow_other,default_permissions,exec"
-opts="$base_opts,$([[ $want_suid -eq 1 ]] && echo suid || echo nosuid)"
-
-# fully unstack any prior bindfs at the target
-while findmnt -rn -T "$mp" -t fuse.bindfs >/dev/null 2>&1; do
- umount "$mp" 2>/dev/null || umount -l "$mp" || break
- sleep 0.1
-done
-
-echo "mounting $src -> $mp (opts: $opts)"
-bindfs -o "$opts" --map="${masu}-${subu}/${masu}:@${masu}-${subu}/@${masu}" "$src" "$mp"
-
-# verify (single line, kernel-only)
-findmnt -rn -T "$mp" -S "$src" -o TARGET,SOURCE,FSTYPE,OPTIONS | head -n1
-echo "OK"
-if [[ $want_suid -eq 1 ]]; then
- echo "note: suid enabled at $mp"
-else
- echo "note: nosuid (default) — setuid will NOT take effect at $mp"
-fi
+++ /dev/null
-#!/usr/bin/env bash
-
-# Function to bind mount with UID/GID mapping
-subu_bind() {
- local user=$1
- local subu=$2
-
- # Check if bindfs is installed
- if ! command -v bindfs &> /dev/null; then
- echo "Error: bindfs is not installed!"
- return 1
- fi
-
- # Get the username and group name for the main user
- master_user_name=$user
- master_group=$user
-
- # Get the username and group name for the sub-user
- subu_user_name="${user}-${subu}"
- subu_group="${user}-${subu}"
-
- # Check if the user and sub-user exist
- if ! id "$master_user_name" &>/dev/null; then
- echo "Error: User '$master_user_name' not found!"
- return 1
- fi
- if ! id "$subu_user_name" &>/dev/null; then
- echo "Error: Sub-user '${master_user_name}-${subu}' not found!"
- return 1
- fi
-
- # Directories to be bind-mounted
- subu_data_path="/home/$user/subu_data/$subu"
- subu_mount_point_path="/home/$user/subu/$subu"
-
- # Check if sub-user directory exists
- if [ ! -d "$subu_data_path" ]; then
- echo "Error: Sub-user directory '$subu_data_path' does not exist!"
- return 1
- fi
-
- # Create the mount point if it doesn't exist
- mkdir -p "$subu_mount_point_path"
-
- # Perform the bind mount using bindfs with UID/GID mapping
- sudo bindfs\
- --map="$subu_user_name/$master_user_name:@$subu_group/@$master_group" \
- "$subu_data_path" \
- "$subu_mount_point_path"
-
- # Verify if the mount was successful
- if [ $? -eq 0 ]; then
- echo "Successfully bind-mounted $subu_data_path to $subu_mount_point_path with UID/GID mapping."
- else
- echo "Error: Failed to bind-mount $subu_data_path to $subu_mount_point_path, might already exist."
- fi
-}
-
-# Call the function with user and subu as arguments
-subu_bind "$1" "$2"
+++ /dev/null
-#!/usr/bin/env bash
-
-# Function to lookup the UID of the user and sub-user combination
-get_subu_uid() {
- local user=$1
- local subu=$2
-
- # Concatenate user and sub-user name (no space around the = sign)
- local subu_user="${user}-${subu}"
-
- # Lookup the UID for the sub-user (user-subuser) combination
- subu_uid=$(id -u "$subu_user" 2>/dev/null)
-
- # If found, return only the UID, otherwise return nothing
- if [ -n "$subu_uid" ]; then
- echo "$subu_uid"
- fi
-}
-
-# Call the function with user and subu as arguments
-get_subu_uid "$1" "$2"
+++ /dev/null
-#!/usr/bin/env bash
-
-# Function to list available devices under /mnt, excluding /mnt itself
-mount_pt_list() {
- # List all directories in /mnt that are potentially available for mounting
- find /mnt -mindepth 1 -maxdepth 1 -type d -exec basename {} \;
-}
-
-# Call the function to display available devices
-mount_pt_list
--- /dev/null
+*
+!/.gitignore
--- /dev/null
+#!/usr/bin/env python3
+# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*-
+"""
+developer/tool/build - Build Orchestrator Wrapper
+
+This script acts as the primary entry point for staging the project.
+It parses colon-separated arguments mapping specific namespaces to their
+intended build arguments.
+
+It enforces the structural convention where authored directories must
+be named <Namespace>.<Tool>. The script delegates execution to
+developer/tool/build_component/<Tool>.
+"""
+
+import sys, os, subprocess, fnmatch
+from typing import Dict, Tuple
+
+def check_environment() -> None:
+ setup_must_be = "developer/tool/setup"
+ current_setup = os.environ.get("SETUP", "")
+ if current_setup != setup_must_be:
+ print(f"developer/tool/build:: error: must be run in the {setup_must_be} environment", file=sys.stderr)
+ sys.exit(1)
+
+def get_namespaces() -> Dict[str, Tuple[str, str]]:
+ authored_dir = os.path.join(os.environ.get("REPO_HOME", "."), "developer", "authored")
+ if not os.path.isdir(authored_dir):
+ return {}
+
+ namespaces = {}
+ for TM_item in os.listdir(authored_dir):
+ path = os.path.join(authored_dir, TM_item)
+ if os.path.isdir(path) and not TM_item.startswith("."):
+ parts = TM_item.rsplit(".", 1)
+ if len(parts) == 2:
+ ns = parts[0]
+ tool = parts[1]
+ namespaces[ns] = (tool, TM_item)
+ else:
+ print(f"(warning) Ignoring '{TM_item}': directory lacks the required .<tool> suffix.", file=sys.stderr)
+
+ return dict(sorted(namespaces.items()))
+
+def print_usage(namespaces: Dict[str, Tuple[str, str]]) -> None:
+ print("Usage: build [<namespace_pattern>[:<argument>]*]*\n")
+ print("Arguments are passed directly to the component specified by the directory suffix.\n")
+
+ print("Available namespaces:")
+ if namespaces:
+ for TM_ns, (TM_tool, TM_raw_dir) in namespaces.items():
+ print(f" {TM_ns:<20} (Component: {TM_tool}, Dir: {TM_raw_dir})")
+ else:
+ print(" (No conforming namespaces currently found in authored/)")
+
+ print("\nExamples:")
+ print(" build (Displays this usage message)")
+ print(" build ExampleGreet (Executes 'all' via its assigned component)")
+ print(" build Ex*:library RT:all:information (Batch execution with pattern matching)")
+ print(" build \"*:clean\" (Cleans all namespaces. Quotes prevent Bash expansion)")
+
+def run_build(argument: str, namespace: str, tool: str, raw_dir: str) -> None:
+ tool_path = os.path.join("tool", "build_component", tool)
+
+ if not os.path.isfile(tool_path) or not os.access(tool_path, os.X_OK):
+ print(f"error: Build component '{tool}' for namespace '{namespace}' is missing or not executable at {tool_path}", file=sys.stderr)
+ sys.exit(1)
+
+ env = os.environ.copy()
+ env["NAMESPACE"] = namespace
+ env["NAMESPACE_DIR"] = raw_dir
+
+ tool_cmd = [tool_path, argument]
+
+ try:
+ subprocess.run(tool_cmd, env=env, check=True)
+ except subprocess.CalledProcessError as e:
+ print(f"Build component '{tool}' failed for namespace '{namespace}' with exit code {e.returncode}", file=sys.stderr)
+ sys.exit(e.returncode)
+
+def CLI() -> None:
+ check_environment()
+
+ repo_home = os.environ.get("REPO_HOME", ".")
+ dev_dir = os.path.join(repo_home, "developer")
+ os.chdir(dev_dir)
+
+ args = sys.argv[1:]
+ namespaces = get_namespaces()
+
+ if not args or args[0] in ["usage", "help", "-h", "--help"]:
+ print_usage(namespaces)
+ sys.exit(0)
+
+ matched_any = False
+ ns_keys = list(namespaces.keys())
+
+ for TM_arg in args:
+ parts = TM_arg.split(":")
+ pattern = parts[0]
+ arguments = parts[1:]
+
+ if not arguments:
+ arguments = ["all"]
+
+ matches = fnmatch.filter(ns_keys, pattern)
+
+ if not matches:
+ print(f"(warning) Pattern '{pattern}' matched no namespaces.", file=sys.stderr)
+ continue
+
+ matched_any = True
+ for TM_ns in matches:
+ tool, raw_dir = namespaces[TM_ns]
+ for TM_argument in arguments:
+ run_build(TM_argument, TM_ns, tool, raw_dir)
+
+ if not matched_any:
+ sys.exit(1)
+
+ print("build done.")
+
+if __name__ == "__main__":
+ CLI()
--- /dev/null
+#!/usr/bin/env -S python3 -B
+# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*-
+# developer/tool/build_component/copy
+
+import sys
+import os
+import shutil
+
+sys.path.insert(0, os.path.join(os.path.abspath("tool"), "build_component"))
+import sync_engine
+
+HELP = """usage: copy {all|clean|help}
+ all Stages files from authored to scratchpad/made/<namespace>.
+ clean Removes scratchpad/made/<namespace>.
+ help Shows this message.
+"""
+
+def cmd_all():
+ namespace = os.environ.get("NAMESPACE")
+ namespace_dir = os.environ.get("NAMESPACE_DIR")
+
+ if not namespace or not namespace_dir:
+ print("copy: error: NAMESPACE and NAMESPACE_DIR environment variables must be set.", file=sys.stderr)
+ sys.exit(1)
+
+ src_root = os.path.join("authored", namespace_dir)
+ dst_root = os.path.join("scratchpad", "made", namespace)
+
+ sync_engine.execute_sync(src_root, dst_root, prefix="copy", apply_modes=False)
+
+def cmd_clean():
+ namespace = os.environ.get("NAMESPACE")
+ if not namespace:
+ print("copy: error: NAMESPACE environment variable must be set.", file=sys.stderr)
+ sys.exit(1)
+
+ dst_root = os.path.join("scratchpad", "made", namespace)
+ if os.path.exists(dst_root):
+ shutil.rmtree(dst_root)
+ print(f"copy: Cleaned {dst_root}")
+
+def CLI():
+ if len(sys.argv) < 2:
+ cmd = "all"
+ else:
+ cmd = sys.argv[1]
+
+ if cmd == "all":
+ cmd_all()
+ elif cmd == "clean":
+ cmd_clean()
+ elif cmd in ["help", "-h", "--help"]:
+ print(HELP)
+ else:
+ print(f"copy: Unrecognized command '{cmd}'", file=sys.stderr)
+ sys.exit(1)
+
+if __name__ == "__main__":
+ CLI()
--- /dev/null
+#!/usr/bin/env bash
+# developer/tool/build_component/make
+
+set -euo pipefail
+CMD="${1:-all}"
+
+# The GNU Make orchestrator expects C_SOURCE_DIR to point to the raw directory
+export C_SOURCE_DIR="authored/$NAMESPACE_DIR"
+export KMOD_SOURCE_DIR="authored/$NAMESPACE_DIR"
+
+# Isolate build products into their specific namespace directory
+export LIBRARY_DIR="scratchpad/made/$NAMESPACE"
+export MACHINE_DIR="scratchpad/made/$NAMESPACE"
+
+/bin/make -f tool/build_component/makefile "$CMD"
--- /dev/null
+# developer/tool/build_component/makefile
+.SUFFIXES:
+.EXPORT_ALL_VARIABLES:
+
+RT_MAKEFILE_DP := $(REPO_HOME)/shared/tool/makefile
+
+.PHONY: usage
+usage:
+ @printf "Usage: make [usage|version|information|all|lib|CLI|kmod|clean]\n"
+
+.PHONY: version
+version:
+ @printf "local ----------------------------------------\n"
+ @echo tool/makefile version 2.0
+ @printf "Harmony.mk ----------------------------------------\n"
+ @$(MAKE) -f $(RT_MAKEFILE_DP)/Harmony.mk version
+ @printf "target_kernel-module.mk ----------------------------------------\n"
+ @$(MAKE) -f $(RT_MAKEFILE_DP)/target_kernel-module.mk version
+
+.PHONY: information
+information:
+ @printf "local ----------------------------------------\n"
+ -@echo CURDIR='$(CURDIR)'
+ @echo REPO_HOME="$(REPO_HOME)"
+ @echo NAMESPACE="$(NAMESPACE)"
+ @echo C_SOURCE_DIR="$(C_SOURCE_DIR)"
+ @echo KMOD_BUILD_DIR="/lib/modules/$(shell uname -r)/build"
+ @echo CURDIR="$(CURDIR)"
+ @printf "Harmony.mk ----------------------------------------\n"
+ @$(MAKE) -f $(RT_MAKEFILE_DP)/Harmony.mk information
+ @printf "target_kernel-module.mk ----------------------------------------\n"
+ @$(MAKE) -f $(RT_MAKEFILE_DP)/target_kernel-module.mk information
+
+.PHONY: all
+all: library CLI kmod
+
+.PHONY: library lib
+library lib:
+ @$(MAKE) -f $(RT_MAKEFILE_DP)/Harmony.mk library
+
+.PHONY: CLI
+CLI:
+ @$(MAKE) -f $(RT_MAKEFILE_DP)/Harmony.mk CLI
+
+.PHONY: kmod
+kmod:
+ @$(MAKE) -f $(RT_MAKEFILE_DP)/target_kernel-module.mk kmod
+
+.PHONY: clean
+clean:
+ @printf "local ----------------------------------------\n"
+ @printf "Harmony.mk ----------------------------------------\n"
+ @$(MAKE) -f $(RT_MAKEFILE_DP)/Harmony.mk clean
+ @printf "target_kernel-module.mk ----------------------------------------\n"
+ @$(MAKE) -f $(RT_MAKEFILE_DP)/target_kernel-module.mk clean
--- /dev/null
+#!/usr/bin/env -S python3 -B
+# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*-
+# developer/tool/sync_engine.py
+
+import os
+import sys
+import shutil
+import filecmp
+import stat
+import tempfile
+
+def copy_file_atomic(src_abs, dst_abs, mode, dry=False, prefix="sync"):
+ if dry:
+ print(f"(dry) {prefix}: install -m {oct(mode)[2:]} '{src_abs}' -> '{dst_abs}'")
+ return
+
+ parent = os.path.dirname(dst_abs)
+ os.makedirs(parent, exist_ok=True)
+
+ fd, tmp_path = tempfile.mkstemp(prefix=".tmp.", dir=parent)
+ try:
+ with os.fdopen(fd, "wb") as tmpf, open(src_abs, "rb") as sf:
+ shutil.copyfileobj(sf, tmpf)
+ tmpf.flush()
+ os.chmod(tmp_path, mode)
+ os.replace(tmp_path, dst_abs)
+ except Exception as e:
+ if os.path.exists(tmp_path):
+ os.unlink(tmp_path)
+ raise e
+
+def execute_sync(src_root, dst_root, prefix="sync", dry=False, apply_modes=False, ignore_list=None):
+ wrote = False
+ if ignore_list is None:
+ ignore_list = []
+
+ if not os.path.isdir(src_root):
+ print(f"{prefix}: error: Source directory '{src_root}' does not exist.", file=sys.stderr)
+ return False
+
+ for root, dirs, files in os.walk(src_root):
+ dirs.sort()
+ files.sort()
+ for fn in files:
+ if fn in ignore_list:
+ continue
+
+ src_abs = os.path.join(root, fn)
+ rel = os.path.relpath(src_abs, src_root)
+ dst_abs = os.path.join(dst_root, rel)
+
+ st = os.stat(src_abs)
+ if apply_modes:
+ is_exec = st.st_mode & stat.S_IXUSR
+ mode = 0o500 if is_exec else 0o400
+ else:
+ mode = stat.S_IMODE(st.st_mode)
+
+ if not os.path.exists(dst_abs):
+ copy_file_atomic(src_abs, dst_abs, mode, dry, prefix)
+ if not dry: print(f"{prefix}: Added {rel}")
+ wrote = True
+ continue
+
+ if filecmp.cmp(src_abs, dst_abs, shallow=False):
+ continue
+
+ dst_st = os.stat(dst_abs)
+ if dst_st.st_mtime > st.st_mtime:
+ print(f"{prefix}: error: {rel} skipped. Destination version is newer.")
+ continue
+
+ copy_file_atomic(src_abs, dst_abs, mode, dry, prefix)
+ if not dry: print(f"{prefix}: Updated {rel}")
+ wrote = True
+
+ if not wrote and not dry:
+ print(f"{prefix}: (info) nothing new to synchronize from {src_root}")
+
+ return wrote
+++ /dev/null
-#!/usr/bin/env bash
-
-script_afp=$(realpath "${BASH_SOURCE[0]}")
-
-# input guards
- env_must_be="developer/tool/env"
- error=false
- if [ "$ENV" != "$env_must_be" ]; then
- echo "$(script_fp):: error: must be run in the $env_must_be environment"
- error=true
- fi
- if $error; then exit 1; fi
-
-# remove files
-
- cd "$REPO_HOME"/developer/
- set -x
- make dist-clean
- cd mockup/subu_server_home
- rm server hello socket
- set +x
-
-echo "$(script_fn) done."
+++ /dev/null
-#!/usr/bin/env bash
-script_afp=$(realpath "${BASH_SOURCE[0]}")
+++ /dev/null
-#!/usr/bin/env bash
-script_afp=$(realpath "${BASH_SOURCE[0]}")
-
-# input guards
- env_must_be="developer/tool/env"
- error=false
- if [ "$ENV" != "$env_must_be" ]; then
- echo "$(script_fp):: error: must be run in the $env_must_be environment"
- error=true
- fi
- if $error; then exit 1; fi
-
-set -e
-cd ${REPO_HOME}/developer
-/bin/make -f tool/makefile $@
+++ /dev/null
-# after adding this file `make dependency` should be run again
-
-RESOURCE:=$(REPO_HOME)/tool_shared/third_party/resource/developer
-include $(RESOURCE)/make/environment_RT_0
-
-# override defaults
-LIBDIR=scratchpad
-
-# assign a compiler to use
-C=gcc
-
-# _GNU_SOURCE is needed to get ucred, the credentials of the pid that connects to a socket
-CFLAGS= -Werror -ggdb -D_GNU_SOURCE -DDEBUG -DDEBUGDB -include "$(RESOURCE)/make/RT_0.h" -I$(INCDIR)
-LINKFLAGS=-L$(LIBDIR) -L/usr/lib64 -L/usr/lib -l$(PROJECT) -lsqlite3
-
-# Import the rules. The dash prefix means to ignore include errors. This is
-# required because the DEPFILE might not exist yet.
-include $(RESOURCE)/make/targets
--include $(DEPFILE)
+++ /dev/null
-#!/usr/bin/env bash
-script_afp=$(realpath "${BASH_SOURCE[0]}")
-
-# input guards
-
- env_must_be="developer/tool/env"
- error=false
- if [ "$ENV" != "$env_must_be" ]; then
- echo "$(script_fp):: error: must be run in the $env_must_be environment"
- error=true
- fi
- if $error; then exit 1; fi
-
-set -e
-cd ${REPO_HOME}/developer
-cp amd64/* mockup/subu_server_home
-
--- /dev/null
+#!/usr/bin/env -S python3 -B
+# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*-
+# developer/tool/promote
+
+import os
+import sys
+import shutil
+import stat
+import pwd
+import grp
+import filecmp
+
+# Calculate the path relative to this script's location
+SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
+# Assumes this script is in developer/tool/
+# sync_engine is in developer/tool/build_component
+sys.path.insert(0, os.path.join(SCRIPT_DIR, "build_component"))
+import sync_engine
+
+HELP = """usage: promote {write|clean|ls|diff|help|dry write}
+ write Copies what is staged in scratchpad/made into consumer. Only updates newer files.
+ clean Remove all contents of the consumer directory (preserves .gitignore).
+ ls List consumer as an indented tree: PERMS OWNER NAME.
+ diff List missing, orphaned, or out of sync files between scratchpad/made and consumer.
+ help Show this message.
+ dry write Preview what write would do without modifying the filesystem.
+
+Promoted files are read-only: 400, or 500 when executable. Directories below
+consumer/ are 500, so not even the owner can add, replace or remove a file in
+them; write and clean open them (700) for the duration and lock them again.
+consumer/ itself stays 700, as it holds the .gitignore git manages.
+"""
+
+SETUP_MUST_BE = "developer/tool/setup"
+DEFAULT_DIR_MODE = 0o700
+LOCKED_DIR_MODE = 0o500
+
+def exit_with_status(msg, code=1):
+ print(f"promote: {msg}", file=sys.stderr)
+ sys.exit(code)
+
+def assert_setup():
+ setup_val = os.environ.get("SETUP", "")
+ if setup_val != SETUP_MUST_BE:
+ hint = (
+ "SETUP is not 'developer/tool/setup'.\n"
+ "Enter the developer role with: ./setup developer\n"
+ "That script exports: ROLE=developer; SETUP=$ROLE/tool/setup"
+ )
+ exit_with_status(f"bad environment: SETUP='{setup_val}'. {hint}")
+
+def repo_home():
+ rh = os.environ.get("REPO_HOME")
+ if not rh:
+ exit_with_status("REPO_HOME not set")
+ return rh
+
+def dpath(*parts):
+ return os.path.join(repo_home(), "developer", *parts)
+
+def cpath(*parts):
+ return os.path.join(repo_home(), "consumer", *parts)
+
+def ensure_mode(path_str, mode):
+ try: os.chmod(path_str, mode)
+ except Exception: pass
+
+def ensure_dir(path_str, mode=DEFAULT_DIR_MODE, dry=False):
+ if dry:
+ return
+ os.makedirs(path_str, exist_ok=True)
+ ensure_mode(path_str, mode)
+
+def consumer_subdirs(root_dp):
+ """Every directory below root_dp; root_dp itself is not included."""
+ found = []
+ for TM_root, TM_dirs, TM_files in os.walk(root_dp):
+ for TM_d in TM_dirs:
+ found.append(os.path.join(TM_root, TM_d))
+ return found
+
+def set_subdir_modes(root_dp, mode):
+ for TM_d in consumer_subdirs(root_dp):
+ ensure_mode(TM_d, mode)
+
+def filemode(m):
+ try: return stat.filemode(m)
+ except Exception: return oct(m & 0o777)
+
+def owner_group(st):
+ try: return f"{pwd.getpwuid(st.st_uid).pw_name}:{grp.getgrgid(st.st_gid).gr_name}"
+ except Exception: return f"{st.st_uid}:{st.st_gid}"
+
+def list_tree(root_dp):
+ if not os.path.isdir(root_dp):
+ return
+
+ entries = []
+ def gather(path_str, depth, is_root):
+ try:
+ it = list(os.scandir(path_str))
+ except FileNotFoundError:
+ return
+ dirs = [TM_e for TM_e in it if TM_e.is_dir(follow_symlinks=False)]
+ files = [TM_e for TM_e in it if not TM_e.is_dir(follow_symlinks=False)]
+ dirs.sort(key=lambda TM_e: TM_e.name)
+ files.sort(key=lambda TM_e: TM_e.name)
+
+ if is_root:
+ for TM_f in (TM_e for TM_e in files if TM_e.name.startswith(".")):
+ st = os.lstat(TM_f.path)
+ entries.append((False, depth, filemode(st.st_mode), owner_group(st), TM_f.name))
+ for TM_d in dirs:
+ st = os.lstat(TM_d.path)
+ entries.append((True, depth, filemode(st.st_mode), owner_group(st), TM_d.name + "/"))
+ gather(TM_d.path, depth + 1, False)
+ for TM_f in (TM_e for TM_e in files if not TM_e.name.startswith(".")):
+ st = os.lstat(TM_f.path)
+ entries.append((False, depth, filemode(st.st_mode), owner_group(st), TM_f.name))
+ else:
+ for TM_d in dirs:
+ st = os.lstat(TM_d.path)
+ entries.append((True, depth, filemode(st.st_mode), owner_group(st), TM_d.name + "/"))
+ gather(TM_d.path, depth + 1, False)
+ for TM_f in files:
+ st = os.lstat(TM_f.path)
+ entries.append((False, depth, filemode(st.st_mode), owner_group(st), TM_f.name))
+
+ gather(root_dp, 1, True)
+
+ ogw = 0
+ for TM_isdir, TM_depth, TM_perms, TM_ownergrp, TM_name in entries:
+ if len(TM_ownergrp) > ogw:
+ ogw = len(TM_ownergrp)
+
+ print("consumer/")
+ for TM_isdir, TM_depth, TM_perms, TM_ownergrp, TM_name in entries:
+ indent = " " * TM_depth
+ print(f"{TM_perms} {TM_ownergrp:<{ogw}} {indent}{TM_name}")
+
+def cmd_write(dry=False):
+ dst_root = cpath()
+ ensure_dir(dst_root, DEFAULT_DIR_MODE, dry=dry)
+ src_root = dpath("scratchpad", "made")
+
+ # Files are replaced by writing a temporary file beside them and renaming
+ # it into place, which needs write permission on the directory.
+ if not dry: set_subdir_modes(dst_root, DEFAULT_DIR_MODE)
+ try:
+ sync_engine.execute_sync(
+ src_root,
+ dst_root,
+ prefix="promote",
+ dry=dry,
+ apply_modes=True,
+ ignore_list=[".gitignore"]
+ )
+ finally:
+ if not dry: set_subdir_modes(dst_root, LOCKED_DIR_MODE)
+
+def cmd_diff():
+ dst_root = cpath()
+ src_root = dpath("scratchpad", "made")
+
+ src_files = set()
+ if os.path.isdir(src_root):
+ for TM_root, TM_dirs, TM_files in os.walk(src_root):
+ for TM_fn in TM_files:
+ if TM_fn == ".gitignore":
+ continue
+ src_files.add(os.path.relpath(os.path.join(TM_root, TM_fn), src_root))
+
+ dst_files = set()
+ if os.path.isdir(dst_root):
+ for TM_root, TM_dirs, TM_files in os.walk(dst_root):
+ for TM_fn in TM_files:
+ if TM_fn == ".gitignore":
+ continue
+ dst_files.add(os.path.relpath(os.path.join(TM_root, TM_fn), dst_root))
+
+ if not src_files and not dst_files:
+ print("No differences found. Both directories are empty or missing.")
+ return
+
+ all_files = sorted(list(src_files | dst_files))
+ found_diff = False
+
+ for TM_rel in all_files:
+ if TM_rel not in dst_files:
+ print(f"Pending promotion (missing in consumer): {TM_rel}")
+ found_diff = True
+ elif TM_rel not in src_files:
+ print(f"Orphaned in consumer (missing in scratchpad/made): {TM_rel}")
+ found_diff = True
+ else:
+ src_abs = os.path.join(src_root, TM_rel)
+ dst_abs = os.path.join(dst_root, TM_rel)
+
+ if not filecmp.cmp(src_abs, dst_abs, shallow=False):
+ src_mtime = os.stat(src_abs).st_mtime
+ dst_mtime = os.stat(dst_abs).st_mtime
+
+ if src_mtime > dst_mtime:
+ print(f"Pending update (contents differ, newer in scratchpad/made): {TM_rel}")
+ else:
+ print(f"Contents differ (locally modified in consumer): {TM_rel}")
+ found_diff = True
+
+ if not found_diff:
+ print("No differences found. Consumer matches developer scratchpad/made.")
+
+def cmd_clean():
+ consumer_root_dir = cpath()
+ if not os.path.isdir(consumer_root_dir):
+ return
+ # Removing an entry needs write permission on the directory holding it.
+ set_subdir_modes(consumer_root_dir, DEFAULT_DIR_MODE)
+ for TM_name in os.listdir(consumer_root_dir):
+ if TM_name == ".gitignore":
+ continue
+ p = os.path.join(consumer_root_dir, TM_name)
+ if os.path.isdir(p) and not os.path.islink(p):
+ shutil.rmtree(p, ignore_errors=True)
+ else:
+ try: os.unlink(p)
+ except FileNotFoundError: pass
+
+def CLI():
+ assert_setup()
+
+ repo_home = os.environ.get("REPO_HOME", ".")
+ dev_dir = os.path.join(repo_home, "developer")
+ os.chdir(dev_dir)
+
+ if len(sys.argv) < 2:
+ print(HELP)
+ return
+
+ cmd = sys.argv[1]
+ args = sys.argv[2:]
+
+ if cmd == "write":
+ cmd_write(dry=False)
+ elif cmd == "clean":
+ cmd_clean()
+ elif cmd == "ls":
+ list_tree(cpath())
+ elif cmd == "diff":
+ cmd_diff()
+ elif cmd == "help":
+ print(HELP)
+ elif cmd == "dry":
+ if args and args[0] == "write":
+ cmd_write(dry=True)
+ else:
+ print(HELP)
+ else:
+ print(HELP)
+
+if __name__ == "__main__":
+ CLI()
+++ /dev/null
-#!/usr/bin/env -S python3 -B
-# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*-
-
-import os, sys, shutil, stat, pwd, grp, glob, tempfile, datetime
-
-HELP = """usage: release {write|clean|ls|help|dry write} [DIR]
- write [DIR] Write released files.
- - For DIR=manager: copy $REPO_HOME/developer/manager into $REPO_HOME/release/manager.
- - For other DIR values: write from developer/scratchpad/DIR into $REPO_HOME/release/DIR.
- clean [DIR] Remove the contents of the release directories.
- - For DIR=manager: clean $REPO_HOME/release/manager.
- - For other DIR values: clean only that subdirectory under the release root.
- list List $REPO_HOME/release as an indented tree: PERMS OWNER DATE NAME.
- ls List $REPO_HOME/release as an indented tree: PERMS OWNER DATE NAME.
- help Show this message.
- dry write [DIR]
- Preview what write would do without modifying the filesystem.
-"""
-
-ENV_MUST_BE = "developer/tool/env"
-DEFAULT_DIR_MODE = 0o700 # 077-congruent dirs
-
-def exit_with_status(msg, code=1):
- print(f"release: {msg}", file=sys.stderr)
- sys.exit(code)
-
-def format_mtime_utc(ts: float) -> str:
- dt = datetime.datetime.fromtimestamp(ts, datetime.timezone.utc)
- return dt.strftime("%Y-%m-%d_%H:%M:%S_Z")
-
-def assert_env():
- env = os.environ.get("ENV", "")
- if env != ENV_MUST_BE:
- hint = (
- "ENV is not 'developer/tool/env'.\n"
- "Enter the project with: source ./env_developer\n"
- "That script exports: ROLE=developer; ENV=$ROLE/tool/env"
- )
- exit_with_status(f"bad environment: ENV='{env}'. {hint}")
-
-
-def repo_home():
- rh = os.environ.get("REPO_HOME")
- if not rh:
- exit_with_status("REPO_HOME not set (did you 'source ./env_developer'?)")
- return rh
-
-
-def dpath(*parts):
- # Developer tree root: $REPO_HOME/developer/...
- return os.path.join(repo_home(), "developer", *parts)
-
-
-def rpath(*parts):
- """
- Release root: $REPO_HOME/release/...
-
- Manager releases go to $REPO_HOME/release/manager.
- """
- return os.path.join(repo_home(), "release", *parts)
-
-
-def dev_root():
- return dpath()
-
-
-def rel_root():
- return rpath()
-
-
-def _display_src(p_abs: str) -> str:
- try:
- if os.path.commonpath([dev_root()]) == os.path.commonpath([dev_root(), p_abs]):
- return os.path.relpath(p_abs, dev_root())
- except Exception:
- pass
- return p_abs
-
-
-def _display_dst(p_abs: str) -> str:
- try:
- rel = os.path.relpath(p_abs, rel_root())
- rel = "" if rel == "." else rel
- return "$REPO_HOME/release" + ("/" + rel if rel else "")
- except Exception:
- return p_abs
-
-
-def ensure_mode(path, mode):
- try:
- os.chmod(path, mode)
- except Exception:
- pass
-
-
-def ensure_dir(path, mode=DEFAULT_DIR_MODE, dry=False):
- if dry:
- if not os.path.isdir(path):
- shown = _display_dst(path) if path.startswith(rel_root()) else (
- os.path.relpath(path, dev_root()) if path.startswith(dev_root()) else path
- )
- print(f"(dry) mkdir -m {oct(mode)[2:]} '{shown}'")
- return
- os.makedirs(path, exist_ok=True)
- ensure_mode(path, mode)
-
-def filemode(m):
- try:
- return stat.filemode(m)
- except Exception:
- return oct(m & 0o777)
-
-def owner_group(st):
- try:
- user = pwd.getpwuid(st.st_uid).pw_name
- group = grp.getgrgid(st.st_gid).gr_name
- except Exception:
- user = str(st.st_uid)
- group = str(st.st_gid)
- return user if user == group else f"{user}:{group}"
-
-
-# ---------- LS (two-pass owner:group width) ----------
-# ---------- LS (two-pass owner:group width) ----------
-def list_tree(root):
- if not os.path.isdir(root):
- return
- entries = []
-
- def gather(path: str, depth: int, is_root: bool):
- try:
- it = list(os.scandir(path))
- except FileNotFoundError:
- return
- dirs = [e for e in it if e.is_dir(follow_symlinks=False)]
- files = [e for e in it if not e.is_dir(follow_symlinks=False)]
- dirs.sort(key=lambda e: e.name)
- files.sort(key=lambda e: e.name)
-
- if is_root:
- # dotfiles at root first
- for f in (e for e in files if e.name.startswith(".")):
- st = os.lstat(f.path)
- perms = filemode(st.st_mode)
- og = owner_group(st)
- mtime = format_mtime_utc(st.st_mtime)
- entries.append((False, depth, perms, og, mtime, f.name))
- # dirs
- for d in dirs:
- st = os.lstat(d.path)
- perms = filemode(st.st_mode)
- og = owner_group(st)
- mtime = format_mtime_utc(st.st_mtime)
- entries.append((True, depth, perms, og, mtime, d.name + "/"))
- gather(d.path, depth + 1, False)
- # other files
- for f in (e for e in files if not e.name.startswith(".")):
- st = os.lstat(f.path)
- perms = filemode(st.st_mode)
- og = owner_group(st)
- mtime = format_mtime_utc(st.st_mtime)
- entries.append((False, depth, perms, og, mtime, f.name))
- else:
- # normal subdirs: dirs then files
- for d in dirs:
- st = os.lstat(d.path)
- perms = filemode(st.st_mode)
- og = owner_group(st)
- mtime = format_mtime_utc(st.st_mtime)
- entries.append((True, depth, perms, og, mtime, d.name + "/"))
- gather(d.path, depth + 1, False)
- for f in files:
- st = os.lstat(f.path)
- perms = filemode(st.st_mode)
- og = owner_group(st)
- mtime = format_mtime_utc(st.st_mtime)
- entries.append((False, depth, perms, og, mtime, f.name))
-
- gather(root, depth=1, is_root=True)
-
- ogw = 0
- for (_isdir, _depth, _perms, ownergrp, _mtime, _name) in entries:
- if len(ownergrp) > ogw:
- ogw = len(ownergrp)
-
- print("release/")
- for (_isdir, depth, perms, ownergrp, mtime, name) in entries:
- indent = " " * depth
- print(f"{perms} {ownergrp:<{ogw}} {mtime} {indent}{name}")
-# ---------- end LS ----------
-
-def iter_src_files(topdir, src_root):
- """
- Yield (src_abs, rel) pairs.
-
- For manager:
- - base is $REPO_HOME/developer/manager
-
- For other topdirs (future reuse), preserves original Harmony behavior:
- - base is src_root/topdir (usually developer/scratchpad/topdir)
- """
- if topdir == "manager":
- base = dpath("manager")
- else:
- base = os.path.join(src_root, topdir)
-
- if not os.path.isdir(base):
- return
- yield
-
- if topdir == "kmod":
- for p in sorted(glob.glob(os.path.join(base, "*.ko"))):
- yield (p, os.path.basename(p))
- else:
- for root, dirs, files in os.walk(base):
- dirs.sort()
- files.sort()
- for fn in files:
- src = os.path.join(root, fn)
- rel = os.path.relpath(src, base)
- yield (src, rel)
-
-
-def _target_mode_from_source(src_abs: str) -> int:
- """077 policy: files 0600; if source has owner-exec, make 0700."""
- try:
- sm = stat.S_IMODE(os.stat(src_abs).st_mode)
- except FileNotFoundError:
- return 0o600
- return 0o700 if (sm & stat.S_IXUSR) else 0o600
-
-
-def copy_one(src_abs, dst_abs, dry=False):
- src_show = _display_src(src_abs)
- dst_show = _display_dst(dst_abs)
- parent = os.path.dirname(dst_abs)
- os.makedirs(parent, exist_ok=True)
- target_mode = _target_mode_from_source(src_abs)
-
- def _is_writable_dir(p):
- return os.access(p, os.W_OK)
-
- flip_needed = not _is_writable_dir(parent)
- restore_mode = None
- parent_show = _display_dst(parent)
-
- if dry:
- if flip_needed:
- print(f"(dry) chmod u+w '{parent_show}'")
- if os.path.exists(dst_abs):
- print(f"(dry) unlink '{dst_show}'")
- print(f"(dry) install -m {oct(target_mode)[2:]} -D '{src_show}' '{dst_show}'")
- if flip_needed:
- print(f"(dry) chmod u-w '{parent_show}'")
- return
-
- try:
- if flip_needed:
- try:
- st_parent = os.stat(parent)
- restore_mode = stat.S_IMODE(st_parent.st_mode)
- os.chmod(parent, restore_mode | stat.S_IWUSR)
- except PermissionError:
- exit_with_status(
- f"cannot write: parent dir not writable and chmod failed on {parent_show}"
- )
-
- fd, tmp_path = tempfile.mkstemp(prefix=".tmp.", dir=parent)
- try:
- with os.fdopen(fd, "wb") as tmpf, open(src_abs, "rb") as sf:
- shutil.copyfileobj(sf, tmpf)
- tmpf.flush()
- os.chmod(tmp_path, target_mode)
- os.replace(tmp_path, dst_abs)
- finally:
- try:
- if os.path.exists(tmp_path):
- os.unlink(tmp_path)
- except Exception:
- pass
- finally:
- if restore_mode is not None:
- try:
- os.chmod(parent, restore_mode)
- except Exception:
- pass
-
- print(f"+ install -m {oct(target_mode)[2:]} '{src_show}' '{dst_show}'")
-
-
-def write_one_dir(topdir, dry):
- """
- For manager:
-
- - Source: $REPO_HOME/developer/manager
- - Dest: $REPO_HOME/release/manager
-
- For other topdirs:
-
- - Source: $REPO_HOME/developer/scratchpad/<topdir>
- - Dest: $REPO_HOME/release/<topdir>
- """
- rel_root_dir = rpath()
- src_root = dpath("scratchpad")
-
- if topdir == "manager":
- src_dir = dpath("manager")
- dst_dir = rpath("manager")
- else:
- src_dir = os.path.join(src_root, topdir)
- dst_dir = os.path.join(rel_root_dir, topdir)
-
- if not os.path.isdir(src_dir):
- shown = _display_src(src_dir)
- if topdir == "manager":
- exit_with_status(
- f"cannot write: expected developer manager tree at '{shown}'. "
- f"Expected directory: $REPO_HOME/developer/manager"
- )
- else:
- exit_with_status(
- f"cannot write: expected '{shown}' to exist. "
- f"Create developer/scratchpad/{topdir} (Makefiles may need to populate it)."
- )
-
- ensure_dir(dst_dir, DEFAULT_DIR_MODE, dry=dry)
-
- wrote = False
- for src_abs, rel in iter_src_files(topdir, src_root):
- dst_abs = os.path.join(dst_dir, rel)
- copy_one(src_abs, dst_abs, dry=dry)
- wrote = True
- if not wrote:
- msg = "no matching artifacts found"
- if topdir == "kmod":
- msg += " (looking for *.ko)"
- print(f"(info) {msg} in {_display_src(src_dir)}")
-
-
-def cmd_write(dir_arg, dry=False):
- assert_env()
- ensure_dir(rpath(), DEFAULT_DIR_MODE, dry=dry)
-
- if dir_arg:
- write_one_dir(dir_arg, dry=dry)
- else:
- # Legacy behavior: auto-discover from scratchpad/
- src_root = dpath("scratchpad")
- if not os.path.isdir(src_root):
- print(f"(info) nothing to release; no scratchpad/ directory at {_display_src(src_root)}")
- return
- subs = sorted(
- [e.name for e in os.scandir(src_root) if e.is_dir(follow_symlinks=False)]
- )
- if not subs:
- print(
- f"(info) nothing to release; no subdirectories found under {_display_src(src_root)}"
- )
- return
- for td in subs:
- write_one_dir(td, dry=dry)
-
-
-def _clean_contents(dir_path):
- if not os.path.isdir(dir_path):
- return
- for name in os.listdir(dir_path):
- p = os.path.join(dir_path, name)
- if os.path.isdir(p) and not os.path.islink(p):
- shutil.rmtree(p, ignore_errors=True)
- else:
- try:
- os.unlink(p)
- except FileNotFoundError:
- pass
-
-
-def cmd_clean(dir_arg):
- assert_env()
- rel_root_dir = rpath()
- if not os.path.isdir(rel_root_dir):
- return
- if dir_arg:
- _clean_contents(os.path.join(rel_root_dir, dir_arg))
- else:
- _clean_contents(rel_root_dir)
-
-
-def CLI():
- if len(sys.argv) < 2:
- print(HELP)
- return
- cmd, *args = sys.argv[1:]
- if cmd == "write":
- cmd_write(args[0] if args else None, dry=False)
- elif cmd == "clean":
- cmd_clean(args[0] if args else None)
- elif cmd == "ls":
- list_tree(rpath())
- elif cmd == "list":
- list_tree(rpath())
- elif cmd == "help":
- print(HELP)
- elif cmd == "dry":
- if args and args[0] == "write":
- cmd_write(args[1] if len(args) >= 2 else None, dry=True)
- else:
- print(HELP)
- else:
- print(HELP)
-
-
-if __name__ == "__main__":
- CLI()
+++ /dev/null
-#!/usr/bin/env python3
-# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*-
-
-"""
-Remove all Python cache directories under the current working directory.
-
-Specifically:
- * __pycache__
- * __psycache__ (included in case of typo'd cache dirs)
-
-Usage:
- python3 clean_pycache.py
- ./clean_pycache.py
-"""
-
-import os, shutil, sys
-
-
-def is_cache_dir(name: str) -> bool:
- """
- Return True if the directory name should be treated as a cache directory.
- """
- return name in ("__pycache__", "__psycache__")
-
-
-def remove_cache_dir(path: str) -> None:
- """
- Remove a cache directory and print what we did.
- """
- try:
- shutil.rmtree(path)
- print(f"removed: {path}")
- except FileNotFoundError:
- # Someone else removed it; ignore.
- pass
- except PermissionError as e:
- print(f"warning: cannot remove {path}: {e}", file =sys.stderr)
- except OSError as e:
- print(f"warning: error removing {path}: {e}", file =sys.stderr)
-
-
-def walk_and_clean(start: str) -> None:
- """
- Walk the tree from 'start' downward and remove all cache dirs.
- """
- for root, dirs, files in os.walk(start, topdown =True):
- # Work on a copy so we can safely modify 'dirs' while iterating.
- for d in list(dirs):
- if is_cache_dir(d):
- full = os.path.join(root, d)
- remove_cache_dir(full)
- # Prevent os.walk from descending into it.
- try:
- dirs.remove(d)
- except ValueError:
- pass
-
-
-def main(argv=None) -> int:
- if argv is None:
- argv = sys.argv[1:]
-
- start_dir = os.getcwd()
- walk_and_clean(start_dir)
- return 0
-
-
-if __name__ == "__main__":
- raise SystemExit(main())
--- /dev/null
+#!/usr/bin/env bash
+script_afp=$(realpath "${BASH_SOURCE[0]}")
+
+++ /dev/null
-
-__pycache__
-
+++ /dev/null
-
-*
-!.gitignore
-
+++ /dev/null
-#!/usr/bin/env python3
-# db_bind_user_to_iface.py — bind ONE linux user to ONE interface in the DB (no schema writes)
-# Usage: ./db_bind_user_to_iface.py <username> <iface> # e.g. ./db_bind_user_to_iface.py Thomas-x6 x6
-
-from __future__ import annotations
-import sys, sqlite3, pwd
-from pathlib import Path
-from typing import Optional
-import incommon as ic # ROOT_DIR/DB_PATH, open_db()
-
-def system_uid_or_none(username: str) -> Optional[int]:
- """Return the system UID for username, or None if the user doesn't exist locally."""
- try:
- return pwd.getpwnam(username).pw_uid
- except KeyError:
- return None
-
-def bind_user_to_iface(conn: sqlite3.Connection, iface: str, username: str) -> str:
- """
- Given (iface, username):
- - Look up client.id by iface (table: client)
- - Upsert into User(iface_id, username, uid)
- - Update uid based on local /etc/passwd (None if user not found)
- Returns a concise status string.
- """
- row = conn.execute("SELECT id FROM Iface WHERE iface=? LIMIT 1;", (iface,)).fetchone()
- if not row:
- raise RuntimeError(f"Interface '{iface}' not found in client")
-
- iface_id = int(row[0])
- uid_val = system_uid_or_none(username)
-
- # Upsert binding
- conn.execute("""
- INSERT INTO User (iface_id, username, uid, created_at, updated_at)
- VALUES (?, ?, ?, strftime('%Y-%m-%dT%H:%M:%SZ','now'), strftime('%Y-%m-%dT%H:%M:%SZ','now'))
- ON CONFLICT(iface_id, username) DO UPDATE SET
- uid = excluded.uid,
- updated_at = strftime('%Y-%m-%dT%H:%M:%SZ','now');
- """, (iface_id, username, uid_val))
-
- if uid_val is None:
- return f"bound {username} → {iface} (uid=NULL; user not present on this system)"
- return f"bound {username} → {iface} (uid={uid_val})"
-
-def main(argv: list[str]) -> int:
- if len(argv) != 2:
- prog = Path(sys.argv[0]).name
- print(f"Usage: {prog} <username> <iface>", file=sys.stderr)
- return 2
-
- username, iface = argv
- try:
- with ic.open_db() as conn:
- msg = bind_user_to_iface(conn, iface, username)
- conn.commit()
- except FileNotFoundError as e:
- print(f"❌ {e}", file=sys.stderr); return 1
- except sqlite3.Error as e:
- print(f"❌ sqlite error: {e}", file=sys.stderr); return 1
- except RuntimeError as e:
- print(f"❌ {e}", file=sys.stderr); return 1
-
- print(f"✔ {msg}")
- return 0
-
-if __name__ == "__main__":
- sys.exit(main(sys.argv[1:]))
+++ /dev/null
-#!/usr/bin/env python3
-# db_checks.py — quick audit for common misconfigurations
-
-from __future__ import annotations
-import sys, sqlite3, ipaddress
-import incommon as ic
-
-def audit(conn: sqlite3.Connection) -> int:
- errs = 0
-
- # 1) client present?
- C = ic.rows(conn, """
- SELECT id, iface, local_address_cidr, rt_table_name_eff
- FROM v_client_effective
- ORDER BY iface;
- """)
- if not C:
- print("WARN: no client present"); return 1
-
- # 2) CIDR sanity
- for cid, iface, cidr, rtname in C:
- try:
- ipaddress.IPv4Interface(cidr)
- except Exception as e:
- print(f"ERR: client {iface} has invalid CIDR {cidr}: {e}")
- errs += 1
-
- # 3) server exist and map to client
- S = ic.rows(conn, """
- SELECT s.id, c.iface, s.name, s.public_key, s.endpoint_host, s.endpoint_port, s.allowed_ips
- FROM server s
- JOIN Iface c ON c.id = s.iface_id
- ORDER BY c.iface, s.name;
- """)
- if not S:
- print("WARN: no server present for any client")
-
- # 4) user bindings exist? (not required, but useful)
- UB = ic.rows(conn, """
- SELECT c.iface, ub.username, ub.uid
- FROM User ub
- JOIN Iface c ON c.id = ub.iface_id
- ORDER BY c.iface, ub.username;
- """)
- if not UB:
- print("WARN: no User present")
-
- # 5) duplicate tunnel IPs across client (/32 equality)
- tunnel_hosts = {}
- for _, iface, cidr, _ in C:
- try:
- host = str(ipaddress.IPv4Interface(cidr).ip)
- if host in tunnel_hosts and tunnel_hosts[host] != iface:
- print(f"ERR: duplicate tunnel host {host} on {tunnel_hosts[host]} and {iface}")
- errs += 1
- else:
- tunnel_hosts[host] = iface
- except Exception:
- pass
-
- # 6) Server AllowedIPs hygiene: warn when 0.0.0.0/0 appears in server table
- for sid, iface, sname, pub, host, port, allow in S:
- if allow.strip() == "0.0.0.0/0":
- # client-side full-tunnel is fine; server-side peer should use /32 entries
- print(f"NOTE: server(name={sname}, client={iface}) has AllowedIPs=0.0.0.0/0 (client-side full-tunnel). Ensure server peer uses /32(s).")
-
- # 7) meta.subu_cidr present?
- M = dict(ic.rows(conn, "SELECT key, value FROM meta;"))
- if "subu_cidr" not in M:
- print("WARN: meta.subu_cidr missing; default tooling may assume 10.0.0.0/24")
-
- print("OK: audit complete" if errs == 0 else f"FAIL: {errs} error(s)")
- return 1 if errs else 0
-
-def main(argv: list[str]) -> int:
- try:
- with ic.open_db() as conn:
- return audit(conn)
- except (sqlite3.Error, FileNotFoundError) as e:
- print(f"❌ {e}", file=sys.stderr)
- return 2
-
-if __name__ == "__main__":
- sys.exit(main(sys.argv[1:]))
+++ /dev/null
-#!/usr/bin/env python3
-# db_init_StanleyPark.py — initialize the DB for the StanleyPark client
-
-from __future__ import annotations
-import sys, subprocess, sqlite3
-from pathlib import Path
-import incommon as ic
-
-# Use existing business functions (no duplication)
-from db_init_iface_x6 import init_iface_x6
-from db_init_iface_US import init_iface_US
-from db_init_server_x6 import init_server_x6
-from db_init_server_US import init_server_US
-from db_bind_user_to_iface import bind_user_to_iface
-from db_init_ip_table_registration import assign_missing_rt_table_ids
-from db_init_ip_iface_addr_assign import reconcile_kernel_and_db_ipv4_addresses
-from db_init_route_defaults import seed_default_routes
-
-ROOT = Path(__file__).resolve().parent
-DB = ic.DB_PATH
-
-def msg_wrapped_call(title: str, fn=None, *args, **kwargs):
- """Print a before/after status line around calling `fn(*args, **kwargs)`.
- Returns the function’s return value."""
- print(f"→ {title}", flush=True)
- res = fn(*args, **kwargs) if fn else None
- print(f"✔ {title}" + (f": {res}" if res not in (None, "") else ""), flush=True)
- return res
-
-def _run_local(script: str, *argv: str):
- subprocess.run([str(ROOT / script), *argv], check=True)
-
-def db_init_StanleyPark() -> int:
- """
- Given the local SQLite DB at ic.DB_PATH, this:
- 1) loads schema
- 2) upserts ifaces (x6, US)
- 3) upserts servers (x6, US)
- 4) binds users (Thomas-x6→x6, Thomas-US→US)
- 5) seeds per-iface default routes into Route
- 6) assigns missing rt_table_id values from /etc/iproute2/rt_tables
- 7) reconciles/assigns interface IPv4 addresses (kernel→DB, then pool)
- 8) commits and prints status
- Returns 0 on success (raises on failure).
- """
- # 1) Schema
- msg_wrapped_call("db_schema_load.sh", _run_local, "db_schema_load.sh")
-
- # 2) DB work in one connection/commit
- with ic.open_db(DB) as conn:
- # ifaces + servers + user bindings
- msg_wrapped_call("db_init_iface_x6.py (init_iface_x6)", init_iface_x6, conn)
- msg_wrapped_call("db_init_server_x6.py (init_server_x6)", init_server_x6, conn)
- msg_wrapped_call("bind_user_to_iface: Thomas-x6 → x6", bind_user_to_iface, conn, "x6", "Thomas-x6")
-
- msg_wrapped_call("db_init_iface_US.py (init_iface_US)", init_iface_US, conn)
- msg_wrapped_call("db_init_server_US.py (init_server_US)", init_server_US, conn)
- msg_wrapped_call("bind_user_to_iface: Thomas-US → US", bind_user_to_iface, conn, "US", "Thomas-US")
-
- # 5) seed default routes for the selected ifaces (no duplicates; idempotent)
- msg_wrapped_call(
- "db_init_route_defaults (x6,US)",
- lambda: seed_default_routes(conn, iface_names=["x6","US"], overwrite=False)
- )
-
- # 6) assign rt_table_id from system tables (DB-only; no file writes)
- msg_wrapped_call(
- "db_init_ip_table_registration",
- lambda: assign_missing_rt_table_ids(conn, low=20000, high=29999, dry_run=False)
- )
-
- # 7) reconcile/assign interface IPv4 addresses (kernel → DB; pool for missing)
- msg_wrapped_call(
- "db_init_ip_iface_addr_assign",
- lambda: reconcile_kernel_and_db_ipv4_addresses(
- conn,
- pool_cidr="10.0.0.0/16",
- assign_prefix=32,
- reserve_first=0,
- dry_run=False
- )
- )
-
- # 8) commit
- conn.commit()
- print("✔ commit: database updated")
-
- return 0
-
-def main(argv):
- if argv:
- print(f"Usage: {Path(sys.argv[0]).name}", file=sys.stderr)
- return 2
- try:
- return db_init_StanleyPark()
- except (subprocess.CalledProcessError, sqlite3.Error, FileNotFoundError) as e:
- print(f"❌ {e}", file=sys.stderr)
- return 1
-
-if __name__ == "__main__":
- sys.exit(main(sys.argv[1:]))
+++ /dev/null
-#!/usr/bin/env python3
-# Helpers to seed/update a row in client.
-
-from __future__ import annotations
-import sqlite3
-from typing import Any, Optional, Dict
-import incommon as ic # provides DB_PATH, open_db
-
-# Normally don't set the addr_cidr, the system will automically
-# assign a free address, or reuse one that is already set.
-
-def upsert_client(conn: sqlite3.Connection,
- *,
- iface: str,
- addr_cidr: Optional[str] = None,
- rt_table_name: Optional[str] = None,
- rt_table_id: Optional[int] = None,
- mtu: Optional[int] = None,
- fwmark: Optional[int] = None,
- dns_mode: Optional[str] = None, # 'none' or 'static'
- dns_servers: Optional[str] = None,
- autostart: Optional[int] = None, # 0 or 1
- bound_user: Optional[str] = None,
- bound_uid: Optional[int] = None
- ) -> str:
- row = conn.execute(
- """SELECT id, iface, rt_table_id, rt_table_name, local_address_cidr,
- mtu, fwmark, dns_mode, dns_servers, autostart,
- bound_user, bound_uid
- FROM Iface WHERE iface=? LIMIT 1;""",
- (iface,)
- ).fetchone()
-
- defname = rt_table_name if rt_table_name is not None else iface
- desired: Dict[str, Any] = {"iface": iface, "local_address_cidr": addr_cidr}
- if rt_table_id is not None: desired["rt_table_id"] = rt_table_id
- if rt_table_name is not None: desired["rt_table_name"] = rt_table_name
- if mtu is not None: desired["mtu"] = mtu
- if fwmark is not None: desired["fwmark"] = fwmark
- if dns_mode is not None: desired["dns_mode"] = dns_mode
- if dns_servers is not None: desired["dns_servers"] = dns_servers
- if autostart is not None: desired["autostart"] = autostart
- if bound_user is not None: desired["bound_user"] = bound_user
- if bound_uid is not None: desired["bound_uid"] = bound_uid
-
- if row is None:
- fields = ["iface","local_address_cidr","rt_table_name"]
- vals = [iface, addr_cidr, defname]
- for k in ("rt_table_id","mtu","fwmark","dns_mode","dns_servers","autostart","bound_user","bound_uid"):
- if k in desired: fields.append(k); vals.append(desired[k])
- q = f"INSERT INTO Iface ({','.join(fields)}) VALUES ({','.join('?' for _ in vals)});"
- cur = conn.execute(q, vals); conn.commit()
- return f"seeded: client(iface={iface}) id={cur.lastrowid} addr={addr_cidr} rt={defname}"
- else:
- cid, _, rt_id, rt_name, cur_addr, cur_mtu, cur_fwm, cur_dns_mode, cur_dns_srv, cur_auto, cur_buser, cur_buid = row
- current = {
- "local_address_cidr": cur_addr, "rt_table_id": rt_id, "rt_table_name": rt_name,
- "mtu": cur_mtu, "fwmark": cur_fwm, "dns_mode": cur_dns_mode, "dns_servers": cur_dns_srv,
- "autostart": cur_auto, "bound_user": cur_buser, "bound_uid": cur_buid
- }
- changes: Dict[str, Any] = {}
- for k, v in desired.items():
- if k == "iface": continue
- if current.get(k) != v: changes[k] = v
- if rt_name is None and "rt_table_name" not in changes:
- changes["rt_table_name"] = defname
- if not changes:
- return f"ok: client(iface={iface}) unchanged id={cid} addr={cur_addr} rt={rt_name or defname}"
- sets = ", ".join(f"{k}=?" for k in changes)
- vals = list(changes.values()) + [iface]
- conn.execute(f"UPDATE Iface SET {sets} WHERE iface=?;", vals); conn.commit()
- return f"updated: client(iface={iface}) id={cid} " + " ".join(f"{k}={changes[k]}" for k in changes)
+++ /dev/null
-# db_init_iface_US.py
-from db_init_iface import upsert_client
-
-def init_iface_US(conn):
- # iface US with dedicated table 'US' and a distinct host /32
- return upsert_client(conn, iface="US", rt_table_name="US")
+++ /dev/null
-# db_init_iface_x6.py
-from db_init_iface import upsert_client
-
-def init_iface_x6(conn):
- # iface x6 with dedicated table 'x6' and host /32
- return upsert_client(conn, iface="x6", rt_table_name="x6")
+++ /dev/null
-#!/usr/bin/env python3
-"""
-db_init_ip_iface_addr_assign.py
-
-Business API:
- reconcile_kernel_and_db_ipv4_addresses(conn ,pool_cidr="10.0.0.0/16" ,assign_prefix=32 ,reserve_first=0 ,dry_run=False)
- -> (updated_count ,notes)
-"""
-
-from __future__ import annotations
-import argparse
-import ipaddress
-import json
-import sqlite3
-import subprocess
-from typing import Dict ,Iterable ,List ,Optional ,Sequence ,Tuple
-
-import incommon as ic
-
-
-def fetch_ifaces(conn: sqlite3.Connection) -> List[Tuple[int ,str ,Optional[str]]]:
- sql = """
- SELECT id,
- iface,
- NULLIF(TRIM(local_address_cidr),'') AS local_address_cidr
- FROM Iface
- ORDER BY id;
- """
- cur = conn.execute(sql)
- rows = cur.fetchall()
- return [
- (int(r[0]) ,str(r[1]) ,(str(r[2]) if r[2] is not None else None))
- for r in rows
- ]
-
-
-def update_iface_addresses(conn: sqlite3.Connection ,updates: Dict[int ,str]) -> int:
- if not updates:
- return 0
- with conn:
- for iface_id ,cidr in updates.items():
- conn.execute("UPDATE Iface SET local_address_cidr=? WHERE id=?" ,(cidr ,iface_id))
- return len(updates)
-
-
-def kernel_ipv4_cidr_for(iface: str) -> Optional[str]:
- try:
- cp = subprocess.run(
- ["ip","-j","addr","show","dev",iface]
- ,check=False
- ,capture_output=True
- ,text=True
- )
- except Exception:
- return None
- if cp.returncode != 0 or not cp.stdout.strip():
- return None
- try:
- data = json.loads(cp.stdout)
- except json.JSONDecodeError:
- return None
- if not isinstance(data ,list) or not data:
- return None
- addr_info = data[0].get("addr_info") or []
- for a in addr_info:
- if a.get("family") == "inet" and a.get("scope") == "global":
- local = a.get("local"); plen = a.get("prefixlen")
- if local and isinstance(plen ,int):
- return f"{local}/{plen}"
- for a in addr_info:
- if a.get("family") == "inet":
- local = a.get("local"); plen = a.get("prefixlen")
- if local and isinstance(plen ,int):
- return f"{local}/{plen}"
- return None
-
-
-def kernel_ipv4_map(ifaces: Sequence[str]) -> Dict[str ,Optional[str]]:
- return {name: kernel_ipv4_cidr_for(name) for name in ifaces}
-
-
-def _host_ip_from_cidr(cidr: str):
- try:
- ipi = ipaddress.ip_interface(cidr)
- except ValueError:
- return None
- if isinstance(ipi.ip ,ipaddress.IPv4Address):
- return ipaddress.IPv4Address(int(ipi.ip))
- return None
-
-
-def _collect_used_hosts_from(cidrs: Iterable[str] ,pool: ipaddress.IPv4Network) -> List[ipaddress.IPv4Address]:
- used: List[ipaddress.IPv4Address] = []
- for c in cidrs:
- hip = _host_ip_from_cidr(c)
- if hip is not None and hip in pool:
- used.append(hip)
- return used
-
-
-def _first_free_hosts(
- count: int
- ,used_hosts: Iterable[ipaddress.IPv4Address]
- ,pool: ipaddress.IPv4Network
- ,reserve_first: int = 0
-) -> List[ipaddress.IPv4Address]:
- used_set = {int(h) for h in used_hosts}
- result: List[ipaddress.IPv4Address] = []
- start = int(pool.network_address) + 1 + max(0 ,reserve_first)
- end = int(pool.broadcast_address) - 1
- for val in range(start ,end+1):
- if val not in used_set:
- result.append(ipaddress.IPv4Address(val))
- if len(result) >= count:
- break
- if len(result) < count:
- raise RuntimeError(f"address pool exhausted in {pool} (needed {count} more)")
- return result
-
-
-def plan_address_updates(
- rows: Sequence[Tuple[int ,str ,Optional[str]]]
- ,pool_cidr: str
- ,assign_prefix: int
- ,reserve_first: int
- ,kmap: Dict[str ,Optional[str]]
-) -> Tuple[Dict[int ,str] ,List[str]]:
- notes: List[str] = []
- pool = ipaddress.IPv4Network(pool_cidr ,strict=False)
- if pool.version != 4:
- raise ValueError("only IPv4 pools supported")
-
- kernel_present = [c for c in kmap.values() if c]
- db_present = [c for (_i ,_n ,c) in rows if c]
- used_hosts = (
- _collect_used_hosts_from(kernel_present ,pool)
- + _collect_used_hosts_from(db_present ,pool)
- )
-
- alloc_targets: List[Tuple[int ,str]] = []
- updates: Dict[int ,str] = {}
-
- for iface_id ,iface_name ,db_cidr in rows:
- k_cidr = kmap.get(iface_name)
-
- if k_cidr:
- if db_cidr != k_cidr:
- updates[iface_id] = k_cidr
- if db_cidr:
- notes.append(f"sync: iface '{iface_name}' DB {db_cidr} -> kernel {k_cidr}")
- else:
- notes.append(f"sync: iface '{iface_name}' set from kernel {k_cidr}")
- continue
-
- if db_cidr:
- notes.append(f"note: iface '{iface_name}' has DB {db_cidr} but no kernel IPv4")
- continue
-
- alloc_targets.append((iface_id ,iface_name))
-
- if alloc_targets:
- free = _first_free_hosts(len(alloc_targets) ,used_hosts ,pool ,reserve_first=reserve_first)
- for idx ,(iface_id ,iface_name) in enumerate(alloc_targets):
- cidr = f"{free[idx]}/{assign_prefix}"
- updates[iface_id] = cidr
- notes.append(f"assign: iface '{iface_name}' -> {cidr} (from pool {pool_cidr})")
-
- return (updates ,notes)
-
-
-def reconcile_kernel_and_db_ipv4_addresses(
- conn: sqlite3.Connection
- ,pool_cidr: str = "10.0.0.0/16"
- ,assign_prefix: int = 32
- ,reserve_first: int = 0
- ,dry_run: bool = False
-) -> Tuple[int ,List[str]]:
- rows = fetch_ifaces(conn)
- iface_names = [n for (_i ,n ,_c) in rows]
- kmap = kernel_ipv4_map(iface_names)
-
- updates ,notes = plan_address_updates(
- rows
- ,pool_cidr
- ,assign_prefix
- ,reserve_first
- ,kmap
- )
- if not updates:
- return (0 ,notes or ["noop: nothing to change"])
- if dry_run:
- return (0 ,notes)
-
- updated = update_iface_addresses(conn ,updates)
- return (updated ,notes)
-
-
-# --- thin CLI ---
-
-def main(argv=None) -> int:
- ap = argparse.ArgumentParser()
- ap.add_argument("--pool" ,type=str ,default="10.0.0.0/16")
- ap.add_argument("--assign-prefix" ,type=int ,default=32)
- ap.add_argument("--reserve-first" ,type=int ,default=0)
- ap.add_argument("--dry-run" ,action="store_true")
- args = ap.parse_args(argv)
- with ic.open_db() as conn:
- updated ,notes = reconcile_kernel_and_db_ipv4_addresses(
- conn
- ,pool_cidr=args.pool
- ,assign_prefix=args.assign_prefix
- ,reserve_first=args.reserve_first
- ,dry_run=args.dry_run
- )
- if notes:
- print("\n".join(notes))
- if not args.dry_run:
- print(f"updated rows: {updated}")
- return 0
-
-
-if __name__ == "__main__":
- import sys
- sys.exit(main())
+++ /dev/null
-#!/usr/bin/env python3
-"""
-db_init_ip_table_registration.py
-
-Business API:
- assign_missing_rt_table_ids(conn ,low=20000 ,high=29999 ,dry_run=False)
- -> (updated_count ,planned_map ,notes)
-
-Policy:
-- Effective table name per iface is COALESCE(rt_table_name ,iface).
-- If that name exists in /etc/iproute2/rt_tables, reuse its number.
-- Else allocate first free number in [low ,high].
-- Writes DB only. Does NOT write rt_tables.
-"""
-
-from __future__ import annotations
-import argparse
-import sqlite3
-from pathlib import Path
-from typing import Dict ,Iterable ,List ,Optional ,Sequence ,Tuple
-
-import incommon as ic # for CLI path only
-
-RT_TABLES_PATH = Path("/etc/iproute2/rt_tables")
-
-
-def parse_rt_tables(path: Path) -> Tuple[List[str] ,Dict[str ,int] ,Dict[int ,str]]:
- text = path.read_text() if path.exists() else ""
- lines = text.splitlines()
- name_to_num: Dict[str ,int] = {}
- num_to_name: Dict[int ,str] = {}
- for ln in lines:
- s = ln.strip()
- if not s or s.startswith("#"):
- continue
- parts = s.split()
- if len(parts) >= 2 and parts[0].isdigit():
- n = int(parts[0]); name = parts[1]
- if name not in name_to_num and n not in num_to_name:
- name_to_num[name] = n
- num_to_name[n] = name
- return (lines ,name_to_num ,num_to_name)
-
-
-def first_free_id(used: Iterable[int] ,low: int ,high: int) -> int:
- used_set = set(u for u in used if low <= u <= high)
- for n in range(low ,high+1):
- if n not in used_set:
- return n
- raise RuntimeError(f"no free routing-table IDs in [{low},{high}]")
-
-
-def fetch_effective_ifaces(conn: sqlite3.Connection) -> List[Tuple[int ,str ,Optional[int]]]:
- sql = """
- SELECT i.id,
- COALESCE(i.rt_table_name, i.iface) AS eff_name,
- i.rt_table_id
- FROM Iface i
- ORDER BY i.id;
- """
- cur = conn.execute(sql)
- rows = cur.fetchall()
- return [
- (int(r[0]) ,str(r[1]) ,(int(r[2]) if r[2] is not None else None))
- for r in rows
- ]
-
-
-def update_rt_ids(conn: sqlite3.Connection ,updates: Dict[int ,int]) -> int:
- if not updates:
- return 0
- with conn:
- for iface_id ,rt_id in updates.items():
- conn.execute("UPDATE Iface SET rt_table_id=? WHERE id=?" ,(rt_id ,iface_id))
- return len(updates)
-
-
-def plan_rt_id_assignments(
- ifaces: Sequence[Tuple[int ,str ,Optional[int]]]
- ,name_to_num_sys: Dict[str ,int]
- ,existing_ids_in_db: Iterable[int]
- ,low: int
- ,high: int
-) -> Dict[int ,int]:
- used_numbers = set(int(x) for x in existing_ids_in_db) | set(name_to_num_sys.values())
- planned: Dict[int ,int] = {}
-
- names_seen: Dict[str ,int] = {}
- for iface_id ,eff_name ,_ in ifaces:
- if eff_name in names_seen and names_seen[eff_name] != iface_id:
- raise RuntimeError(
- f"duplicate effective table name in DB: '{eff_name}' used by Iface.id {names_seen[eff_name]} and {iface_id}"
- )
- names_seen[eff_name] = iface_id
-
- for iface_id ,eff_name ,current_id in ifaces:
- if current_id is not None:
- used_numbers.add(int(current_id))
- continue
- if eff_name in name_to_num_sys:
- rt_id = int(name_to_num_sys[eff_name])
- else:
- rt_id = first_free_id(used_numbers ,low ,high)
- planned[iface_id] = rt_id
- used_numbers.add(rt_id)
-
- return planned
-
-
-def assign_missing_rt_table_ids(
- conn: sqlite3.Connection
- ,low: int = 20000
- ,high: int = 29999
- ,dry_run: bool = False
-) -> Tuple[int ,Dict[int ,int] ,List[str]]:
- _ ,name_to_num_sys ,_ = parse_rt_tables(RT_TABLES_PATH)
- notes: List[str] = []
-
- rows = fetch_effective_ifaces(conn)
- existing_ids = [r[2] for r in rows if r[2] is not None]
- planned = plan_rt_id_assignments(rows ,name_to_num_sys ,existing_ids ,low ,high)
-
- if not planned:
- return (0 ,{} ,["noop: all Iface.rt_table_id already set"])
-
- for iface_id ,eff_name ,current in rows:
- if iface_id in planned:
- notes.append(f"Iface.id={iface_id} name='{eff_name}' rt_table_id: {current} -> {planned[iface_id]}")
-
- if dry_run:
- return (0 ,planned ,notes)
-
- updated = update_rt_ids(conn ,planned)
- return (updated ,planned ,notes)
-
-
-# --- thin CLI ---
-
-def main(argv=None) -> int:
- ap = argparse.ArgumentParser()
- ap.add_argument("--low" ,type=int ,default=20000)
- ap.add_argument("--high" ,type=int ,default=29999)
- ap.add_argument("--dry-run" ,action="store_true")
- args = ap.parse_args(argv)
- if args.low < 0 or args.high < args.low:
- print(f"error: invalid range [{args.low},{args.high}]")
- return 2
- with ic.open_db() as conn:
- updated ,_planned ,notes = assign_missing_rt_table_ids(conn ,low=args.low ,high=args.high ,dry_run=args.dry_run)
- if notes:
- print("\n".join(notes))
- if not args.dry_run:
- print(f"updated rows: {updated}")
- return 0
-
-
-if __name__ == "__main__":
- import sys
- sys.exit(main())
+++ /dev/null
-#!/usr/bin/env python3
-"""
-db_init_route_defaults.py
-
-Business API:
- seed_default_routes(conn ,iface_names ,overwrite=False ,metric=None)
- -> (inserted_count ,notes[list])
-
-What it does:
-- For each iface in iface_names, ensure a default route "0.0.0.0/0"
- is present in the Route table (on_up=1, no via/metric/table override).
-- If overwrite=True, it first deletes existing Route rows for those ifaces,
- then inserts the defaults.
-- Writes **DB only**. It does not touch the kernel or /etc/iproute2/rt_tables.
-
-Why:
-- Your apply script reads Route rows and emits `ip -4 route replace … table <rtname>`.
- Seeding a per-iface default route makes policy-routed tables usable out of the box.
-"""
-
-from __future__ import annotations
-import argparse
-import sqlite3
-from typing import Dict ,Iterable ,List ,Optional ,Sequence ,Tuple
-
-# import helper to open DB when run as CLI; the business API accepts a conn
-try:
- import incommon as ic # type: ignore
-except Exception:
- ic = None # ok when used as a lib
-
-
-def _iface_map(conn: sqlite3.Connection ,iface_names: Sequence[str]) -> Dict[str ,int]:
- """Return {iface_name -> iface_id} for provided names (must exist)."""
- if not iface_names:
- return {}
- ph = ",".join("?" for _ in iface_names)
- sql = f"""SELECT id ,iface FROM Iface WHERE iface IN ({ph}) ORDER BY id;"""
- rows = conn.execute(sql ,tuple(iface_names)).fetchall()
- found = {str(name): int(iid) for (iid ,name) in rows}
- missing = [n for n in iface_names if n not in found]
- if missing:
- raise RuntimeError(f"iface(s) not found: {', '.join(missing)}")
- return found
-
-
-def _existing_defaults(conn: sqlite3.Connection ,iface_ids: Iterable[int]) -> Dict[int ,bool]:
- """Return {iface_id -> True/False} whether a default route row already exists (on_up=1)."""
- ids = list(iface_ids)
- if not ids:
- return {}
- ph = ",".join("?" for _ in ids)
- sql = f"""
- SELECT iface_id ,COUNT(1)
- FROM Route
- WHERE iface_id IN ({ph})
- AND cidr='0.0.0.0/0'
- AND on_up=1
- GROUP BY iface_id;
- """
- out: Dict[int ,bool] = {i: False for i in ids}
- for iid ,cnt in conn.execute(sql ,tuple(ids)).fetchall():
- out[int(iid)] = int(cnt) > 0
- return out
-
-
-def seed_default_routes(
- conn: sqlite3.Connection
- ,iface_names: Sequence[str]
- ,overwrite: bool = False
- ,metric: Optional[int] = None
-) -> Tuple[int ,List[str]]:
- """
- Upsert per-iface default routes into Route.
-
- Inserts rows:
- (iface_id ,cidr='0.0.0.0/0' ,via=NULL ,table_name=NULL ,metric=<metric or NULL> ,on_up=1 ,on_down=0)
- """
- if not iface_names:
- raise RuntimeError("no interfaces provided")
-
- id_map = _iface_map(conn ,iface_names)
- iface_ids = list(id_map.values())
- notes: List[str] = []
- inserted = 0
-
- with conn:
- if overwrite:
- ph = ",".join("?" for _ in iface_ids)
- conn.execute(f"DELETE FROM Route WHERE iface_id IN ({ph});" ,tuple(iface_ids))
- notes.append(f"cleared existing Route rows for: {', '.join(iface_names)}")
-
- exists = _existing_defaults(conn ,iface_ids)
-
- for name in iface_names:
- iid = id_map[name]
- if exists.get(iid):
- notes.append(f"keep: default route already present for {name}")
- continue
- conn.execute(
- """
- INSERT INTO Route(iface_id ,cidr ,via ,table_name ,metric ,on_up ,on_down
- ,created_at ,updated_at)
- VALUES( ? ,'0.0.0.0/0' ,NULL ,NULL ,? ,1 ,0
- ,strftime('%Y-%m-%dT%H:%M:%SZ','now') ,strftime('%Y-%m-%dT%H:%M:%SZ','now'))
- """
- ,(iid ,metric)
- )
- inserted += 1
- notes.append(f"add: default route 0.0.0.0/0 for {name}")
-
- return (inserted ,notes)
-
-
-# ---- thin CLI for ad-hoc use ----
-
-def main(argv: Optional[Sequence[str]] = None) -> int:
- ap = argparse.ArgumentParser(description="Seed per-iface default Route rows.")
- ap.add_argument("ifaces" ,nargs="+")
- ap.add_argument("--overwrite" ,action="store_true")
- ap.add_argument("--metric" ,type=int ,default=None)
- args = ap.parse_args(argv)
-
- if ic is None:
- print("error: cannot locate incommon.open_db() for CLI use")
- return 2
-
- with ic.open_db() as conn:
- n ,notes = seed_default_routes(conn ,args.ifaces ,overwrite=args.overwrite ,metric=args.metric)
- if notes:
- print("\n".join(notes))
- print(f"inserted: {n}")
- return 0
-
-
-if __name__ == "__main__":
- import sys
- sys.exit(main())
+++ /dev/null
-# db_init_server_US.py
-from db_init_server_incommon import upsert_server
-
-def init_server_US(conn):
- # Endpoint from the historical config; adjust if needed
- return upsert_server(
- conn,
- client_iface="US",
- server_name="US",
- server_public_key="h8ZYEEVMForvv9p5Wx+9+eZ87t692hTN7sks5Noedw8=",
- endpoint_host="35.194.71.194",
- endpoint_port=443,
- allowed_ips="0.0.0.0/0",
- keepalive_s=25,
- route_allowed_ips=0,
- priority=100,
- )
+++ /dev/null
-#!/usr/bin/env python3
-# Helpers to upsert a row in server bound to a client iface.
-
-from __future__ import annotations
-import sqlite3
-from typing import Optional, Any, Dict
-import incommon as ic # provides open_db, get_client_id
-
-def upsert_server(conn: sqlite3.Connection,
- *,
- client_iface: str,
- server_name: str,
- server_public_key: str,
- endpoint_host: str,
- endpoint_port: int,
- allowed_ips: str,
- preshared_key: Optional[str] = None,
- keepalive_s: Optional[int] = None,
- route_allowed_ips: int = 0,
- priority: int = 100) -> str:
- cid = ic.get_client_id(conn, client_iface)
-
- row = conn.execute(
- "SELECT id, public_key, preshared_key, endpoint_host, endpoint_port, allowed_ips, "
- " keepalive_s, route_allowed_ips, priority "
- "FROM server WHERE iface_id=? AND name=? LIMIT 1;",
- (cid, server_name),
- ).fetchone()
-
- desired = {
- "public_key": server_public_key,
- "preshared_key": preshared_key,
- "endpoint_host": endpoint_host,
- "endpoint_port": endpoint_port,
- "allowed_ips": allowed_ips,
- "keepalive_s": keepalive_s,
- "route_allowed_ips": route_allowed_ips,
- "priority": priority,
- }
-
- if row is None:
- q = (
- "INSERT INTO server (iface_id,name,public_key,preshared_key,"
- " endpoint_host,endpoint_port,allowed_ips,keepalive_s,route_allowed_ips,priority,"
- " created_at,updated_at) "
- "VALUES (?,?,?,?,?,?,?,?,?,?, strftime('%Y-%m-%dT%H:%M:%SZ','now'), strftime('%Y-%m-%dT%H:%M:%SZ','now'));"
- )
- params = (cid, server_name, desired["public_key"], desired["preshared_key"],
- desired["endpoint_host"], desired["endpoint_port"], desired["allowed_ips"],
- desired["keepalive_s"], desired["route_allowed_ips"], desired["priority"])
- cur = conn.execute(q, params); conn.commit()
- return f"seeded: server(name={server_name}) client={client_iface} id={cur.lastrowid}"
- else:
- sid, pub, psk, host, port, allow, ka, route_ai, prio = row
- current = {
- "public_key": pub, "preshared_key": psk, "endpoint_host": host, "endpoint_port": port,
- "allowed_ips": allow, "keepalive_s": ka, "route_allowed_ips": route_ai, "priority": prio
- }
- changes: Dict[str, Any] = {k: v for k, v in desired.items() if v != current.get(k)}
- if not changes:
- return f"ok: server(name={server_name}) client={client_iface} unchanged id={sid}"
- sets = ", ".join(f"{k}=?" for k in changes)
- params = list(changes.values()) + [cid, server_name]
- conn.execute(
- f"UPDATE server SET {sets}, updated_at=strftime('%Y-%m-%dT%H:%M:%SZ','now') "
- "WHERE iface_id=? AND name=?;", params
- )
- conn.commit()
- return f"updated: server(name={server_name}) client={client_iface} id={sid} " + " ".join(f"{k}={changes[k]}" for k in changes)
+++ /dev/null
-# db_init_server_x6.py
-from db_init_server_incommon import upsert_server
-
-def init_server_x6(conn):
- return upsert_server(
- conn,
- client_iface="x6",
- server_name="x6",
- server_public_key="pcbDlC1ZVoBYaN83/zAsvIvhgw0iQOL1YZKX5hcAqno=",
- endpoint_host="66.248.243.113",
- endpoint_port=51820,
- allowed_ips="0.0.0.0/0",
- keepalive_s=25,
- route_allowed_ips=0,
- priority=100,
- )
+++ /dev/null
-PRAGMA foreign_keys = ON;
-PRAGMA journal_mode = WAL;
-PRAGMA user_version = 300; -- v3.00: singular, capitalized tables; private_key removed
-
--- meta first (so later INSERTs succeed)
-CREATE TABLE IF NOT EXISTS Meta (
- key TEXT PRIMARY KEY
- ,value TEXT NOT NULL
-);
-INSERT OR REPLACE INTO Meta(key,value) VALUES ('schema','wg-client-v3.00-Ifaces');
-INSERT OR IGNORE INTO Meta(key,value) VALUES ('subu_cidr','10.0.0.0/24');
-
--- Iface, interface, device, netdevice, link — table of them
-CREATE TABLE IF NOT EXISTS Iface (
- id INTEGER PRIMARY KEY
- ,created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
- ,updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
- ,iface TEXT NOT NULL UNIQUE -- kernel interface name as shown by ip link (e.g., wg0, x6)
- ,rt_table_id INTEGER -- e.g. 1002, unused
- ,rt_table_name TEXT -- if NULL, default to iface (see view)
- -- legacy caches (kept for compatibility; may be NULL)
- ,bound_user TEXT
- ,bound_uid INTEGER
- ,local_address_cidr TEXT -- e.g. '10.8.0.2/32'
- -- secrets: private key is NO LONGER stored in DB (lives under key/<machine>)
- ,public_key TEXT CHECK (public_key IS NULL OR length(public_key) BETWEEN 43 AND 45)
- ,mtu INTEGER
- ,fwmark INTEGER
- ,dns_mode TEXT NOT NULL DEFAULT 'none' CHECK (dns_mode IN ('none','static'))
- ,dns_servers TEXT
- ,autostart INTEGER NOT NULL DEFAULT 0
-);
-
--- Server (one or more remote peers for an Iface)
-CREATE TABLE IF NOT EXISTS Server (
- id INTEGER PRIMARY KEY
- ,created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
- ,updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
- ,iface_id INTEGER NOT NULL REFERENCES Iface(id) ON DELETE CASCADE
- ,name TEXT NOT NULL -- e.g. 'x6', 'US'
- ,public_key TEXT NOT NULL CHECK (length(public_key) BETWEEN 43 AND 45)
- ,preshared_key TEXT CHECK (preshared_key IS NULL OR length(preshared_key) BETWEEN 43 AND 45)
- ,endpoint_host TEXT NOT NULL
- ,endpoint_port INTEGER NOT NULL CHECK (endpoint_port BETWEEN 1 AND 65535)
- ,allowed_ips TEXT NOT NULL -- typically '0.0.0.0/0'
- ,keepalive_s INTEGER
- ,route_allowed_ips INTEGER NOT NULL DEFAULT 1
- ,priority INTEGER NOT NULL DEFAULT 100
- ,UNIQUE(iface_id, name)
-);
-
--- Route (optional extra routes applied by post-up script)
-CREATE TABLE IF NOT EXISTS Route (
- id INTEGER PRIMARY KEY
- ,created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
- ,updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
- ,iface_id INTEGER NOT NULL REFERENCES Iface(id) ON DELETE CASCADE
- ,cidr TEXT NOT NULL
- ,via TEXT
- ,table_name TEXT
- ,metric INTEGER
- ,on_up INTEGER NOT NULL DEFAULT 1
- ,on_down INTEGER NOT NULL DEFAULT 0
-);
-
--- User (many linux users → one Iface)
--- each user is bound to an iface via an 'ip rule add uidrange ..' command
-CREATE TABLE IF NOT EXISTS User (
- id INTEGER PRIMARY KEY
- ,created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
- ,updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
- ,iface_id INTEGER NOT NULL REFERENCES Iface(id) ON DELETE CASCADE
- ,username TEXT NOT NULL
- ,uid INTEGER -- cached UID if resolved
- ,UNIQUE(iface_id, username)
-);
-
--- Effective view (provides computed defaults like rt_table_name_eff)
-CREATE VIEW IF NOT EXISTS v_iface_effective AS
-SELECT
- i.id
- ,i.iface
- ,COALESCE(i.rt_table_name, i.iface) AS rt_table_name_eff
- ,i.local_address_cidr
-FROM Iface i;
-
--- mtime triggers
-CREATE TRIGGER IF NOT EXISTS trg_iface_mtime
-AFTER UPDATE ON Iface FOR EACH ROW
-BEGIN
- UPDATE Iface
- SET updated_at=strftime('%Y-%m-%dT%H:%M:%SZ','now')
- WHERE id=NEW.id;
-END;
-
-CREATE TRIGGER IF NOT EXISTS trg_server_mtime
-AFTER UPDATE ON Server FOR EACH ROW
-BEGIN
- UPDATE Server
- SET updated_at=strftime('%Y-%m-%dT%H:%M:%SZ','now')
- WHERE id=NEW.id;
-END;
-
-CREATE TRIGGER IF NOT EXISTS trg_route_mtime
-AFTER UPDATE ON Route FOR EACH ROW
-BEGIN
- UPDATE Route
- SET updated_at=strftime('%Y-%m-%dT%H:%M:%SZ','now')
- WHERE id=NEW.id;
-END;
-
-CREATE TRIGGER IF NOT EXISTS trg_user_binding_mtime
-AFTER UPDATE ON User FOR EACH ROW
-BEGIN
- UPDATE User
- SET updated_at=strftime('%Y-%m-%dT%H:%M:%SZ','now')
- WHERE id=NEW.id;
-END;
+++ /dev/null
-#!/usr/bin/env bash
-# db_init.sh — create/upgrade db/store by loading schema.sql (idempotent)
-
-set -euo pipefail
-DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
-DB="$DIR/db/store"
-SCHEMA="$DIR/db_schema.sql"
-
-command -v sqlite3 >/dev/null || { echo "❌ sqlite3 not found"; exit 1; }
-[[ -f "$SCHEMA" ]] || { echo "❌ schema file missing: $SCHEMA"; exit 1; }
-
-if [[ -f "$DB" ]]; then
- ts="$(date -u +%Y%m%dT%H%M%SZ)"
- cp -f -- "$DB" "$DB.bak-$ts"
- echo "↩︎ Backed up existing DB to $DB.bak-$ts"
-fi
-
-sqlite3 -cmd '.bail on' "$DB" < "$SCHEMA"
-
-ver="$(sqlite3 "$DB" 'PRAGMA user_version;')"
-echo "✔ DB ready: $DB (user_version=$ver)"
-echo " Tables:"
-sqlite3 -noheader -list "$DB" "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name;"
+++ /dev/null
-#!/usr/bin/env python3
-"""
-db_wipe.py
-
-Remove regular (non-directory) files in ./db, keeping the directory.
-
-Safety
-- Refuses to run if the target directory does not exist or its basename is not exactly "db".
-- Prints a plan, then asks "Are you sure? [y/N]" unless --force is used.
-- --dry-run prints what would be removed without deleting.
-- Hidden files (names starting with '.') are preserved by default; use --include-hidden to delete them too.
-
-Usage
- ./db_wipe.py # plan + prompt, non-hidden files only, ./db next to this script
- ./db_wipe.py --force # no prompt
- ./db_wipe.py --dry-run # show what would be deleted
- ./db_wipe.py --include-hidden
- ./db_wipe.py --db /path/to/db
-"""
-
-from __future__ import annotations
-from pathlib import Path
-from typing import Iterable, List, Tuple
-import argparse
-import sys
-import os
-
-# ---------- business ----------
-
-def plan_db_wipe(db_dir: Path, include_hidden: bool = False) -> List[Path]:
- """
- Return a sorted list of file Paths (depth=1) to delete from db_dir.
- """
- if not db_dir.exists():
- raise FileNotFoundError(f"not found: {db_dir}")
- if not db_dir.is_dir():
- raise NotADirectoryError(f"not a directory: {db_dir}")
- if db_dir.name != "db":
- raise RuntimeError(f"expected directory named 'db', got: {db_dir.name}")
-
- def _is_hidden(p: Path) -> bool:
- return p.name.startswith(".")
-
- files = [p for p in db_dir.iterdir() if p.is_file()]
- if not include_hidden:
- files = [p for p in files if not _is_hidden(p)]
-
- # Sort by name for stable output
- return sorted(files, key=lambda p: p.name)
-
-
-def wipe_db(
- db_dir: Path,
- include_hidden: bool = False,
- dry_run: bool = False,
- assume_yes: bool = False,
- _prompt_fn=input,
-) -> Tuple[int, List[str]]:
- """
- Delete planned files from db_dir. Returns (deleted_count, logs).
- Does not prompt if assume_yes=True or dry_run=True.
- """
- targets = plan_db_wipe(db_dir, include_hidden=include_hidden)
-
- logs: List[str] = []
- script_dir = Path(__file__).resolve().parent
-
- if not targets:
- logs.append(f"db_wipe: no matching files in: {db_dir.relative_to(script_dir)}")
- return (0, logs)
-
- logs.append("db_wipe: plan")
- for p in targets:
- # Show path relative to script directory like the original
- rel = p.resolve().relative_to(script_dir)
- logs.append(f" delete: {rel}")
-
- if dry_run:
- logs.append("db_wipe: dry-run; no changes made")
- return (0, logs)
-
- if not assume_yes:
- print("\n".join(logs))
- try:
- ans = _prompt_fn("Are you sure? [y/N] ").strip().lower()
- except EOFError:
- ans = ""
- if ans not in ("y", "yes"):
- logs.append("db_wipe: aborted")
- return (0, logs)
-
- deleted = 0
- for p in targets:
- try:
- p.unlink(missing_ok=True) # py3.8+: if not available, catch FileNotFoundError
- deleted += 1
- except FileNotFoundError:
- # Equivalent to rm -f
- pass
-
- rel_db = db_dir.resolve().relative_to(script_dir)
- logs.append(f"db_wipe: deleted {deleted} file(s) from {rel_db}")
- return (deleted, logs)
-
-
-# ---------- CLI wrapper ----------
-
-def _default_db_dir() -> Path:
- return Path(__file__).resolve().parent / "db"
-
-def main(argv: list[str] | None = None) -> int:
- ap = argparse.ArgumentParser(description="Remove regular files in ./db, keeping the directory.")
- ap.add_argument("--db", default=str(_default_db_dir()), help="path to the db directory (default: ./db next to this script)")
- ap.add_argument("--force", action="store_true", help="do not prompt for confirmation")
- ap.add_argument("--dry-run", action="store_true", help="print what would be removed without deleting")
- ap.add_argument("--include-hidden", action="store_true", help="include dotfiles (e.g., .gitignore)")
- args = ap.parse_args(argv)
-
- db_dir = Path(args.db)
-
- try:
- deleted, logs = wipe_db(
- db_dir=db_dir,
- include_hidden=args.include_hidden,
- dry_run=args.dry_run,
- assume_yes=args.force or args.dry_run,
- )
- if logs:
- print("\n".join(logs))
- return 0
- except (FileNotFoundError, NotADirectoryError, RuntimeError) as e:
- print(f"❌ {e}", file=sys.stderr)
- return 1
- except Exception as e:
- print(f"❌ unexpected error: {e}", file=sys.stderr)
- return 2
-
-if __name__ == "__main__":
- sys.exit(main())
+++ /dev/null
-#!/usr/bin/env python3
-"""
-deploy_StanleyPark.py — stop → install staged files → start (for selected ifaces)
-
-- Requires root. Exits after reporting *all* detected CLI/import errors.
-- Calls business functions directly:
- * stop_clean_iface.stop_clean_ifaces(ifaces)
- * install_staged_tree.install_staged_tree(stage_root, dest_root, create_dirs, skip_identical)
- * start_iface.start_ifaces(ifaces)
-- If no ifaces provided on CLI, it discovers them from the stage tree.
-
-Usage:
- sudo ./deploy_StanleyPark.py # discover ifaces from stage, stop→install→start
- sudo ./deploy_StanleyPark.py x6 US # explicit iface list
- sudo ./deploy_StanleyPark.py --no-stop # skip stop step
- sudo ./deploy_StanleyPark.py --no-start # skip start step
- sudo ./deploy_StanleyPark.py --stage ./stage --root / --create-dirs
-"""
-
-from __future__ import annotations
-from pathlib import Path
-from typing import List, Sequence, Tuple
-import argparse
-import os
-import sys
-import traceback
-
-ROOT = Path(__file__).resolve().parent
-sys.path.insert(0, str(ROOT)) # ensure sibling modules importable
-
-# --- lightweight staged-iface discovery (duplicated here to avoid importing internals) ---
-def _discover_ifaces_from_stage(stage_root: Path) -> List[str]:
- names = set()
- # from /etc/wireguard/<iface>.conf
- wg_dir = stage_root / "etc" / "wireguard"
- if wg_dir.is_dir():
- for p in wg_dir.glob("*.conf"):
- names.add(p.stem)
- # from /etc/systemd/system/wg-quick@<iface>.service.d/
- sysd = stage_root / "etc" / "systemd" / "system"
- if sysd.is_dir():
- for d in sysd.glob("wg-quick@*.service.d"):
- nm = d.name # wg-quick@IFACE.service.d
- at = nm.find("@")
- dot = nm.find(".service.d")
- if at != -1 and dot != -1 and dot > at:
- names.add(nm[at+1:dot])
- return sorted(names)
-
-def _is_root() -> bool:
- try:
- return os.geteuid() == 0
- except AttributeError:
- # Non-POSIX: best effort
- return False
-
-def _validate_iface_name(n: str) -> bool:
- # conservative: letters, digits, dash, underscore (WireGuard allows more, but keep it safe)
- import re
- return bool(re.fullmatch(r"[A-Za-z0-9_-]{1,32}", n))
-
-def _collect_errors(args) -> Tuple[List[str], List[str]]:
- """
- Return (errors, ifaces). Does *not* raise.
- """
- errors: List[str] = []
-
- # Root required
- if not _is_root():
- errors.append("must be run as root (sudo)")
-
- # Stage root
- stage_root = Path(args.stage)
- if not stage_root.exists():
- errors.append(f"stage path does not exist: {stage_root}")
-
- # Import modules
- inst_mod = None
- stop_mod = None
- start_mod = None
- try:
- import install_staged_tree as inst_mod # type: ignore
- except Exception as e:
- errors.append(f"failed to import install_staged_tree: {e}")
- try:
- import stop_clean_iface as stop_mod # type: ignore
- except Exception as e:
- errors.append(f"failed to import stop_clean_iface: {e}")
- try:
- import start_iface as start_mod # type: ignore
- except Exception as e:
- errors.append(f"failed to import start_iface: {e}")
-
- # Business functions existence (only if imports worked)
- if inst_mod is not None and not hasattr(inst_mod, "install_staged_tree"):
- errors.append("install_staged_tree module missing function: install_staged_tree")
- if stop_mod is not None and not hasattr(stop_mod, "stop_clean_ifaces"):
- errors.append("stop_clean_iface module missing function: stop_clean_ifaces")
- if start_mod is not None and not hasattr(start_mod, "start_ifaces"):
- errors.append("start_iface module missing function: start_ifaces")
-
- # Ifaces
- ifaces: List[str]
- if args.ifaces:
- ifaces = list(dict.fromkeys(args.ifaces)) # dedup preserve order
- else:
- ifaces = _discover_ifaces_from_stage(stage_root)
- if not ifaces:
- errors.append("no interfaces provided and none discovered from stage")
- else:
- bad = [n for n in ifaces if not _validate_iface_name(n)]
- if bad:
- errors.append(f"invalid iface name(s): {', '.join(bad)}")
-
- return (errors, ifaces)
-
-def deploy_StanleyPark(
- ifaces: Sequence[str],
- stage_root: Path,
- dest_root: Path,
- create_dirs: bool,
- skip_identical: bool,
- do_stop: bool,
- do_start: bool,
-) -> int:
- """
- Orchestration: stop (optional) → install → start (optional).
- """
- # Late imports so unit tests can monkeypatch easily
- import install_staged_tree as inst
- import stop_clean_iface as stopm
- import start_iface as startm
-
- print(f"Deploy plan:\n ifaces: {', '.join(ifaces)}\n stage: {stage_root}\n root: {dest_root}\n")
-
- # Stop
- if do_stop:
- print(f"Stopping: {' '.join(ifaces)}")
- try:
- stop_logs = stopm.stop_clean_ifaces(ifaces)
- if isinstance(stop_logs, (list, tuple)):
- for line in stop_logs:
- print(line)
- except Exception:
- print("warn: stop_clean_ifaces raised an exception (continuing):")
- traceback.print_exc()
-
- # Install
- print("\nInstalling staged artifacts…")
- try:
- logs, detected = inst.install_staged_tree(
- stage_root=stage_root,
- dest_root=dest_root,
- create_dirs=create_dirs,
- skip_identical=skip_identical,
- )
- for line in logs:
- print(line)
- except Exception:
- print("❌ install failed with exception:", file=sys.stderr)
- traceback.print_exc()
- return 2
-
- # Start
- if do_start:
- # Prefer explicit ifaces; fall back to what installer detected
- start_list = list(ifaces) if ifaces else list(detected)
- if not start_list:
- print("\nNo interfaces to start (none detected).")
- else:
- print(f"\nStarting: {' '.join(start_list)}")
- try:
- start_logs = startm.start_ifaces(start_list)
- if isinstance(start_logs, (list, tuple)):
- for line in start_logs:
- print(line)
- except Exception:
- print("warn: start_ifaces raised an exception:", file=sys.stderr)
- traceback.print_exc()
- return 2
-
- print("\n✓ Deploy complete.")
- return 0
-
-def main(argv: List[str] | None = None) -> int:
- ap = argparse.ArgumentParser(description="Deploy staged WG artifacts for StanleyPark (stop→install→start).")
- ap.add_argument("ifaces", nargs="*", help="interfaces to manage (default: discover from stage)")
- ap.add_argument("--stage", default=str(ROOT / "stage"), help="stage root (default: ./stage)")
- ap.add_argument("--root", default="/", help="destination root (default: /)")
- ap.add_argument("--create-dirs", action="store_true", help="create missing parent directories")
- ap.add_argument("--no-skip-identical", action="store_true", help="always replace even if content identical")
- ap.add_argument("--no-stop", action="store_true", help="do not stop interfaces before install")
- ap.add_argument("--no-start", action="store_true", help="do not start interfaces after install")
- args = ap.parse_args(argv)
-
- # Collect all errors up front
- errors, ifaces = _collect_errors(args)
- if errors:
- print("❌ deploy preflight found issue(s):", file=sys.stderr)
- for e in errors:
- print(f" - {e}", file=sys.stderr)
- return 2
-
- # Proceed
- return deploy_StanleyPark(
- ifaces=ifaces,
- stage_root=Path(args.stage),
- dest_root=Path(args.root),
- create_dirs=args.create_dirs,
- skip_identical=(not args.no_skip_identical),
- do_stop=(not args.no_stop),
- do_start=(not args.no_start),
- )
-
-if __name__ == "__main__":
- sys.exit(main())
+++ /dev/null
-
-*
-!.gitignore
-
+++ /dev/null
-#+TITLE: Interface vs Link vs Netdevice: a cynical field guide
-#+AUTHOR: Thomas & Nerith (session)
-#+LANGUAGE: en
-#+OPTIONS: toc:2 num:t
-
-* TL;DR
-In Linux networking (and in this doc), /interface/, /link/, and /netdevice/ can all refer to the same kernel object, e.g., wg0, x6, eth0. This conflation of terms came about because different tribes named the same thing differently.
-
-* What these words actually refer to
-- interface: common admin usage for referring to said kernel network object.
-- link: iproute2's vocabulary for said kernel network object (as in the command: `ip link show <name>` which gives information about said kernel network object).
-- netdevice: the kernel's term (struct net_device under the hood)
-
-* Where the words come from
-- Kernel engineers: /netdevice/ is the internal type that packets touch.
-- iproute2 authors: named their subcommands by subsystem; the L2-ish one is /link/. Hence ip link, ip addr, ip route, ip rule, ip neigh.
-- Humans: kept saying /interface/ because that was the word from ifconfig days and textbooks.
-
-* Cynic's guide to commands (map the terrain)
-- ip link show x6 → show properties of interface x6 (state, mtu, type, flags); not L3 addresses or routes (here /link/ == /interface/)
-- ip addr add A dev x6 → attach IP address A as a property of interface x6; this alone does not force source choice or egress path (here /dev/ = /interface/)
-- ip route add <prefix> dev x6 → write a route entry: map destination <prefix> → interface x6 (here /dev/ = /interface/)
-- ip rule add ... → write a policy rule that selects which routing table to consult
-- ip neigh ... → view/manage the neighbor cache (ARP/ND) per interface; maps L3 neighbor → L2 address; not routing
-
-
-* Device
-
-In computing, a /device/ is a piece of hardware. This includes printers, disk drives, memory cards, NIC cards, etc. An emulated device is software that is written to do the same thing as an actual device. This is sometimes done when compatibility with an old device is needed, but the old device is not available. A virtual device is software that is written to do the same thing as an imagined device. This is sometimes done to make available features that no physical device provides. A virtual device can also be state that is kept to support multiplexing a real device among many users, while giving each user the appearance of having sole ownership of said device. It is also common to call a device emulator a virtual device.
-
-In unix operating systems special files are used for interfacing to devices. Such an interface is often called a /device file/, which inevitably gets shortened to /device/.
-
-In networking, the kernel keeps state data for a device, and software drivers for shipping data to and from a device used for networking. Such software objects are often called /network devices/. The interface to the kernel used for talking to devices inevitably gets called a /device/.
-
-The terms, /physical device/, /device file/, and /netdevice/ are used to distinguish among the various possible meanings of /device/. We observe that generally terminology suffers due to a desire to flatten and thus simplify the discussion of the communication abstraction stack.
-
-* Interface
-
-An /interface/ is a shared surface between two systems. A user interface is the shared surface between a user and a system. E.g. the dashboard of a car is a user interface to the car.
-
-In software programming, an interface is a set of data and routines used for communication between software systems. For example, an API is a application programming interface.
-
-The OS provides named interfaces for communicating with network devices. Within the context of network programming, The literature will refer to such an interface as the /device/, /link/, or /interface/, the latter being the only term fitting the wider scope conventional definition.
-
-* Link
-
-A /link/ is a pathway that connects two systems. With an interface, there is no link, as the systems touch. A link has two interfaces, one on each end. Hence it was inevitable that a link interface would be called a /link/. And if the link connects to a device, then that link interface itself gets called a /device/.
-
-In iproute2 /link/ means the local endpoint object. Do not assume a remote counterpart exists just because you saw the word /link/.
-
-* Machine Peers
-
-- Client
-
-In these documents, the client machine is the local machine users are working on. Inevitably this gets shortened to /client/ in polite conversation. The example client used in this distribution is StanleyPark. That is a host name of a computer on our network.
-
-- Server
-
-In these document, the server machine is the remote machine that the write guard tunnels to. We have nicknames for machines. The example used here has the server nicknames of x6, and US.
-These nicknames are also used for the names of the client machine side interface that connects to the tunnel that leads to said server machine. The nickname is also used for the name of the routing table on the client that routes traffic go said wireguard tunnel.
-
-Hence, a nickname, like x6 or US, refers to a machine, an interface, and an IP route table.
-
-* Software Peers
-
-Programs that run as daemons while listening for connections, and once connected to,k they provide services, are server programs. The program that connects to said software server is called a client program. You guessed it, the terms 'server program' and 'client program' often get shortened to /server/ and /client/.
-
-
-* WireGuard mini-map
-We will use this terminology:
-
-- We will consider that WireGuard is conceptually a virtual device.
-- There can be many interfaces to said WireGuard device, taking names like wg0 or x6. Each has a keypair, a listen port, and a set of peers.
-- Config tools: "wg" (CLI, not a daemon), "wg-quick" (oneshot helper per interface).
-- Reality check:
- - ip link show type wireguard → lists all WG interfaces
- - ip -d link show x6 → detailed information about the x6 interface
- - wg show x6 → peer/crypto state for the x6 interface
-
-* Sanity tests you can run
-#+begin_src sh
-# list all WireGuard interfaces
-ip link show type wireguard
-
-# detailed view of one interface
-ip -d link show x6
-
-# see handshake and byte counters
-wg show x6
-
-# show L3 addresses bound to an interface
-ip addr show dev x6
-
-# show routes in a named table (if you use policy routing)
-ip route show table x6
-#+end_src
-
-
+++ /dev/null
-
-1. create/update the client configuration files.
-
- These are the configuration files for the machine called StanleyPark, which is on
- our local network. (Yes, we capitalize popper nouns, and thus have some "bad names".)
-
- db_init_StanleyPark.py
- stage_StanleyPark
- deploy_StanleyPark
-
- They are in Python.
-
-2. Wipe the database and the stage.
-
- Wiping the db will erase keys and any other client configurations. This does not effect already installed configuration files. Also, the database can always be rebuilt by running the client configuration files again.
-
- ./db_wipe.py
- ./stage_wipe.py
-
-3. Setup the database
-
- ./db_init_StanleyPark
-
-4. setup the keys
-
- ./key_generate StanleyPark.py
- ./key_server_set.py <sever's public key>
-
- to see the keys in the database
-
- ./ls_key.py
-
- if the database was wiped, it will be necessary to key_generate again. Currently
- there is one client machine key pair.
-
-5. stage the configuration files to be installed
-
- ./stage_StanleyPark
-
- check them make sure they are what you want
-
-6. install the staged files
-
- ./deploy_StanlwayPark
-
-
-The goal here is work towards each subu as a container, with its networking tunneled
-to the specified interface. Perhaps the configuration scripts should be subu based instead of client machine based. Perhaps in the next version.
-
-
-
+++ /dev/null
--New interface:
-
-copy `db_init_iface_x6.py` to `db_init_iface_<name>.py`, replacing <name> with the name of the interface. Then edit `db_init_iface_<name>.py`
-
--New Client
-
--New User
-
-
+++ /dev/null
-
-From the point of view of setting up the client (we are in the client setup directory after all):
-
-1. login to the server and get the server public key.
-
- This public key is written into the db_init_iface_<server_name>>.py configuration file. Note the examples `db_init_iface_US.py` and `db_init_iface_x6`. `x6` and `US` are nicknames for two servers. These nicknames are also used for the interface names.
-
- Note that the server private key remains on the server. The client has no knowledge of the server private key. It is not entered anywhere in the client configuration.
-
-2. run the program `key_client_generate1
-
- This will print the client public key. It will also place a copy in the database.
-
- This will write the client private key into a local directory called `key/`. The admin need not do anything concerning this key. Scripts that need it will find it in the 'key/' directory.
+++ /dev/null
-
-stage programs write to the stage directory. Later install copies from the stage
-directory to a provided root, which if it is the local machine, will be '/'.
-
-
-* stage_IP_register_route_table.py
-
- stages a replacement etc/iproute2/rt_tables file.
-
-* stage_wg_conf.py
-
- stages etc/wireguard/ conf files for the configured interfaces
-
-* stage_IP_routes_script.py
-
- 1. stages a shell script that when called writes the IP rule table. Said script binds UIDs to route tables.
-
- 2. stages a priority 10 systemd guard systemd dropin that will call said shell script when
- WireGuard is started or restarted.
-
-* stage_IP_rules_script.py
-
- 1. stages a shell script that when called writes the required IP route tables
-
- 2. stages a priority 20 systemd guard systemd dropin that will call said shell script when
- WireGuard is started or restarted.
-
-* stage_client_StanleyPark.py
-
- A local use client machine configuration file. Calls the other stage programs
- while providing the correct parameters for configuring wireguard on the
- machine StanleyPark. Typically these will be a database connection and a list of
- users.
-
- The admin will write such a file for each machine he/she/ai is configuring.
-
-* stage_incommon.py
-
- Utility functions for stage programs.
-
-
-
+++ /dev/null
-#!/usr/bin/env python3
-# iface_down.py — stop wg-quick@<iface> and remove uid→rt rules
-
-from __future__ import annotations
-import os, sys, sqlite3, subprocess
-import incommon as ic # provides open_db()
-
-def sh(args: list[str], check: bool=False) -> subprocess.CompletedProcess:
- return subprocess.run(args, text=True, capture_output=True, check=check)
-
-def get_rt_table_name(conn: sqlite3.Connection, iface: str) -> str:
- row = conn.execute(
- "SELECT rt_table_name_eff FROM v_client_effective WHERE iface=? LIMIT 1;",
- (iface,)
- ).fetchone()
- if not row:
- raise RuntimeError(f"Interface not found in DB: {iface}")
- return str(row[0])
-
-def get_bound_uids(conn: sqlite3.Connection, iface: str) -> list[int]:
- rows = conn.execute(
- """SELECT ub.uid
- FROM User ub
- JOIN Iface c ON c.id = ub.iface_id
- WHERE c.iface=? AND ub.uid IS NOT NULL
- ORDER BY ub.uid;""",
- (iface,)
- ).fetchall()
- return [int(r[0]) for r in rows]
-
-def iface_down(iface: str) -> str:
- if os.geteuid() != 0:
- raise PermissionError("This script must be run as root.")
-
- # Stop interface (ignore failure)
- sh(["systemctl", "stop", f"wg-quick@{iface}"])
-
- # DB lookups
- with ic.open_db() as conn:
- table = get_rt_table_name(conn, iface)
- uids = get_bound_uids(conn, iface)
-
- # Snapshot rules once for existence checks
- rules = sh(["ip", "-4", "rule", "list"]).stdout
-
- removed = 0
- for uid in uids:
- needle = f"uidrange {uid}-{uid} "
- if needle in rules and f" lookup {table}" in rules:
- # Try to delete; ignore failure to keep idempotence
- sh(["ip", "-4", "rule", "del", "uidrange", f"{uid}-{uid}", "table", table])
- sh(["logger", f"iface_down: removed uid {uid} rule for table {table}"])
- removed += 1
-
- return f"✅ {iface} stopped; removed {removed} uid rules from table {table}."
-
-def main(argv: list[str]) -> int:
- if len(argv) != 1:
- print(f"Usage: {os.path.basename(sys.argv[0])} <iface>", file=sys.stderr)
- return 2
- iface = argv[0]
- try:
- msg = iface_down(iface)
- except (PermissionError, FileNotFoundError, sqlite3.Error, RuntimeError) as e:
- print(f"❌ {e}", file=sys.stderr); return 1
- print(msg); return 0
-
-if __name__ == "__main__":
- sys.exit(main(sys.argv[1:]))
+++ /dev/null
-#!/usr/bin/env python3
-# iface_status.py — show unit/wg/route/uid-rule status for <iface>
-
-from __future__ import annotations
-import os, sys, shutil, sqlite3, subprocess, time
-from pathlib import Path
-import incommon as ic # provides open_db()
-
-# --- small shell helpers -----------------------------------------------------
-
-def sh(args: list[str]) -> subprocess.CompletedProcess:
- """Run command; never raise; text mode; capture stdout/stderr."""
- return subprocess.run(args, text=True, capture_output=True)
-
-def which(cmd: str) -> bool:
- return shutil.which(cmd) is not None
-
-def print_block(title: str, body: str | None = None) -> None:
- print(f"=== {title} ===")
- if body is not None and body != "":
- print(body.rstrip())
- print()
-
-# --- DB helpers ---------------------------------------------------------------
-
-def get_rt_table_name(conn: sqlite3.Connection, iface: str) -> str:
- row = conn.execute(
- "SELECT rt_table_name_eff FROM v_client_effective WHERE iface=? LIMIT 1;",
- (iface,)
- ).fetchone()
- if not row:
- raise RuntimeError(f"Interface not found in DB: {iface}")
- return str(row[0])
-
-def get_bound_users(conn: sqlite3.Connection, iface: str) -> list[tuple[str, int | None]]:
- rows = conn.execute(
- """SELECT ub.username, ub.uid
- FROM User ub
- JOIN Iface c ON c.id = ub.iface_id
- WHERE c.iface=?
- ORDER BY ub.username;""",
- (iface,)
- ).fetchall()
- return [(str(u), (None if v is None else int(v))) for (u, v) in rows]
-
-# --- core --------------------------------------------------------------------
-
-def iface_status(iface: str) -> int:
- # DB open + resolve table name early for helpful errors
- with ic.open_db() as conn:
- table = get_rt_table_name(conn, iface)
-
- # systemd status
- en = sh(["systemctl", "is-enabled", f"wg-quick@{iface}"])
- ac = sh(["systemctl", "is-active", f"wg-quick@{iface}"])
- sys_body = "\n".join([
- (en.stdout.strip() if en.stdout.strip() else "").strip(),
- (ac.stdout.strip() if ac.stdout.strip() else "").strip(),
- ]).strip()
- print_block(f"systemd: wg-quick@{iface}", sys_body)
-
- # wg presence + handshake age
- wg_title = f"wg: {iface}"
- if which("wg"):
- if Path(f"/sys/class/net/{iface}").exists():
- lines: list[str] = ["(present)"]
- # Try sudo-less handshake read; if not permitted, show hint
- hs_try = sh(["sudo", "-n", "wg", "show", iface, "latest-handshakes"])
- if hs_try.returncode == 0 and hs_try.stdout.strip():
- # expected format: "<pubkey> <epoch>"
- epoch_part = hs_try.stdout.strip().split()[-1]
- try:
- hs = int(epoch_part)
- if hs > 0:
- age = int(time.time()) - hs
- lines.append(f"latest-handshake: {age}s ago")
- else:
- lines.append("latest-handshake: none")
- except ValueError:
- lines.append("latest-handshake: unknown")
- else:
- prog = Path(sys.argv[0]).name or "iface_status.py"
- lines.append(f"⚠ need sudo to read peers/handshake (try: sudo {prog} {iface})")
- print_block(wg_title, "\n".join(lines))
- else:
- print_block(wg_title, "(interface down or not present)")
- else:
- print_block(wg_title, "wg tool not found.")
-
- # route for table
- rt = sh(["ip", "-4", "route", "show", "table", table])
- print_block(f"route: table {table}", rt.stdout if rt.stdout else "")
-
- # uid rules targeting table
- rules = sh(["ip", "-4", "rule", "show"]).stdout.splitlines()
- hits = [ln for ln in rules if f"lookup {table}" in ln]
- print_block(f"uid rules → table {table}", "\n".join(hits) if hits else "(none)")
-
- # DB: bound users
- with ic.open_db() as conn:
- bound = get_bound_users(conn, iface)
-
- if not bound:
- print_block(f"DB: bound users for {iface}", "(none)")
- else:
- # simple column render
- header = ("username", "uid")
- rows = [(u, ("" if v is None else str(v))) for (u, v) in bound]
- w1 = max(len(header[0]), *(len(r[0]) for r in rows))
- w2 = max(len(header[1]), *(len(r[1]) for r in rows))
- body_lines = [f"{header[0]:<{w1}} {header[1]:<{w2}}",
- f"{'-'*w1} {'-'*w2}"]
- body_lines += [f"{u:<{w1}} {v:<{w2}}" for (u, v) in rows]
- print_block(f"DB: bound users for {iface}", "\n".join(body_lines))
-
- return 0
-
-# --- cli ---------------------------------------------------------------------
-
-def main(argv: list[str]) -> int:
- if len(argv) != 1:
- print(f"Usage: {Path(sys.argv[0]).name} <iface>", file=sys.stderr)
- return 2
- try:
- return iface_status(argv[0])
- except (sqlite3.Error, FileNotFoundError, RuntimeError) as e:
- print(f"❌ {e}", file=sys.stderr)
- return 1
-
-if __name__ == "__main__":
- sys.exit(main(sys.argv[1:]))
+++ /dev/null
-#!/usr/bin/env bash
-# iface_up.sh — enable/start wg-quick@<iface>
-set -euo pipefail
-
-(( $# == 1 )) || { echo "Usage: $0 <iface>"; exit 2; }
-IFACE="$1"
-
-# Require root because systemd + net ops
-if [[ $EUID -ne 0 ]]; then
- echo "❌ This script must be run as root." >&2
- exit 1
-fi
-
-# Sanity: config must exist
-[[ -r "/etc/wireguard/${IFACE}.conf" ]] || {
- echo "❌ Missing: /etc/wireguard/${IFACE}.conf"; exit 1; }
-
-# Bring it up
-systemctl enable --now "wg-quick@${IFACE}"
-
-# Quick confirmation
-systemctl is-active --quiet "wg-quick@${IFACE}" \
- && echo "✅ ${IFACE} is active." \
- || { echo "⚠️ ${IFACE} failed to start."; exit 1; }
+++ /dev/null
-#!/usr/bin/env python3
-# Shared helpers (DB path + small SQLite utilities). No side effects on import.
-
-from __future__ import annotations
-from pathlib import Path
-import sqlite3
-from typing import Iterable, Sequence, Any, List, Tuple, Optional
-
-# Base paths
-ROOT_DIR: Path = Path(__file__).resolve().parent
-DB_PATH: Path = ROOT_DIR / "db" / "store" # default location
-
-def open_db(path: Optional[Path]=None) -> sqlite3.Connection:
- p = path or DB_PATH
- if not p.exists():
- raise FileNotFoundError(f"DB not found: {p}")
- conn = sqlite3.connect(p.as_posix())
- # enforce FK; journal mode is set by schema, but enabling FK here is harmless and desired
- conn.execute("PRAGMA foreign_keys = ON;")
- return conn
-
-def rows(conn: sqlite3.Connection, sql: str, params: Sequence[Any]=()) -> List[tuple]:
- cur = conn.execute(sql, tuple(params))
- out = cur.fetchall()
- cur.close()
- return out
-
-def get_client_id(conn: sqlite3.Connection, iface: str) -> int:
- r = conn.execute("SELECT id FROM Iface WHERE iface=? LIMIT 1;", (iface,)).fetchone()
- if not r: raise RuntimeError(f"client iface not found: {iface}")
- return int(r[0])
-
-# Tx helpers (optional but nice)
-def begin_immediate(conn: sqlite3.Connection) -> None:
- conn.execute("BEGIN IMMEDIATE;")
-
-def commit(conn: sqlite3.Connection) -> None:
- conn.commit()
-
+++ /dev/null
-#!/usr/bin/env bash
-# inspect.sh — prime sudo only if needed, then run inspect_1.py
-set -euo pipefail
-SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
-
-# If not primed, prompt via the tty (works inside Emacs shell without echoing)
-if ! sudo -n true 2>/dev/null; then
- sudo echo -n
-fi
-
-sudo python3 "${SCRIPT_DIR}/inspect_1.py" "$@"
+++ /dev/null
-#!/usr/bin/env python3
-# inspect.py — deep health: DB + systemd/drop-in + wg + route + uid rules + DNS plug
-
-from __future__ import annotations
-import os, sys, re, time, shutil, sqlite3, subprocess
-from pathlib import Path
-from typing import List, Tuple, Optional
-import incommon as ic # open_db()
-
-# ---------- small shell helpers ----------
-
-def sh(args: List[str]) -> subprocess.CompletedProcess:
- """Run command; never raise; text mode; capture stdout/stderr."""
- return subprocess.run(args, text=True, capture_output=True)
-
-def which(cmd: str) -> bool:
- return shutil.which(cmd) is not None
-
-def print_block(title: str, body: str | None = None) -> None:
- print(f"=== {title} ===")
- if body: print(body.rstrip())
- print()
-
-def format_table(headers: List[str], rows: List[Tuple]) -> str:
- cols = list(zip(*([headers] + [[str(c) for c in r] for r in rows]))) if rows else [headers]
- widths = [max(len(x) for x in col) for col in cols]
- line = lambda r: " ".join(f"{str(c):<{w}}" for c, w in zip(r, widths))
- out = [line(headers), line(tuple("-"*w for w in widths))]
- for r in rows: out.append(line(tuple("" if c is None else str(c) for c in r)))
- return "\n".join(out)
-
-# ---------- DB helpers ----------
-
-def client_row(conn: sqlite3.Connection, iface: str):
- return conn.execute("""
- SELECT c.iface,
- v.rt_table_name_eff AS rt_table_name,
- c.bound_user, c.bound_uid,
- c.local_address_cidr,
- substr(c.public_key,1,10)||'…' AS pub,
- c.autostart, c.updated_at
- FROM Iface c
- JOIN v_client_effective v ON v.id=c.id
- WHERE c.iface=? LIMIT 1;
- """,(iface,)).fetchone()
-
-def server_rows(conn: sqlite3.Connection, iface: str) -> List[tuple]:
- return conn.execute("""
- SELECT s.name,
- s.endpoint_host || ':' || s.endpoint_port AS endpoint,
- substr(s.public_key,1,10)||'…' AS pub,
- s.allowed_ips, s.keepalive_s, s.priority
- FROM server s
- JOIN Iface c ON c.id=s.iface_id
- WHERE c.iface=?
- ORDER BY s.priority, s.name;
- """,(iface,)).fetchall()
-
-def rtname_and_cidr(conn: sqlite3.Connection, iface: str) -> Tuple[str, str]:
- row = conn.execute("SELECT rt_table_name_eff, local_address_cidr FROM v_client_effective WHERE iface=? LIMIT 1;",(iface,)).fetchone()
- if not row: raise RuntimeError(f"Interface not found in DB: {iface}")
- return str(row[0]), str(row[1])
-
-def bound_uids(conn: sqlite3.Connection, iface: str) -> List[int]:
- rows = conn.execute("""
- SELECT ub.uid
- FROM User ub
- JOIN Iface c ON c.id=ub.iface_id
- WHERE c.iface=? AND ub.uid IS NOT NULL AND ub.uid!=''
- ORDER BY ub.uid;
- """,(iface,)).fetchall()
- return [int(r[0]) for r in rows]
-
-def legacy_bound_uid(conn: sqlite3.Connection, iface: str) -> Optional[int]:
- r = conn.execute("SELECT bound_uid FROM Iface WHERE iface=? AND bound_uid IS NOT NULL AND bound_uid!='';",(iface,)).fetchone()
- return (int(r[0]) if r and r[0] is not None and str(r[0])!="" else None)
-
-def primary_server_ep_and_allowed(conn: sqlite3.Connection, iface: str) -> Tuple[str,str]:
- ep = conn.execute("""
- SELECT s.endpoint_host||':'||s.endpoint_port
- FROM server s JOIN Iface c ON c.id=s.iface_id
- WHERE c.iface=? ORDER BY s.priority, s.name LIMIT 1;
- """,(iface,)).fetchone()
- allow = conn.execute("""
- SELECT s.allowed_ips
- FROM server s JOIN Iface c ON c.id=s.iface_id
- WHERE c.iface=? ORDER BY s.priority, s.name LIMIT 1;
- """,(iface,)).fetchone()
- return (str(ep[0]) if ep and ep[0] else ""), (str(allow[0]) if allow and allow[0] else "")
-
-# ---------- file checks ----------
-
-def check_file(path: str, mode_oct: int, user: str, group: str) -> str:
- p = Path(path)
- if not p.exists(): return f"WARN: missing {path}"
- try:
- st = p.stat()
- actual_mode = st.st_mode & 0o777
- import pwd, grp
- u = pwd.getpwuid(st.st_uid).pw_name
- g = grp.getgrgid(st.st_gid).gr_name
- want = f"{oct(mode_oct)[2:]} {user} {group}"
- got = f"{oct(actual_mode)[2:]} {u} {g}"
- if actual_mode==mode_oct and u==user and g==group:
- return f"OK: {path} ({got})"
- else:
- return f"WARN: {path} perms/owner {got} (expected {want})"
- except Exception as e:
- return f"WARN: {path} stat error: {e}"
-
-def rt_tables_has(table: str) -> bool:
- try:
- txt = Path("/etc/iproute2/rt_tables").read_text()
- except Exception:
- return False
- pat = re.compile(rf"^\s*\d+\s+{re.escape(table)}\s*$", re.M)
- return pat.search(txt) is not None
-
-# ---------- wg helpers ----------
-
-def wg_present(iface: str) -> bool:
- return Path(f"/sys/class/net/{iface}").exists()
-
-def wg_handshake_age_sec(iface: str) -> Optional[int]:
- cp = sh(["sudo","-n","wg","show",iface,"latest-handshakes"])
- if cp.returncode != 0 or not cp.stdout.strip(): return None
- try:
- epoch = int(cp.stdout.split()[-1])
- if epoch<=0: return None
- return int(time.time()) - epoch
- except Exception:
- return None
-
-def wg_endpoints_joined(iface: str) -> str:
- cp = sh(["sudo","-n","wg","show",iface,"endpoints"])
- if cp.returncode != 0: return ""
- vals = []
- for line in cp.stdout.splitlines():
- parts = line.split()
- if len(parts)>=2: vals.append(parts[1])
- return "".join(vals)
-
-def wg_allowedips_csv(iface: str) -> str:
- cp = sh(["sudo","-n","wg","show",iface,"allowed-ips"])
- if cp.returncode != 0: return ""
- vals=[]
- for line in cp.stdout.splitlines():
- parts = line.split()
- if len(parts)>=2: vals.append(parts[1])
- return ",".join(vals)
-
-# ---------- redact helpers ----------
-
-def redact_conf(text: str) -> str:
- text = re.sub(r"^(PrivateKey\s*=\s*).+$", r"\1<redacted>", text, flags=re.M)
- text = re.sub(r"^(PresharedKey\s*=\s*).+$", r"\1<redacted>", text, flags=re.M)
- return text
-
-def sudo_cat(path: str) -> Optional[str]:
- cp = sh(["sudo","-n","cat", path])
- if cp.returncode != 0: return None
- return cp.stdout
-
-# ---------- main inspect ----------
-
-def inspect_iface(iface: str) -> int:
- # DB open
- with ic.open_db() as conn:
- crow = client_row(conn, iface)
- if not crow:
- print(f"❌ client row not found for iface={iface}", file=sys.stderr); return 1
- srv_rows = server_rows(conn, iface)
- rtname, local_cidr = rtname_and_cidr(conn, iface)
- local_ip = local_cidr.split("/",1)[0]
- db_ep, db_allowed = primary_server_ep_and_allowed(conn, iface)
- uids = bound_uids(conn, iface)
- leg = legacy_bound_uid(conn, iface)
- if leg is not None: uids.append(leg)
-
- # DB snapshot
- print("=== DB: client '{}' ===".format(iface))
- headers = ["iface","rt_table_name","bound_user","bound_uid","local_address_cidr","pub","autostart","updated_at"]
- print(format_table(headers, [crow]))
- print()
- print(f"--- server for '{iface}' ---")
- if srv_rows:
- print(format_table(["name","endpoint","pub","allowed_ips","keepalive_s","priority"], srv_rows))
- else:
- print("(none)")
- print()
-
- # systemd + drop-in
- print(f"=== systemd: wg-quick@{iface} ===")
- if which("systemctl"):
- en = sh(["systemctl","is-enabled",f"wg-quick@{iface}"]).stdout.strip()
- ac = sh(["systemctl","is-active", f"wg-quick@{iface}"]).stdout.strip()
- if en: print(en)
- if ac: print(ac)
- drop_dir = f"/etc/systemd/system/wg-quick@{iface}.service.d"
- # common filenames: legacy 'restart.conf' or new '10-postup-IP-scripts.conf'
- candidates = [f"{drop_dir}/restart.conf", f"{drop_dir}/10-postup-IP-scripts.conf"]
- print(f"-- drop-in expected: {candidates[0]}")
- found = [p for p in candidates if Path(p).is_file()]
- if found:
- print("OK: drop-in file exists")
- else:
- print("WARN: drop-in file missing or unreadable")
- dpaths = sh(["systemctl","show",f"wg-quick@{iface}","-p","DropInPaths","--value"]).stdout.strip()
- if dpaths and any(p in dpaths for p in candidates):
- print("OK: drop-in is loaded by systemd")
- else:
- print("WARN: drop-in not reported by systemd (need daemon-reload?)")
- else:
- print("(systemctl not available)")
- print()
-
- # installed targets
- print("=== installed targets ===")
- print(check_file(f"/etc/wireguard/{iface}.conf", 0o600, "root", "root"))
- # check both possible drop-in names
- d1 = check_file(f"/etc/systemd/system/wg-quick@{iface}.service.d/restart.conf", 0o644, "root", "root")
- d2 = check_file(f"/etc/systemd/system/wg-quick@{iface}.service.d/10-postup-IP-scripts.conf", 0o644, "root", "root")
- # show OK if either exists
- if d1.startswith("OK") or d2.startswith("OK"):
- print(d1 if d1.startswith("OK") else d2)
- else:
- # print both warnings for clarity
- print(d1); print(d2)
- print(check_file("/usr/local/bin/IP_rule_add_UID.sh", 0o500, "root", "root"))
- print(check_file(f"/usr/local/bin/route_init_{iface}.sh", 0o500, "root", "root"))
- print("OK: rt_tables entry for '{}' present".format(rtname) if rt_tables_has(rtname)
- else f"WARN: rt_tables entry for '{rtname}' missing")
- print()
-
- # wg + addr
- print(f"=== wg + addr: {iface} ===")
- present = wg_present(iface)
- print("(present)" if present else "(interface down or not present)")
- if present:
- has_ip = sh(["ip","-4","addr","show","dev",iface]).stdout.find(f" {local_ip}/")>=0
- print(f"OK: {iface} has {local_ip}" if has_ip else f"WARN: {iface} missing {local_ip}")
- if which("wg"):
- age = wg_handshake_age_sec(iface)
- if age is None:
- print("latest-handshake: none")
- else:
- print(f"latest-handshake: {age}s ago")
- if age>600: print("WARN: handshake is stale (>600s)")
- # endpoint and allowed-ips comparison (requires sudo)
- wg_ep = wg_endpoints_joined(iface)
- if db_ep:
- if wg_ep == db_ep:
- print(f"OK: endpoint matches DB ({wg_ep})")
- else:
- print(f"WARN: endpoint mismatch (wg={wg_ep or 'n/a'} db={db_ep})")
- wg_allowed = wg_allowedips_csv(iface)
- if db_allowed:
- if wg_allowed == db_allowed:
- print(f"OK: allowed-ips match DB ({wg_allowed})")
- else:
- print(f"WARN: allowed-ips mismatch (wg={wg_allowed or 'n/a'} db={db_allowed})")
- else:
- prog = Path(sys.argv[0]).name
- print(f"⚠ need sudo for handshake/peer checks (try: sudo {prog} {iface})")
- print()
-
- # route table checks
- print(f"=== route: table {rtname} ===")
- rt = sh(["ip","-4","route","show","table",rtname]).stdout
- print(rt or "")
- def_ok = any(re.match(rf"^default\s+dev\s+{re.escape(iface)}\b", ln) for ln in rt.splitlines())
- bh_ok = any(re.match(r"^blackhole\s+default\b", ln) for ln in rt.splitlines())
- print("OK: default -> {}".format(iface) if def_ok else f"WARN: default route not on {iface}")
- print("OK: blackhole guard present" if bh_ok else "WARN: blackhole guard missing")
- print()
-
- # uid rules
- print(f"=== ip rules for bound UIDs → table {rtname} ===")
- rules_txt = sh(["ip","-4","rule","show"]).stdout
- if uids:
- for u in uids:
- if re.search(rf"uidrange {u}-{u}.*lookup {re.escape(rtname)}", rules_txt):
- print(f"OK: uid {u} -> table {rtname}")
- else:
- print(f"WARN: missing rule for uid {u} -> table {rtname}")
- else:
- print("(no bound UIDs recorded)")
- print()
- print(f"=== ip rule lines targeting '{rtname}' (all) ===")
- hit_lines = [ln for ln in rules_txt.splitlines() if f"lookup {rtname}" in ln]
- print("\n".join(hit_lines) if hit_lines else "(none)")
- print()
-
- # DNS leak plug: iptables redirects
- print("=== iptables nat OUTPUT DNS redirect (→ 127.0.0.1:53) ===")
- if which("iptables"):
- nat = sh(["iptables","-t","nat","-S","OUTPUT"]).stdout
- r_udp = re.search(r"-A OUTPUT.*-p udp .* --dport 53 .* REDIRECT .*to-ports 53", nat or "")
- r_tcp = re.search(r"-A OUTPUT.*-p tcp .* --dport 53 .* REDIRECT .*to-ports 53", nat or "")
- print(r_udp.group(0) if r_udp else "WARN: no UDP:53 redirect")
- print(r_tcp.group(0) if r_tcp else "WARN: no TCP:53 redirect")
- else:
- print("(iptables not available)")
- print()
-
- # on-disk configs (redacted)
- conf = f"/etc/wireguard/{iface}.conf"
- drop_restart = f"/etc/systemd/system/wg-quick@{iface}.service.d/restart.conf"
- drop_postup = f"/etc/systemd/system/wg-quick@{iface}.service.d/10-postup-IP-scripts.conf"
-
- print(f"=== file: {conf} (redacted) ===")
- txt = sudo_cat(conf)
- if txt is None:
- print("(missing or unreadable; need sudo to view)")
- else:
- print(redact_conf(txt))
- print()
-
- pick_drop = drop_restart if Path(drop_restart).exists() else drop_postup
- print(f"=== file: {pick_drop} (hooks) ===")
- txt = sudo_cat(pick_drop)
- if txt is None:
- print("(missing or unreadable; need sudo to view)")
- else:
- # Show only interesting service lines if present
- lines = [ln for ln in txt.splitlines()
- if ln.startswith(("ExecStart","Restart","RestartSec","ExecStartPre","ExecStartPost"))]
- print("\n".join(lines) if lines else txt)
- print()
-
- # summary verdict
- print("=== summary ===")
- ok = True
- ok &= def_ok
- ok &= bh_ok
- if uids:
- for u in uids:
- if not re.search(rf"uidrange {u}-{u}.*lookup {re.escape(rtname)}", rules_txt): ok = False
- ok &= rt_tables_has(rtname)
- ok &= Path(f"/etc/wireguard/{iface}.conf").exists()
- ok &= (Path(drop_restart).exists() or Path(drop_postup).exists())
- ok &= wg_present(iface)
- if db_ep and which("wg"):
- # If wg is present and sudo works, compare endpoint; otherwise skip
- wg_ep = wg_endpoints_joined(iface)
- if wg_ep and wg_ep != db_ep: ok = False
- print("✅ Looks consistent for '{}'.".format(iface) if ok else "⚠️ Something is off — check WARN lines above.")
- return 0 if ok else 1
-
-# ---------- cli ----------
-
-def main(argv: List[str]) -> int:
- if len(argv)!=1:
- print(f"Usage: {Path(sys.argv[0]).name} <iface>", file=sys.stderr)
- return 2
- try:
- return inspect_iface(argv[0])
- except (sqlite3.Error, FileNotFoundError, RuntimeError) as e:
- print(f"❌ {e}", file=sys.stderr); return 1
-
-if __name__ == "__main__":
- sys.exit(main(sys.argv[1:]))
+++ /dev/null
-#!/usr/bin/env python3
-# inspect_client_public_key.py — show the client's WireGuard public key for one iface
-# Sources checked (in this order): DB, staged conf, installed conf, kernel
-# The “client public key” is generated locally from the client’s PrivateKey and must be
-# copied to the **server** as the peer’s PublicKey in the server’s WireGuard config.
-
-from __future__ import annotations
-from pathlib import Path
-from typing import List, Optional, Tuple
-import argparse
-import os
-import subprocess
-import sqlite3
-import sys
-
-# Project helper providing DB_PATH and open_db()
-import incommon as ic
-
-ROOT = Path(__file__).resolve().parent
-DEFAULT_STAGE = ROOT / "stage"
-LIVE_WG_DIR = Path("/etc/wireguard")
-
-def _is_root() -> bool:
- return os.geteuid() == 0
-
-def _format_table(headers: List[str], rows: List[Tuple]) -> str:
- if not rows:
- return "(none)"
- cols = list(zip(*([headers] + [[("" if c is None else str(c)) for c in r] for r in rows])))
- widths = [max(len(x) for x in col) for col in cols]
- def line(r): return " ".join(f"{str(c):<{w}}" for c, w in zip(r, widths))
- out = [line(headers), line(tuple("-"*w for w in widths))]
- for r in rows:
- out.append(line(r))
- return "\n".join(out)
-
-def _read_conf_private_key(conf_path: Path) -> Optional[str]:
- """Return the PrivateKey value from a wg conf (first [Interface] block), or None."""
- try:
- txt = conf_path.read_text()
- except FileNotFoundError:
- return None
- section = None
- for raw in txt.splitlines():
- line = raw.strip()
- if not line or line.startswith("#") or line.startswith(";"):
- continue
- if line.startswith("[") and line.endswith("]"):
- section = line[1:-1].strip()
- continue
- if section == "Interface":
- if line.lower().startswith("privatekey"):
- parts = line.split("=", 1)
- if len(parts) == 2:
- val = parts[1].strip()
- return val if val else None
- return None
-
-def _pub_from_private_key(priv: str) -> Optional[str]:
- """Compute public key from a WireGuard base64 private key using `wg pubkey`."""
- if not priv:
- return None
- try:
- cp = subprocess.run(
- ["wg", "pubkey"],
- input=(priv + "\n").encode("utf-8"),
- stdout=subprocess.PIPE,
- stderr=subprocess.DEVNULL,
- check=True,
- )
- pub = cp.stdout.decode("utf-8", "replace").strip()
- return pub or None
- except (subprocess.CalledProcessError, FileNotFoundError):
- return None
-
-def _kernel_iface_public_key(iface: str) -> Optional[str]:
- try:
- cp = subprocess.run(
- ["wg", "show", iface, "public-key"],
- stdout=subprocess.PIPE,
- stderr=subprocess.DEVNULL,
- check=True,
- )
- k = cp.stdout.decode("utf-8", "replace").strip()
- return k or None
- except (subprocess.CalledProcessError, FileNotFoundError):
- return None
-
-def _db_client_public_key(conn: sqlite3.Connection, iface: str) -> Optional[str]:
- row = conn.execute("SELECT public_key FROM Iface WHERE iface=? LIMIT 1;", (iface,)).fetchone()
- if not row:
- return None
- k = row[0]
- return k if k else None
-
-def _rel_from_stage(path: Path, stage_root: Path) -> str:
- """Return a short, stage-relative display path when under stage_root."""
- try:
- rel = path.relative_to(stage_root)
- return str(rel)
- except ValueError:
- return str(path)
-
-def _gather(iface: str, stage_root: Path) -> Tuple[List[Tuple[str, str, str]], List[str]]:
- """
- Return (rows, notes)
- rows: list of (source, location, public_key or "(missing)")
- """
- notes: List[str] = []
-
- # DB
- db_pub: Optional[str] = None
- if ic.DB_PATH.exists():
- try:
- with ic.open_db() as conn:
- db_pub = _db_client_public_key(conn, iface)
- except sqlite3.Error as e:
- notes.append(f"DB error: {e}")
- else:
- notes.append(f"DB not found at {ic.DB_PATH}")
-
- # staged conf -> derive pub from PrivateKey
- staged_conf = stage_root / "etc" / "wireguard" / f"{iface}.conf"
- staged_priv = _read_conf_private_key(staged_conf)
- staged_pub = _pub_from_private_key(staged_priv) if staged_priv else None
- if staged_priv is None and staged_conf.exists():
- notes.append(f"staged conf present but PrivateKey missing: { _rel_from_stage(staged_conf, stage_root) }")
-
- # live conf -> derive pub from PrivateKey
- live_conf = LIVE_WG_DIR / f"{iface}.conf"
- live_priv = _read_conf_private_key(live_conf)
- live_pub = _pub_from_private_key(live_priv) if live_priv else None
- if live_conf.exists() and live_priv is None:
- notes.append(f"installed conf present but PrivateKey missing: {live_conf}")
-
- # kernel
- kern_pub = _kernel_iface_public_key(iface)
-
- rows: List[Tuple[str, str, str]] = []
- rows.append(("DB", f"Iface.public_key[{iface}]", db_pub or "(missing)"))
- rows.append(("Stage", _rel_from_stage(staged_conf, stage_root),
- staged_pub or ("(missing)" if not staged_conf.exists() else "(could not derive)")))
- rows.append(("Installed", str(live_conf),
- live_pub or ("(missing)" if not live_conf.exists() else "(could not derive)")))
- rows.append(("Kernel", f"wg show {iface} public-key", kern_pub or "(missing)"))
-
- # Quick consistency summary
- present = [v for _s, _loc, v in rows if not v.startswith("(")]
- if len(present) >= 2:
- all_same = all(v == present[0] for v in present[1:])
- if all_same:
- notes.append("All present sources agree.")
- else:
- notes.append("Mismatch detected between sources.")
- elif len(present) == 1:
- notes.append("Only one source has a key (cannot check consistency).")
- else:
- notes.append("No source has a client public key.")
-
- return (rows, notes)
-
-def inspect_client_public_key(iface: str, stage_root: Optional[Path] = None) -> str:
- """
- Business function: returns a formatted report string.
- """
- sr = stage_root or DEFAULT_STAGE
- rows, notes = _gather(iface, sr)
-
- header = (
- f"Client public key inspection for iface '{iface}'\n"
- "This public key is generated locally from the client’s PrivateKey and must be\n"
- "installed on the *server* as the peer’s PublicKey in the server’s WireGuard config.\n"
- )
- table = _format_table(["source", "where", "public_key"], rows)
- if notes:
- note_block = "\nNotes:\n- " + "\n- ".join(notes)
- else:
- note_block = ""
- return f"{header}\n{table}\n{note_block}\n"
-
-def main(argv: Optional[List[str]] = None) -> int:
- ap = argparse.ArgumentParser(
- description="Inspect the client’s WireGuard public key for a single interface."
- )
- # Make iface optional so we can aggregate errors ourselves
- ap.add_argument("iface", nargs="?", help="interface name (e.g., x6)")
- ap.add_argument("--stage-root", default=str(DEFAULT_STAGE), help="stage directory (default: ./stage)")
- args = ap.parse_args(argv)
-
- # Aggregate invocation errors
- errors: List[str] = []
- if not _is_root():
- errors.append("must run as root (needs access to /etc/wireguard and wg)")
- if not args.iface:
- errors.append("missing required positional argument: iface")
- if args.stage_root:
- sr = Path(args.stage_root)
- if not sr.exists():
- errors.append(f"--stage-root does not exist: {sr}")
- elif not sr.is_dir():
- errors.append(f"--stage-root is not a directory: {sr}")
-
- if errors:
- ap.print_usage(sys.stderr)
- print(f"{ap.prog}: error: " + "; ".join(errors), file=sys.stderr)
- return 2
-
- try:
- report = inspect_client_public_key(args.iface, Path(args.stage_root))
- print(report, end="")
- return 0
- except Exception as e:
- print(f"❌ {e}", file=sys.stderr)
- return 2
-
-if __name__ == "__main__":
- sys.exit(main())
+++ /dev/null
-#!/usr/bin/env python3
-"""
-install_staged_tree.py
-
-A dumb installer: copy staged files into the target root with backups and
-deterministic permissions. No systemd stop/start, no daemon-reload.
-
-Given:
- - A staged tree (default: ./stage) containing any of:
- /usr/local/bin/apply_ip_state.sh
- /etc/wireguard/*.conf
- /etc/systemd/system/wg-quick@IFACE.service.d/*.conf
- /etc/iproute2/rt_tables
- - A destination root (default: /). Parent dirs may be created with --create-dirs.
-
-Does:
- - For each whitelisted staged file:
- * if a target already exists, copy it back into the stage as a timestamped backup
- * atomically replace target with staged version
- * set root:root ownership (best-effort) and explicit permissions
- - Prints a summary and suggests next steps (e.g., ./start_iface.py <ifaces>)
-
-Returns:
- - Exit 0 on success; non-zero on error
-"""
-
-from __future__ import annotations
-from pathlib import Path
-from typing import Dict, Iterable, List, Optional, Sequence, Tuple
-import argparse
-import datetime as dt
-import hashlib
-import os
-import shutil
-import sys
-
-ROOT = Path(__file__).resolve().parent
-DEFAULT_STAGE = ROOT / "stage"
-
-# Whitelisted install targets → mode
-# (These are *relative* to the stage root)
-MODE_RULES: List[Tuple[str, int]] = [
- ("usr/local/bin", 0o500), # files under here (scripts)
- ("etc/wireguard", 0o600), # *.conf
- ("etc/systemd/system", 0o644), # wg-quick@*.service.d/*.conf
- ("etc/iproute2", 0o644), # rt_tables
-]
-
-def _sha256(path: Path) -> str:
- h = hashlib.sha256()
- with path.open("rb") as f:
- for chunk in iter(lambda: f.read(1<<20), b""):
- h.update(chunk)
- return h.hexdigest()
-
-def _ensure_parents(dest_root: Path, rel: Path, create: bool) -> None:
- parent = (dest_root / rel).parent
- if parent.exists():
- return
- if not create:
- raise RuntimeError(f"missing parent directory: {parent}")
- parent.mkdir(parents=True, exist_ok=True)
-
-def _backup_existing_to_stage(stage_root: Path, dest_root: Path, rel: Path) -> Optional[Path]:
- """If target exists, copy it back into stage/_backups/<ts>/<rel> and return backup path."""
- target = dest_root / rel
- if not target.exists():
- return None
- ts = dt.datetime.utcnow().strftime("%Y%m%dT%H%M%SZ")
- backup = stage_root / "_backups" / ts / rel
- backup.parent.mkdir(parents=True, exist_ok=True)
- shutil.copy2(target, backup)
- return backup
-
-def _atomic_install(src: Path, dst: Path, mode: int) -> None:
- tmp = dst.with_suffix(dst.suffix + ".tmp")
- shutil.copyfile(src, tmp)
- os.chmod(tmp, mode)
- try:
- os.chown(tmp, 0, 0) # best-effort; may fail if not root
- except PermissionError:
- pass
- os.replace(tmp, dst)
-
-def _mode_for_rel(rel: Path) -> Optional[int]:
- """Choose a mode based on the relative path bucket."""
- s = str(rel)
- if s.startswith("usr/local/bin/"):
- return 0o500
- if s.startswith("etc/wireguard/") and rel.suffix == ".conf":
- return 0o600
- if s == "etc/iproute2/rt_tables":
- return 0o644
- if s.startswith("etc/systemd/system/") and s.endswith(".conf"):
- return 0o644
- return None
-
-def _iter_stage_targets(stage_root: Path) -> List[Path]:
- """Return a list of *relative* paths under stage that match our whitelist."""
- rels: List[Path] = []
-
- # /usr/local/bin/*
- bin_dir = stage_root / "usr" / "local" / "bin"
- if bin_dir.is_dir():
- for p in sorted(bin_dir.glob("*")):
- if p.is_file():
- rels.append(p.relative_to(stage_root))
-
- # /etc/wireguard/*.conf
- wg_dir = stage_root / "etc" / "wireguard"
- if wg_dir.is_dir():
- for p in sorted(wg_dir.glob("*.conf")):
- rels.append(p.relative_to(stage_root))
-
- # /etc/systemd/system/wg-quick@*.service.d/*.conf
- sysd_dir = stage_root / "etc" / "systemd" / "system"
- if sysd_dir.is_dir():
- for p in sorted(sysd_dir.rglob("wg-quick@*.service.d/*.conf")):
- rels.append(p.relative_to(stage_root))
-
- # /etc/iproute2/rt_tables
- rt = stage_root / "etc" / "iproute2" / "rt_tables"
- if rt.is_file():
- rels.append(rt.relative_to(stage_root))
-
- return rels
-
-def _discover_ifaces_from_stage(stage_root: Path) -> List[str]:
- """Peek into staged artifacts to guess iface names (for friendly next-steps)."""
- names = set()
-
- # from /etc/wireguard/<iface>.conf
- wg_dir = stage_root / "etc" / "wireguard"
- if wg_dir.is_dir():
- for p in wg_dir.glob("*.conf"):
- names.add(p.stem)
-
- # from /etc/systemd/system/wg-quick@<iface>.service.d/
- sysd = stage_root / "etc" / "systemd" / "system"
- if sysd.is_dir():
- for d in sysd.glob("wg-quick@*.service.d"):
- name = d.name
- # name looks like: wg-quick@X.service.d
- at = name.find("@")
- dot = name.find(".service.d")
- if at != -1 and dot != -1 and dot > at:
- names.add(name[at+1:dot])
-
- return sorted(names)
-
-def install_staged_tree(
- stage_root: Path,
- dest_root: Path,
- create_dirs: bool = False,
- skip_identical: bool = True,
-) -> Tuple[List[str], List[str]]:
- """
- Copy files from stage_root to dest_root.
- Returns (logs, detected_ifaces).
- """
- old_umask = os.umask(0o077)
- logs: List[str] = []
- try:
- staged = _iter_stage_targets(stage_root)
- if not staged:
- raise RuntimeError("nothing to install (stage is empty or whitelist didn’t match)")
-
- for rel in staged:
- src = stage_root / rel
- dst = dest_root / rel
-
- mode = _mode_for_rel(rel)
- if mode is None:
- logs.append(f"skip (not whitelisted): {rel}")
- continue
-
- _ensure_parents(dest_root, rel, create_dirs)
-
- backup = _backup_existing_to_stage(stage_root, dest_root, rel)
- if backup:
- logs.append(f"backup: {dst} -> {backup}")
-
- if skip_identical and dst.exists():
- try:
- if _sha256(src) == _sha256(dst):
- logs.append(f"identical: skip {rel}")
- continue
- except Exception:
- pass
-
- _atomic_install(src, dst, mode)
- logs.append(f"install: {rel} (mode {oct(mode)})")
-
- ifaces = _discover_ifaces_from_stage(stage_root)
- return (logs, ifaces)
- finally:
- os.umask(old_umask)
-
-def _require_root(allow_nonroot: bool) -> None:
- if not allow_nonroot and os.geteuid() != 0:
- raise RuntimeError("must run as root (use --force-nonroot to override)")
-
-def main(argv: Optional[Sequence[str]] = None) -> int:
- ap = argparse.ArgumentParser(description="Install staged artifacts into a target root. No service control.")
- ap.add_argument("--stage", default=str(DEFAULT_STAGE))
- ap.add_argument("--root", default="/")
- ap.add_argument("--create-dirs", action="store_true", help="create missing parent directories")
- ap.add_argument("--no-skip-identical", action="store_true", help="always replace even if content identical")
- ap.add_argument("--force-nonroot", action="store_true", help="allow non-root install (ownership may be wrong)")
- args = ap.parse_args(argv)
-
- try:
- _require_root(allow_nonroot=args.force_nonroot)
- logs, ifaces = install_staged_tree(
- stage_root=Path(args.stage),
- dest_root=Path(args.root),
- create_dirs=args.create_dirs,
- skip_identical=(not args.no_skip_identical),
- )
- for line in logs:
- print(line)
-
- # Summary + suggested next steps
- print("\n=== Summary ===")
- print(f"Installed {sum(1 for l in logs if l.startswith('install:'))} file(s).")
- if ifaces:
- lst = " ".join(ifaces)
- print(f"Detected interfaces from stage: {lst}")
- print(f"\nNext steps:")
- print(f" # (optional) verify configs")
- print(f" sudo wg-quick strip /etc/wireguard/{ifaces[0]}.conf >/dev/null 2>&1 || true")
- print(f"\n # start interfaces")
- print(f" sudo ./start_iface.py {lst}")
- else:
- print("No interfaces detected in staged artifacts.")
- print("\nNext steps:")
- print(" # start your interface(s)")
- print(" sudo ./start_iface.py <iface> [more ifaces]")
- return 0
- except Exception as e:
- print(f"❌ install failed: {e}", file=sys.stderr)
- return 2
-
-if __name__ == "__main__":
- sys.exit(main())
+++ /dev/null
-
-*
-!.gitignore
-
+++ /dev/null
-#!/usr/bin/env python3
-# key_client_generate.py — generate a machine-wide WG keypair
-# Usage: ./key_client_generate.py <machine_name>
-# - Writes private key to: key/<machine_name>
-# - Updates ALL client.public_key in local DB (no private key stored in DB)
-
-from __future__ import annotations
-import sys, shutil, subprocess, sqlite3, os
-from pathlib import Path
-import incommon as ic # ROOT_DIR, DB_PATH, open_db()
-
-def generate_keypair() -> tuple[str, str]:
- if not shutil.which("wg"):
- raise RuntimeError("wg not found; install wireguard-tools")
- priv = subprocess.run(["wg","genkey"], check=True, text=True, capture_output=True).stdout.strip()
- pub = subprocess.run(["wg","pubkey"], check=True, input=priv.encode(), capture_output=True).stdout.decode().strip()
- # quick sanity
- if not (43 <= len(pub) <= 45):
- raise RuntimeError(f"generated public key length looks wrong ({len(pub)})")
- return priv, pub
-
-def write_private_key(machine: str, private_key: str) -> Path:
- key_dir = ic.ROOT_DIR / "key"
- key_dir.mkdir(parents=True, exist_ok=True)
- out_path = key_dir / machine
- if out_path.exists():
- raise FileExistsError(f"refusing to overwrite existing private key file: {out_path}")
- with open(out_path, "w", encoding="utf-8") as f:
- f.write(private_key + "\n")
- os.chmod(out_path, 0o600)
- return out_path
-
-def update_client_public_keys(pub: str) -> int:
- if not ic.DB_PATH.exists():
- raise FileNotFoundError(f"DB not found: {ic.DB_PATH}")
- with ic.open_db() as conn:
- cur = conn.execute(
- "UPDATE Iface "
- " SET public_key=?, updated_at=strftime('%Y-%m-%dT%H:%M:%SZ','now');",
- (pub,)
- )
- conn.commit()
- return cur.rowcount or 0
-
-def main(argv: list[str]) -> int:
- if len(argv) != 1:
- print(f"Usage: {Path(sys.argv[0]).name} <machine_name>", file=sys.stderr)
- return 2
- machine = argv[0]
- try:
- priv, pub = generate_keypair()
- out_path = write_private_key(machine, priv)
- n = update_client_public_keys(pub)
- print(f"wrote: {out_path.relative_to(ic.ROOT_DIR)} (600)")
- print(f"updated client.public_key for {n} row(s)")
- print(f"public_key: {pub}")
- return 0
- except (RuntimeError, FileExistsError, FileNotFoundError, sqlite3.Error, subprocess.CalledProcessError) as e:
- print(f"❌ {e}", file=sys.stderr)
- return 1
-
-if __name__ == "__main__":
- sys.exit(main(sys.argv[1:]))
+++ /dev/null
-#!/usr/bin/env python3
-# key_server_set.py — set a server's public key by nickname
-# Usage: ./key_server_set.py <server_name> <public_key>
-
-from __future__ import annotations
-import sys, sqlite3
-from pathlib import Path
-import incommon as ic # DB_PATH, open_db()
-
-def valid_pub(pub: str) -> bool:
- # wg public keys are base64-like and typically 44 chars; allow 43–45 as used elsewhere
- return isinstance(pub, str) and (43 <= len(pub.strip()) <= 45)
-
-def set_server_pubkey(server_name: str, pubkey: str) -> int:
- if not ic.DB_PATH.exists():
- raise FileNotFoundError(f"DB not found: {ic.DB_PATH}")
- with ic.open_db() as conn:
- cur = conn.execute(
- "UPDATE server "
- " SET public_key=?, updated_at=strftime('%Y-%m-%dT%H:%M:%SZ','now') "
- " WHERE name=?;",
- (pubkey.strip(), server_name)
- )
- conn.commit()
- return cur.rowcount or 0
-
-def main(argv: list[str]) -> int:
- if len(argv) != 2:
- print(f"Usage: {Path(sys.argv[0]).name} <server_name> <public_key>", file=sys.stderr)
- return 2
- name, pub = argv
- if not valid_pub(pub):
- print(f"❌ public_key length looks wrong ({len(pub)})", file=sys.stderr)
- return 1
- try:
- n = set_server_pubkey(name, pub)
- if n == 0:
- print(f"⚠️ no matching server rows for name='{name}'")
- else:
- print(f"updated server.public_key for {n} row(s) where name='{name}'")
- return 0
- except (sqlite3.Error, FileNotFoundError) as e:
- print(f"❌ {e}", file=sys.stderr)
- return 1
-
-if __name__ == "__main__":
- sys.exit(main(sys.argv[1:]))
+++ /dev/null
-#!/usr/bin/env python3
-"""
-ls_client.py — list client from the DB
-
-Default output: interface names, one per line.
-
-Options:
- -i, --iface IFACE Filter to a single interface (exact match)
- -l, --long Show a table with iface, rt_table_name, rt_table_id, addr, autostart, updated_at
- -h, --help Show usage
-"""
-
-from __future__ import annotations
-import sys
-import argparse
-import sqlite3
-from typing import List, Tuple
-import incommon as ic # DB_PATH, open_db()
-
-def parse_args(argv: List[str]) -> argparse.Namespace:
- ap = argparse.ArgumentParser(add_help=False, prog="ls_client.py", description="List client from the DB")
- ap.add_argument("-i","--iface", help="Filter by interface (exact match)")
- ap.add_argument("-l","--long", action="store_true", help="Long table output")
- ap.add_argument("-h","--help", action="help", help="Show this help and exit")
- return ap.parse_args(argv)
-
-def fmt_table(headers: List[str], rows: List[Tuple]) -> str:
- if not rows: return ""
- # normalize to strings; keep empty for None
- rows = [[("" if c is None else str(c)) for c in r] for r in rows]
- cols = list(zip(*([headers] + rows)))
- widths = [max(len(x) for x in col) for col in cols]
- line = lambda r: " ".join(f"{str(c):<{w}}" for c, w in zip(r, widths))
- out = [line(headers), line(tuple("-"*w for w in widths))]
- out += [line(r) for r in rows]
- return "\n".join(out)
-
-def list_names(conn: sqlite3.Connection, iface: str | None) -> int:
- if iface:
- rows = conn.execute("SELECT iface FROM Iface WHERE iface=? ORDER BY iface;", (iface,)).fetchall()
- else:
- rows = conn.execute("SELECT iface FROM Iface ORDER BY iface;").fetchall()
- for (name,) in rows:
- print(name)
- return 0
-
-def list_long(conn: sqlite3.Connection, iface: str | None) -> int:
- if iface:
- rows = conn.execute("""
- SELECT c.iface,
- v.rt_table_name_eff AS rt_table_name,
- COALESCE(c.rt_table_id,'') AS rt_table_id,
- c.local_address_cidr,
- c.autostart,
- c.updated_at
- FROM Iface c
- JOIN v_client_effective v ON v.id = c.id
- WHERE c.iface = ?
- ORDER BY c.iface;
- """, (iface,)).fetchall()
- else:
- rows = conn.execute("""
- SELECT c.iface,
- v.rt_table_name_eff AS rt_table_name,
- COALESCE(c.rt_table_id,'') AS rt_table_id,
- c.local_address_cidr,
- c.autostart,
- c.updated_at
- FROM Iface c
- JOIN v_client_effective v ON v.id = c.id
- ORDER BY c.iface;
- """).fetchall()
-
- hdr = ["iface","rt_table_name","rt_table_id","addr","autostart","updated_at"]
- txt = fmt_table(hdr, rows)
- if txt: print(txt)
- return 0
-
-def main(argv: List[str]) -> int:
- args = parse_args(argv)
- try:
- with ic.open_db() as conn:
- return list_long(conn, args.iface) if args.long else list_names(conn, args.iface)
- except (sqlite3.Error, FileNotFoundError) as e:
- print(f"❌ {e}", file=sys.stderr)
- return 2
-
-if __name__ == "__main__":
- sys.exit(main(sys.argv[1:]))
+++ /dev/null
-#!/usr/bin/env python3
-# ls_keys.py — list WireGuard public keys only
-# Usage:
-# ./ls_keys.py # all client/server
-# ./ls_keys.py -i x6 # only iface x6
-
-from __future__ import annotations
-import sys, argparse, sqlite3
-from pathlib import Path
-from typing import List, Tuple
-import incommon as ic # DB_PATH, open_db()
-
-def format_table(headers: List[str], rows: List[Tuple]) -> str:
- if not rows:
- return "(none)"
- cols = list(zip(*([headers] + [[("" if c is None else str(c)) for c in r] for r in rows])))
- widths = [max(len(x) for x in col) for col in cols]
- def line(r): return " ".join(f"{str(c):<{w}}" for c, w in zip(r, widths))
- out = [line(headers), line(tuple("-"*w for w in widths))]
- for r in rows: out.append(line(r))
- return "\n".join(out)
-
-def list_client_keys(conn: sqlite3.Connection, iface: str | None, banner=False) -> str:
- if banner:
- print("\n=== Public keys generated locally by client, probably by using `key_client_generate.py`===")
- rows = conn.execute(
- "SELECT iface, public_key AS client_public_key "
- "FROM Iface "
- + ("WHERE iface=? " if iface else "")
- + "ORDER BY iface;",
- ((iface,) if iface else tuple()),
- ).fetchall()
- return format_table(["iface","client_public_key"], rows)
-
-def list_server_keys(conn: sqlite3.Connection, iface: str | None ,banner=False) -> str:
- if banner:
- print("\n=== Public keys imported from remote server, probably edited into db_init_server_<name>.py ===")
- rows = conn.execute(
- "SELECT c.iface AS client, s.name AS server, s.public_key AS server_public_key "
- "FROM server s JOIN Iface c ON c.id = s.iface_id "
- + ("WHERE c.iface=? " if iface else "")
- + "ORDER BY c.iface, s.name;",
- ((iface,) if iface else tuple()),
- ).fetchall()
- return format_table(["client","server","server_public_key"], rows)
-
-def client_pub_for_iface(conn: sqlite3.Connection, iface: str) -> str | None:
- r = conn.execute("SELECT public_key FROM Iface WHERE iface=? LIMIT 1;", (iface,)).fetchone()
- return (r[0] if r and r[0] else None)
-
-def main(argv: List[str]) -> int:
- ap = argparse.ArgumentParser(description="List WireGuard public keys from the local DB.")
- ap.add_argument("-i","--iface", help="filter for one iface (e.g., x6)")
- args = ap.parse_args(argv)
-
- try:
- # Ensure DB exists
- if not ic.DB_PATH.exists():
- print(f"❌ DB not found: {ic.DB_PATH}", file=sys.stderr)
- return 1
- with ic.open_db() as conn:
- print(list_client_keys(conn, args.iface, banner=True))
- print()
- print(list_server_keys(conn, args.iface, banner=True))
- if args.iface:
- cpub = client_pub_for_iface(conn, args.iface)
- if cpub:
- print()
- print("# Copy to server peer config if needed:")
- print(f'CLIENT_PUB="{cpub}"')
- return 0
- except (sqlite3.Error, FileNotFoundError) as e:
- print(f"❌ {e}", file=sys.stderr)
- return 1
-
-if __name__ == "__main__":
- sys.exit(main(sys.argv[1:]))
+++ /dev/null
-#!/usr/bin/env python3
-"""
-ls_server.py — list server from the DB
-
-Default output: server names, one per line.
-
-Options:
- -i, --iface IFACE Filter to a single client interface (e.g., x6, US)
- -l, --long Show a table with client, name, endpoint, allowed_ips, priority
- -h, --help Show usage
-"""
-
-from __future__ import annotations
-import sys
-import sqlite3
-import argparse
-from typing import List, Tuple
-import incommon as ic # DB_PATH, open_db()
-
-def parse_args(argv: List[str]) -> argparse.Namespace:
- ap = argparse.ArgumentParser(add_help=False, prog="ls_server.py", description="List server from the DB")
- ap.add_argument("-i","--iface", help="Filter by client interface")
- ap.add_argument("-l","--long", action="store_true", help="Long table output")
- ap.add_argument("-h","--help", action="help", help="Show this help and exit")
- return ap.parse_args(argv)
-
-def fmt_table(headers: List[str], rows: List[Tuple]) -> str:
- if not rows: return ""
- cols = list(zip(*([headers] + [[("" if c is None else str(c)) for c in r] for r in rows])))
- widths = [max(len(x) for x in col) for col in cols]
- line = lambda r: " ".join(f"{str(c):<{w}}" for c, w in zip(r, widths))
- out = [line(headers), line(tuple("-"*w for w in widths))]
- for r in rows: out.append(line(r))
- return "\n".join(out)
-
-def list_names(conn: sqlite3.Connection, iface: str | None) -> int:
- if iface:
- rows = conn.execute("""
- SELECT s.name
- FROM server s
- JOIN Iface c ON c.id = s.iface_id
- WHERE c.iface = ?
- ORDER BY s.name
- """, (iface,)).fetchall()
- else:
- rows = conn.execute("SELECT name FROM server ORDER BY name").fetchall()
- for (name,) in rows:
- print(name)
- return 0
-
-def list_long(conn: sqlite3.Connection, iface: str | None) -> int:
- if iface:
- rows = conn.execute("""
- SELECT c.iface,
- s.name,
- s.endpoint_host || ':' || CAST(s.endpoint_port AS TEXT) AS endpoint,
- s.allowed_ips,
- s.priority
- FROM server s
- JOIN Iface c ON c.id = s.iface_id
- WHERE c.iface = ?
- ORDER BY c.iface, s.priority, s.name
- """, (iface,)).fetchall()
- else:
- rows = conn.execute("""
- SELECT c.iface,
- s.name,
- s.endpoint_host || ':' || CAST(s.endpoint_port AS TEXT) AS endpoint,
- s.allowed_ips,
- s.priority
- FROM server s
- JOIN Iface c ON c.id = s.iface_id
- ORDER BY c.iface, s.priority, s.name
- """).fetchall()
-
- hdr = ["client","name","endpoint","allowed_ips","priority"]
- txt = fmt_table(hdr, rows)
- if txt: print(txt)
- return 0
-
-def main(argv: List[str]) -> int:
- args = parse_args(argv)
- try:
- with ic.open_db() as conn:
- return list_long(conn, args.iface) if args.long else list_names(conn, args.iface)
- except (sqlite3.Error, FileNotFoundError) as e:
- print(f"❌ {e}", file=sys.stderr)
- return 2
-
-if __name__ == "__main__":
- sys.exit(main(sys.argv[1:]))
+++ /dev/null
-#!/usr/bin/env python3
-"""
-ls_server_settings.py — print server-side WireGuard [Peer] stanzas from the DB
-
-Purpose:
- Emit configuration that belongs in a *server* wg conf (e.g., /etc/wireguard/wg0.conf).
- One [Peer] block per (client, server) row.
-
-What is printed (per block):
- - PublicKey = client's public key (from client.public_key)
- - AllowedIPs = client's tunnel address(es) as seen by the server (from client.local_address_cidr)
- (Use /32 per client. If multiple /32 per client are later added, enumerate them.)
- - PresharedKey = server.preshared_key (only if present)
-
-Notes:
- - Endpoint is NOT set on the server for client peers (client usually dials the server).
- - PersistentKeepalive is generally set on the client; server may omit it.
-
-Usage:
- ./ls_server_settings.py # all client and their server entries
- ./ls_server_settings.py x6 us # only for these client ifaces
- ./ls_server_settings.py --server x6 # filter by server.name
-"""
-
-from __future__ import annotations
-import sys, sqlite3
-from typing import Iterable, List, Optional, Sequence, Tuple
-from pathlib import Path
-
-# local helper import is optional; only used to locate db path if present
-try:
- import incommon as ic
- DB_PATH = ic.DB_PATH
-except Exception:
- DB_PATH = Path(__file__).resolve().parent / "db" / "store"
-
-def die(msg: str, code: int = 1) -> None:
- print(f"❌ {msg}", file=sys.stderr); sys.exit(code)
-
-def open_db(path: Path) -> sqlite3.Connection:
- if not path.exists(): die(f"DB not found: {path}")
- return sqlite3.connect(path.as_posix())
-
-def parse_args(argv: Sequence[str]) -> Tuple[List[str], Optional[str]]:
- ifaces: List[str] = []
- server_filter: Optional[str] = None
- it = iter(argv)
- for a in it:
- if a == "--server":
- try: server_filter = next(it)
- except StopIteration: die("--server requires a value")
- else:
- ifaces.append(a)
- return ifaces, server_filter
-
-def rows(conn: sqlite3.Connection, q: str, params: Iterable = ()) -> List[tuple]:
- cur = conn.execute(q, tuple(params))
- out = cur.fetchall()
- cur.close()
- return out
-
-def collect(conn: sqlite3.Connection, ifaces: List[str], server_filter: Optional[str]) -> List[dict]:
- where = []
- args: List = []
- if ifaces:
- ph = ",".join("?" for _ in ifaces)
- where.append(f"c.iface IN ({ph})")
- args.extend(ifaces)
- if server_filter:
- where.append("s.name = ?")
- args.append(server_filter)
- w = ("WHERE " + " AND ".join(where)) if where else ""
- q = f"""
- SELECT c.id, c.iface, c.public_key, c.local_address_cidr,
- s.name, s.preshared_key, s.endpoint_host, s.endpoint_port
- FROM Iface c
- LEFT JOIN server s ON s.iface_id = c.id
- {w}
- ORDER BY s.name, c.iface, s.priority ASC, s.id ASC;
- """
- R = rows(conn, q, args)
- out: List[dict] = []
- for cid, iface, cpub, cidr, sname, psk, host, port in R:
- out.append({
- "iface_id": cid,
- "iface": iface or "",
- "client_pub": cpub or "",
- "client_cidr": cidr or "",
- "server_name": sname or "(unassigned)",
- "server_host": host or "",
- "server_port": port or None,
- "psk": psk or None,
- })
- return out
-
-def print_header() -> None:
- print("# === Server-side WireGuard peer stanzas ===")
- print("# Place each [Peer] block into the server's wg conf (e.g., /etc/wireguard/wg0.conf).")
- print("# Endpoint is not set for client peers on the server.")
- print("# AllowedIPs must be /32 per client address; enumerate multiple /32 if a client uses several.")
- print()
-
-def print_blocks(items: List[dict]) -> None:
- if not items:
- print("# (no rows matched)"); return
- print_header()
- # group by server_name for readability
- cur_group = None
- for r in items:
- grp = r["server_name"]
- if grp != cur_group:
- cur_group = grp
- ep = f" ({r['server_host']}:{r['server_port']})" if r["server_host"] and r["server_port"] else ""
- print(f"## Server: {grp}{ep}")
- # stanza
- print("[Peer]")
- print(f"# client iface={r['iface']} tunnel={r['client_cidr']}")
- print(f"PublicKey = {r['client_pub']}")
- # AllowedIPs: prefer the exact CIDR stored for the client (typically /32)
- print(f"AllowedIPs = {r['client_cidr']}")
- if r["psk"]:
- print(f"PresharedKey = {r['psk']}")
- print()
- # end
-
-def main(argv: Sequence[str]) -> int:
- ifaces, server_filter = parse_args(argv)
- try:
- with open_db(DB_PATH) as conn:
- items = collect(conn, ifaces, server_filter)
- except sqlite3.Error as e:
- die(f"sqlite error: {e}")
- print_blocks(items)
- return 0
-
-if __name__ == "__main__":
- sys.exit(main(sys.argv[1:]))
+++ /dev/null
-
-# ls_server.sh
-#!/usr/bin/env bash
-set -euo pipefail
-DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
-DB="$DIR/db/store"
-sqlite3 -noheader -batch "$DB" "SELECT name FROM server ORDER BY name;"
+++ /dev/null
-#!/usr/bin/env python3
-"""
-ls_users.py — print "<username> <iface>" from DB (names only)
-
-- Validates required tables exist (client, User)
-- No side effects; read-only
-"""
-
-from __future__ import annotations
-import sys
-import sqlite3
-import incommon as ic # DB_PATH, open_db()
-
-HELP = """Usage: ls_users.py
-Prints one line per user binding as: "<username> <iface>".
-"""
-
-def tables_ok(conn: sqlite3.Connection) -> bool:
- row = conn.execute(
- """
- SELECT
- (SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='client'),
- (SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='User')
- """
- ).fetchone()
- return row == (1, 1)
-
-def list_users(conn: sqlite3.Connection) -> None:
- cur = conn.execute(
- """
- SELECT ub.username, c.iface
- FROM User ub
- JOIN Iface c ON c.id = ub.iface_id
- ORDER BY c.iface, ub.username
- """
- )
- for username, iface in cur.fetchall():
- print(f"{username} {iface}")
-
-def main(argv: list[str]) -> int:
- if argv and argv[0] in ("-h", "--help"):
- print(HELP.strip()); return 0
- try:
- with ic.open_db() as conn:
- if not tables_ok(conn):
- print("❌ Missing tables (client/User). Initialize the database first.", file=sys.stderr)
- return 1
- list_users(conn)
- return 0
- except (sqlite3.Error, FileNotFoundError) as e:
- print(f"❌ {e}", file=sys.stderr)
- return 2
-
-if __name__ == "__main__":
- sys.exit(main(sys.argv[1:]))
+++ /dev/null
-#+title: WireGuard Client — Reference
-#+author: Thomas / Aerenis
-#+startup: showall
-
-* Directory layout (wg/)
-- =schema.sql= :: SQLite schema for clients/servers/routes/meta (keys stored in DB).
-- =wg_client.db= :: SQLite DB (created by =db_init.sh=).
-- =db_init.sh= :: Creates/initializes DB from =schema.sql= (user-space).
-- =client_create_keys.sh= :: Creates a fresh client keypair for an =iface= and stores into DB.
-- =config_client_StanleyPark.sh= :: Upserts the StanleyPark client row (iface, addr, mtu, dns_mode, autostart, etc.).
-- =config_server_x6.sh= :: Upserts the remote server (“x6”) row linked to the client.
-- =bind_user.sh= :: Binds a Linux username (and resolves UID) to a client interface in DB.
-- =ls_clients.sh= :: Lists interface names only (one per line).
-- =ls_servers.sh= :: Lists server names (optionally grouped by client).
-- =ls_users.sh= :: Lists =<linux-user> <iface>= pairs.
-- =inspect.sh= :: Shows effective config from DB and current system state for a given iface.
-- =IP_rule_add_UID.sh= :: Helper installed to =/usr/local/bin= (adds =ip rule uidrange= entries idempotently).
-- =stage_generate.sh= :: Builds staged artifacts from DB:
- - =stage/wireguard/<iface>.conf=
- - =stage/systemd/wg-quick@<iface>.d/restart.conf=
- - =stage/usr/local/bin/routes_init_<iface>.sh=
- - copies =IP_rule_add_UID.sh= into stage for install
- - Offers to clean stage first; supports =--clean=, =--no-clean=, =--dry-clean=
-- =stage_install.sh= :: Copies staged files into:
- - =/etc/wireguard/<iface>.conf=
- - =/etc/systemd/system/wg-quick@<iface>.d/restart.conf=
- - =/usr/local/bin/routes_init_<iface>.sh=
- - =/usr/local/bin/IP_rule_add_UID.sh=
- - Reloads systemd daemon and prints next steps.
-- =stage_clean.sh= :: Empties =./stage= safely (with confirmation).
-- =routes_init_x6.sh= :: (Legacy) Example per-iface route script; superseded by staged =routes_init_<iface>.sh=
-- =deprecated/= :: Old scripts retained for reference.
-- =stage/= :: Generated artifacts awaiting installation.
-- =scratchpad/= :: (Optional) Temporary workspace for ad-hoc edits before installation.
-
-* Schema (summary)
-- =clients=
- - =iface= (TEXT UNIQUE): bare interface name (e.g., ‘x6’)
- - =rt_table_id= (INTEGER): e.g., 1002
- - =rt_table_name= (TEXT): defaults to iface if NULL (used by route scripts and =ip rule=)
- - =bound_user= (TEXT), =bound_uid= (INTEGER): Linux user + UID that should egress via this iface
- - =local_address_cidr=, =private_key=, =public_key=, =mtu=, =fwmark=
- - =dns_mode= (‘none’ or ‘static’), =dns_servers= (if static)
- - =autostart= (0/1)
-- =servers=
- - Linked by =client_id= → =clients.id=
- - =name= (‘x6’), =public_key=, optional =preshared_key=
- - =endpoint_host=, =endpoint_port=, =allowed_ips=, =keepalive_s=
- - =route_allowed_ips= (0/1): when 0, =Table= is set to =off= in wg conf and routing is handled by our scripts
- - =priority= (lower preferred) — first by priority then id is staged
-- =routes=
- - Linked by =client_id=
- - =cidr=, optional =via=, optional =table_name= (else use client rt name), optional =metric=
- - =on_up= (1/0), =on_down= (1/0) — generator emits only =on_up= routes in =routes_init_<iface>.sh=
-- =meta=
- - =schema= key describing current schema version/string
-
-* Generated files (stage/)
-- wireguard/<iface>.conf ::
- - =[Interface]= :: Address, PrivateKey, optional MTU/FwMark/DNS, optional =Table= off
- - =[Peer]= :: Server public key, optional PSK, Endpoint, AllowedIPs, optional PersistentKeepalive
-- systemd/wg-quick@<iface>.d/restart.conf ::
- - Restart policy; force fresh link; =ExecStartPost= hooks:
- - routes init script
- - =IP_rule_add_UID.sh <rt_table_name> <bound_uid>= (if bound)
- - logger line
-- usr/local/bin/routes_init_<iface>.sh ::
- - Installs default route to device in =rt_table_name= and a blackhole default guard
- - Adds any DB =routes= with =on_up=1
-
-* Operational Notes
-- =iface= names are bare (not prefixed with =wg_=). Systemd unit is =wg-quick@<iface>.service=.
-- Unbound rides the tunnel; leave WireGuard DNS unset (=dns_mode=none=) unless you want static DNS in the conf.
-- Copy-based install preserves an audit trail in =./stage=. Clean explicitly when desired.
-
-* Security
-- The DB contains *private keys*. Restrict permissions:
- #+begin_src bash
- chmod 600 wg_client.db
- #+end_src
-- Back up =wg_client.db= securely.
-
-* Troubleshooting
-- If unit fails to start: =journalctl -u wg-quick@<iface> -b=
-- Handshake age / peer state: =wg show=
-- Routing: =ip rule=, =ip route show table <rt_table_name>=
-- Regenerate & reinstall on mismatch:
- #+begin_src bash
- ./stage_generate.sh --clean && sudo ./stage_install.sh && sudo systemctl restart wg-quick@<iface>
- #+end_src
+++ /dev/null
-#+title: WireGuard Client — Admin User Guide
-#+author: Thomas / Aerenis
-#+startup: showall
-
-* Overview
-Authoritative state lives here:
-- ~/executable/setup/Debian12_client/wg/
-- Keys + config live in *SQLite* (./db/store).
-- You *stage* generated files in ./stage/, then *install* as root.
-- Interface names are *bare* (e.g., =x6=, =US=). Unit: =wg-quick@<iface>=; config: =/etc/wireguard/<iface>.conf=.
-- Unbound is used for DNS; typically =dns_mode= is =none= (no =DNS= line in WG conf).
-- Staging dirs are not auto-cleaned; each of =db/=, =stage/=, =scratchpad/= contains a =.gitignore= that ignores everything except itself.
-
-* Typical Workflow (example: x6)
-1) Initialize DB
-#+begin_src bash
-./db_init.sh
-#+end_src
-
-2) Create/Update *client* record for this host (inserts the =x6= row)
-#+begin_src bash
-./config_client_StanleyPark.sh
-#+end_src
-
-3) Create/rotate *client keys* (writes keys into DB for =x6=)
-#+begin_src bash
-./client_create_keys.sh x6
-#+end_src
-
-4) Configure the *remote server* record (x6)
-#+begin_src bash
-./config_server_x6.sh
-#+end_src
-
-5) Bind Linux user(s) to interface (traffic steering via uid rules)
-#+begin_src bash
-./user_to_iface.sh Thomas-x6 x6
-# or bulk:
-./user_all_to_iface.sh
-#+end_src
-Verify:
-#+begin_src bash
-./ls_users.sh
-#+end_src
-
-6) Generate staged files (will offer to clean ./stage first)
-#+begin_src bash
-./stage_generate.sh
-#+end_src
-Review contents of =./stage= (WG conf, systemd drop-in, route script).
-
-7) Install (as root) — copies staged files into the system
-#+begin_src bash
-sudo ./stage_install.sh
-#+end_src
-
-8) Enable & start the interface
-#+begin_src bash
-sudo systemctl enable wg-quick@x6
-sudo systemctl start wg-quick@x6
-#+end_src
-
-9) Inspect / validate
-#+begin_src bash
-./inspect.sh x6
-ip rule | grep x6
-ip route show table x6
-wg show
-#+end_src
-
-* Key Rotation (client)
-- Update keys in DB and redeploy:
-#+begin_src bash
-./client_create_keys.sh x6
-./stage_generate.sh --clean
-sudo ./stage_install.sh
-sudo systemctl restart wg-quick@x6
-#+end_src
-- Then update the *server’s* peer public key accordingly.
-
-* Listing helpers
-- Interfaces:
-#+begin_src bash
-./ls_clients.sh # prints: x6, US, ...
-#+end_src
-- Servers (per client):
-#+begin_src bash
-./ls_servers.sh # prints server names per client
-#+end_src
-- User bindings:
-#+begin_src bash
-./ls_users.sh # prints: <linux-user> <iface>
-#+end_src
-
-* Notes
-- =./stage= is not auto-cleaned. Use:
-#+begin_src bash
-./stage_clean.sh
-#+end_src
-- Protect your DB (contains private keys):
-#+begin_src bash
-chmod 700 db
-chmod 600 db/store
-#+end_src
+++ /dev/null
-
-*
-!.gitignore
-
+++ /dev/null
-
-*
-!.gitignore
-
+++ /dev/null
-#!/usr/bin/env python3
-"""
-stage_IP_apply_script.py
-
-Given:
- - A SQLite DB (schema you’ve defined), with:
- * Iface(id, iface, local_address_cidr, rt_table_name, rt_table_id)
- * v_iface_effective(id, rt_table_name_eff, local_address_cidr)
- * Route(iface_id, cidr, via, table_name, metric, on_up, on_down)
- * "User"(iface_id, username, uid) — table formerly User_Binding
- * Meta(key='subu_cidr', value)
- - A list of interface names to include (e.g., ["x6","US"]).
-
-Does:
- - Reads DB once and *synthesizes a single* idempotent runtime script
- that, for the selected interfaces, on each `wg-quick@IFACE` start:
- 1) resets IPv4 addresses on the iface (delete-if-present, then add)
- 2) ensures all configured routes exist (using `ip -4 route replace`)
- 3) resets policy rules by preference number (delete-by-pref, then add)
- with **per-iface prefs** to avoid collisions.
- - Stages that script under: stage/usr/local/bin/<script_name>
- - Stages per-iface systemd drop-ins:
- stage/etc/systemd/system/wg-quick@IFACE.service.d/<prio>-postup-IP-state.conf
- which call the script (default prio = 20).
- - Stages a merged copy of rt_tables (does not write the live /etc/iproute2/rt_tables).
-
-Returns:
- (script_path, notes[list of strings])
-
-Errors:
- - Raises RuntimeError if no interfaces provided or there’s nothing to emit.
- - Does not modify kernel state — this is staging only.
-
-Notes:
- - Addresses: reset pattern (del → add) for deterministic convergence.
- - Routes: `ip -4 route replace` (best-practice) with tolerant logging.
- - Rules: reset by `pref` (del-by-pref → add). Prefs are unique per iface:
- base = 17000 + Iface.id * 10
- from_pref = base + 0
- uid_pref = base + 1
- - The runtime script accepts optional IFACE args to limit application.
-"""
-
-from __future__ import annotations
-from pathlib import Path
-from typing import Dict, Iterable, List, Optional, Sequence, Tuple
-import argparse
-import sqlite3
-import sys
-
-import incommon as ic # expected: open_db()
-
-ROOT = Path(__file__).resolve().parent
-STAGE_ROOT = ROOT / "stage"
-
-RT_TABLES_PATH = Path("/etc/iproute2/rt_tables")
-
-
-# ---------- helpers for notes ----------
-
-def _stage_note(path: Path, stage_root: Path) -> str:
- """Return a short path like 'stage:/usr/local/bin/apply_IP_state.sh'."""
- try:
- rel = path.relative_to(stage_root)
- return f"stage:/{rel.as_posix()}"
- except ValueError:
- return str(path)
-
-
-# ---------- rt_tables helpers ----------
-
-def _parse_rt_tables(path: Path) -> Tuple[List[str], Dict[str, int], set[int]]:
- """
- Returns (lines, name_to_num, used_nums).
- Keeps original lines for a non-destructive merge.
- """
- text = path.read_text() if path.exists() else ""
- lines = text.splitlines()
- name_to_num: Dict[str, int] = {}
- used_nums: set[int] = set()
- for ln in lines:
- s = ln.strip()
- if not s or s.startswith("#"):
- continue
- parts = s.split()
- if len(parts) >= 2 and parts[0].isdigit():
- n = int(parts[0]); nm = parts[1]
- if nm not in name_to_num and n not in used_nums:
- name_to_num[nm] = n
- used_nums.add(n)
- return (lines, name_to_num, used_nums)
-
-
-def _first_free_id(used_nums: Iterable[int], low: int, high: int) -> int:
- used = set(used_nums)
- for n in range(low, high + 1):
- if n not in used:
- return n
- raise RuntimeError(f"no free routing-table IDs in [{low},{high}]")
-
-
-def _stage_rt_tables(
- stage_root: Path,
- meta: Dict[str, Tuple[int, Optional[int], str, Optional[str]]],
- low: int = 20000,
- high: int = 29999
-) -> Tuple[Path, List[str]]:
- """
- Ensure entries for all effective table names present in `meta`.
- Prefer DB rt_table_id when available and not conflicting.
- Write merged file to stage/etc/iproute2/rt_tables.
- Returns (staged_path, notes)
- """
- lines, name_to_num, used_nums = _parse_rt_tables(RT_TABLES_PATH)
-
- # Build eff_name -> preferred_num mapping (first non-None rt_id wins)
- eff_to_preferred: Dict[str, Optional[int]] = {}
- for _n, (_iid, rtid, eff, _cidr) in meta.items():
- if eff not in eff_to_preferred:
- eff_to_preferred[eff] = rtid if rtid is not None else None
-
- additions: List[Tuple[int, str]] = []
- for eff_name, preferred_num in eff_to_preferred.items():
- if eff_name in name_to_num:
- continue # already present
- if preferred_num is not None and preferred_num not in used_nums:
- num = preferred_num
- else:
- num = _first_free_id(used_nums, low, high)
- name_to_num[eff_name] = num
- used_nums.add(num)
- additions.append((num, eff_name))
-
- out = stage_root / "etc" / "iproute2" / "rt_tables"
- out.parent.mkdir(parents=True, exist_ok=True)
-
- if not additions:
- # still write a copy of current file so install step is uniform
- out.write_text("\n".join(lines) + ("\n" if lines else ""))
- return (out, ["rt_tables: no additions (kept existing map)"])
-
- new_lines = list(lines)
- for num, name in sorted(additions):
- new_lines.append(f"{num} {name}")
-
- out.write_text("\n".join(new_lines) + "\n")
- notes = [f"rt_tables: add {num} {name}" for num, name in sorted(additions)]
- return (out, notes)
-
-
-# ---------- DB access ----------
-
-def _fetch_meta_subu_cidr(conn: sqlite3.Connection, default="10.0.0.0/24") -> str:
- row = conn.execute("SELECT value FROM Meta WHERE key='subu_cidr' LIMIT 1;").fetchone()
- return str(row[0]) if row and row[0] else default
-
-
-def _fetch_iface_meta(conn: sqlite3.Connection, iface_names: Sequence[str]) -> Dict[str, Tuple[int, Optional[int], str, Optional[str]]]:
- """
- Return {iface_name -> (iface_id, rt_table_id, rt_table_name_eff, local_address_cidr_or_None)}.
- """
- if not iface_names:
- return {}
- ph = ",".join("?" for _ in iface_names)
- sql = f"""
- SELECT i.id,
- i.iface,
- i.rt_table_id,
- v.rt_table_name_eff,
- NULLIF(TRIM(v.local_address_cidr),'') AS cidr
- FROM Iface i
- JOIN v_iface_effective v ON v.id = i.id
- WHERE i.iface IN ({ph})
- ORDER BY i.id;
- """
- rows = conn.execute(sql, tuple(iface_names)).fetchall()
- out: Dict[str, Tuple[int, Optional[int], str, Optional[str]]] = {}
- for r in rows:
- iface_id = int(r[0]); name = str(r[1])
- rt_id = (int(r[2]) if r[2] is not None else None)
- eff = str(r[3])
- cidr = (str(r[4]) if r[4] is not None else None)
- out[name] = (iface_id, rt_id, eff, cidr)
- return out
-
-
-def _fetch_routes_by_iface_id(
- conn: sqlite3.Connection,
- iface_ids: Sequence[int],
- only_on_up: bool = True
-) -> Dict[int, List[Tuple[str, Optional[str], Optional[str], Optional[int]]]]:
- """
- Return {iface_id -> [(cidr, via, table_name_or_None, metric_or_None), ...]}.
- """
- if not iface_ids:
- return {}
- ph = ",".join("?" for _ in iface_ids)
- sql = f"""
- SELECT iface_id,
- cidr,
- NULLIF(TRIM(via),'') AS via,
- NULLIF(TRIM(table_name),'') AS table_name,
- metric,
- on_up
- FROM Route
- WHERE iface_id IN ({ph})
- ORDER BY id;
- """
- rows = conn.execute(sql, tuple(iface_ids)).fetchall()
- out: Dict[int, List[Tuple[str, Optional[str], Optional[str], Optional[int]]]] = {}
- for iface_id, cidr, via, tname, metric, on_up in rows:
- if only_on_up and int(on_up) != 1:
- continue
- out.setdefault(int(iface_id), []).append(
- (str(cidr),
- (str(via) if via is not None else None),
- (str(tname) if tname is not None else None),
- (int(metric) if metric is not None else None))
- )
- return out
-
-
-def _fetch_uids_by_iface_id(conn: sqlite3.Connection, iface_ids: Sequence[int]) -> Dict[int, List[int]]:
- """
- Return {iface_id -> [uid, ...]} using table "User".
- """
- if not iface_ids:
- return {}
- ph = ",".join("?" for _ in iface_ids)
- sql = f"""
- SELECT iface_id,
- uid
- FROM "User"
- WHERE iface_id IN ({ph})
- AND uid IS NOT NULL
- AND CAST(uid AS TEXT) != ''
- ORDER BY iface_id, uid;
- """
- rows = conn.execute(sql, tuple(iface_ids)).fetchall()
- out: Dict[int, List[int]] = {}
- for iface_id, uid in rows:
- out.setdefault(int(iface_id), []).append(int(uid))
- return out
-
-
-# ---------- rendering ----------
-
-def _render_composite_script(
- plan_ifaces: List[str],
- meta: Dict[str, Tuple[int, Optional[int], str, Optional[str]]],
- routes_by_id: Dict[int, List[Tuple[str, Optional[str], Optional[str], Optional[int]]]],
- uids_by_id: Dict[int, List[int]],
- subu_cidr: str
-) -> str:
- """
- Build a single bash script that ensures addresses → routes → rules.
- """
- lines: List[str] = [
- "#!/usr/bin/env bash",
- "# apply IP state for selected interfaces (addresses, routes, rules) — idempotent",
- "set -euo pipefail",
- "",
- "ALL_ARGS=(\"$@\")",
- "",
- "want_iface(){",
- " local t=$1",
- " if [ ${#ALL_ARGS[@]} -eq 0 ]; then return 0; fi",
- " for a in \"${ALL_ARGS[@]}\"; do [ \"$a\" = \"$t\" ] && return 0; done",
- " return 1",
- "}",
- "",
- "exists_iface(){ ip -o link show dev \"$1\" >/dev/null 2>&1; }",
- "",
- "# Reset address: delete the exact CIDR if present, then add it back.",
- "reset_addr(){",
- " local iface=$1; local cidr=$2",
- " ip -4 addr del \"$cidr\" dev \"$iface\" >/dev/null 2>&1 || true",
- " if ip -4 addr add \"$cidr\" dev \"$iface\"; then",
- " logger \"addr set: $iface $cidr\"",
- " else",
- " logger \"addr add failed (non-fatal): $iface $cidr\"",
- " fi",
- "}",
- "",
- "# Ensure route using replace; log but do not fail the unit if kernel says 'exists'.",
- "ensure_route(){",
- " local table=$1; local cidr=$2; local dev=$3; local via=${4:-}; local metric=${5:-}",
- " if [ -n \"$via\" ] && [ -n \"$metric\" ]; then",
- " if ip -4 route replace \"$cidr\" via \"$via\" dev \"$dev\" table \"$table\" metric \"$metric\" 2>/dev/null; then",
- " logger \"route ensure: table=$table cidr=$cidr dev=$dev via=$via metric=$metric\"",
- " else",
- " logger \"route ensure (tolerated failure): table=$table cidr=$cidr dev=$dev via=$via metric=$metric\"",
- " fi",
- " elif [ -n \"$via\" ]; then",
- " if ip -4 route replace \"$cidr\" via \"$via\" dev \"$dev\" table \"$table\" 2>/dev/null; then",
- " logger \"route ensure: table=$table cidr=$cidr dev=$dev via=$via\"",
- " else",
- " logger \"route ensure (tolerated failure): table=$table cidr=$cidr dev=$dev via=$via\"",
- " fi",
- " elif [ -n \"$metric\" ]; then",
- " if ip -4 route replace \"$cidr\" dev \"$dev\" table \"$table\" metric \"$metric\" 2>/dev/null; then",
- " logger \"route ensure: table=$table cidr=$cidr dev=$dev metric=$metric\"",
- " else",
- " logger \"route ensure (tolerated failure): table=$table cidr=$cidr dev=$dev metric=$metric\"",
- " fi",
- " else",
- " if ip -4 route replace \"$cidr\" dev \"$dev\" table \"$table\" 2>/dev/null; then",
- " logger \"route ensure: table=$table cidr=$cidr dev=$dev\"",
- " else",
- " logger \"route ensure (tolerated failure): table=$table cidr=$cidr dev=$dev\"",
- " fi",
- " fi",
- "}",
- "",
- "# Reset a policy rule by numeric preference: delete-by-pref, then add.",
- "reset_IP_rule(){",
- " # Usage: reset_IP_rule <pref> <rule-args...>",
- " local pref=$1; shift",
- " ip -4 rule del pref \"$pref\" >/dev/null 2>&1 || true",
- " if ip -4 rule add \"$@\" pref \"$pref\"; then",
- " logger \"rule set: pref=$pref $*\"",
- " else",
- " logger \"rule add failed (non-fatal): pref=$pref $*\"",
- " fi",
- "}",
- "",
- ]
-
- any_action = False
-
- # 1) Addresses (reset)
- for name in plan_ifaces:
- _iid, _rtid, rtname, cidr = meta[name]
- if cidr:
- lines += [
- f'if want_iface {name}; then',
- f' if exists_iface {name}; then reset_addr {name} {cidr}; else logger "skip: iface missing: {name}"; fi',
- 'fi'
- ]
- any_action = True
-
- # 2) Routes
- for name in plan_ifaces:
- iid, _rtid, rtname, _cidr = meta[name]
- rows = routes_by_id.get(iid, [])
- for cidr, via, t_override, metric in rows:
- table_eff = t_override or rtname
- viastr = (via if via is not None else "")
- mstr = (str(metric) if metric is not None else "")
- lines += [
- f'if want_iface {name}; then',
- f' if exists_iface {name}; then ensure_route "{table_eff}" "{cidr}" "{name}" "{viastr}" "{mstr}"; else logger "skip: iface missing: {name}"; fi',
- 'fi'
- ]
- any_action = True
-
- # 3) Rules (reset by pref: src-cidr, uids, and one global prohibit)
- for name in plan_ifaces:
- iid, _rtid, rtname, cidr = meta[name]
-
- # Per-iface preference block (no collisions)
- base_pref = 17000 + iid * 10
- from_pref = base_pref + 0
- uid_pref = base_pref + 1
-
- if cidr:
- lines += [
- f'if want_iface {name}; then',
- f' reset_IP_rule {from_pref} from "{cidr}" lookup "{rtname}"',
- 'fi'
- ]
- any_action = True
-
- uids = uids_by_id.get(iid, [])
- for u in uids:
- lines += [
- f'if want_iface {name}; then',
- f' reset_IP_rule {uid_pref} uidrange "{u}-{u}" lookup "{rtname}"',
- 'fi'
- ]
- any_action = True
-
- if subu_cidr:
- lines += [
- f'reset_IP_rule 18050 from "{subu_cidr}" prohibit'
- ]
- any_action = True
-
- if not any_action:
- raise RuntimeError("no IP state to emit for requested interfaces")
-
- lines += [""]
- return "\n".join(lines)
-
-
-def _write_dropin_for_iface(stage_root: Path ,iface: str ,script_name: str ,priority: int) -> Path:
- # correct systemd path: /etc/systemd/system/wg-quick@IFACE.service.d/
- d = stage_root / "etc" / "systemd" / "system" / f"wg-quick@{iface}.service.d"
- d.mkdir(parents=True ,exist_ok=True)
- p = d / f"{priority}-postup-IP-state.conf"
- content = (
- "[Service]\n"
- f"ExecStartPost=+/usr/local/bin/{script_name} {iface}\n"
- )
- p.write_text(content)
- return p
-
-
-# ---------- business ----------
-
-def stage_IP_apply_script(
- conn: sqlite3.Connection,
- iface_names: Sequence[str],
- stage_root: Optional[Path] = None,
- script_name: str = "apply_IP_state.sh",
- dropin_priority: int = 20,
- only_on_up: bool = True,
- with_dropins: bool = True,
- dry_run: bool = False
-) -> Tuple[Path, List[str]]:
- """
- Plan and stage the unified runtime script, a merged rt_tables, and per-iface drop-ins.
- """
- if not iface_names:
- raise RuntimeError("no interfaces provided")
-
- meta = _fetch_iface_meta(conn, iface_names)
- if not meta:
- raise RuntimeError("none of the requested interfaces exist in DB")
-
- # preserve caller order but skip unknowns (already handled above)
- ifaces_in_order = [n for n in iface_names if n in meta]
- iface_ids = [meta[n][0] for n in ifaces_in_order]
-
- routes_by_id = _fetch_routes_by_iface_id(conn, iface_ids, only_on_up=only_on_up)
- uids_by_id = _fetch_uids_by_iface_id(conn, iface_ids)
- subu_cidr = _fetch_meta_subu_cidr(conn, default="10.0.0.0/24")
-
- sr = stage_root or STAGE_ROOT
- out = sr / "usr" / "local" / "bin" / script_name
- out.parent.mkdir(parents=True, exist_ok=True)
-
- content = _render_composite_script(ifaces_in_order, meta, routes_by_id, uids_by_id, subu_cidr)
-
- notes: List[str] = []
- if dry_run:
- notes.append(f"dry-run: would write {_stage_note(out, sr)}")
- if with_dropins:
- for n in ifaces_in_order:
- notes.append(f"dry-run: would write {_stage_note(sr / 'etc' / 'systemd' / 'system' / f'wg-quick@{n}.service.d' / f'{dropin_priority}-postup-IP-state.conf', sr)}")
- rt_out = sr / "etc" / "iproute2" / "rt_tables"
- notes.append(f"dry-run: would write {_stage_note(rt_out, sr)}")
- return (out, notes)
-
- # ensure rt_tables entries for the effective names used by these ifaces
- rt_path, rt_notes = _stage_rt_tables(sr, meta)
- notes.extend(rt_notes)
- notes.append(f"staged: {_stage_note(rt_path, sr)}")
-
- out.write_text(content)
- out.chmod(0o500)
- notes.append(f"staged: {_stage_note(out, sr)}")
-
- if with_dropins:
- for n in ifaces_in_order:
- dp = _write_dropin_for_iface(sr, n, script_name, dropin_priority)
- notes.append(f"staged: {_stage_note(dp, sr)}")
-
- return (out, notes)
-
-# Backwards-compatible alias for callers that still import the old name.
-stage_ip_apply_script = stage_IP_apply_script
-
-
-# ---------- CLI ----------
-
-def main(argv=None) -> int:
- ap = argparse.ArgumentParser(description="Stage one script that applies IP addresses, routes, and rules for selected ifaces.")
- ap.add_argument("ifaces", nargs="+", help="interface names to include")
- ap.add_argument("--script-name", default="apply_IP_state.sh")
- ap.add_argument("--dropin-priority", type=int, default=20)
- ap.add_argument("--all", action="store_true", help="include routes where on_up=0 as well")
- ap.add_argument("--no-dropins", action="store_true", help="do not stage systemd drop-ins")
- ap.add_argument("--dry-run", action="store_true")
- args = ap.parse_args(argv)
-
- with ic.open_db() as conn:
- try:
- out, notes = stage_IP_apply_script(
- conn,
- args.ifaces,
- script_name=args.script_name,
- dropin_priority=args.dropin_priority,
- only_on_up=(not args.all),
- with_dropins=(not args.no_dropins),
- dry_run=args.dry_run
- )
- except Exception as e:
- print(f"error: {e}", file=sys.stderr)
- return 2
-
- if notes:
- print("\n".join(notes))
- return 0
-
-
-if __name__ == "__main__":
- sys.exit(main())
+++ /dev/null
-#!/usr/bin/env python3
-"""
-stage_StanleyPark.py
-
-Minimal config wrapper for client 'StanleyPark'.
-Calls the generic stage orchestrator with the chosen ifaces.
-"""
-
-from __future__ import annotations
-from stage_client import stage_client_artifacts
-
-CLIENT = "StanleyPark"
-IFACES = ["x6","US"] # keep this list minimal & declarative
-
-if __name__ == "__main__":
- ok = stage_client_artifacts(
- CLIENT
- ,IFACES
- )
- raise SystemExit(0 if ok else 2)
+++ /dev/null
-#!/usr/bin/env python3
-"""
-stage_client.py
-
-Given:
- - A SQLite DB via incommon.open_db()
- - A client machine name (for WG PrivateKey lookup under ./key/<client>)
- - One or more interface names (e.g., x6, US)
-
-Does:
- 1) Stage WireGuard confs for each iface
- 2) Stage a unified IP apply script (addresses, routes, rules) + per-iface drop-ins
-
-Returns:
- True on success, False on failure (prints progress)
-"""
-
-from __future__ import annotations
-from pathlib import Path
-from typing import Callable ,Optional ,Sequence ,Tuple
-import argparse
-import subprocess
-import sys
-
-import incommon as ic # open_db()
-
-ROOT = Path(__file__).resolve().parent
-STAGE_ROOT = ROOT / "stage"
-
-
-def _msg_wrapped_call(label: str ,fn: Callable[[], Tuple[Path ,Sequence[str]]]) -> bool:
- print(f"→ {label}")
- try:
- path ,notes = fn()
- for n in notes:
- print(n)
- if path:
- print(f"✔ {label}: staged: {path}")
- else:
- print(f"✔ {label}")
- return True
- except Exception as e:
- print(f"❌ {label}: {e}")
- return False
-
-
-def _call_cli(argv: Sequence[str]) -> Tuple[Path ,Sequence[str]]:
- cp = subprocess.run(list(argv) ,text=True ,capture_output=True)
- if cp.returncode != 0:
- raise RuntimeError(cp.stderr.strip() or f"exit {cp.returncode}")
- notes = []
- staged_path: Optional[Path] = None
- for line in (cp.stdout or "").splitlines():
- notes.append(line)
- if line.startswith("staged: "):
- try:
- staged_path = Path(line.split("staged:",1)[1].strip())
- except Exception:
- pass
- return (staged_path or STAGE_ROOT ,notes)
-
-
-def _stage_wg_conf_step(client_name: str ,ifaces: Sequence[str]) -> bool:
- def _do():
- try:
- from stage_wg_conf import stage_wg_conf # type: ignore
- with ic.open_db() as conn:
- path ,notes = stage_wg_conf(
- conn
- ,ifaces
- ,client_name
- ,stage_root=STAGE_ROOT
- ,dry_run=False
- )
- return (path ,notes)
- except Exception:
- return _call_cli([str(ROOT / "stage_wg_conf.py") ,client_name ,*ifaces])
- return _msg_wrapped_call(f"stage_wg_conf ({client_name}; {','.join(ifaces)})" ,_do)
-
-
-def _stage_apply_ip_state_step(ifaces: Sequence[str]) -> bool:
- def _do():
- try:
- from stage_IP_apply_script import stage_ip_apply_script # type: ignore
- with ic.open_db() as conn:
- path ,notes = stage_ip_apply_script(
- conn
- ,ifaces
- ,stage_root=STAGE_ROOT
- ,script_name="apply_ip_state.sh"
- ,only_on_up=True
- ,dry_run=False
- )
- return (path ,notes)
- except Exception:
- return _call_cli([str(ROOT / "stage_IP_apply_script.py") ,*ifaces])
- return _msg_wrapped_call(f"stage_IP_apply_script ({','.join(ifaces)})" ,_do)
-
-
-def stage_client_artifacts(
- client_name: str
- ,iface_names: Sequence[str]
- ,stage_root: Optional[Path] = None
-) -> bool:
- if not iface_names:
- raise ValueError("no interfaces provided")
- if stage_root:
- global STAGE_ROOT
- STAGE_ROOT = stage_root
-
- STAGE_ROOT.mkdir(parents=True ,exist_ok=True)
-
- ok = True
- ok = _stage_wg_conf_step(client_name ,iface_names) and ok
- ok = _stage_apply_ip_state_step(iface_names) and ok
- return ok
-
-
-def main(argv: Optional[Sequence[str]] = None) -> int:
- ap = argparse.ArgumentParser(description="Stage all artifacts for a client.")
- ap.add_argument("--client" ,required=True ,help="client machine name (for key lookup)")
- ap.add_argument("ifaces" ,nargs="+")
- args = ap.parse_args(argv)
-
- ok = stage_client_artifacts(
- args.client
- ,args.ifaces
- )
- return 0 if ok else 2
-
-
-if __name__ == "__main__":
- sys.exit(main())
+++ /dev/null
-#!/usr/bin/env python3
-"""
-stage_wg_conf.py
-
-Given:
- - SQLite DB reachable via incommon.open_db()
- - A list of interface names (e.g., x6 ,US)
- - client_machine_name used to locate the private key file under ./key/<client_machine_name>
-
-Does:
- - For each iface, stage a minimal WireGuard config to stage/etc/wireguard/<iface>.conf:
- [Interface]
- PrivateKey = <from ./key/<client_machine_name>>
- Table = off
- ListenPort = <Iface.listen_port> (if the column exists and value is not NULL)
- # ListenPort = 51820 (commented if value is absent)
- [Peer] (one per Server row for that iface)
- PublicKey = <Server.public_key>
- PresharedKey = <Server.preshared_key> (only if present)
- AllowedIPs = <Server.allowed_ips>
- Endpoint = <Server.endpoint_host>:<Server.endpoint_port>
- PersistentKeepalive = <Server.keepalive_s> (only if present)
- - Omits Address ,PostUp ,SaveConfig (your systemd drop-in + script handle L3 state)
-
-Returns:
- - (list_of_staged_paths ,notes)
-
-Errors:
- - Missing private key file
- - Iface not found
- - Server rows missing required fields for that iface
-"""
-
-from __future__ import annotations
-from pathlib import Path
-from typing import Dict ,Iterable ,List ,Optional ,Sequence ,Tuple
-import argparse
-import sqlite3
-import sys
-
-import incommon as ic # expected: open_db()
-
-ROOT = Path(__file__).resolve().parent
-STAGE_ROOT = ROOT / "stage"
-
-
-# ---------- helpers ----------
-
-def _has_column(conn: sqlite3.Connection ,table: str ,col: str) -> bool:
- cur = conn.execute(f"PRAGMA table_info({table});")
- cols = [str(r[1]) for r in cur.fetchall()]
- return col in cols
-
-
-def _read_private_key(client_machine_name: str ,key_root: Optional[Path] = None) -> str:
- kr = key_root or (ROOT / "key")
- path = kr / client_machine_name
- if not path.exists():
- raise RuntimeError(f"private key file missing: {path}")
- text = path.read_text().strip()
- if not text:
- raise RuntimeError(f"private key file empty: {path}")
- # WireGuard private keys are base64 (typically 44 chars), but don't over-validate here.
- return text
-
-
-# ---------- DB ----------
-
-def _fetch_iface_ids_and_ports(
- conn: sqlite3.Connection
- ,iface_names: Sequence[str]
-) -> Dict[str ,Tuple[int ,Optional[int]]]:
- """
- Return {iface_name -> (iface_id ,listen_port_or_None)} for requested names.
- If the listen_port column does not exist, value is None.
- """
- if not iface_names:
- return {}
- ph = ",".join("?" for _ in iface_names)
- has_lp = _has_column(conn ,"Iface" ,"listen_port")
- select_lp = ", i.listen_port" if has_lp else ", NULL as listen_port"
- sql = f"""
- SELECT i.id
- , i.iface
- {select_lp}
- FROM Iface i
- WHERE i.iface IN ({ph})
- ORDER BY i.id;
- """
- rows = conn.execute(sql ,tuple(iface_names)).fetchall()
- out: Dict[str ,Tuple[int ,Optional[int]]] = {}
- for iid ,name ,lp in rows:
- out[str(name)] = (int(iid) ,(int(lp) if lp is not None else None))
- return out
-
-
-def _fetch_peers_for_iface(
- conn: sqlite3.Connection
- ,iface_id: int
-) -> List[Tuple[str ,Optional[str] ,str ,int ,str ,Optional[int] ,int ,int]]:
- """
- Return peers as tuples:
- (public_key ,preshared_key ,endpoint_host ,endpoint_port ,allowed_ips ,keepalive_s ,priority ,id)
- """
- sql = """
- SELECT public_key
- , NULLIF(TRIM(preshared_key),'') as preshared_key
- , endpoint_host
- , endpoint_port
- , allowed_ips
- , keepalive_s
- , priority
- , id
- FROM Server
- WHERE iface_id = ?
- ORDER BY priority ASC , id ASC;
- """
- rows = conn.execute(sql ,(iface_id,)).fetchall()
- out: List[Tuple[str ,Optional[str] ,str ,int ,str ,Optional[int] ,int ,int]] = []
- for pub ,psk ,host ,port ,alips ,ka ,prio ,sid in rows:
- out.append((str(pub) ,(str(psk) if psk is not None else None) ,str(host) ,int(port) ,str(alips) ,(int(ka) if ka is not None else None) ,int(prio) ,int(sid)))
- return out
-
-
-# ---------- rendering ----------
-
-def _render_conf(
- iface_name: str
- ,private_key: str
- ,listen_port: Optional[int]
- ,peers: Sequence[Tuple[str ,Optional[str] ,str ,int ,str ,Optional[int] ,int ,int]]
-) -> str:
- lines: List[str] = []
- lines += [
- "[Interface]"
- ,f"PrivateKey = {private_key}"
- ,"Table = off"
- ]
- if listen_port is not None:
- lines.append(f"ListenPort = {listen_port}")
- else:
- lines.append("# ListenPort = 51820")
-
- lines.append("") # blank before peers
-
- if not peers:
- # You may choose to raise instead; keeping an empty peer set is valid but rarely useful.
- lines.append("# (no peers found for this interface)")
-
- for pub ,psk ,host ,port ,alips ,ka ,_prio ,_sid in peers:
- lines += [
- "[Peer]"
- ,f"PublicKey = {pub}"
- ]
- if psk is not None:
- lines.append(f"PresharedKey = {psk}")
- lines += [
- f"AllowedIPs = {alips}"
- ,f"Endpoint = {host}:{port}"
- ]
- if ka is not None:
- lines.append(f"PersistentKeepalive = {ka}")
- lines.append("") # blank line between peers
-
- return "\n".join(lines).rstrip() + "\n"
-
-
-# ---------- business ----------
-
-def stage_wg_conf(
- conn: sqlite3.Connection
- ,iface_names: Sequence[str]
- ,client_machine_name: str
- ,stage_root: Optional[Path] = None
- ,dry_run: bool = False
-) -> Tuple[List[Path] ,List[str]]:
- """
- Stage /etc/wireguard/<iface>.conf for selected ifaces under stage root.
- """
- if not iface_names:
- raise RuntimeError("no interfaces provided")
- priv = _read_private_key(client_machine_name)
-
- meta = _fetch_iface_ids_and_ports(conn ,iface_names)
- if not meta:
- raise RuntimeError("none of the requested interfaces exist in DB")
-
- staged: List[Path] = []
- notes: List[str] = []
- sr = stage_root or STAGE_ROOT
- outdir = sr / "etc" / "wireguard"
- outdir.mkdir(parents=True ,exist_ok=True)
-
- for name in iface_names:
- if name not in meta:
- notes.append(f"skip: iface '{name}' missing from DB")
- continue
-
- iface_id ,listen_port = meta[name]
- peers = _fetch_peers_for_iface(conn ,iface_id)
-
- # basic validation of required peer fields
- bad = []
- for pub ,_psk ,host ,port ,alips ,_ka ,_prio ,sid in peers:
- if not pub or not host or not alips or not (1 <= int(port) <= 65535):
- bad.append(sid)
- if bad:
- raise RuntimeError(f"iface '{name}': invalid peer rows id={bad}")
-
- conf_text = _render_conf(name ,priv ,listen_port ,peers)
-
- out = outdir / f"{name}.conf"
- if dry_run:
- notes.append(f"dry-run: would write {out}")
- else:
- out.write_text(conf_text)
- out.chmod(0o600)
- staged.append(out)
- notes.append(f"staged: {out}")
-
- if not staged and not dry_run:
- raise RuntimeError("nothing staged (all missing or skipped)")
-
- return (staged ,notes)
-
-
-# ---------- CLI ----------
-
-def main(argv=None) -> int:
- ap = argparse.ArgumentParser(description="Stage minimal WireGuard configs with Table=off and no Address.")
- ap.add_argument("client_machine_name" ,help="name used to read ./key/<client_machine_name>")
- ap.add_argument("ifaces" ,nargs="+" ,help="interface names to stage")
- ap.add_argument("--dry-run" ,action="store_true")
- args = ap.parse_args(argv)
-
- with ic.open_db() as conn:
- try:
- paths ,notes = stage_wg_conf(
- conn
- ,args.ifaces
- ,args.client_machine_name
- ,dry_run=args.dry_run
- )
- except Exception as e:
- print(f"error: {e}" ,file=sys.stderr)
- return 2
-
- if notes:
- print("\n".join(notes))
- return 0
-
-
-if __name__ == "__main__":
- sys.exit(main())
+++ /dev/null
-#!/usr/bin/env python3
-# stage_wipe.py — safely wipe ./stage (keeps hidden files unless --hard)
-
-from __future__ import annotations
-import argparse, shutil, sys
-from pathlib import Path
-
-ROOT = Path(__file__).resolve().parent
-STAGE_ROOT = ROOT / "stage"
-
-def wipe_stage(*, yes: bool=False, dry_run: bool=False, hard: bool=False) -> int:
- """Given flags, deletes staged output. Keeps dotfiles unless hard=True."""
- st = STAGE_ROOT
- if not st.exists():
- print(f"Nothing to wipe: {st} does not exist.")
- return 0
-
- # safety: only operate on ./stage relative to this repo folder
- if st.resolve() != (ROOT / "stage").resolve():
- print(f"Refusing: unsafe STAGE path: {st}", file=sys.stderr)
- return 1
-
- # quick stats
- try:
- count = sum(1 for _ in st.rglob("*"))
- except Exception:
- count = 0
-
- if dry_run:
- print(f"DRY RUN — would wipe: {st} (items: {count})")
- for p in sorted(st.iterdir()):
- print(f" {p.name}")
- return 0
-
- if not yes:
- try:
- ans = input(f"Permanently delete contents of {st}? [y/N] ").strip()
- except EOFError:
- ans = ""
- if ans.lower() not in ("y","yes"):
- print("Aborted.")
- return 0
-
- if hard:
- shutil.rmtree(st, ignore_errors=True)
- print(f"Removed stage dir: {st}")
- else:
- # remove non-hidden entries only; keep dotfiles (e.g. .gitignore)
- for p in st.iterdir():
- if p.name.startswith("."):
- continue # preserve hidden entries
- try:
- if p.is_dir():
- shutil.rmtree(p, ignore_errors=True)
- else:
- p.unlink(missing_ok=True)
- except Exception:
- pass
- print(f"Cleared contents of: {st} (hidden files preserved)")
- return 0
-
-def main(argv):
- ap = argparse.ArgumentParser()
- ap.add_argument("--yes", action="store_true", help="do not prompt")
- ap.add_argument("--dry-run", action="store_true", help="show what would be removed")
- ap.add_argument("--hard", action="store_true", help="remove the stage dir itself")
- args = ap.parse_args(argv)
- return wipe_stage(yes=args.yes, dry_run=args.dry_run, hard=args.hard)
-
-if __name__ == "__main__":
- sys.exit(main(sys.argv[1:]))
+++ /dev/null
-#!/usr/bin/env python3
-"""
-start_iface.py
-
-Given:
- - One or more WireGuard interface names (e.g., x6, US).
- - Optional presence of systemd and wg-quick(8).
- - Expected config at /etc/wireguard/<iface>.conf.
- - Optional staged IP state script at /usr/local/bin/apply_ip_state.sh.
-
-Does:
- - For each iface (best-effort, non-fatal steps):
- 0) (optional) systemctl daemon-reload
- 1) Start via systemd: systemctl start wg-quick@IFACE.service (unless --no-systemd)
- else via wg-quick: wg-quick up IFACE (unless --no-wg-quick)
- If the iface already exists and --force is given, it will attempt a
- best-effort teardown then retry the start once.
- 2) If started (or already present), optionally run IP state script:
- /usr/local/bin/apply_ip_state.sh IFACE (unless --skip-ip-state)
- - Logs each action taken or skipped.
-
-Returns:
- - Exit 0 on success (even if some steps were no-ops); 2 on argument/privilege errors.
- - Prints a concise, per-iface action log.
-
-Errors:
- - If no ifaces are provided, or if not running as root (unless --force-nonroot).
-
-Notes:
- - This does NOT edit config files or DB; it just brings the iface up cleanly.
- - Safe to re-run: “already up/exist” conditions are handled. Use --force to
- tear down and recreate if needed.
-"""
-
-from __future__ import annotations
-from pathlib import Path
-from typing import Iterable, List, Sequence
-import argparse
-import os
-import shutil
-import subprocess
-import sys
-
-
-# ---------- helpers ----------
-
-def _run(cmd: Sequence[str]) -> tuple[int, str, str]:
- """Run a command, capture stdout/stderr, return (rc, out, err)."""
- try:
- cp = subprocess.run(cmd, check=False, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
- return (cp.returncode, cp.stdout.strip(), cp.stderr.strip())
- except FileNotFoundError:
- return (127, "", f"{cmd[0]}: not found")
-
-def _exists_iface(name: str) -> bool:
- rc, _, _ = _run(["ip", "-o", "link", "show", "dev", name])
- return rc == 0
-
-def _systemd_present() -> bool:
- return shutil.which("systemctl") is not None
-
-def _wg_quick_present() -> bool:
- return shutil.which("wg-quick") is not None
-
-def _conf_present(name: str) -> bool:
- return Path(f"/etc/wireguard/{name}.conf").is_file()
-
-def _best_effort_teardown(name: str, logs: List[str]) -> None:
- """Try to bring an iface down using systemd/wg-quick, then delete link; non-fatal."""
- unit = f"wg-quick@{name}.service"
- if _systemd_present():
- rc, out, err = _run(["systemctl", "stop", unit])
- if rc == 0:
- logs.append(f"systemctl: stopped {unit}")
- else:
- logs.append(f"systemctl: stop {unit} (ignored): {err or out or f'rc={rc}'}")
- if _wg_quick_present():
- rc, out, err = _run(["wg-quick", "down", name])
- if rc == 0:
- logs.append("wg-quick: down ok")
- else:
- logs.append(f"wg-quick: down (ignored): {err or out or f'rc={rc}'}")
- if _exists_iface(name):
- rc, out, err = _run(["ip", "link", "del", "dev", name])
- if rc == 0:
- logs.append("ip link: deleted leftover device")
- else:
- logs.append(f"ip link: delete (ignored): {err or out or f'rc={rc}'}")
-
-
-# ---------- business ----------
-
-def start_ifaces(
- ifaces: Sequence[str],
- use_systemd: bool = True,
- use_wg_quick: bool = True,
- run_ip_state: bool = True,
- ip_state_path: str = "/usr/local/bin/apply_ip_state.sh",
- daemon_reload: bool = False,
- force: bool = False,
-) -> List[str]:
- """
- Start the given WG ifaces and optionally apply IP state.
- Returns a list of log lines.
- """
- logs: List[str] = []
-
- if not ifaces:
- raise RuntimeError("no interfaces provided")
-
- have_systemd = _systemd_present()
- have_wgquick = _wg_quick_present()
- have_ipstate = Path(ip_state_path).is_file()
-
- if use_systemd and daemon_reload and have_systemd:
- rc, _out, err = _run(["systemctl", "daemon-reload"])
- if rc == 0:
- logs.append("systemctl: daemon-reload")
- else:
- logs.append(f"systemctl: daemon-reload (ignored): {err or f'rc={rc}'}")
-
- for name in ifaces:
- logs.append(f"== {name} ==")
-
- # Ensure config exists
- if not _conf_present(name):
- logs.append(f"config missing: /etc/wireguard/{name}.conf (skip start)")
- logs.append(f"status: absent")
- logs.append("")
- continue
-
- started = False
- already_present = _exists_iface(name)
-
- # Optionally force recreate if device already around
- if already_present and force:
- logs.append("iface exists, --force given: tearing down before start")
- _best_effort_teardown(name, logs)
- already_present = _exists_iface(name)
-
- # Start via systemd or wg-quick
- if use_systemd and have_systemd:
- unit = f"wg-quick@{name}.service"
- rc, out, err = _run(["systemctl", "start", unit])
- if rc == 0:
- logs.append(f"systemctl: started {unit}")
- started = True
- else:
- # If iface already exists, treat as running
- if _exists_iface(name):
- logs.append(f"systemctl: start {unit} reported error, but iface exists (continuing): {err or out or f'rc={rc}'}")
- started = True
- else:
- logs.append(f"systemctl: start {unit} failed: {err or out or f'rc={rc}'}")
- elif use_wg_quick and have_wgquick:
- if already_present:
- logs.append("wg-quick: iface already present")
- started = True
- else:
- rc, out, err = _run(["wg-quick", "up", name])
- if rc == 0:
- logs.append("wg-quick: up ok")
- started = True
- else:
- # If iface popped up anyway, continue
- if _exists_iface(name):
- logs.append(f"wg-quick: up reported error, but iface exists (continuing): {err or out or f'rc={rc}'}")
- started = True
- else:
- logs.append(f"wg-quick: up failed: {err or out or f'rc={rc}'}")
-
- else:
- logs.append("no start method available (systemd/wg-quick disabled or not found)")
-
- # If requested, apply IP state post-start (useful when not using systemd drop-ins)
- if run_ip_state and have_ipstate:
- if _exists_iface(name):
- rc, out, err = _run([ip_state_path, name])
- if rc == 0:
- logs.append(f"ip-state: applied ({ip_state_path} {name})")
- else:
- logs.append(f"ip-state: apply failed: {err or out or f'rc={rc}'}")
- else:
- logs.append("ip-state: skipped (iface not present)")
-
- # Final status
- logs.append(f"status: {'up' if _exists_iface(name) else 'down'}")
- logs.append("") # spacer
-
- return logs
-
-
-# ---------- CLI (wrapper only) ----------
-
-def _require_root(allow_nonroot: bool) -> None:
- if not allow_nonroot and os.geteuid() != 0:
- raise RuntimeError("must run as root (use --force-nonroot to override)")
-
-def main(argv: Sequence[str] | None = None) -> int:
- ap = argparse.ArgumentParser(description="Start one or more WireGuard interfaces safely.")
- ap.add_argument("ifaces", nargs="+", help="interface names to start (e.g., x6 US)")
- ap.add_argument("--no-systemd", action="store_true", help="do not call systemctl start wg-quick@IFACE")
- ap.add_argument("--no-wg-quick", action="store_true", help="do not call wg-quick up IFACE")
- ap.add_argument("--skip-ip-state", action="store_true", help="do not run apply_ip_state.sh after start")
- ap.add_argument("--ip-state-path", default="/usr/local/bin/apply_ip_state.sh", help="path to the IP state script")
- ap.add_argument("--daemon-reload", action="store_true", help="run systemctl daemon-reload before starts")
- ap.add_argument("--force", action="store_true", help="if iface exists, tear down first and retry start")
- ap.add_argument("--force-nonroot", action="store_true", help="allow running without root (best-effort)")
- args = ap.parse_args(argv)
-
- try:
- _require_root(allow_nonroot=args.force_nonroot)
- logs = start_ifaces(
- args.ifaces,
- use_systemd=(not args.no_systemd),
- use_wg_quick=(not args.no_wg_quick),
- run_ip_state=(not args.skip_ip_state),
- ip_state_path=args.ip_state_path,
- daemon_reload=args.daemon_reload,
- force=args.force,
- )
- for line in logs:
- print(line)
- return 0
- except Exception as e:
- print(f"error: {e}", file=sys.stderr)
- return 2
-
-if __name__ == "__main__":
- sys.exit(main(sys.argv[1:]))
+++ /dev/null
-#!/usr/bin/env python3
-"""
-stop_clean_iface.py
-
-Stop one or more WireGuard interfaces and clean IP state (rules/routes/addresses).
-"""
-
-from __future__ import annotations
-from pathlib import Path
-from typing import Iterable, List, Optional, Sequence, Tuple, Set
-import argparse
-import os
-import re
-import shutil
-import subprocess
-import sys
-
-__VERSION__ = "1.1-agg-errors"
-
-RT_TABLES_FILE = Path("/etc/iproute2/rt_tables")
-
-# ---------- helpers (shell) ----------
-
-def _run(cmd: Sequence[str], dry: bool=False) -> tuple[int, str, str]:
- if dry:
- return (0, "", "")
- try:
- cp = subprocess.run(cmd, check=False, text=True,
- stdout=subprocess.PIPE, stderr=subprocess.PIPE)
- return (cp.returncode, cp.stdout.strip(), cp.stderr.strip())
- except FileNotFoundError:
- return (127, "", f"{cmd[0]}: not found")
-
-def _exists_iface(name: str) -> bool:
- rc, _, _ = _run(["ip", "-o", "link", "show", "dev", name])
- return rc == 0
-
-def _systemd_present() -> bool:
- return shutil.which("systemctl") is not None
-
-def _wg_quick_present() -> bool:
- return shutil.which("wg-quick") is not None
-
-# ---------- helpers (routing tables & rules) ----------
-
-def _rt_table_num_for_name(name: str) -> Optional[int]:
- if not RT_TABLES_FILE.exists():
- return None
- try:
- text = RT_TABLES_FILE.read_text()
- except Exception:
- return None
- for line in text.splitlines():
- s = line.strip()
- if not s or s.startswith("#"):
- continue
- parts = s.split()
- if len(parts) >= 2 and parts[0].isdigit():
- num = int(parts[0]); nm = parts[1]
- if nm == name:
- return num
- return None
-
-_RULE_RE = re.compile(r"""^\s*(\d+):\s*(.+?)\s*$""")
-
-def _current_rule_lines() -> List[Tuple[int,str]]:
- rc, out, _ = _run(["ip", "-4", "rule", "show"])
- if rc != 0 or not out:
- return []
- rows: List[Tuple[int,str]] = []
- for ln in out.splitlines():
- m = _RULE_RE.match(ln)
- if not m:
- continue
- pref = int(m.group(1))
- rest = m.group(2)
- rows.append((pref, rest))
- return rows
-
-def _prefs_matching_lookups(lookups: Sequence[str]) -> Set[int]:
- toks = [t for t in lookups if t]
- prefs: Set[int] = set()
- if not toks:
- return prefs
- for pref, rest in _current_rule_lines():
- for t in toks:
- if re.search(rf"\blookup\s+{re.escape(t)}\b", rest):
- prefs.add(pref)
- break
- return prefs
-
-def _rule_del_by_pref(pref: int, logs: List[str], dry: bool) -> None:
- rc, _out, err = _run(["ip", "-4", "rule", "del", "pref", str(pref)], dry=dry)
- if rc == 0:
- logs.append(f"ip rule: deleted pref {pref}")
- else:
- logs.append(f"ip rule: delete pref {pref} (ignored): {err or f'rc={rc}'}")
-
-def _flush_routes_for_table(table: str, logs: List[str], dry: bool) -> None:
- rc, _out, err = _run(["ip", "-4", "route", "flush", "table", table], dry=dry)
- if rc == 0:
- logs.append(f"ip route: flushed table {table}")
- else:
- logs.append(f"ip route: flush table {table} (ignored): {err or f'rc={rc}'}")
-
-def _addr_del_all_v4_on_iface(iface: str, logs: List[str], dry: bool) -> None:
- rc, out, err = _run(["ip", "-4", "-o", "addr", "show", "dev", iface], dry=dry)
- if rc != 0:
- logs.append(f"ip addr: list on {iface} (ignored): {err or f'rc={rc}'}")
- return
- cidrs: List[str] = []
- for ln in out.splitlines():
- parts = ln.split()
- if len(parts) >= 4:
- cidrs.append(parts[3])
- if not cidrs:
- logs.append("ip addr: none to remove")
- return
- for cidr in cidrs:
- rc2, _o2, e2 = _run(["ip", "-4", "addr", "del", cidr, "dev", iface], dry=dry)
- if rc2 == 0:
- logs.append(f"ip addr: deleted {cidr}")
- else:
- logs.append(f"ip addr: delete {cidr} (ignored): {e2 or f'rc={rc2}'}")
-
-# ---------- business ----------
-
-def _clean_iface_ip_state(name: str, logs: List[str], *, dry: bool=False, aggressive: bool=False) -> None:
- tokens: List[str] = [name]
- num = _rt_table_num_for_name(name)
- if num is not None:
- tokens.append(str(num))
-
- # Delete rules matching either numeric or named lookup tokens; loop to catch chains.
- deleted_any = True
- safety = 0
- while deleted_any and safety < 10:
- safety += 1
- prefs = sorted(_prefs_matching_lookups(tokens))
- if not prefs:
- deleted_any = False
- break
- for p in prefs:
- _rule_del_by_pref(p, logs, dry)
- if aggressive:
- for p in range(17000, 17060):
- _rule_del_by_pref(p, logs, dry)
-
- # Flush routes in the table by name and numeric (if known)
- _flush_routes_for_table(name, logs, dry)
- if num is not None:
- _flush_routes_for_table(str(num), logs, dry)
-
- # Remove all IPv4 addresses on the iface
- _addr_del_all_v4_on_iface(name, logs, dry)
-
-def stop_clean_ifaces(
- ifaces: Sequence[str],
- use_systemd: bool = True,
- use_wg_quick: bool = True,
- do_clean: bool = True,
- aggressive: bool = False,
- dry_run: bool = False,
-) -> List[str]:
- logs: List[str] = []
- if not ifaces:
- raise RuntimeError("no interfaces provided")
-
- have_systemd = _systemd_present()
- have_wgquick = _wg_quick_present()
-
- for name in ifaces:
- logs.append(f"== {name} ==")
-
- if use_systemd and have_systemd:
- unit = f"wg-quick@{name}.service"
- rc, out, err = _run(["systemctl", "stop", unit], dry=dry_run)
- if rc == 0:
- logs.append(f"systemctl: stopped {unit}")
- else:
- msg = err or out or f"rc={rc}"
- logs.append(f"systemctl: stop {unit} (ignored): {msg}")
- elif use_systemd and not have_systemd:
- logs.append("systemctl: not found; skipped")
-
- if use_wg_quick and have_wgquick:
- rc, out, err = _run(["wg-quick", "down", name], dry=dry_run)
- if rc == 0:
- logs.append("wg-quick: down ok")
- else:
- msg = err or out or f"rc={rc}"
- logs.append(f"wg-quick: down (ignored): {msg}")
- elif use_wg_quick and not have_wgquick:
- logs.append("wg-quick: not found; skipped")
-
- if do_clean:
- _clean_iface_ip_state(name, logs, dry=dry_run, aggressive=aggressive)
- else:
- logs.append("clean: skipped (--no-clean)")
-
- if _exists_iface(name):
- rc, out, err = _run(["ip", "link", "del", "dev", name], dry=dry_run)
- if rc == 0:
- logs.append("ip link: deleted device")
- else:
- msg = err or out or f"rc={rc}"
- logs.append(f"ip link: delete (ignored): {msg}")
- else:
- logs.append("ip link: device not present; nothing to delete")
-
- final_present = _exists_iface(name)
- logs.append(f"status: {'gone' if not final_present else 'still present'}")
- logs.append("")
-
- return logs
-
-# ---------- CLI (wrapper with aggregated errors) ----------
-
-def main(argv: Sequence[str] | None = None) -> int:
- ap = argparse.ArgumentParser(
- description="Stop one or more WireGuard interfaces and clean IP state.",
- add_help=True)
- ap.add_argument("ifaces", nargs="*", help="interface names to stop (e.g., x6 US)")
- ap.add_argument("--no-systemd", action="store_true", help="do not call systemctl stop wg-quick@IFACE")
- ap.add_argument("--no-wg-quick", action="store_true", help="do not call wg-quick down IFACE")
- ap.add_argument("--no-clean", action="store_true", help="skip IP cleanup (rules/routes/addresses)")
- ap.add_argument("--aggressive", action="store_true", help="also purge common rule pref window (17000-17059)")
- ap.add_argument("--dry-run", action="store_true", help="print what would be done without changing state")
- ap.add_argument("--force-nonroot", action="store_true", help="allow running without root (best-effort)")
-
- args = ap.parse_args(argv)
-
- # Aggregate invocation errors
- errors: List[str] = []
- if os.geteuid() != 0 and not args.force_nonroot:
- errors.append("must run as root (use --force-nonroot to override)")
- if not args.ifaces:
- errors.append("no interfaces provided")
-
- if errors:
- sys.stderr.write(ap.format_usage())
- prog = Path(sys.argv[0]).name or "stop_clean_iface.py"
- sys.stderr.write(f"{prog}: error: " + "; ".join(errors) + "\n")
- return 2
-
- try:
- logs = stop_clean_ifaces(
- args.ifaces,
- use_systemd=(not args.no_systemd),
- use_wg_quick=(not args.no_wg_quick),
- do_clean=(not args.no_clean),
- aggressive=args.aggressive,
- dry_run=args.dry_run,
- )
- for line in logs:
- print(line)
- return 0
- except Exception as e:
- print(f"error: {e}", file=sys.stderr)
- return 2
-
-if __name__ == "__main__":
- sys.exit(main(sys.argv[1:]))
+++ /dev/null
-n#+TITLE: subu / WireGuard — TODO
-#+AUTHOR: Thomas & Nerith (session)
-#+LANGUAGE: en
-#+OPTIONS: toc:2 num:t
-#+TODO: TODO(t) NEXT(n) WAITING(w) BLOCKED(b) | DONE(d) CANCELED(c)
-
-- Your current DB schema (the one you pasted earlier) does not include a listen-port field on Iface. So if you want ListenPort = … to be driven from the DB, add a column like Iface.listen_port INTEGER CHECK(listen_port BETWEEN 1 AND 65535).
-
-- have the stage commands echo relative pathnames instead of absolute as they do now.
-
-- the one private key pair per client (instead of per interface), turns out to be a bad idea, as we can't manage tunnels individually, say, by revoking keys. We need to move to a key pair per interface instead.
-
-- db_wipe needs to delete the key directory contents also
-
--------------------------------
-
-- Known gaps / open decisions
- - Systemd drop-in to call staged scripts on ~wg-quick@IFACE~ up (IPv4 addrs + policy rules).
- - Staged policy-rules script (source-based + uidrange rules) to replace the old global ~IP_rule_add.sh~ usage.
- - Installer flow & atomic writes (copy staged files, set owner/perms; safe update of ~/etc/iproute2/rt_tables~).
- - Pool size policy: default /16 with /32 hosts is implemented; decision pending on /8 vs /16.
- - Style guardrails (RT commas / two-space indent) are manual; optional linter TBD.
-
-* NEXT wiring (high-level order)
-1) Stage: /etc/iproute2/rt_tables (merge) for selected ifaces.
-2) Stage: /usr/local/bin/set_iface_ipv4_addrs.sh for same ifaces.
-3) Stage: /usr/local/bin/set_policy_rules_for_ifaces.sh (new; replaces old global add tool).
-4) Stage: systemd drop-ins for ~wg-quick@IFACE.service.d/10-postup.conf~ to call (2) then (3).
-5) Install: copy staged files → system, set perms/owner; ~systemctl daemon-reload~.
-6) Bring-up: ~wg-quick up IFACE~; verify routes/rules; smoke tests.
-
-* TODO Add “missing-iface” guard to staged IPv4 script
-- When iface doesn’t exist yet, log and continue (no non-zero exit).
-
-* TODO Stage policy rules script (idempotent)
-- For each iface:
- - Source-based rule: =from <local_address_cidr> lookup <rt_table_name_eff>=.
- - UID rules: =uidrange U-U lookup <rt_table_name_eff>= for each bound UID.
-- Only for ifaces passed on the CLI; DB-driven; no kernel writes here.
-- Emit with checks (skip if grep finds the exact rule).
-
-* TODO Systemd drop-in generator
-- Emit to: ~stage/etc/systemd/wg-quick@IFACE.service.d/10-postup.conf~.
-- Include:
- - =ExecStartPre=-/usr/sbin/ip link delete IFACE= (clean stale link).
- - =ExecStartPost=+/usr/local/bin/set_iface_ipv4_addrs.sh=.
- - =ExecStartPost=+/usr/local/bin/set_policy_rules_for_ifaces.sh=.
- - =ExecStartPost=+/usr/bin/logger 'wg-quick@IFACE up: addrs+rules applied'=.
-
-* TODO Installer flow
-- Copy staged files with perms (0500 for scripts; 0644 for rt_tables; 0755 for dirs).
-- Atomic update for ~/etc/iproute2/rt_tables~ (write temp + move); keep timestamped backup.
-- ~systemctl daemon-reload~ after installing drop-ins.
-
-* WAITING Decide “no-op staging” policy for rt_tables
-- Option A: Always stage a copy (deterministic deployment).
-- Option B: Stage only when there are new entries (quieter diffs).
-
-* TODO Tests
-- Unit-ish: parse/plan functions for both staging scripts (dry-run cases, collisions, skip-missing cases).
-- Integration:
- - Create temp WG iface: ~ip link add dev t0 type wireguard~ (and delete after).
- - Run staged scripts; verify ~ip -4 addr show dev t0~, ~ip rule show~, ~ip route show table <name>~.
- - Bring up real ~wg-quick up x6~; repeat verifications.
-
-* TODO Docs
-- Append “operational runbook” to the org manual (bring-up, verify, recover, teardown).
-
-* DONE What’s already proven by commands (from log)
-- all db_init is running, orchestrated by db_init_StanleyPark
-- =stage_rt_tables_merge.py --from-db x6 US= created staged rt_tables with merges.
-- =stage_iface_ipv4_script.py x6 US= staged ~set_iface_ipv4_addrs.sh~.
-
+++ /dev/null
-#!/usr/bin/env python3
-# wg_keys_incommon.py — predicates + actuators for WG keypairs
-
-from __future__ import annotations
-import shutil, subprocess, sqlite3
-
-def wellformed_client_keypair(conn: sqlite3.Connection, iface: str) -> bool:
- """Predicate: True iff client IFACE has a syntactically valid WG keypair."""
- row = conn.execute(
- "SELECT private_key, public_key FROM Iface WHERE iface=? LIMIT 1;", (iface,)
- ).fetchone()
- if not row: return False
- priv, pub = (row[0] or ""), (row[1] or "")
- return (43 <= len(priv.strip()) <= 45) and (43 <= len(pub.strip()) <= 45)
-
-def generate_client_keypair_if_missing(conn: sqlite3.Connection, iface: str) -> bool:
- """
- Actuator: if IFACE lacks a well-formed keypair, generate one with `wg`,
- store it in the DB, and return True. Return False if nothing changed.
- """
- if wellformed_client_keypair(conn, iface):
- return False
- if not shutil.which("wg"):
- raise RuntimeError("wg not found; cannot generate keys")
- gen = subprocess.run(["wg","genkey"], capture_output=True, text=True, check=True)
- priv = gen.stdout.strip()
- pubp = subprocess.run(["wg","pubkey"], input=priv.encode(), capture_output=True, check=True)
- pub = pubp.stdout.decode().strip()
- conn.execute(
- "UPDATE Iface SET private_key=?, public_key=?, "
- "updated_at=strftime('%Y-%m-%dT%H:%M:%SZ','now') WHERE iface=?",
- (priv, pub, iface),
- )
- return True
+++ /dev/null
-#!/usr/bin/env bash
-# set_client_key.sh — replace/set a client's public key on the server
-# Usage: set_client_key.sh <client-public-key> [allowed-ips=10.8.0.2/32] [iface=wg0]
-# Example: set_client_key.sh 88gTdpESSwAc0iip6tVotc8/taZErY18n3lzrgAd+XY= 10.8.0.2/32 wg0
-
-set -euo pipefail
-
-PUB="${1:-}"
-ALLOWED="${2:-10.8.0.2/32}"
-IFACE="${3:-wg0}"
-CFG="/etc/wireguard/${IFACE}.conf"
-
-[[ $EUID -eq 0 ]] || { echo "❌ Must be run as root."; exit 1; }
-command -v wg >/dev/null || { echo "❌ wg not found."; exit 1; }
-command -v wg-quick >/dev/null || { echo "❌ wg-quick not found."; exit 1; }
-
-[[ -n "$PUB" ]] || { echo "Usage: $0 <client-public-key> [allowed-ips] [iface]"; exit 2; }
-# quick sanity on key length
-kl=${#PUB}; [[ $kl -ge 43 && $kl -le 45 ]] || { echo "❌ Public key length looks wrong."; exit 2; }
-[[ -f "$CFG" ]] || { echo "❌ Config not found: $CFG"; exit 1; }
-
-# Require the interface to be up (simplest, reliable path)
-if ! wg show "$IFACE" >/dev/null 2>&1; then
- echo "❌ Interface $IFACE is not up. Start it first: wg-quick up $IFACE"
- echo " Or stop it and edit $CFG manually (replace the peer that has AllowedIPs = $ALLOWED)."
- exit 1
-fi
-
-# Remove any existing peer that currently owns the same AllowedIPs (typical /32 per client)
-while read -r oldkey oldips; do
- if [[ "$oldips" == "$ALLOWED" ]]; then
- echo "→ Removing existing peer $oldkey with AllowedIPs $ALLOWED"
- wg set "$IFACE" peer "$oldkey" remove || true
- fi
-done < <(wg show "$IFACE" allowed-ips | awk '{print $1, $2}')
-
-# Add the new peer
-wg set "$IFACE" peer "$PUB" allowed-ips "$ALLOWED"
-
-# Persist runtime state back to the config (works great even if SaveConfig=true)
-wg-quick save "$IFACE"
-
-echo "✔ Updated $IFACE: set peer $PUB with AllowedIPs $ALLOWED and saved to $CFG"
-wg show "$IFACE"
+++ /dev/null
-#!/usr/bin/env bash
-# 2025-09-05
-# Debian 12 Setup: WireGuard egress server + one client (safe/idempotent)
-set -euo pipefail
-umask 0077
-[[ $EUID -eq 0 ]] || { echo "❌ run as root"; exit 1; }
-run(){ echo "+ $*"; eval "$@"; }
-
-WG_IF="wg0"
-WG_PORT="${WG_PORT:-51820}"
-WG_DIR="/etc/wireguard"
-CLIENT_DIR="/root/wireguard"
-CLIENT_NAME="${CLIENT_NAME:-client1}"
-
-SERVER_NET_V4="${SERVER_NET_V4:-10.8.0.0/24}"
-SERVER_ADDR_V4="${SERVER_ADDR_V4:-10.8.0.1/24}"
-CLIENT_ADDR_V4="${CLIENT_ADDR_V4:-10.8.0.2/32}"
-
-# --- Packages ---
-need_pkgs=()
-for p in wireguard qrencode iproute2; do command -v ${p%% *} >/dev/null 2>&1 || need_pkgs+=("$p"); done
-if ((${#need_pkgs[@]})); then
- DEBIAN_FRONTEND=noninteractive run apt-get update
- run apt-get install -y "${need_pkgs[@]}"
-fi
-
-install -d -m 0700 "$WG_DIR" "$CLIENT_DIR"
-
-# --- Detect WAN IF + public IPv4 ---
-WAN_IF=$(ip -o -4 route show to default | awk '{print $5; exit}')
-[[ -n "${WAN_IF:-}" ]] || { echo "❌ Could not detect WAN interface"; exit 1; }
-SERVER_IPv4=$(ip -o -4 addr show dev "$WAN_IF" | awk '{print $4}' | cut -d/ -f1 | head -n1)
-[[ -n "${SERVER_IPv4:-}" ]] || SERVER_IPv4="<PASTE_PUBLIC_IP>"
-
-# --- Keys (server) ---
-if [[ ! -f "$WG_DIR/server.key" ]]; then
- (umask 077; wg genkey | tee "$WG_DIR/server.key" | wg pubkey > "$WG_DIR/server.pub")
- chmod 600 "$WG_DIR/server.key"
-fi
-SERVER_PRIV=$(cat "$WG_DIR/server.key")
-SERVER_PUB=$(cat "$WG_DIR/server.pub")
-
-# --- Keys (client) ---
-if [[ ! -f "$CLIENT_DIR/${CLIENT_NAME}.key" ]]; then
- (umask 077; wg genkey | tee "$CLIENT_DIR/${CLIENT_NAME}.key" | wg pubkey > "$CLIENT_DIR/${CLIENT_NAME}.pub")
- chmod 600 "$CLIENT_DIR/${CLIENT_NAME}.key"
-fi
-CLIENT_PRIV=$(cat "$CLIENT_DIR/${CLIENT_NAME}.key")
-CLIENT_PUB=$(cat "$CLIENT_DIR/${CLIENT_NAME}.pub")
-
-# --- IPv4 forwarding ---
-install -d -m 0755 /etc/sysctl.d
-cat > /etc/sysctl.d/99-wireguard-forwarding.conf <<'EOF'
-net.ipv4.ip_forward=1
-# net.ipv6.conf.all.forwarding=1
-EOF
-sysctl --system >/dev/null
-
-# --- Write server config (backup if existing) ---
-CFG="$WG_DIR/${WG_IF}.conf"
-if [[ -f "$CFG" ]]; then
- cp -a "$CFG" "$CFG.bak.$(date -u +%Y%m%dT%H%M%SZ)"
-fi
-cat > "$CFG" <<EOF
-[Interface]
-PrivateKey = $SERVER_PRIV
-Address = $SERVER_ADDR_V4
-ListenPort = $WG_PORT
-SaveConfig = true
-
-# NAT & forward for IPv4
-PostUp = iptables -A FORWARD -i %i -j ACCEPT; iptables -A FORWARD -o %i -m state --state RELATED,ESTABLISHED -j ACCEPT; iptables -t nat -A POSTROUTING -o $WAN_IF -j MASQUERADE
-PostDown = iptables -D FORWARD -i %i -j ACCEPT; iptables -D FORWARD -o %i -m state --state RELATED,ESTABLISHED -j ACCEPT; iptables -t nat -D POSTROUTING -o $WAN_IF -j MASQUERADE
-
-[Peer]
-# $CLIENT_NAME
-PublicKey = $CLIENT_PUB
-AllowedIPs = $CLIENT_ADDR_V4
-EOF
-chmod 600 "$CFG"
-
-# --- Client config ---
-CLIENT_CFG="$CLIENT_DIR/${CLIENT_NAME}.conf"
-cat > "$CLIENT_CFG" <<EOF
-[Interface]
-PrivateKey = $CLIENT_PRIV
-Address = ${CLIENT_ADDR_V4%/*}/24
-DNS = 1.1.1.1
-
-[Peer]
-PublicKey = $SERVER_PUB
-Endpoint = ${SERVER_IPv4}:${WG_PORT}
-AllowedIPs = 0.0.0.0/0
-PersistentKeepalive = 25
-EOF
-chmod 600 "$CLIENT_CFG"
-
-# --- UFW allow if active ---
-if command -v ufw >/dev/null 2>&1 && ufw status | grep -q "Status: active"; then
- ufw status | grep -q "^${WG_PORT}/udp" || ufw allow "${WG_PORT}/udp" || true
-fi
-
-# --- Enable interface ---
-run systemctl enable --now wg-quick@"$WG_IF"
-
-# --- Status + QR ---
-echo
-wg show "$WG_IF" || true
-echo
-echo "Client file: $CLIENT_CFG"
-command -v qrencode >/dev/null 2>&1 && { echo "QR (WireGuard mobile import):"; qrencode -t ansiutf8 < "$CLIENT_CFG"; }
-echo
-echo "If Endpoint autodetection is wrong, edit it to your public IP or DNS."
--- /dev/null
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta charset="UTF-8">
+ <title>Introduction to Harmony</title>
+ <script>
+ {
+ let dirpr_library = null;
+ try{ dirpr_library = localStorage.getItem("RT.dirpr_library"); }catch(e){}
+ window.RT = window.RT || {};
+ RT.dirpr_library = RT.dirpr_library || dirpr_library || "https://style.ReasoningTechnology.com/Manuscript";
+ document.write('<script src="' + RT.dirpr_library + '/Core/RT-Manuscript_make.js"><\/script>');
+ }
+ </script>
+ <script>
+ window.RT.theme_preference('inverse_wheat');
+ window.RT.load('Layout/paginate');
+ window.RT.load('Layout/article_tech_ref');
+ window.RT.load('Element/theme_selector');
+ </script>
+ </head>
+ <body>
+ <RT·theme-selector></RT·theme-selector>
+ <RT·article>
+ <RT·title
+ author="Thomas Walker Lynch"
+ date="2026-10-09"
+ title="Introduction to Harmony">
+ </RT·title>
+
+ <RT·TOC level="1"></RT·TOC>
+
+ <h1>Purpose</h1>
+ <p>
+ Harmony is a project skeleton: a language-agnostic directory structure and a set of maintenance tools, kept in a git repository, from which new projects are made. It is designed for long-lived software developed by a team, and serves equally well for one person, who takes on each role in turn. The structure exists to give:
+ </p>
+ <ol>
+ <li>Clarity about where things live.
+ <ul>
+ <li>Role-based work areas.</li>
+ <li>Separation of skeleton, team member authored, machine-made, and depended-upon software.</li>
+ </ul>
+ </li>
+ <li>A safe, predictable build and promotion workflow.</li>
+ </ol>
+
+ <h1>Making a project</h1>
+ <p>
+ A project administrator makes a new project from a clone of Harmony, by taking on the administrator role and running <RT·code>new-project</RT·code> with the name of the new project:
+ </p>
+ <RT·code>
+ > cd Harmony
+ > ./setup administrator
+ > new-project <name>
+ </RT·code>
+ <p>
+ <RT·code>new-project</RT·code> makes the new project as a sibling directory of Harmony, then:
+ </p>
+ <ol>
+ <li>Copies the files of Harmony's last commit, leaving out Harmony's git history, untracked files, and uncommitted changes.</li>
+ <li>Renames <RT·code>0pus_Harmony</RT·code> to <RT·code>0pus_<name></RT·code>.</li>
+ <li>Adds a line for the new project to <RT·code>shared/tool/version</RT·code>.</li>
+ <li>Initializes a git repository on the <RT·code>core-developer_branch</RT·code> branch, and commits the copied skeleton.</li>
+ <li>Adds any remote repositories given as <RT·code>-remote <url></RT·code>.</li>
+ </ol>
+ <p>
+ Because the new project starts with its own git history, work done in it cannot be checked in to the Harmony skeleton by accident.
+ </p>
+ <p>
+ Leave the Harmony line in the version file. It records which skeleton the project was made from, so that an administrator can later see what has changed in the skeleton since, and bring the project up to date. The administrator tool <RT·code>skeleton_diff</RT·code> compares a project with a Harmony checkout for this purpose.
+ </p>
+
+ <h1>Taking on a role</h1>
+ <p>
+ A person works in a project by taking on a <RT·term>role</RT·term>. The roles are <RT·code>administrator</RT·code>, <RT·code>developer</RT·code>, <RT·code>tester</RT·code>, and <RT·code>consumer</RT·code>. From the top directory of the project, run <RT·code>setup</RT·code> with the role:
+ </p>
+ <RT·code>
+ > ./setup developer
+ </RT·code>
+ <p>
+ <RT·code>setup</RT·code> starts a new bash shell configured for the role, and leaves the person in the role's directory. The prompt shows the project and the role. <RT·code>exit</RT·code> leaves the role and returns to the shell <RT·code>setup</RT·code> was run from. It is common to have several terminal windows or Emacs shells open, each in a different role.
+ </p>
+ <p>
+ Python programmers will recognize this as similar to a virtual environment. Harmony calls it the project setup, and the command is <RT·code>setup</RT·code> rather than <RT·code>activate</RT·code>. Unlike a Python virtual environment, there is one setup for each role.
+ </p>
+ <p>
+ In the new shell, the setup:
+ </p>
+ <ul>
+ <li>Sets <RT·code>ROLE</RT·code>, <RT·code>ROLE_HOME</RT·code> and <RT·code>SETUP</RT·code>, and puts <RT·code><role>/tool</RT·code> on the <RT·code>PATH</RT·code>.</li>
+ <li>Sources the project-wide <RT·code>shared/tool/setup</RT·code>, which sets <RT·code>REPO_HOME</RT·code> and <RT·code>PROJECT</RT·code>, puts <RT·code>shared/tool</RT·code> and <RT·code>shared/authored</RT·code> on the <RT·code>PATH</RT·code>, and sources each project dependency's <RT·code>.init</RT·code> file.</li>
+ <li>Sources <RT·code>shared/authored/setup</RT·code>, when the project has one, for project-specific additions to every role.</li>
+ <li>Changes into the role's directory and sources <RT·code><role>/tool/setup</RT·code>, the setup customized for that role in this project.</li>
+ <li>When running inside Emacs, copies the environment to Emacs with <RT·code>env_to_emacs</RT·code>.</li>
+ </ul>
+ <p>
+ Tools rely on this environment. Absolute paths are formed from <RT·code>REPO_HOME</RT·code>, and some tools, such as <RT·code>build</RT·code> and <RT·code>promote</RT·code>, refuse to run outside the role they belong to.
+ </p>
+
+ <h1>Roles and their directories</h1>
+ <p>
+ Each role has a directory at the top of the project. The <RT·code>shared</RT·code> directory holds what all roles use.
+ </p>
+
+ <h2>Administrator</h2>
+ <p>
+ The administrator keeps the project in step with the Harmony skeleton, maintains the role environments, installs project dependencies, and makes releases. <RT·code>administrator/tool</RT·code> holds the tools for this, among them <RT·code>new-project</RT·code>, <RT·code>skeleton_diff</RT·code>, and <RT·code>archive</RT·code>, which writes an archive of the repository to the scratchpad.
+ </p>
+
+ <h2>Developer</h2>
+ <p>
+ The developer writes the project's source and builds it.
+ </p>
+ <ul>
+ <li><RT·code>authored/</RT·code> : Source written by the team. Tracked.</li>
+ <li><RT·code>document/</RT·code> : Documents for developers, such as coding and naming conventions.</li>
+ <li><RT·code>experiment/</RT·code> : Informal, short-lived trials.</li>
+ <li><RT·code>scratchpad/</RT·code> : Untracked. Intermediate build files and the staged products of a build.</li>
+ <li><RT·code>tool/</RT·code> : The developer's tools, including <RT·code>build</RT·code> and <RT·code>promote</RT·code>.</li>
+ </ul>
+
+ <h2>Tester</h2>
+ <p>
+ The tester holds the formal tests, including regression suites, and runs them against the work product in <RT·code>consumer/</RT·code>. A developer may keep informal tests in <RT·code>developer/experiment</RT·code>. A test that is to be kept is moved to <RT·code>tester/</RT·code>, which keeps writing code and validating it apart.
+ </p>
+
+ <h2>Consumer</h2>
+ <p>
+ <RT·code>consumer/</RT·code> receives the work product when a developer promotes it. It is untracked, apart from its <RT·code>.gitignore</RT·code>, and is treated as a deployment target: files arrive there only through <RT·code>promote</RT·code>, and nothing is built in it. The consumer role uses the work product as its end users would.
+ </p>
+
+ <h2>Shared</h2>
+ <ul>
+ <li><RT·code>shared/tool/</RT·code> : Tools for all roles, and the project-wide setup. The <RT·code>version</RT·code> command prints the version lines.</li>
+ <li><RT·code>shared/authored/</RT·code> : Project-specific additions for all roles. It is on every role's <RT·code>PATH</RT·code>, and may hold a <RT·code>setup</RT·code> that every role sources.</li>
+ <li><RT·code>shared/project-dependency/</RT·code> : Software the project uses but did not author. See "Project dependencies" below.</li>
+ </ul>
+
+ <h1>Building and promoting</h1>
+ <p>
+ Building and promotion are separate steps. In the developer role, <RT·code>build</RT·code> makes the work product from <RT·code>developer/authored</RT·code> and stages it on the developer's scratchpad. <RT·code>promote write</RT·code> then copies what changed to <RT·code>consumer/</RT·code>. The result is a <RT·term>promotion candidate</RT·term>, which stays as it is until the next promotion.
+ </p>
+ <p>
+ Source in <RT·code>developer/authored</RT·code> is grouped into directories named <RT·code><Namespace>.<Tool></RT·code>. The suffix names the build component in <RT·code>developer/tool/build_component</RT·code> that builds that directory, such as <RT·code>make</RT·code> for C, or <RT·code>copy</RT·code> for files used as they are. Given no arguments, <RT·code>build</RT·code> prints its usage and the namespaces it found. <RT·code>promote</RT·code> given no arguments prints its commands, among them <RT·code>dry write</RT·code>, which shows what <RT·code>write</RT·code> would copy.
+ </p>
+ <p>
+ The tester runs tests against the promotion candidate. Tests read from <RT·code>consumer/</RT·code> and never write to it, though a tester may copy files elsewhere to experiment with them. A developer often has a second window open in the tester role, and cycles through edit, build, promote and test.
+ </p>
+
+ <h2>After cloning a project</h2>
+ <p>
+ Built products are not tracked, so a fresh clone has an empty <RT·code>consumer/</RT·code>. A team member who needs the work product builds it, taking on the developer role to do so:
+ </p>
+ <RT·code>
+ > ./setup developer
+ > build <namespace>
+ > promote write
+ > exit
+ > ./setup <role>
+ </RT·code>
+ <p>
+ The developer documents any project-specific build steps in <RT·code>developer/document/build.html</RT·code>. A fresh clone also lacks the project's dependencies, which each team member installs as described beside each dependency in <RT·code>shared/project-dependency</RT·code>.
+ </p>
+
+ <h1>Project dependencies</h1>
+ <p>
+ Harmony is language agnostic. When a project uses C, Python, NodeJS, Java, or other software it did not author, the administrator installs that software as a sibling directory of the project and links it in under <RT·code>shared/project-dependency</RT·code>, with an <RT·code>.init</RT·code> file that every role's setup sources to put the dependency into the environment. Installation notes for each dependency sit beside its <RT·code>.init</RT·code> file, so that each team member can repeat the installation after cloning.
+ </p>
+ <p>
+ The directory comes with a <RT·code>project</RT·code> link to the directory holding the project and its siblings, through which dependencies are linked, and with Python as an example dependency. The steps are given in <RT·code>administrator/document/how-to_add-project-dependency.html</RT·code>.
+ </p>
+
+ <h1>Directory names</h1>
+ <p>
+ A directory name is a <RT·term>property</RT·term> of each file in the directory, so a full path reads as a description of the file. Because a name states a property of each file, it is rarely plural: when every file in a directory is a test, the directory is named <RT·code>test</RT·code>.
+ </p>
+ <p>
+ A conventional file system can only approximate a property-based one. Each file sits in one directory, so it receives one property per level of the path. Files sharing several properties can be given a compound name with the properties separated by an underscore, but overlapping groupings cannot be expressed.
+ </p>
+ <p>
+ When choosing a directory name, prefer properties in this order:
+ </p>
+ <ul>
+ <li><strong>Role</strong> (<RT·code>administrator</RT·code>, <RT·code>developer</RT·code>, <RT·code>tester</RT·code>, <RT·code>consumer</RT·code>): the role, human or AI, that works with the files.</li>
+ <li><strong>Provenance</strong> (<RT·code>authored</RT·code>, <RT·code>made</RT·code>): whether a person wrote the file or a tool produced it.</li>
+ <li><strong>Capability</strong> (<RT·code>tool</RT·code>, <RT·code>document</RT·code>, <RT·code>experiment</RT·code>): what the file is for.</li>
+ <li><strong>Lifecycle</strong> (<RT·code>scratchpad</RT·code>, <RT·code>stage</RT·code>): how long the file is expected to last, or where it is in promotion.</li>
+ <li><strong>Tracking</strong> (<RT·code>tracked</RT·code>, <RT·code>untracked</RT·code>): what version control does with it.</li>
+ </ul>
+
+ <h2>Authored, made, scratchpad, inherited</h2>
+ <p>
+ Files in a directory named <RT·code>authored</RT·code> were written by the project's team. They did not come with the skeleton, nor with the installation of other software, and they typically constitute the project's intellectual property. Build tools treat <RT·code>authored</RT·code> directories as read-only. All source that is built into a promotion or a release is placed in <RT·code>developer/authored</RT·code>.
+ </p>
+ <p>
+ Files that came with the skeleton are <RT·term>inherited</RT·term>. When the team edits one, such as <RT·code>developer/tool/build</RT·code>, it becomes a <RT·term>customization</RT·term>. New documents go in document directories and new tools in tool directories. Because the Harmony line stays in the version file, an administrator can tell which skeleton files a project has customized and which it has added.
+ </p>
+ <p>
+ Files in a directory named <RT·code>scratchpad</RT·code> are untracked, so a clone always has empty scratchpads. Tools place intermediate and staged files there, each in its own subdirectory, so a person who knows those names can also use a scratchpad as a temporary directory. The <RT·code>scratchpad</RT·code> tool maintains the scratchpad of the current directory. Note that its <RT·code>clear</RT·code> command deletes everything on that scratchpad.
+ </p>
+
+ <h1>Documents</h1>
+ <p>
+ Each directory named <RT·code>document</RT·code> holds documents for the role, or the scope, of its parent:
+ </p>
+ <ul>
+ <li><RT·code>document/</RT·code> : The project as a whole, such as this introduction.</li>
+ <li><RT·code>administrator/document/</RT·code> : Maintaining the project skeleton, adding dependencies, and making releases.</li>
+ <li><RT·code>developer/document/</RT·code> : Coding standards, conventions, and the build.</li>
+ <li><RT·code>tester/document/</RT·code> : Test plans and testing tools.</li>
+ <li><RT·code>consumer/<namespace>/document/</RT·code> : Documents released with the work product, such as manuals and API references.</li>
+ </ul>
+ <p>
+ Documents are written in HTML with the RT semantic tags, and formatted by RT-Style, a separate project at <RT·code>https://github.com/Thomas-Walker-Lynch/RT-Style</RT·code>. A document loads RT-Style through a short script block at the top of its head. To write a new document, copy an existing one, keep that block as it is, and type over the contents.
+ </p>
+ <p>
+ Documents are read in Chrome. With nothing set up, a document loads RT-Style from <RT·code>style.ReasoningTechnology.com</RT·code>. To read without a network connection, or against a local copy of RT-Style, open that copy's <RT·code>Manuscript/setup.html</RT·code> once in Chrome. Chrome then loads RT-Style from it for every local document.
+ </p>
+ <p>
+ <RT·code>document/writing_tool-script.html</RT·code> gives the conventions for writing tools.
+ </p>
+
+ <h1>What git tracks</h1>
+ <p>
+ Everything is tracked except:
+ </p>
+ <ul>
+ <li><RT·code>consumer/</RT·code>, apart from its <RT·code>.gitignore</RT·code>.</li>
+ <li>Every <RT·code>scratchpad/</RT·code>, apart from its <RT·code>.gitignore</RT·code>.</li>
+ <li>In <RT·code>shared/project-dependency/</RT·code>, whatever its <RT·code>.gitignore</RT·code> does not list as tracked.</li>
+ <li>Archives, editor backups, and compiled files, as listed in the top-level <RT·code>.gitignore</RT·code>.</li>
+ </ul>
+
+ <h1>Workflow and releases</h1>
+ <p>
+ The project moves forward through a cycle: the developer promotes a candidate, the tester tests it and files issues, and the developer fixes them and promotes again. The <RT·code>core-developer_branch</RT·code> is the branch the core development team works on. When the product manager judges the work product reliable and complete enough, the administrator makes a <RT·term>project release</RT·term> on a branch named <RT·code>release_v<major></RT·code>, and tags it.
+ </p>
+ <p>
+ These documents give the details:
+ </p>
+ <ul>
+ <li><RT·code>document/role-and-workflow_product-development.html</RT·code> : The roles, and how their work interacts.</li>
+ <li><RT·code>document/role-and-workflow_product-maintenance.html</RT·code> : Issues, triage, and patching releases.</li>
+ <li><RT·code>administrator/document/how-to_release.html</RT·code> : Making a release.</li>
+ </ul>
+
+ </RT·article>
+ </body>
+</html>
--- /dev/null
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta charset="UTF-8">
+ <title>Product development roles and workflow</title>
+ <script>
+ {
+ let dirpr_library = null;
+ try{ dirpr_library = localStorage.getItem("RT.dirpr_library"); }catch(e){}
+ window.RT = window.RT || {};
+ RT.dirpr_library = RT.dirpr_library || dirpr_library || "https://style.ReasoningTechnology.com/Manuscript";
+ document.write('<script src="' + RT.dirpr_library + '/Core/RT-Manuscript_make.js"><\/script>');
+ }
+ </script>
+ <script>
+ window.RT.theme_preference('inverse_wheat');
+ window.RT.load('Layout/paginate');
+ window.RT.load('Layout/article_tech_ref');
+ window.RT.load('Element/theme_selector');
+ </script>
+ </head>
+ <body>
+ <RT·theme-selector></RT·theme-selector>
+ <RT·article>
+ <RT·title
+ author="Thomas Walker Lynch"
+ date="2026-03-09"
+ title="Product development roles and workflow">
+ </RT·title>
+
+ <RT·TOC level="1"></RT·TOC>
+
+ <h1>Roles as hats</h1>
+ <p>
+ In Harmony, a role is a hat a person wears. There can be multiple people sharing a single role, and a single person can wear many hats. Some roles interact directly with the project directory structure, while others guide the process from outside the codebase.
+ </p>
+
+ <h2>Workspace roles</h2>
+ <p>
+ These roles interact directly with the repository. To enter a workspace, change directory to the top-level of the project and source the setup file for the desired role:
+ </p>
+ <RT·code>
+ > ./setup administrator
+ > ./setup developer
+ > ./setup tester
+ > ./setup consumer
+ </RT·code>
+
+ <p>This will run a new shell with the proper environment setting. It is common for a person to have multiple shells, terminal sessions, or IDEs open, each running under a different role environment. Development and tests is a common pair, though tests are intended to be production tests. The developer has an <RT·code>experiment</RT·code> directory for experiments.
+ </p>
+
+ <h3>Administrator role</h3>
+ <p>Responsibilities:</p>
+ <ol>
+ <li>Set up the project directory and keep it in sync with the Harmony skeleton.</li>
+ <li>Maintain role environments (apart from role-specific <RT·code>tool/setup</RT·code> files).</li>
+ <li>Install and maintain shared and third_party tools, addressing issues with the project workflow. Note that the term "third_party" encompasses any software not authored within this specific project.</li>
+ </ol>
+
+ <h3>Developer role</h3>
+ <p>Responsibilities and Boundaries:</p>
+ <ol>
+ <li>Write and modify <RT·code>authored/</RT·code> source.</li>
+ <li>Run builds and place artifacts in <RT·code>scratchpad/made</RT·code>, then execute the <RT·code>promote write</RT·code> script to copy artifacts to <RT·code>consumer/made</RT·code> for testing.</li>
+ <li>Run experiments in <RT·code>experiment/</RT·code>. These experiments can sometimes be promoted to formal tests, but there is no requirement to do so. The developer role should not blur into the tester role; experiments are informal, whereas tests are formal and retained.</li>
+ <li><strong>Strict Boundary:</strong> A developer never writes into the <RT·code>tester/</RT·code> directory. Instead, a developer adds tests to <RT·code>developer/experiment/</RT·code> and offers to share them.</li>
+ </ol>
+
+ <h3>Tester role</h3>
+ <p>Responsibilities and Boundaries:</p>
+ <ol>
+ <li>Evaluate candidates under <RT·code>consumer/made/</RT·code> and run regression suites to confirm:
+ <ul>
+ <li>That the code does not crash.</li>
+ <li>That consumers do not have a bad experience.</li>
+ <li>That the goals specified by the product manager are met.</li>
+ </ul>
+ </li>
+ <li>File issues and communicate feedback to the developers.</li>
+ <li><strong>Strict Boundary:</strong> A tester never patches code in the <RT·code>developer/</RT·code> directory. Instead, the tester files issues or proposes code fixes on a separate branch.</li>
+ </ol>
+
+ <h3>Consumer role</h3>
+ <p>Responsibilities:</p>
+ <ol>
+ <li>Act as the end-user simulation environment.</li>
+ <li>Consume and deploy artifacts exclusively from the <RT·code>consumer/made/</RT·code> target.</li>
+ <li>Never author or modify code; strictly run local builds or deployments for architecture-specific testing.</li>
+ <li>Report issues. (Anyone can report an issue, and consumers regularly do).</li>
+ </ol>
+
+ <h2>External roles</h2>
+ <p>
+ These roles drive the project forward but do not have a dedicated <RT·code>setup <role></RT·code> workspace, as they do not directly build or test the code in that capacity. If these individuals need to interface with the code, they simply put on a workspace role hat, such as administer, developer, tester, or consumer.
+ </p>
+
+ <h3>Product manager</h3>
+ <p>
+ The product manager receives specifications from the architect and sets the specific goals for a release. When the project manager provides updates indicating readiness, the product manager makes the final decision to cut a project release.
+ </p>
+
+ <h3>Project manager</h3>
+ <p>
+ The project manager owns the schedule (such as the Gantt chart) and monitors progress. They coordinate code reviews, read issues and announcements, reformulate the schedule, and instruct the product manager. When parts of the codebase are outsourced, the project manager serves as the primary point of contact with the external teams.
+ </p>
+
+ <h3>Architect</h3>
+ <p>
+ The architect writes the original specification, sets the technical direction, and performs code reviews. The architect might be a contractor or an in-house team member.
+ </p>
+
+ <h1>The four interacting loops</h1>
+ <p>
+ The project moves forward through the continuous interaction of four distinct operational cycles.
+ </p>
+
+ <ol>
+ <li><strong>Developer Loop:</strong> Write code, compile to the scratchpad made directory, promote to the consumer made directory, announce the promotion to the tester, read issues, and repeat.</li>
+ <li><strong>Tester Loop:</strong> Read developer announcements, read the consumer made directory, run tests, and file issues.</li>
+ <li><strong>Product Manager Loop:</strong> Provide specifications to the developer, receive progress updates from the project manager, and instruct the project administrator to make new git branch project releases and deploy.</li>
+ <li><strong>Project Manager Loop:</strong> Own the Gantt chart, coordinate code reviews, read issues and announcements, reformulate the Gantt chart, and instruct the product manager.</li>
+ </ol>
+
+ <h1>Promotion mechanics</h1>
+ <p>
+ Building and promotion are separate activities. The developer compiles and places files in <RT·code>developer/scratchpad/made</RT·code>. The developer then runs <RT·code>promote write</RT·code> to transfer those files to <RT·code>consumer/made</RT·code>.
+ </p>
+ <p>
+ The <RT·code>consumer/made</RT·code> directory is strictly an untracked deployment target. No tools are permitted to rebuild during promotion, and no builds are run directly inside the consumer made directory.
+ </p>
+
+ </RT·article>
+ </body>
+</html>
--- /dev/null
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta charset="UTF-8">
+ <title>Product maintenance roles and workflow</title>
+ <script>
+ {
+ let dirpr_library = null;
+ try{ dirpr_library = localStorage.getItem("RT.dirpr_library"); }catch(e){}
+ window.RT = window.RT || {};
+ RT.dirpr_library = RT.dirpr_library || dirpr_library || "https://style.ReasoningTechnology.com/Manuscript";
+ document.write('<script src="' + RT.dirpr_library + '/Core/RT-Manuscript_make.js"><\/script>');
+ }
+ </script>
+ <script>
+ window.RT.theme_preference('inverse_wheat');
+ window.RT.load('Layout/paginate');
+ window.RT.load('Layout/article_tech_ref');
+ window.RT.load('Element/theme_selector');
+ </script>
+ </head>
+ <body>
+ <RT·theme-selector></RT·theme-selector>
+ <RT·article>
+ <RT·title
+ author="Thomas Walker Lynch"
+ date="2026-03-10"
+ title="Product maintenance roles and workflow">
+ </RT·title>
+
+ <RT·TOC level="1"></RT·TOC>
+
+ <h1>Maintenance philosophy</h1>
+ <p>
+ Deployed software must be a usable quality product for the customer, beyond that we favor addressing issues in new releases, and encourage customers who are having issues to upgrade. This approach is purely one of keeping the maintenance problem tractable.
+ </p>
+
+ <h1>Maintenance structure</h1>
+ <p>
+ Teams mentioned in this document:
+ </p>
+ <ol>
+ <li>tester team</li>
+ <li>core developer team</li>
+ <li>branch maintenance team</li>
+ <li>triage team</li>
+ </ol>
+
+ <p>
+ Team membership is a role a person takes on. Multiple people can share a single role, and one person might take on multiple roles.
+ </p>
+
+ <p>
+ The tester team develops and maintains the <RT·term>regression suite</RT·term>, the <RT·term>reliability suite</RT·term>, and <RT·term>additional tests</RT·term>.
+ </p>
+
+ <p>
+ The core developer team writes the source code and compiles promotion candidates.
+ </p>
+
+ <p>
+ The branch maintenance team is typically a subset of the core developer team. This group is dedicated to applying approved fixes to specific release branches.
+ </p>
+
+ <p>
+ The triage team evaluates defects reported against active releases. The team usually includes a developer contact, the project manager, and the product manager.
+ </p>
+
+ <h1>Issue queues</h1>
+ <p>
+ The project maintains two primary issue queues:
+ </p>
+ <ol>
+ <li>released product</li>
+ <li>core developer</li>
+ </ol>
+
+ <p>
+ There can be additional queues for multiple branches of development, and for experiments.
+ </p>
+
+ <h2>Core developer queue</h2>
+ <p>
+ This queue serves the <RT·code>core_developer_branch</RT·code>. A <RT·term>tester</RT·term> writes a test for every issue reported in this queue. Doing so isolates the defect, proves the fix, and guards against future regressions.
+ </p>
+
+ <h2>Released product queue</h2>
+ <p>
+ All issues found in release branches go onto this queue initially. It tracks defects reported against deployed software across any major version.
+ </p>
+
+ <h1>Triage and patching</h1>
+ <p>
+ Guided by the project philosophy, the triage team reviews each release queue issue to determine its impact. The team assesses whether the defect affects the <RT·code>core_developer_branch</RT·code>, assuming by default that it probably does. The team also determines if the defect is critical enough to warrant a patch on one or more active release branches.
+ </p>
+ <p>
+ Based on this assessment, the triage team files actionable tickets in the core developer queue. A ticket explicitly specifies its target branches. A ticket will be filed against either the core developer branch, specific release branches, or a combination of both.
+ </p>
+ <p>
+ Members of the tester team also file tickets directly into the core developer queue when they discover defects in the core branch.
+ </p>
+ <p>
+ Responsibility for resolving a ticket depends on its target. The core developer team addresses fixes required on the <RT·code>core_developer_branch</RT·code>. The branch maintenance team addresses fixes required on the <RT·code>release_v<major></RT·code> branches.
+ </p>
+ <p>
+ When a release is patched, the branch name remains static. The administrator advances the minor release number in the <RT·code>shared/tool/version</RT·code> file and tags the commit.
+ </p>
+
+ </RT·article>
+ </body>
+</html>
--- /dev/null
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta charset="UTF-8">
+ <title>Writing a tool script</title>
+ <script>
+ {
+ let dirpr_library = null;
+ try{ dirpr_library = localStorage.getItem("RT.dirpr_library"); }catch(e){}
+ window.RT = window.RT || {};
+ RT.dirpr_library = RT.dirpr_library || dirpr_library || "https://style.ReasoningTechnology.com/Manuscript";
+ document.write('<script src="' + RT.dirpr_library + '/Core/RT-Manuscript_make.js"><\/script>');
+ }
+ </script>
+ <script>
+ window.RT.theme_preference('inverse_wheat');
+ window.RT.load('Layout/paginate');
+ window.RT.load('Layout/article_tech_ref');
+ window.RT.load('Element/theme_selector');
+ </script>
+ </head>
+ <body>
+ <RT·theme-selector></RT·theme-selector>
+ <RT·article>
+ <RT·title
+ author="RT"
+ date="2026-10-09"
+ title="Writing a tool script">
+ </RT·title>
+
+ <RT·TOC level="1"></RT·TOC>
+
+ <h1>Purpose</h1>
+
+ <p>
+ A tool is a script a person runs from a role setup to do work on files. This document gives the conventions for how an RT tool presents itself on the command line, reports what it does, and changes files safely. It complements <RT·code>RT-code-format.html</RT·code>, which covers identifier names, layout, and the separation of the <RT·code>CLI()</RT·code> function from the work functions. Those rules apply here and are not repeated.
+ </p>
+
+ <p>
+ The conventions serve three needs of the person at the keyboard. They must be able to see what a tool will do before it does it, read afterward what it did, and run it again without harm.
+ </p>
+
+ <p>
+ Examples are taken from <RT·code>RT-Style_header_v5_to_v6</RT·code>, the administrator tool that converts RT-Style version 5 document headers to version 6.
+ </p>
+
+ <h1>The command line</h1>
+
+ <h2>No arguments prints the help</h2>
+
+ <p>
+ A tool given no arguments prints its help message and exits with status 0. A person who types the bare name of a tool is asking what it does. Nothing on the file system changes.
+ </p>
+
+ <h2>The first argument is a command</h2>
+
+ <p>
+ The first argument names a command, written as a word, not a flag. The words <RT·code>help</RT·code>, <RT·code>-h</RT·code> and <RT·code>--help</RT·code> all print the help message. Further arguments, when a command accepts any, follow the command. A command that accepts no further arguments rejects them with an error.
+ </p>
+
+ <p>
+ An unknown command prints an error naming it, then the help message, and exits with a nonzero status.
+ </p>
+
+ <h2>The help message is the module docstring</h2>
+
+ <p>
+ In Python the help message is the module docstring, printed by <RT·code>print(__doc__.strip())</RT·code>. The documentation at the top of the file and the message the person sees are then the same text, and cannot drift apart. The docstring opens with the tool name, a paragraph on what the tool does, then the usage and one line per command:
+ </p>
+
+ <RT·code>
+ Usage: RT-Style_header_v5_to_v6 <command> [file ...]
+ RT-Style_header_v5_to_v6 preview <command> [file ...]
+
+ help | -h | --help print this message (also printed when given no arguments)
+ project walk the directory tree rooted at $REPO_HOME
+ tree walk the directory tree rooted at the current directory
+ local only the current directory, no subdirectories
+ file <name> ... only the named .html files
+ preview <command> print the log of what <command> would do, change nothing
+ </RT·code>
+
+ <h2>Scope commands</h2>
+
+ <p>
+ A tool that acts on files found in a directory tree offers the same four commands for choosing which files:
+ </p>
+ <ul>
+ <li><RT·code>project</RT·code> walks the tree rooted at <RT·code>$REPO_HOME</RT·code>. When <RT·code>REPO_HOME</RT·code> is not set, the command is an error. Otherwise it behaves as <RT·code>tree</RT·code> run from <RT·code>$REPO_HOME</RT·code>, from whatever directory the person is in.</li>
+ <li><RT·code>tree</RT·code> walks the tree rooted at the current directory.</li>
+ <li><RT·code>local</RT·code> acts on the current directory only, without descending.</li>
+ <li><RT·code>file</RT·code> acts on the files named after it.</li>
+ </ul>
+
+ <p>
+ Only <RT·code>project</RT·code> depends on a Harmony project. The others work anywhere, because the files a tool serves are not always in a project. RT-Style documents, for example, appear in projects and outside them.
+ </p>
+
+ <h2>Preview</h2>
+
+ <p>
+ A tool that changes files offers <RT·code>preview</RT·code>, placed before any other command. <RT·code>preview tree</RT·code> prints the log that <RT·code>tree</RT·code> would print and changes nothing. The person reads it, decides, then runs the command without <RT·code>preview</RT·code>.
+ </p>
+
+ <p>
+ Preview is not a separate code path. The work functions are given a <RT·code>preview</RT·code> flag and test it only at the point of each action, immediately before writing or deleting. Everything else, finding files, deciding what to do, and logging, runs the same in both modes. A preview log that came from different code could not be trusted to describe the real run.
+ </p>
+
+ <h1>The log</h1>
+
+ <p>
+ A tool that acts on files prints a log of each thing it did. The log is for a person reading a terminal, and for that same person reading it again in a scrollback or a saved file later. This is the log of converting the Harmony skeleton documents, with the path on the first line shortened to fit the page:
+ </p>
+
+ <RT·code>
+ RT-Style_header_v5_to_v6 project in /home/Thomas/.../project/Harmony
+ converted administrator/document/how-to_release.html
+ converted developer/document/naming_file-and-directory.html
+ converted developer/document/single-file_C-module-and-namespace.html
+ converted document/introduction_Harmony.html
+ converted document/role-and-workflow_product-development.html
+ converted document/role-and-workflow_product-maintenance.html
+
+ summary:
+ 6 .html files converted to version 6
+ 0 RT-Manuscript_locator.js files deleted
+ 0 .html files already version 6
+ 0 .html files with mentions to edit by hand
+ 0 other .html files left alone
+ </RT·code>
+
+ <ul>
+ <li>The first line names the tool, the command, and the absolute directory the work is rooted in, so a saved log says where it came from.</li>
+ <li>Each action is one line. A fixed-width tag in the past tense says what happened, followed by the path relative to the root. Relative paths keep lines short and make logs from different machines comparable.</li>
+ <li>In a preview the tags read <RT·code>would convert</RT·code>, <RT·code>would delete</RT·code>, and the summary ends with <RT·code>preview only: nothing was changed</RT·code>.</li>
+ <li>A decision not to act is logged too, with the reason in parentheses, as in <RT·code>kept shared/document/RT-Manuscript_locator.js (no version 5 document beside it)</RT·code>. A file the tool left alone on purpose must be distinguishable from one it missed.</li>
+ <li>Work the tool cannot do safely is logged with the tag <RT·code>edit by hand</RT·code>, the file, the line number and the line, as in <RT·code>edit by hand .../Document/user.html:83: &lt;script src=...</RT·code>, so a person can go straight to it.</li>
+ <li>A summary of counts ends the log. A count line that would always be zero for a run, such as errors, may be left out when it is zero.</li>
+ <li>Walk directories and files in sorted order, so two runs over the same tree produce the same log.</li>
+ </ul>
+
+ <p>
+ Errors are logged on their own lines with the tag <RT·code>error</RT·code>, the tool carries on with the remaining files, and the exit status is nonzero when any error occurred. Errors in the command line itself are printed to standard error before any work starts.
+ </p>
+
+ <h1>Walking directories</h1>
+
+ <p>
+ A tool walks every directory in its scope, whatever the directory is named. It does not skip directories by name, since a name that means nothing to the tool's author may hold files that matter to its user.
+ </p>
+
+ <p>
+ A tool does not follow symbolic links to directories. A Harmony project has <RT·code>shared/project-dependency/project</RT·code> pointing to <RT·code>../../../</RT·code>, the directory holding all the projects. Following it would send the walk into every neighboring project and around the loop back into this one. In Python, <RT·code>os.walk</RT·code> does not follow such links unless given <RT·code>followlinks=True</RT·code>, so leave that argument out.
+ </p>
+
+ <h1>Paths</h1>
+
+ <p>
+ A tool forms an absolute path only by starting from an environment variable that the role setup provides, such as <RT·code>$REPO_HOME</RT·code>. Paths the tool writes into files, and symbolic links it creates, are relative.
+ </p>
+
+ <p>
+ An absolute path written into a file is correct for only one view of the file system. Under the subu arrangement, the master user reaches a sub-user's files through a mapped mount at a different absolute path from the one the sub-user sees. A path recorded by one of them is then unreadable, or points somewhere else, for the other. A relative path is resolved from wherever the file is reached, so it is correct in every view.
+ </p>
+
+ <h1>Changing files safely</h1>
+
+ <h2>Checkpoint first</h2>
+
+ <p>
+ A tool edits files in place and does not make backup copies. The person checkpoints the project with git before running it, and reviews the result with <RT·code>git diff</RT·code>. Run <RT·code>preview</RT·code> first.
+ </p>
+
+ <h2>Change only what is recognized exactly</h2>
+
+ <p>
+ Match the precise form the tool was written to change, in the precise place it occurs. <RT·code>RT-Style_header_v5_to_v6</RT·code> replaces a locator script line only when it stands alone on a line inside <RT·code><head></RT·code>. A mention of the locator anywhere else, such as an escaped example in a manual, is not touched. It is logged for a person to edit.
+ </p>
+
+ <h2>Keep everything not being changed</h2>
+
+ <p>
+ Carry over what the edit is not about: the indentation of a replaced line, line endings, file permissions, and every other line of the file byte for byte. After a run, <RT·code>git diff</RT·code> should show the intended change and nothing else.
+ </p>
+
+ <h2>Running twice does no harm</h2>
+
+ <p>
+ A second run of the same command finds nothing left to do, changes nothing, and says so in its summary. A tool achieves this by recognizing work already done. The header converter counts a file holding the version 6 block as <RT·code>already version 6</RT·code> and leaves it alone. A person can therefore rerun a tool to confirm a job is complete, or to finish one that stopped part way.
+ </p>
+
+ <h2>Before deleting, check nothing still uses it</h2>
+
+ <p>
+ A tool deletes a file only when nothing remaining depends on it. In testing, the header converter run with <RT·code>file</RT·code> on two of six test documents in a directory deleted the shared locator file while the other four still needed it. The rule now is that a locator is deleted only when no unconverted version 5 document beside it still loads it. Otherwise it is kept, and the log names the documents still using it.
+ </p>
+
+ <h2>Leave alone what the tool cannot identify as its own</h2>
+
+ <p>
+ A file with the right name in an unexpected place is not evidence that the tool should remove it. The converter keeps a locator file that has no version 5 document beside it, and logs it as <RT·code>kept</RT·code>, leaving the decision to a person.
+ </p>
+
+ <h1>Testing a tool</h1>
+
+ <p>
+ Test a new tool on a copy of real material before it runs on the original. An archive of the project, extracted into a scratch directory, gives realistic files that can be discarded.
+ </p>
+ <ol>
+ <li>Run with no arguments, an unknown command, and each command's error cases, such as <RT·code>project</RT·code> with <RT·code>REPO_HOME</RT·code> unset or <RT·code>file</RT·code> naming a file that does not exist.</li>
+ <li>Run <RT·code>preview</RT·code> and confirm that nothing changed, for example with <RT·code>diff -r</RT·code> against an untouched copy.</li>
+ <li>Run each scope command from more than one directory, including from outside any project.</li>
+ <li>Run each command twice. The second run must report nothing left to do.</li>
+ <li>Run on part of a job, such as <RT·code>file</RT·code> on some of the documents in a directory, and confirm the rest still work.</li>
+ <li>Read the diff of a changed file in full.</li>
+ </ol>
+
+ <h1>A skeleton</h1>
+
+ <p>
+ A Python tool laid out to these conventions begins with <RT·code>#!/usr/bin/env python3</RT·code>, then the Emacs mode line the Harmony tools carry, which sets two-space indentation and no tabs. The rest of the skeleton follows:
+ </p>
+
+ <RT·code>
+ """
+ tool_name
+
+ What the tool does, in a paragraph.
+
+ Usage: tool_name <command> [file ...]
+ tool_name preview <command> [file ...]
+
+ help | -h | --help print this message, also printed given no arguments
+ project walk the tree rooted at $REPO_HOME
+ tree walk the tree rooted at the current directory
+ local only the current directory
+ file <name> ... only the named files
+ preview <command> print the log of <command>, change nothing
+ """
+
+ import sys ,os
+ from pathlib import Path
+
+ def work(dirpa_root ,recursive ,preview):
+ ...
+
+ def print_help():
+ print(__doc__.strip())
+ return 0
+
+ def CLI(argv=None):
+ if argv is None: argv = sys.argv[1:]
+ if not argv or argv[0] in ("help" ,"-h" ,"--help"): return print_help()
+
+ preview = argv[0] == "preview"
+ if preview: argv = argv[1:]
+ if not argv:
+ print("Error: 'preview' needs a command after it." ,file=sys.stderr)
+ return 1
+
+ command ,rest = argv[0] ,argv[1:]
+ ...
+
+ if __name__ == "__main__":
+ sys.exit(CLI())
+ </RT·code>
+
+ <h1>Exercises</h1>
+
+ <h2>Exercise 1: The bare tool</h2>
+ <p>
+ A tool named <RT·code>tidy</RT·code> deletes editor backup files ending in <RT·code>~</RT·code>. When run with no arguments it deletes them from the current directory. List what is wrong with that behavior under these conventions, and give the command line it should have instead.
+ </p>
+
+ <h2>Exercise 2: A log line</h2>
+ <p>
+ A tool prints <RT·code>Processing /home/Thomas/subu/developer/project/Two/document/a.html ... done</RT·code> for every file it looks at, whether or not it changed it. Rewrite the log for a run in which one of three files was changed, one was already correct, and one could not be read.
+ </p>
+
+ <h2>Exercise 3: A safe delete</h2>
+ <p>
+ A tool replaces each <RT·code>include.css</RT·code> link in an HTML document with an inline style block, then deletes <RT·code>include.css</RT·code> from the directory. Describe the run in which this breaks a document, and the check that prevents it.
+ </p>
+
+ </RT·article>
+ </body>
+</html>
+++ /dev/null
-#!/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_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 <masu> <subu> [<subu>]*
- subu capture <masu> <subu> [<subu>]*
- subu remove <Subu_ID> | <masu> <subu> [<subu>]*
- subu list
- subu info <Subu_ID> | <masu> <subu> [<subu>]*
- subu option set|clear incommon <Subu_ID> | <masu> <subu> [<subu>]*
- """
- 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 <Subu_ID> <WG_ID>
- detach WG <Subu_ID>
- """
- 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 <Subu_ID>
- """
- 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 <Subu_ID> -- <cmd> ...
- """
- 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 <Subu_ID>
- """
- 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_lo_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 _collect_parse_errors(ns, program_name: str) -> list[str]:
- """Check for semantic argument problems and collect error strings."""
- errors: list[str] = []
-
- 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 <masu> and one <subu> 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} <masu> <subu> [<subu> ...] 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 "
- "<masu> <subu> [<subu> ...] 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 <Subu_ID> -- <cmd> ...", 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())
+++ /dev/null
-# dispatch.py
-# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*-
-
-import os, sys, sqlite3, stat
-import env
-from domain import subu as subu_domain
-from infrastructure.db import open_db, ensure_schema
-from infrastructure.options_store import set_option, get_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 _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_<id>'",
- 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 <masu> and one <subu> 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 <masu> <subu> [<subu> ...]
-
- 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 <masu> and one <subu> 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 <masu> and one <subu> 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_ID>|<masu> <subu> [<subu> ...]
- subu option clear incommon <Subu_ID>|<masu> <subu> [<subu> ...]
- """
- 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
+++ /dev/null
-"""
-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)
+++ /dev/null
-"""
-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_<id>
- run(["ip", "netns", "add", netns_name])
- # ip netns exec ns-subu_<id> ip link set lo down
- run(["ip", "netns", "exec", netns_name, "ip", "link", "set", "lo", "down"])
+++ /dev/null
-"""
-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}")
-
+++ /dev/null
-# 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,
-)
-
-
-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 subu_username(masu: str, path_components: list[str]) -> str:
- """
- Build the Unix username for a subu.
-
- Examples:
- masu = "Thomas", path = ["S0"] -> "Thomas_S0"
- masu = "Thomas", path = ["S0","S1"] -> "Thomas_S0_S1"
-
- The path is:
- masu subu subu ...
- """
- masu_s = _validate_token("masu", masu).replace(" ", "_")
- subu_parts: list[str] = []
- for s in path_components:
- subu_parts.append(_validate_token("subu", s).replace(" ", "_"))
- parts = [masu_s] + subu_parts
- return "_".join(parts)
-
-
-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/<parent>/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
+++ /dev/null
-"""
-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 <CIDR>` 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)")
-
+++ /dev/null
-# 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_<schema>.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())
+++ /dev/null
-"""
-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}")
-
+++ /dev/null
-// -*- 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 <linux/bpf.h>
-#include <bpf/bpf_helpers.h>
-#include <bpf/bpf_endian.h>
-
-
-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;
-}
+++ /dev/null
-# -*- 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
+++ /dev/null
-# 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()
+++ /dev/null
-# 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)
+++ /dev/null
--- 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
-);
+++ /dev/null
-# 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])
+++ /dev/null
-# 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 <masu> <subu> [<subu> ...]\n"
- f" {p} subu capture <masu> <subu> [<subu> ...]\n"
- f" {p} subu list\n"
- f" {p} subu info subu_<id>\n"
- f" {p} subu info <masu> <subu> [<subu> ...]\n"
- f" {p} subu remove subu_<id>\n"
- f" {p} subu remove <masu> <subu> [<subu> ...]\n"
- f" {p} subu option set incommon subu_<id>\n"
- f" {p} subu option set incommon <masu> <subu> [<subu> ...]\n"
- f" {p} subu option clear incommon subu_<id>\n"
- f" {p} subu option clear incommon <masu> <subu> [<subu> ...]\n"
- "\n"
-
- f" {p} lo up|down <Subu_ID>\n"
- "\n"
-
- f" {p} WG global <BaseCIDR>\n"
- f" {p} WG make <host:port>\n"
- f" {p} WG server_provided_public_key <WG_ID> <Base64Key>\n"
- f" {p} WG info|information <WG_ID>\n"
- f" {p} WG up <WG_ID>\n"
- f" {p} WG down <WG_ID>\n"
- "\n"
-
- f" {p} attach WG <Subu_ID> <WG_ID>\n"
- f" {p} detach WG <Subu_ID>\n"
- "\n"
-
- f" {p} network up|down <Subu_ID>\n"
- "\n"
-
- f" {p} option set <Subu_ID> <name> <value>\n"
- f" {p} option get <Subu_ID> <name>\n"
- f" {p} option list <Subu_ID>\n"
- "\n"
-
- f" {p} exec <Subu_ID> -- <cmd> ...\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"
- " (<masu> <subu> [<subu> ...]) 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)
+++ /dev/null
-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)
-#!/usr/bin/env bash
+#!/usr/bin/env python3
# setup - enter a project role environment
-# (must be sourced)
-script_afp=$(realpath "${BASH_SOURCE[0]}")
-if [ "${BASH_SOURCE[0]}" == "${0}" ]; then
- echo "${script_afp}:: This script must be sourced, not executed."
- exit 1
-fi
+import os
+import sys
+import argparse
+import tempfile
-project_roles="administrator consumer developer tester"
+PROJECT_ROLES = ["administrator", "consumer", "developer", "tester"]
-print_usage(){
- echo "usage: . setup <role>"
- echo "known roles: ${project_roles}"
-}
+def main():
+ parser = argparse.ArgumentParser(
+ description="Enter a project role environment by spawning an isolated subshell."
+ )
+ parser.add_argument("role", choices=PROJECT_ROLES, help="The project role to assume.")
+ args = parser.parse_args()
-if [ -z "${1:-}" ] || [ "${1}" == "-h" ] || [ "${1}" == "--help" ]; then
- print_usage
- return 0
-fi
+ env = os.environ.copy()
-role_is_valid=false
-for r in ${project_roles}; do
- if [ "${1}" == "${r}" ]; then
- role_is_valid=true
- break
- fi
-done
+ # Python-domain logic: Variables and Paths
+ repo_home = env.get("REPO_HOME", os.getcwd())
+ role = args.role
+ role_home = os.path.join(repo_home, role)
+ tool_dir = os.path.join(role_home, "tool")
+ setup_file = os.path.join(role, "tool", "setup")
-if [ "${role_is_valid}" == "false" ]; then
- echo "setup: unrecognized role or option '${1}'"
- print_usage
- return 1
-fi
+ env["ROLE"] = role
+ env["ROLE_HOME"] = role_home
+ env["SETUP"] = setup_file
-# setup the project
-#
- source shared/tool/setup
- if [[ -f "shared/authored/setup" ]]; then
- source shared/authored/setup
- fi
+ print(f"ROLE_HOME {role_home}")
-# setup tools
-#
- export PYTHON_HOME="${REPO_HOME}/shared/linked-project/Python"
- if [[ ":${PATH}:" != *":${PYTHON_HOME}/bin:"* ]]; then
- export PATH="${PYTHON_HOME}/bin:${PATH}"
- fi
+ # Python-domain logic: Directory validation
+ if not os.path.isdir(role):
+ print(f"setup: failed to locate directory for role '{role}'")
+ sys.exit(1)
- RT_gcc="${REPO_HOME}/shared/linked-project/RT_gcc/release"
- if [[ ":${PATH}:" != *":${RT_gcc}:"* ]]; then
- export PATH="${RT_gcc}:${PATH}"
- fi
+ # Bash-domain logic: The rcfile only handles operations Python cannot inherit
+ # (sourcing bash functions) and performs the directory change internally to
+ # guarantee the shared scripts are sourced from the project root.
+ bash_logic = [
+ 'if [[ -f ~/.bashrc ]]; then source ~/.bashrc; fi',
+ 'if [[ -f "shared/tool/setup" ]]; then source "shared/tool/setup"; fi',
+ 'if [[ -f "shared/authored/setup" ]]; then source "shared/authored/setup"; fi',
+ '# the role tool directory is searched before everything the shared setup added',
+ f'PATH="{tool_dir}:$PATH"; export PATH',
+ f'cd "{role}"',
+ 'if [[ -f "tool/setup" ]]; then',
+ ' source "tool/setup"',
+ f' echo "in environment: {setup_file}"',
+ 'else',
+ f' echo "not found: {setup_file}"',
+ 'fi',
+ 'if [[ -n "${INSIDE_EMACS}" ]] && command -v env_to_emacs >/dev/null 2>&1; then',
+ ' env_to_emacs',
+ 'fi',
+ 'rm -f "${BASH_SOURCE[0]}"'
+ ]
-# setup the role
-#
- export ROLE="${1}"
+ fd, rcfile_path = tempfile.mkstemp(suffix="-rt-setup.sh")
+ with os.fdopen(fd, "w") as f:
+ f.write("\n".join(bash_logic) + "\n")
- tool="${REPO_HOME}/${ROLE}/tool"
- if [[ ":${PATH}:" != *":${tool}:"* ]]; then
- export PATH="${tool}:${PATH}"
- fi
+ os.execvpe("bash", ["bash", "--rcfile", rcfile_path], env)
- export SETUP="${ROLE}/tool/setup"
-
- cd "${ROLE}" || return 1
- if [ -f "tool/setup" ]; then
- source "tool/setup"
- echo "in environment: ${SETUP}"
- else
- echo "not found: ${SETUP}"
- fi
+if __name__ == "__main__":
+ main()
+++ /dev/null
-window.StyleRT_namespaces = {
- "RT": window.RT_REPO_ROOT + "shared/linked-project/RT-style-JS_public/consumer/release/RT"
- ,"Project": window.RT_REPO_ROOT + "shared/authored/style"
-};
+++ /dev/null
-#+TITLE: Installing Python in Harmony
-#+AUTHOR: Thomas Walker Lynch
-#+OPTIONS: toc:2 num:nil
-
-* Overview
-
-This document describes how to install a project-local Python environment under:
-
-#+begin_src bash
-tool_shared/third_party/Python
-#+end_src
-
-This environment is shared across the =developer= and =tester= roles and is automatically activated through their respective =env_<role>= scripts.
-
-* Precondition
-
-Ensure the following:
-
-- You are in a POSIX shell with =python3= installed.
-- The =python3-venv= package is available (on Debian: =sudo apt install python3-venv=).
-- You have sourced the Harmony environment via =env_toolsmith= to initialize =REPO_HOME= and related variables.
-
-* Step-by-Step Installation
-
-1. Source the Harmony environment:
- #+begin_src bash
- source env_toolsmith
- #+end_src
-
-2. Create the virtual environment:
- #+begin_src bash
- python3 -m venv "$REPO_HOME/tool_shared/third_party/Python"
- #+end_src
-
-3. Activate it temporarily to install required packages:
- #+begin_src bash
- source "$REPO_HOME/tool_shared/third_party/Python/bin/activate"
- pip install --upgrade pip
- pip install pytest # Add any shared packages here
- deactivate
- #+end_src
-
-4. Rename Python's default activate and deactivate:
- Harmony provides its own role-aware environment management. Using Python’s default activation scripts may interfere with prompt logic, PATH order, and role-specific behavior.
-
- Disable the default scripts by renaming them:
- #+begin_src bash
- mv "$REPO_HOME/tool_shared/third_party/Python/bin/activate" \
- "$REPO_HOME/tool_shared/third_party/Python/bin/activate_deprecated"
- #+end_src
-
- This ensures that accidental sourcing of Python’s =activate= script won't override Harmony's environment setup.
-
-5. Verify installation:
- #+begin_src bash
- ls "$REPO_HOME/tool_shared/third_party/Python/bin/python3"
- #+end_src
-
- The binary should exist and report a working Python interpreter when run.
-
-* Notes
-
-- The virtual environment is deliberately named =Python=, not =venv=, to reflect its role as a shared system component.
-- Harmony environment scripts define and control =VIRTUAL_ENV=, =PYTHON_HOME=, and =PATH=, making Python activation seamless and uniform.
-- There is no need to use Python’s =bin/activate= directly — it is fully replaced by Harmony’s environment logic.
-
-* Related Files
-
-- =tool_shared/bespoke/env=
-- =tool_shared/bespoke/env_source=
-- =env_developer=, =env_tester=, =env_toolsmith=
-
-* Last Verified
-
-2025-05-19 :: Activate/deactivate renamed post-install. Requires Harmony environment sourcing prior to execution.
+++ /dev/null
-
-This is the generic install.org doc that comes with the skeleton.
-
-1. $REPO_HOME/tool_shared/third_party/.gitignore:
-
- *
- !/.gitignore
- !/patch
-
- The only things from the third party directory that will be pushed to the repo origin is the .gitignore file and the patches.
-
-
-2. downloaded tar files etc. go into the directory `upstream`
-
- $REPO_HOME/tool_shared/upstream
-
- Typically the contents of upstream are deleted after the install.
-
-3. for the base install
-
- cd $REPO_HOME/tool_shared/third_party
- do whatever it takes to install tool, as examples:
- git clone <tool_path>
- tar -xzf ../upstream/tar
- ...
-
- Be sure to add the path to the tool executable(s) in the $REPO_HOME/env_$ROLE files for the $ROLE who uses the tool.
-
- Assuming you are not also developing the tool, for safety
- change each installed git project to a local branch:
-
- b=<site>_<project>_local_$USER
- git switch -c "$b"
-
-
-4. Define some variables to simplify our discussion. Lowercase variable names
- are not exported from the shell.
-
- # already set in the environment
- # REPO_HOME
- # PROJECT
- # USER
-
- # example tool names: 'RT_gcc' 'RT-project share` etc.
- tool=<tool-name>
- tool_dpath="$REPO_HOME/tool_shared/third_party/$tool"
- patch_dpath="$REPO_HOME/tool_shared/patch/"
-
-
-5. create a patch series (from current vendor state → your local edits)
-
- # this can be repeated and will create an encompassing diff file
-
- # optionally crate a new branch after cloning the third party tool repo and work from there. You won't make any commits, but in case you plan to ever check the changes in, or have a the bad habit of doing ommits burned into your brain-stem, making a brnch will help.
-
- # make changes
-
- cd "$tool_dpath"
-
- # do your edits
-
- # Stage edits. Do not commit them!! Be sure you are in the third party
- # tool directory when doing `git add -A` and `git diff` commands.
- git add -A
-
- # diff the stage from the current repo to create the patch file
- git diff --staged > "$patch_dpath/$tool"
-
- # the diff file can be added to the project and checked in at the project level.
-
-
-6. how to apply an existing patch
-
- Get a fresh clone of the tool into $tool_dpath.
-
- cd "$tool_dpath"
- git apply "$patch_dpath/$tool"
-
- You can see what `git apply` would do by running
-
- git apply --check /path/to/your/patch_dpath/$tool
+++ /dev/null
-#+TITLE: Installing Python in Harmony
-#+AUTHOR: Thomas Walker Lynch
-#+OPTIONS: toc:2 num:nil
-
-* Overview
-
-This document describes how to install a project-local Python environment under:
-
-#+begin_src bash
-shared/linked-project/Python
-#+end_src
-
-* Precondition
-
-Ensure the following:
-
-- You are in a POSIX shell with =python3= installed.
-- The =python3-venv= package is available (on Debian: =sudo apt install python3-venv=).
-- You have sourced the Harmony environment via =env_toolsmith= to initialize =REPO_HOME= and related variables.
-
-* Step-by-Step Installation
-
-1. Source the Harmony environment:
- #+begin_src bash
- source env_toolsmith
- #+end_src
-
-2. Create the virtual environment:
- #+begin_src bash
- python3 -m venv "$REPO_HOME/shared/linked-project/Python"
- #+end_src
-
-3. Activate it temporarily to install required packages:
- #+begin_src bash
- source "$REPO_HOME/shared/linked-project/Python/bin/activate"
- pip install --upgrade pip
- pip install pytest # Add any shared packages here
- deactivate
- #+end_src
-
-4. Rename Python's default activate and deactivate:
- Harmony provides its own role-aware environment management. Using Python’s default activation scripts may interfere with prompt logic, PATH order, and role-specific behavior.
-
- Disable the default scripts by renaming them:
- #+begin_src bash
- mv "$REPO_HOME/shared/linked-project/Python/bin/activate" \
- "$REPO_HOME/shared/linked-project/Python/bin/activate_deprecated"
- #+end_src
-
- This ensures that accidental sourcing of Python’s =activate= script won't override Harmony's environment setup.
-
-5. Verify installation:
- #+begin_src bash
- ls "$REPO_HOME/shared/linked-project/Python/bin/python3"
- #+end_src
-
- The binary should exist and report a working Python interpreter when run.
-
-* Notes
-
-- The virtual environment is deliberately named =Python=, not =venv=, to reflect its role as a shared system component.
-- Harmony environment scripts define and control =VIRTUAL_ENV=, =PYTHON_HOME=, and =PATH=, making Python activation seamless and uniform.
-- There is no need to use Python’s =bin/activate= directly — it is fully replaced by Harmony’s environment logic.
-
-* Related Files
-
-- =shared/authored/env=
-- =shared/authored/env_source=
-- =env_developer=, =env_tester=, =env_toolsmith=
-
-* Last Verified
-
-2025-05-19 :: Activate/deactivate renamed post-install. Requires Harmony environment sourcing prior to execution.
+++ /dev/null
-
-This is the generic install.org doc that comes with the skeleton.
-
-1. $REPO_HOME/shared/linked-project/.gitignore:
-
- *
- !/.gitignore
- !/patch
-
- The only things from the third party directory that will be pushed to the repo origin is the .gitignore file and the patches.
-
-
-2. downloaded tar files etc. go into the directory `upstream`
-
- $REPO_HOME/shared/upstream
-
- Typically the contents of upstream are deleted after the install.
-
-3. for the base install
-
- cd $REPO_HOME/shared/linked-project
- do whatever it takes to install tool, as examples:
- git clone <tool_path>
- tar -xzf ../upstream/tar
- ...
-
- Be sure to add the path to the tool executable(s) in the $REPO_HOME/env_$ROLE files for the $ROLE who uses the tool.
-
- Assuming you are not also developing the tool, for safety
- change each installed git project to a local branch:
-
- b=<site>_<project>_local_$USER
- git switch -c "$b"
-
-
-4. Define some variables to simplify our discussion. Lowercase variable names
- are not exported from the shell.
-
- # already set in the environment
- # REPO_HOME
- # PROJECT
- # USER
-
- # example tool names: 'RT_gcc' 'RT-project share` etc.
- tool=<tool-name>
- tool_dpath="$REPO_HOME/shared/linked-project/$tool"
- patch_dpath="$REPO_HOME/shared/patch/"
-
-
-5. create a patch series (from current vendor state → your local edits)
-
- # this can be repeated and will create an encompassing diff file
-
- # optionally crate a new branch after cloning the third party tool repo and work from there. You won't make any commits, but in case you plan to ever check the changes in, or have a the bad habit of doing ommits burned into your brain-stem, making a brnch will help.
-
- # make changes
-
- cd "$tool_dpath"
-
- # do your edits
-
- # Stage edits. Do not commit them!! Be sure you are in the third party
- # tool directory when doing `git add -A` and `git diff` commands.
- git add -A
-
- # diff the stage from the current repo to create the patch file
- git diff --staged > "$patch_dpath/$tool"
-
- # the diff file can be added to the project and checked in at the project level.
-
-
-6. how to apply an existing patch
-
- Get a fresh clone of the tool into $tool_dpath.
-
- cd "$tool_dpath"
- git apply "$patch_dpath/$tool"
-
- You can see what `git apply` would do by running
-
- git apply --check /path/to/your/patch_dpath/$tool
+++ /dev/null
-
-RT-share is used by a number of RT project. So I usually install RT-share in the projects directory along side of other projects, then symlink it into third_party.
-
-An alternative is to git clone RT-share under third_party.
+++ /dev/null
-
-sudo apt-get install bindfs cryptsetup
+++ /dev/null
-window.RT_REPO_ROOT = "../../";
-document.write('<script src="' + window.RT_REPO_ROOT + 'shared/dictionary_style-directory.js"></script>');
-document.write('<script src="' + window.RT_REPO_ROOT + 'shared/linked-project/RT-style-JS_public/consumer/release/RT/core/loader.js"></script>');
-document.write('<script src="' + window.RT_REPO_ROOT + 'shared/linked-project/RT-style-JS_public/consumer/release/RT/core/body_visibility_hidden.js"></script>');
+++ /dev/null
-/home/Thomas/subu_data/developer/subu_data/RT-project-share/
\ No newline at end of file
+++ /dev/null
-../../../RT-style-JS_public/
\ No newline at end of file
--- /dev/null
+# shared/project-dependency/.gitignore
+# Tracks the skeleton's parts. Add a ! line for anything else to track,
+# such as a dependency's link.
+*
+!.gitignore
+!project
+!*.init
+!*.install
+!*.org
+!*.html
+!Python-3.12.3
--- /dev/null
+project/Python-3.12.3
\ No newline at end of file
--- /dev/null
+# -*- mode: sh; sh-shell: bash; -*-
+# init files are bash script
+
+# Python.init
+# --------------------------------------------------------------------------------
+# Ensure the local Python build is prioritized in the PATH.
+# The user must establish the Python-3.12.3 symlink for this to activate.
+
+if [[ -d "$REPO_HOME/shared/project-dependency/Python-3.12.3/bin" ]]; then
+ PATH="$REPO_HOME/shared/project-dependency/Python-3.12.3/bin:$PATH"
+else
+ # Optional: Issue a quiet warning for the administrator role
+ if [[ "$ROLE" == "administrator" ]]; then
+ echo "Notice: Local Python-3.12.3 bin not found. Using system Python."
+ fi
+fi
--- /dev/null
+#+TITLE: Python Installation Guide
+#+AUTHOR: Project Administrator
+
+This project requires a dedicated Python environment. The configuration plugin (`shared/project-dependency/Python.init`) was written targeting Python 3.12.3, though the architecture does not rely on any known version-dependent features.
+
+* 1. Download and Extract
+Download the Python source code and extract it as a sibling to the main project directory. Assuming you are at the top level of your project workspace:
+
+#+begin_src bash
+cd ../
+wget https://www.python.org/ftp/python/3.12.3/Python-3.12.3.tgz
+tar -xzf Python-3.12.3.tgz
+cd Python-3.12.3
+#+end_src
+
+* 2. Build the Environment
+Compile the Python binaries in place. Setting the installation prefix to the current directory isolates the build, and enabling shared libraries ensures compatibility with C extensions.
+
+#+begin_src bash
+./configure --prefix="$(pwd)" --enable-optimizations --enable-shared
+make -j8
+make install
+#+end_src
+
+* 3. Link the Project
+
+Python-3.12.3 is linked to a Harmony derived project by default.
+
+See administrator/document/how-to_add-project-dependency.html for details about linking a project, if you want to check the setup.
+
+If you are installing a different version, then at least the Python-3.12.3 version project linking is there as a good example.
+
+Project linking is not difficult, make a sym link to `project/<dependency>`, and then add an init file, as described in administrator/document/how-to_add-project-dependency.html. The 'project' in the link is literal.
+
+
+
+
+
--- /dev/null
+../../../
\ No newline at end of file
+++ /dev/null
-#!/usr/bin/env -S python3 -B
-# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*-
-"""
-RTfmt — Reasoning Technology code formatter (Predicate Tokenizer)
-
-Commands:
- RTfmt write [--lisp] <file ...> Format files in place (rewrite originals)
- RTfmt copy [--lisp] <file ...> Save backups as <file>~ then format originals
- RT-formatter pipe [--lisp] Read from stdin, write to stdout
- RTfmt self_test Run built-in tests
- RTfmt version Show tool version
- RTfmt help | --help Show usage
-"""
-
-import sys ,re ,shutil ,os
-from typing import List ,Tuple ,Optional ,TextIO
-
-RTF_VERSION = "0.5.0-predicate"
-
-def get_usage() -> str:
- prog_name = os.path.basename(sys.argv[0])
- return f"""\
-Usage:
- {prog_name} write [--lisp] <file ...>
- {prog_name} copy [--lisp] <file ...>
- {prog_name} pipe [--lisp]
- {prog_name} self_test
- {prog_name} version
- {prog_name} help | --help
-"""
-
-# Removed < and > so they are treated as standard CODE operators
-BR_OPEN = "([{"
-BR_CLOSE = ")]}"
-PAIR = dict( zip(BR_OPEN ,BR_CLOSE) )
-REV = dict( zip(BR_CLOSE ,BR_OPEN) )
-
-# --------------- Lexer ----------------
-
-class RT_Token:
- def __init__(self ,kind: str ,text: str):
- self.kind = kind
- self.text = text
-
- def __repr__(self):
- return f"<{self.kind}:{repr(self.text)}>"
-
-TOKEN_REGEX = re.compile(
- r'(?P<COMMENT>//[^\n]*|#[^\n]*|(?s:/\*.*?\*/))'
- r'|(?P<STRING>"""[\s\S]*?"""|\'\'\'[\s\S]*?\'\'\'|"(?:\\.|[^"\\])*"|\'(?:\\.|[^\'\\])*\')'
- r'|(?P<SPACE>[ \t]+)'
- r'|(?P<NEWLINE>\n)'
- r'|(?P<COMMA>,)'
- r'|(?P<BR_OPEN>[\[\(\{])'
- r'|(?P<BR_CLOSE>[\]\)\}])'
- r'|(?P<CODE>[^ \t\n,\[\(\{\]\)\}"\'#/]+|/)'
-)
-
-def tokenize(text: str) -> List[RT_Token]:
- tokens = []
- for TM_match in TOKEN_REGEX.finditer(text):
- kind = TM_match.lastgroup
- text_val = TM_match.group(kind)
- tokens.append( RT_Token(kind ,text_val) )
- return tokens
-
-# --------------- Intelligence API ----------------
-
-class TokenStream:
- def __init__(self ,tokens: List[RT_Token]):
- self.tokens = tokens
-
- def get_token(self ,index: int) -> Optional[RT_Token]:
- if 0 <= index < len(self.tokens):
- return self.tokens[index]
- return None
-
- def next_sig_index(self ,index: int) -> Optional[int]:
- for TM_i in range(index + 1 ,len(self.tokens)):
- if self.tokens[TM_i].kind not in ("SPACE" ,"NEWLINE" ,"COMMENT"):
- return TM_i
- return None
-
- def is_first_on_line(self ,index: int) -> bool:
- for TM_i in range(index - 1 ,-1 ,-1):
- k = self.tokens[TM_i].kind
- if k == "NEWLINE":
- return True
- if k != "SPACE":
- return False
- return True # Start of file
-
- def indent_of_line(self ,index: int) -> str:
- for TM_i in range(index ,-1 ,-1):
- if self.tokens[TM_i].kind == "NEWLINE":
- if TM_i + 1 < len(self.tokens) and self.tokens[TM_i + 1].kind == "SPACE":
- return self.tokens[TM_i + 1].text
- return ""
- if self.tokens and self.tokens[0].kind == "SPACE":
- return self.tokens[0].text
- return ""
-
- def indent_of_left_match(self ,index: int) -> Optional[str]:
- tok = self.get_token(index)
- if not tok or tok.kind != "BR_CLOSE":
- return None
- target_opener = REV[tok.text]
- depth = 0
- for TM_i in range(index - 1 ,-1 ,-1):
- t = self.tokens[TM_i]
- if t.kind == "BR_CLOSE":
- depth += 1
- elif t.kind == "BR_OPEN":
- if depth > 0:
- depth -= 1
- elif t.text == target_opener:
- return self.indent_of_line(TM_i)
- return None
-
-# --------------- Rule Engine ----------------
-
-def rule_migrate_vertical_commas(stream: TokenStream):
- TM_i = 0
- while TM_i < len(stream.tokens):
- if stream.tokens[TM_i].kind == "COMMA":
- is_trailing = False
- next_sig = stream.next_sig_index(TM_i)
- if next_sig is not None:
- for TM_j in range(TM_i + 1 ,next_sig):
- if stream.tokens[TM_j].kind == "NEWLINE":
- is_trailing = True
- break
-
- if is_trailing:
- comma_tok = stream.tokens.pop(TM_i)
- next_sig -= 1 # Shifted because of pop
- stream.tokens.insert(next_sig ,comma_tok)
- continue
- TM_i += 1
-
-def rule_format_horizontal_commas(stream: TokenStream):
- for TM_i in range(len(stream.tokens) - 1 ,-1 ,-1):
- if stream.tokens[TM_i].kind == "COMMA":
- if stream.is_first_on_line(TM_i):
- continue
-
- next_tok = stream.get_token(TM_i + 1)
- if next_tok and next_tok.kind == "SPACE":
- stream.tokens.pop(TM_i + 1)
-
- prev_tok = stream.get_token(TM_i - 1)
- if prev_tok and prev_tok.kind == "SPACE":
- if prev_tok.text != " ":
- prev_tok.text = " "
- else:
- stream.tokens.insert(TM_i ,RT_Token("SPACE" ," "))
-
-def rule_fix_closing_indent(stream: TokenStream):
- for TM_i in range(len(stream.tokens) - 1 ,-1 ,-1):
- if stream.tokens[TM_i].kind == "BR_CLOSE" and stream.is_first_on_line(TM_i):
- target_indent = stream.indent_of_left_match(TM_i)
- if target_indent is not None:
- prev = stream.get_token(TM_i - 1)
- if prev and prev.kind == "SPACE":
- prev.text = target_indent
- else:
- stream.tokens.insert(TM_i ,RT_Token("SPACE" ,target_indent))
-
-def rule_tighten_brackets(stream: TokenStream):
- for TM_i in range(len(stream.tokens) - 1 ,-1 ,-1):
- if stream.tokens[TM_i].kind == "SPACE" and not stream.is_first_on_line(TM_i):
- prev_t = stream.get_token(TM_i - 1)
- next_t = stream.get_token(TM_i + 1)
- if (prev_t and prev_t.kind == "BR_OPEN") or (next_t and next_t.kind == "BR_CLOSE"):
- stream.tokens.pop(TM_i)
-
-def get_bracket_spans(stream: TokenStream) -> List[Tuple[int ,int]]:
- stack = []
- spans = []
- for TM_i ,tok in enumerate(stream.tokens):
- if tok.kind == "BR_OPEN":
- stack.append( (tok.text ,TM_i) )
- elif tok.kind == "BR_CLOSE":
- if stack and REV[tok.text] == stack[-1][0]:
- _ ,pos = stack.pop()
- if not stack:
- spans.append( (pos ,TM_i) )
- return spans
-
-def rule_pad_outermost(stream: TokenStream ,is_lisp: bool):
- if is_lisp:
- return
- while True:
- spans = get_bracket_spans(stream)
- changed = False
- for TM_start ,TM_end in reversed(spans):
- has_inner = False
- for TM_k in range(TM_start + 1 ,TM_end):
- if stream.tokens[TM_k].kind in ("BR_OPEN" ,"BR_CLOSE"):
- has_inner = True
- break
-
- if has_inner:
- left_has = (TM_start + 1 < len(stream.tokens)) and stream.tokens[TM_start + 1].kind == "SPACE"
- right_has = (TM_end - 1 >= 0) and stream.tokens[TM_end - 1].kind == "SPACE"
- if not left_has or not right_has:
- if not right_has:
- stream.tokens.insert(TM_end ,RT_Token("SPACE" ," "))
- if not left_has:
- stream.tokens.insert(TM_start + 1 ,RT_Token("SPACE" ," "))
- changed = True
- break
- if not changed:
- break
-
-# --------------- Public API ----------------
-
-def format_tokens(tokens: List[RT_Token] ,is_lisp: bool) -> str:
- stream = TokenStream(tokens)
-
- rule_migrate_vertical_commas(stream)
- rule_format_horizontal_commas(stream)
- rule_tighten_brackets(stream)
- rule_fix_closing_indent(stream)
- rule_pad_outermost(stream ,is_lisp)
-
- return "".join(t.text for t in stream.tokens)
-
-def rt_format_text(text: str ,is_lisp: bool) -> str:
- tokens = tokenize(text)
- return format_tokens(tokens ,is_lisp)
-
-def rt_format_stream(inp: TextIO ,out: TextIO ,is_lisp: bool) -> None:
- text = inp.read()
- out.write( rt_format_text(text ,is_lisp) )
-
-# --------------- Self-test ----------------
-
-def run_self_test() -> bool:
- ok = True
- def chk(src ,exp):
- nonlocal ok
- got = rt_format_text(src ,False)
- if got != exp:
- print("FAIL:\n" + src + "\n=>\n" + got + "\nexpected:\n" + exp)
- ok = False
-
- chk("a,b,c" ,"a ,b ,c")
- chk("a , b , c" ,"a ,b ,c")
- chk(" ,vertical_arg" ," ,vertical_arg")
-
- chk("int a=0,\n b=1,\n c=2;" ,"int a=0\n ,b=1\n ,c=2;")
-
- chk("f ( x )" ,"f(x)")
- chk("f(x) + g(y)" ,"f(x) + g(y)")
- chk(" {" ," {")
-
- src = "int g(){int a=0,b=1,c=2; return h(a,b,c);}"
- exp = "int g(){ int a=0 ,b=1 ,c=2; return h(a ,b ,c); }"
- chk(src ,exp)
-
- chk("outer( inner(a,b) )" ,"outer( inner(a ,b) )")
-
- # Operator protection check
- chk("for(int TM = 0; TM < count; ++TM)" ,"for(int TM = 0; TM < count; ++TM)")
-
- print("SELFTEST OK" if ok else "SELFTEST FAILED")
- return ok
-
-# --------------- CLI ----------------
-def write_files(paths: List[str] ,is_lisp: bool) -> int:
- for TM_path in paths:
- with open(TM_path ,"r" ,encoding="utf-8") as f:
- data = f.read()
- formatted = rt_format_text(data ,is_lisp)
- with open(TM_path ,"w" ,encoding="utf-8") as f:
- f.write(formatted)
- return 0
-
-def copy_files(paths: List[str] ,is_lisp: bool) -> int:
- for TM_path in paths:
- shutil.copy2(TM_path ,TM_path + "~")
- return write_files(paths ,is_lisp)
-
-def CLI(argv=None) -> int:
- args = list(sys.argv[1:] if argv is None else argv)
- usage_text = get_usage()
-
- if not args or args[0] in {"help" ,"--help" ,"-h"}:
- print(usage_text)
- return 0
-
- is_lisp = "--lisp" in args
- args = [TM_a for TM_a in args if TM_a != "--lisp"]
-
- if not args:
- return 0
-
- cmd = args[0]
- rest = args[1:]
-
- if cmd == "version":
- print(RTF_VERSION)
- return 0
- if cmd == "self_test":
- ok = run_self_test()
- return 0 if ok else 1
- if cmd == "pipe":
- rt_format_stream(sys.stdin ,sys.stdout ,is_lisp)
- return 0
- if cmd == "write":
- if not rest:
- print("write: missing <file ...>\n" + usage_text)
- return 2
- return write_files(rest ,is_lisp)
- if cmd == "copy":
- if not rest:
- print("copy: missing <file ...>\n" + usage_text)
- return 2
- return copy_files(rest ,is_lisp)
-
- print(f"Unknown command: {cmd}\n" + usage_text)
- return 2
-
-if __name__ == "__main__":
- sys.exit( CLI() )
+++ /dev/null
-(defun RT-formatter-buffer ()
- "Format the current buffer using RTfmt0."
- (interactive)
- (if (not (executable-find "RT-formatter"))
- (message "Error: RTfmt0 executable not found in PATH.")
- (let ((temp-buffer (generate-new-buffer " *RTfmt0*"))
- (args (list "pipe")))
- (when (derived-mode-p 'emacs-lisp-mode 'lisp-mode)
- (setq args (append args (list "--lisp"))))
- (unwind-protect
- (let ((exit-code (apply #'call-process-region
- (point-min) (point-max)
- "RT-formatter"
- nil temp-buffer nil
- args)))
- (if (zerop exit-code)
- (progn
- ;; Applies a non-destructive diff, preserving point and markers natively
- (replace-buffer-contents temp-buffer)
- (message "RT-formatter formatting successful."))
- (message "RT-formatter failed with exit code %s. Buffer unchanged." exit-code)))
- (kill-buffer temp-buffer)))))
--- /dev/null
+#!/usr/bin/env bash
+script_afp=$(realpath "${BASH_SOURCE[0]}")
+
+set -euo pipefail
+
+if [ -z "${REPO_HOME:-}" ]; then
+ echo "emacs-sync:: error: REPO_HOME is not set. Source a setup environment first."
+ exit 1
+fi
+
+if ! command -v emacsclient >/dev/null 2>&1; then
+ echo "emacs-sync:: error: emacsclient is not installed or not in PATH."
+ exit 1
+fi
+
+# Push variables to the Emacs global environment
+emacsclient --eval "(setenv \"REPO_HOME\" \"$REPO_HOME\")" > /dev/null
+emacsclient --eval "(setenv \"PROJECT\" \"$PROJECT\")" > /dev/null
+
+if [ -n "${ROLE:-}" ]; then
+ emacsclient --eval "(setenv \"ROLE\" \"$ROLE\")" > /dev/null
+fi
+
+# Sync the PATH so Emacs subprocesses find the local tools
+emacsclient --eval "(setenv \"PATH\" \"$PATH\")" > /dev/null
+
+# Update exec-path for native Emacs executable resolution
+emacsclient --eval "(setq exec-path (append (parse-colon-path \"$PATH\") (list exec-directory)))" > /dev/null
+
+echo "Emacs environment synchronized for project: $PROJECT"
# --------------------------------------------------------------------------------
# project definition
-# actual absolute director path for this script file
-
+# actual absolute directory path for this script file
script_adp(){
dirname "$script_afp"
}
-# assume this script is located $REPO_HOME/tools_shared/authored and work backwards
-# to get $REPO_HOME, etc.
-
+# This script is located at $REPO_HOME/shared/tool/setup.
+# Ascend two directories to discover the absolute $REPO_HOME path.
REPO_HOME=$(dirname "$(dirname "$(script_adp)")")
echo REPO_HOME "$REPO_HOME"
export REPO_HOME PROJECT PROMPT_DECOR
-# --------------------------------------------------------------------------------
-# Project wide Tool setup
-#
-
-export VIRTUAL_ENV="$REPO_HOME/shared/linked-project/Python"
-export PYTHON_HOME="$VIRTUAL_ENV"
-unset PYTHONHOME
-
-
-# --------------------------------------------------------------------------------
-# PATH
-# precedence: last defined, first discovered
-
- PATH="$REPO_HOME/shared/authored:$PATH"
- PATH="$REPO_HOME/shared/made:$PATH"
- PATH="$REPO_HOME/shared/tool:$PATH"
-
- # Remove duplicates
- clean_path() {
- PATH=$(echo ":$PATH" | awk -v RS=: -v ORS=: '!seen[$0]++' | sed 's/^://; s/:$//')
- }
- clean_path
- export PATH
-
# --------------------------------------------------------------------------------
# the following functions are provided for other scripts to use.
# at the top of files that make use of these functions put the following line:
basename "$script_afp"
}
- ## script's dirpath relative to $REPO_HOME
+ ## script's file path relative to $REPO_HOME
script_fp(){
realpath --relative-to="${REPO_HOME}" "$script_afp"
}
export -f script_adp script_fn script_dp script_fp
-#--------------------------------------------------------------------------------
-# used by release scripts
-#
-
- install_file() {
- if [ "$#" -lt 3 ]; then
- echo "env::install_file usage: install_file <source1> <source2> ... <target_dir> <permissions>"
- return 1
+# --------------------------------------------------------------------------------
+# PATH & Plugin Sourcing
+# Each addition goes to the front, so the last added is searched first.
+# Resulting search order, most specific first:
+# shared/authored, shared/made, shared/tool, project dependencies, the rest.
+# The top-level setup then puts the role's tool directory in front of these.
+
+ # Project dependencies: source each .init file. Each prepends its own entries.
+ for init_file in "$REPO_HOME/shared/project-dependency/"*.init; do
+ if [[ -f "$init_file" ]]; then
+ source "$init_file"
fi
+ done
- perms="${@: -1}" # Last argument is permissions
- target_dp="${@: -2:1}" # Second-to-last argument is the target directory
- sources=("${@:1:$#-2}") # All other arguments are source files
-
- if [ ! -d "$target_dp" ]; then
- echo "env::install_file no install done: target directory '$target_dp' does not exist."
- return 1
- fi
+ PATH="$REPO_HOME/shared/tool:$PATH"
+ PATH="$REPO_HOME/shared/made:$PATH"
+ PATH="$REPO_HOME/shared/authored:$PATH"
- for source_fp in "${sources[@]}"; do
- if [ ! -f "$source_fp" ]; then
- echo "env::install_file: source file '$source_fp' does not exist."
- return 1
- fi
-
- target_file="$target_dp/$(basename "$source_fp")"
-
- if ! install -m "$perms" "$source_fp" "$target_file"; then
- echo "env::install_file: Failed to install $(basename "$source_fp") to $target_dp"
- return 1
- else
- echo "env::install_file: installed $(basename "$source_fp") to $target_dp with permissions $perms"
- fi
- done
+ # Remove duplicates, keeping the first occurrence
+ clean_path() {
+ PATH=$(echo ":$PATH" | awk -v RS=: -v ORS=: '!seen[$0]++' | sed 's/^://; s/:$//')
}
+ clean_path
- export -f install_file
+ export PATH
# --------------------------------------------------------------------------------
# closing
if [[ -z "$ENV" ]]; then
export ENV=$(script_fp)
fi
-
-echo "Harmony v3.0 2026-05-10"
-echo "SubU v3.0 2025-05-19"
-
-
-
+echo "Harmony v3.5 2026-10-14 04:51:11 Z"
+echo "SubU v4.0 2026-10-14 04:51:11 Z"
+++ /dev/null
-*
-!/.gitignore
\ No newline at end of file
+++ /dev/null
-/home/Thomas/subu_data/developer/subu_data/subu/release/manager/CLI.py
\ No newline at end of file
+++ /dev/null
-#!/usr/bin/env bash
-
-{ . test_0_driver.sh 2> >(tee /dev/stderr >&3); } 3>test_0_out.sh 1>&3
-set -x
-diff -qr test_0_out.sh test_0_expected.sh
-set +x
+++ /dev/null
-set -x
-CLI.py # -> USAGE (exit 0)
-CLI.py usage # -> USAGE
-CLI.py -h # -> HELP
-CLI.py --help # -> HELP
-CLI.py help # -> HELP
-CLI.py help WG # -> WG topic help (or full HELP if topic unknown)
-CLI.py example # -> EXAMPLE
-CLI.py version # -> 0.1.4
-CLI.py -V # -> 0.1.4
-set +x
+++ /dev/null
-++ CLI.py
-Subu manager (v0.3.3)
-
-1) Init
- CLI.py init <TOKEN>
- Makes ./subu.db. Refuses to run if db exists.
-
-2) Subu
- CLI.py make <masu> <subu> [_<subu>]*
- CLI.py list
- CLI.py info <Subu_ID>
-
-3) Loopback
- CLI.py lo up|down <Subu_ID>
-
-4) WireGuard objects (independent of subu)
- CLI.py WG global <BaseCIDR> # for example, 192.168.112.0/24
- CLI.py WG make <host:port> # allocates next /32
- CLI.py WG server_provided_public_key <WG_ID> <Base64Key>
- CLI.py WG info <WG_ID>
- CLI.py WG up <WG_ID> / CLI.py WG down <WG_ID> # administrative toggle after attached
-
-5) Attach or detach and eBPF steering
- CLI.py attach WG <Subu_ID> <WG_ID>
- - Makes WireGuard device as subu_<M> inside ns-subu_<N>, assigns /32, MTU 1420
- - Installs per-subu cgroup and loads eBPF scaffold (user identifier check, metadata map)
- - Keeps device administrative-down until `CLI.py network up`
- CLI.py detach WG <Subu_ID>
- - Deletes device, removes cgroup and eBPF program
-
-6) Network aggregate
- CLI.py network up|down <Subu_ID>
- - Ensures loopback is up on 'up', toggles attached WireGuard interfaces
-
-7) Options
- CLI.py option set|get|list ...
-
-8) Exec
- CLI.py exec <Subu_ID> -- <cmd> ...
-++ CLI.py usage
-CLI.py — Subu manager (v0.3.3)
-
-Usage:
- CLI.py # usage
- CLI.py help # detailed help
- CLI.py example # example workflow
- CLI.py version # print version
-
- CLI.py init <TOKEN>
- CLI.py make <masu> <subu> [_<subu>]*
- CLI.py list
- CLI.py info <Subu_ID> | CLI.py information <Subu_ID>
-
- CLI.py lo up|down <Subu_ID>
-
- CLI.py WG global <BaseCIDR>
- CLI.py WG make <host:port>
- CLI.py WG server_provided_public_key <WG_ID> <Base64Key>
- CLI.py WG info|information <WG_ID>
- CLI.py WG up <WG_ID>
- CLI.py WG down <WG_ID>
-
- CLI.py attach WG <Subu_ID> <WG_ID>
- CLI.py detach WG <Subu_ID>
-
- CLI.py network up|down <Subu_ID>
-
- CLI.py option set <Subu_ID> <subu> <value>
- CLI.py option get <Subu_ID> <subu>
- CLI.py option list <Subu_ID>
-
- CLI.py exec <Subu_ID> -- <cmd> ...
-++ CLI.py -h
-Subu manager (v0.3.3)
-
-1) Init
- CLI.py init <TOKEN>
- Makes ./subu.db. Refuses to run if db exists.
-
-2) Subu
- CLI.py make <masu> <subu> [_<subu>]*
- CLI.py list
- CLI.py info <Subu_ID>
-
-3) Loopback
- CLI.py lo up|down <Subu_ID>
-
-4) WireGuard objects (independent of subu)
- CLI.py WG global <BaseCIDR> # for example, 192.168.112.0/24
- CLI.py WG make <host:port> # allocates next /32
- CLI.py WG server_provided_public_key <WG_ID> <Base64Key>
- CLI.py WG info <WG_ID>
- CLI.py WG up <WG_ID> / CLI.py WG down <WG_ID> # administrative toggle after attached
-
-5) Attach or detach and eBPF steering
- CLI.py attach WG <Subu_ID> <WG_ID>
- - Makes WireGuard device as subu_<M> inside ns-subu_<N>, assigns /32, MTU 1420
- - Installs per-subu cgroup and loads eBPF scaffold (user identifier check, metadata map)
- - Keeps device administrative-down until `CLI.py network up`
- CLI.py detach WG <Subu_ID>
- - Deletes device, removes cgroup and eBPF program
-
-6) Network aggregate
- CLI.py network up|down <Subu_ID>
- - Ensures loopback is up on 'up', toggles attached WireGuard interfaces
-
-7) Options
- CLI.py option set|get|list ...
-
-8) Exec
- CLI.py exec <Subu_ID> -- <cmd> ...
-++ CLI.py --help
-Subu manager (v0.3.3)
-
-1) Init
- CLI.py init <TOKEN>
- Makes ./subu.db. Refuses to run if db exists.
-
-2) Subu
- CLI.py make <masu> <subu> [_<subu>]*
- CLI.py list
- CLI.py info <Subu_ID>
-
-3) Loopback
- CLI.py lo up|down <Subu_ID>
-
-4) WireGuard objects (independent of subu)
- CLI.py WG global <BaseCIDR> # for example, 192.168.112.0/24
- CLI.py WG make <host:port> # allocates next /32
- CLI.py WG server_provided_public_key <WG_ID> <Base64Key>
- CLI.py WG info <WG_ID>
- CLI.py WG up <WG_ID> / CLI.py WG down <WG_ID> # administrative toggle after attached
-
-5) Attach or detach and eBPF steering
- CLI.py attach WG <Subu_ID> <WG_ID>
- - Makes WireGuard device as subu_<M> inside ns-subu_<N>, assigns /32, MTU 1420
- - Installs per-subu cgroup and loads eBPF scaffold (user identifier check, metadata map)
- - Keeps device administrative-down until `CLI.py network up`
- CLI.py detach WG <Subu_ID>
- - Deletes device, removes cgroup and eBPF program
-
-6) Network aggregate
- CLI.py network up|down <Subu_ID>
- - Ensures loopback is up on 'up', toggles attached WireGuard interfaces
-
-7) Options
- CLI.py option set|get|list ...
-
-8) Exec
- CLI.py exec <Subu_ID> -- <cmd> ...
-++ CLI.py help
-Subu manager (v0.3.3)
-
-1) Init
- CLI.py init <TOKEN>
- Makes ./subu.db. Refuses to run if db exists.
-
-2) Subu
- CLI.py make <masu> <subu> [_<subu>]*
- CLI.py list
- CLI.py info <Subu_ID>
-
-3) Loopback
- CLI.py lo up|down <Subu_ID>
-
-4) WireGuard objects (independent of subu)
- CLI.py WG global <BaseCIDR> # for example, 192.168.112.0/24
- CLI.py WG make <host:port> # allocates next /32
- CLI.py WG server_provided_public_key <WG_ID> <Base64Key>
- CLI.py WG info <WG_ID>
- CLI.py WG up <WG_ID> / CLI.py WG down <WG_ID> # administrative toggle after attached
-
-5) Attach or detach and eBPF steering
- CLI.py attach WG <Subu_ID> <WG_ID>
- - Makes WireGuard device as subu_<M> inside ns-subu_<N>, assigns /32, MTU 1420
- - Installs per-subu cgroup and loads eBPF scaffold (user identifier check, metadata map)
- - Keeps device administrative-down until `CLI.py network up`
- CLI.py detach WG <Subu_ID>
- - Deletes device, removes cgroup and eBPF program
-
-6) Network aggregate
- CLI.py network up|down <Subu_ID>
- - Ensures loopback is up on 'up', toggles attached WireGuard interfaces
-
-7) Options
- CLI.py option set|get|list ...
-
-8) Exec
- CLI.py exec <Subu_ID> -- <cmd> ...
-++ CLI.py help WG
-Subu manager (v0.3.3)
-
-1) Init
- CLI.py init <TOKEN>
- Makes ./subu.db. Refuses to run if db exists.
-
-2) Subu
- CLI.py make <masu> <subu> [_<subu>]*
- CLI.py list
- CLI.py info <Subu_ID>
-
-3) Loopback
- CLI.py lo up|down <Subu_ID>
-
-4) WireGuard objects (independent of subu)
- CLI.py WG global <BaseCIDR> # for example, 192.168.112.0/24
- CLI.py WG make <host:port> # allocates next /32
- CLI.py WG server_provided_public_key <WG_ID> <Base64Key>
- CLI.py WG info <WG_ID>
- CLI.py WG up <WG_ID> / CLI.py WG down <WG_ID> # administrative toggle after attached
-
-5) Attach or detach and eBPF steering
- CLI.py attach WG <Subu_ID> <WG_ID>
- - Makes WireGuard device as subu_<M> inside ns-subu_<N>, assigns /32, MTU 1420
- - Installs per-subu cgroup and loads eBPF scaffold (user identifier check, metadata map)
- - Keeps device administrative-down until `CLI.py network up`
- CLI.py detach WG <Subu_ID>
- - Deletes device, removes cgroup and eBPF program
-
-6) Network aggregate
- CLI.py network up|down <Subu_ID>
- - Ensures loopback is up on 'up', toggles attached WireGuard interfaces
-
-7) Options
- CLI.py option set|get|list ...
-
-8) Exec
- CLI.py exec <Subu_ID> -- <cmd> ...
-++ CLI.py example
-# 0) Initialise the subu database (once per directory)
-CLI.py init dzkq7b
-
-# 1) Make Subu
-CLI.py make Thomas US
-# -> subu_1
-
-# 2) WireGuard pool once
-CLI.py WG global 192.168.112.0/24
-
-# 3) Make WireGuard object with endpoint
-CLI.py WG make ReasoningTechnology.com:51820
-# -> WG_1
-
-# 4) Server public key (placeholder)
-CLI.py WG server_provided_public_key WG_1 ABCDEFG...xyz=
-
-# 5) Attach device and install cgroup and eBPF steering
-CLI.py attach WG subu_1 WG_1
-
-# 6) Bring network up (loopback and WireGuard)
-CLI.py network up subu_1
-
-# 7) Test inside namespace
-CLI.py exec subu_1 -- curl -4v https://ifconfig.me
-++ CLI.py version
-0.3.3++ CLI.py -V
-0.3.3++ set +x
+++ /dev/null
-++ CLI.py
-Subu manager (v0.3.3)
-
-1) Init
- CLI.py init <TOKEN>
- Makes ./subu.db. Refuses to run if db exists.
-
-2) Subu
- CLI.py make <masu> <subu> [_<subu>]*
- CLI.py list
- CLI.py info <Subu_ID>
-
-3) Loopback
- CLI.py lo up|down <Subu_ID>
-
-4) WireGuard objects (independent of subu)
- CLI.py WG global <BaseCIDR> # for example, 192.168.112.0/24
- CLI.py WG make <host:port> # allocates next /32
- CLI.py WG server_provided_public_key <WG_ID> <Base64Key>
- CLI.py WG info <WG_ID>
- CLI.py WG up <WG_ID> / CLI.py WG down <WG_ID> # administrative toggle after attached
-
-5) Attach or detach and eBPF steering
- CLI.py attach WG <Subu_ID> <WG_ID>
- - Makes WireGuard device as subu_<M> inside ns-subu_<N>, assigns /32, MTU 1420
- - Installs per-subu cgroup and loads eBPF scaffold (user identifier check, metadata map)
- - Keeps device administrative-down until `CLI.py network up`
- CLI.py detach WG <Subu_ID>
- - Deletes device, removes cgroup and eBPF program
-
-6) Network aggregate
- CLI.py network up|down <Subu_ID>
- - Ensures loopback is up on 'up', toggles attached WireGuard interfaces
-
-7) Options
- CLI.py option set|get|list ...
-
-8) Exec
- CLI.py exec <Subu_ID> -- <cmd> ...
-++ CLI.py usage
-CLI.py — Subu manager (v0.3.3)
-
-Usage:
- CLI.py # usage
- CLI.py help # detailed help
- CLI.py example # example workflow
- CLI.py version # print version
-
- CLI.py init <TOKEN>
- CLI.py make <masu> <subu> [_<subu>]*
- CLI.py list
- CLI.py info <Subu_ID> | CLI.py information <Subu_ID>
-
- CLI.py lo up|down <Subu_ID>
-
- CLI.py WG global <BaseCIDR>
- CLI.py WG make <host:port>
- CLI.py WG server_provided_public_key <WG_ID> <Base64Key>
- CLI.py WG info|information <WG_ID>
- CLI.py WG up <WG_ID>
- CLI.py WG down <WG_ID>
-
- CLI.py attach WG <Subu_ID> <WG_ID>
- CLI.py detach WG <Subu_ID>
-
- CLI.py network up|down <Subu_ID>
-
- CLI.py option set <Subu_ID> <subu> <value>
- CLI.py option get <Subu_ID> <subu>
- CLI.py option list <Subu_ID>
-
- CLI.py exec <Subu_ID> -- <cmd> ...
-++ CLI.py -h
-Subu manager (v0.3.3)
-
-1) Init
- CLI.py init <TOKEN>
- Makes ./subu.db. Refuses to run if db exists.
-
-2) Subu
- CLI.py make <masu> <subu> [_<subu>]*
- CLI.py list
- CLI.py info <Subu_ID>
-
-3) Loopback
- CLI.py lo up|down <Subu_ID>
-
-4) WireGuard objects (independent of subu)
- CLI.py WG global <BaseCIDR> # for example, 192.168.112.0/24
- CLI.py WG make <host:port> # allocates next /32
- CLI.py WG server_provided_public_key <WG_ID> <Base64Key>
- CLI.py WG info <WG_ID>
- CLI.py WG up <WG_ID> / CLI.py WG down <WG_ID> # administrative toggle after attached
-
-5) Attach or detach and eBPF steering
- CLI.py attach WG <Subu_ID> <WG_ID>
- - Makes WireGuard device as subu_<M> inside ns-subu_<N>, assigns /32, MTU 1420
- - Installs per-subu cgroup and loads eBPF scaffold (user identifier check, metadata map)
- - Keeps device administrative-down until `CLI.py network up`
- CLI.py detach WG <Subu_ID>
- - Deletes device, removes cgroup and eBPF program
-
-6) Network aggregate
- CLI.py network up|down <Subu_ID>
- - Ensures loopback is up on 'up', toggles attached WireGuard interfaces
-
-7) Options
- CLI.py option set|get|list ...
-
-8) Exec
- CLI.py exec <Subu_ID> -- <cmd> ...
-++ CLI.py --help
-Subu manager (v0.3.3)
-
-1) Init
- CLI.py init <TOKEN>
- Makes ./subu.db. Refuses to run if db exists.
-
-2) Subu
- CLI.py make <masu> <subu> [_<subu>]*
- CLI.py list
- CLI.py info <Subu_ID>
-
-3) Loopback
- CLI.py lo up|down <Subu_ID>
-
-4) WireGuard objects (independent of subu)
- CLI.py WG global <BaseCIDR> # for example, 192.168.112.0/24
- CLI.py WG make <host:port> # allocates next /32
- CLI.py WG server_provided_public_key <WG_ID> <Base64Key>
- CLI.py WG info <WG_ID>
- CLI.py WG up <WG_ID> / CLI.py WG down <WG_ID> # administrative toggle after attached
-
-5) Attach or detach and eBPF steering
- CLI.py attach WG <Subu_ID> <WG_ID>
- - Makes WireGuard device as subu_<M> inside ns-subu_<N>, assigns /32, MTU 1420
- - Installs per-subu cgroup and loads eBPF scaffold (user identifier check, metadata map)
- - Keeps device administrative-down until `CLI.py network up`
- CLI.py detach WG <Subu_ID>
- - Deletes device, removes cgroup and eBPF program
-
-6) Network aggregate
- CLI.py network up|down <Subu_ID>
- - Ensures loopback is up on 'up', toggles attached WireGuard interfaces
-
-7) Options
- CLI.py option set|get|list ...
-
-8) Exec
- CLI.py exec <Subu_ID> -- <cmd> ...
-++ CLI.py help
-Subu manager (v0.3.3)
-
-1) Init
- CLI.py init <TOKEN>
- Makes ./subu.db. Refuses to run if db exists.
-
-2) Subu
- CLI.py make <masu> <subu> [_<subu>]*
- CLI.py list
- CLI.py info <Subu_ID>
-
-3) Loopback
- CLI.py lo up|down <Subu_ID>
-
-4) WireGuard objects (independent of subu)
- CLI.py WG global <BaseCIDR> # for example, 192.168.112.0/24
- CLI.py WG make <host:port> # allocates next /32
- CLI.py WG server_provided_public_key <WG_ID> <Base64Key>
- CLI.py WG info <WG_ID>
- CLI.py WG up <WG_ID> / CLI.py WG down <WG_ID> # administrative toggle after attached
-
-5) Attach or detach and eBPF steering
- CLI.py attach WG <Subu_ID> <WG_ID>
- - Makes WireGuard device as subu_<M> inside ns-subu_<N>, assigns /32, MTU 1420
- - Installs per-subu cgroup and loads eBPF scaffold (user identifier check, metadata map)
- - Keeps device administrative-down until `CLI.py network up`
- CLI.py detach WG <Subu_ID>
- - Deletes device, removes cgroup and eBPF program
-
-6) Network aggregate
- CLI.py network up|down <Subu_ID>
- - Ensures loopback is up on 'up', toggles attached WireGuard interfaces
-
-7) Options
- CLI.py option set|get|list ...
-
-8) Exec
- CLI.py exec <Subu_ID> -- <cmd> ...
-++ CLI.py help WG
-Subu manager (v0.3.3)
-
-1) Init
- CLI.py init <TOKEN>
- Makes ./subu.db. Refuses to run if db exists.
-
-2) Subu
- CLI.py make <masu> <subu> [_<subu>]*
- CLI.py list
- CLI.py info <Subu_ID>
-
-3) Loopback
- CLI.py lo up|down <Subu_ID>
-
-4) WireGuard objects (independent of subu)
- CLI.py WG global <BaseCIDR> # for example, 192.168.112.0/24
- CLI.py WG make <host:port> # allocates next /32
- CLI.py WG server_provided_public_key <WG_ID> <Base64Key>
- CLI.py WG info <WG_ID>
- CLI.py WG up <WG_ID> / CLI.py WG down <WG_ID> # administrative toggle after attached
-
-5) Attach or detach and eBPF steering
- CLI.py attach WG <Subu_ID> <WG_ID>
- - Makes WireGuard device as subu_<M> inside ns-subu_<N>, assigns /32, MTU 1420
- - Installs per-subu cgroup and loads eBPF scaffold (user identifier check, metadata map)
- - Keeps device administrative-down until `CLI.py network up`
- CLI.py detach WG <Subu_ID>
- - Deletes device, removes cgroup and eBPF program
-
-6) Network aggregate
- CLI.py network up|down <Subu_ID>
- - Ensures loopback is up on 'up', toggles attached WireGuard interfaces
-
-7) Options
- CLI.py option set|get|list ...
-
-8) Exec
- CLI.py exec <Subu_ID> -- <cmd> ...
-++ CLI.py example
-# 0) Initialise the subu database (once per directory)
-CLI.py init dzkq7b
-
-# 1) Make Subu
-CLI.py make Thomas US
-# -> subu_1
-
-# 2) WireGuard pool once
-CLI.py WG global 192.168.112.0/24
-
-# 3) Make WireGuard object with endpoint
-CLI.py WG make ReasoningTechnology.com:51820
-# -> WG_1
-
-# 4) Server public key (placeholder)
-CLI.py WG server_provided_public_key WG_1 ABCDEFG...xyz=
-
-# 5) Attach device and install cgroup and eBPF steering
-CLI.py attach WG subu_1 WG_1
-
-# 6) Bring network up (loopback and WireGuard)
-CLI.py network up subu_1
-
-# 7) Test inside namespace
-CLI.py exec subu_1 -- curl -4v https://ifconfig.me
-++ CLI.py version
-0.3.3++ CLI.py -V
-0.3.3++ set +x
+++ /dev/null
-#!/usr/bin/env bash
-# test_1_driver.sh — smoke test: Unix dry mode + make
-
-set -x
-
-# 1) Put Unix handling into dry-run mode.
-# In this mode we expect *no real* user/group changes.
-CLI.py option Unix dry
-
-# 2) Make a first-level subu
-CLI.py make Thomas S0
-
-# 3) Make a second-level subu under the first
-CLI.py make Thomas S0 S1
-
-# 4) Return Unix handling to run mode (for future tests)
-CLI.py option Unix run
-
-set +x
+++ /dev/null
-#!/bin/env bash
-
-set -x
-./CLI # -> USAGE (exit 0)
-./CLI usage # -> USAGE
-./CLI -h # -> HELP
-./CLI --help # -> HELP
-./CLI help # -> HELP
-./CLI help WG # -> WG topic help (or full HELP if topic unknown)
-./CLI example # -> EXAMPLE
-./CLI version # -> 0.1.4
-./CLI -V # -> 0.1.4
-
+++ /dev/null
-set -x
-./subu.py # -> USAGE (exit 0)
-./subu.py usage # -> USAGE
-./subu.py -h # -> HELP
-./subu.py --help # -> HELP
-./subu.py help # -> HELP
-./subu.py help WG # -> WG topic help (or full HELP if topic unknown)
-./subu.py example # -> EXAMPLE
-./subu.py version # -> 0.1.4
-./subu.py -V # -> 0.1.4
-set +x
+++ /dev/null
-++ ./subu.py
-usage: subu [-V] <verb> [<args>]
-
-Quick verbs:
- usage Show this usage summary
- help [topic] Detailed help; same as -h / --help
- example End-to-end example session
- version Print version
-
-Main verbs:
- init Initialize a new subu database (refuses if it exists)
- create Create a minimal subu record (defaults only)
- info | information Show details for a subu
- WG WireGuard object operations
- attach Attach a WG object to a subu (netns + cgroup/eBPF)
- detach Detach WG from a subu
- network Bring all attached ifaces up/down inside the subu netns
- lo Bring loopback up/down inside the subu netns
- option Persisted options (list/set/get for future policy)
- exec Run a command inside the subu netns
-
-Tip: `subu help` (or `subu --help`) shows detailed help; `subu help WG` shows topic help.
-++ ./subu.py usage
-usage: subu [-V] <verb> [<args>]
-
-Quick verbs:
- usage Show this usage summary
- help [topic] Detailed help; same as -h / --help
- example End-to-end example session
- version Print version
-
-Main verbs:
- init Initialize a new subu database (refuses if it exists)
- create Create a minimal subu record (defaults only)
- info | information Show details for a subu
- WG WireGuard object operations
- attach Attach a WG object to a subu (netns + cgroup/eBPF)
- detach Detach WG from a subu
- network Bring all attached ifaces up/down inside the subu netns
- lo Bring loopback up/down inside the subu netns
- option Persisted options (list/set/get for future policy)
- exec Run a command inside the subu netns
-
-Tip: `subu help` (or `subu --help`) shows detailed help; `subu help WG` shows topic help.
-++ ./subu.py -h
-subu — manage subu containers, namespaces, and WG attachments
-
-2.1 Core
-
- subu init <TOKEN>
- Create ./subu.db (tables: subu, wg, links, options, state).
- Requires a 6-char token (e.g., dzkq7b). Refuses if DB already exists.
-
- subu create <masu> <subu>
- Make a default subu with netns ns-<Subu_ID> containing lo only (down).
- Returns subu_N.
-
- subu list
- Columns: Subu_ID, Owner, Name, NetNS, WG_Attached?, Up/Down, Steer?
-
- subu info <Subu_ID> | subu information <Subu_ID>
- Full record + attached WG(s) + options + iface states.
-
-2.2 Loopback
-
- subu lo up <Subu_ID> | subu lo down <Subu_ID>
- Toggle loopback inside the subu’s netns.
-
-2.3 WireGuard objects (independent)
-
- subu WG global <BaseCIDR>
- e.g., 192.168.112.0/24; allocator hands out /32 peers sequentially.
- Shows current base and next free on success.
-
- subu WG create <host:port>
- Creates WG object; allocates next /32 local IP; AllowedIPs=0.0.0.0/0.
- Returns WG_M.
-
- subu WG server_provided_public_key <WG_ID> <Base64Key>
- Stores server’s pubkey.
-
- subu WG info <WG_ID> | subu WG information <WG_ID>
- Endpoint, allocated IP, pubkey set?, link state (admin/oper).
-
-2.4 Link WG ↔ subu, bring up/down
-
- subu attach WG <Subu_ID> <WG_ID>
- Creates/configures WG device inside ns-<Subu_ID>:
- - device name: subu_<M> (M from WG_ID)
- - set local /32, MTU 1420, accept_local=1
- - (no default route is added — steering uses eBPF)
- - v1: enforce one WG per Subu; error if another attached
-
- subu detach WG <Subu_ID>
- Remove WG device/config from the subu’s netns; keep WG object.
-
- subu WG up <WG_ID> | subu WG down <WG_ID>
- Toggle interface admin state in the subu’s netns (must be attached).
-
- subu network up <Subu_ID> | subu network down <Subu_ID>
- Only toggles admin state for all attached ifaces. On “up”, loopback
- is brought up first automatically. No route manipulation.
-
-2.5 Execution & (future) steering
-
- subu exec <Subu_ID> -- <cmd> …
- Run a process inside the subu’s netns.
-
- subu steer enable <Subu_ID> | subu steer disable <Subu_ID>
- (Future) Attach/detach eBPF cgroup programs to force SO_BINDTOIFINDEX=subu_<M>
- for TCP/UDP. Default: disabled.
-
-2.6 Options (persist only, for future policy)
-
- subu option list <Subu_ID>
- subu option get <Subu_ID> [name]
- subu option set <Subu_ID> <name> <value>
-
-2.7 Meta
-
- subu usage
- Short usage summary (also printed when no args are given).
-
- subu help [topic]
- This help (or per-topic help such as `subu help WG`).
-
- subu example
- A concrete end-to-end scenario.
-
- subu version
- Print version (same as -V / --version).
-++ ./subu.py --help
-subu — manage subu containers, namespaces, and WG attachments
-
-2.1 Core
-
- subu init <TOKEN>
- Create ./subu.db (tables: subu, wg, links, options, state).
- Requires a 6-char token (e.g., dzkq7b). Refuses if DB already exists.
-
- subu create <masu> <subu>
- Make a default subu with netns ns-<Subu_ID> containing lo only (down).
- Returns subu_N.
-
- subu list
- Columns: Subu_ID, Owner, Name, NetNS, WG_Attached?, Up/Down, Steer?
-
- subu info <Subu_ID> | subu information <Subu_ID>
- Full record + attached WG(s) + options + iface states.
-
-2.2 Loopback
-
- subu lo up <Subu_ID> | subu lo down <Subu_ID>
- Toggle loopback inside the subu’s netns.
-
-2.3 WireGuard objects (independent)
-
- subu WG global <BaseCIDR>
- e.g., 192.168.112.0/24; allocator hands out /32 peers sequentially.
- Shows current base and next free on success.
-
- subu WG create <host:port>
- Creates WG object; allocates next /32 local IP; AllowedIPs=0.0.0.0/0.
- Returns WG_M.
-
- subu WG server_provided_public_key <WG_ID> <Base64Key>
- Stores server’s pubkey.
-
- subu WG info <WG_ID> | subu WG information <WG_ID>
- Endpoint, allocated IP, pubkey set?, link state (admin/oper).
-
-2.4 Link WG ↔ subu, bring up/down
-
- subu attach WG <Subu_ID> <WG_ID>
- Creates/configures WG device inside ns-<Subu_ID>:
- - device name: subu_<M> (M from WG_ID)
- - set local /32, MTU 1420, accept_local=1
- - (no default route is added — steering uses eBPF)
- - v1: enforce one WG per Subu; error if another attached
-
- subu detach WG <Subu_ID>
- Remove WG device/config from the subu’s netns; keep WG object.
-
- subu WG up <WG_ID> | subu WG down <WG_ID>
- Toggle interface admin state in the subu’s netns (must be attached).
-
- subu network up <Subu_ID> | subu network down <Subu_ID>
- Only toggles admin state for all attached ifaces. On “up”, loopback
- is brought up first automatically. No route manipulation.
-
-2.5 Execution & (future) steering
-
- subu exec <Subu_ID> -- <cmd> …
- Run a process inside the subu’s netns.
-
- subu steer enable <Subu_ID> | subu steer disable <Subu_ID>
- (Future) Attach/detach eBPF cgroup programs to force SO_BINDTOIFINDEX=subu_<M>
- for TCP/UDP. Default: disabled.
-
-2.6 Options (persist only, for future policy)
-
- subu option list <Subu_ID>
- subu option get <Subu_ID> [name]
- subu option set <Subu_ID> <name> <value>
-
-2.7 Meta
-
- subu usage
- Short usage summary (also printed when no args are given).
-
- subu help [topic]
- This help (or per-topic help such as `subu help WG`).
-
- subu example
- A concrete end-to-end scenario.
-
- subu version
- Print version (same as -V / --version).
-++ ./subu.py help
-subu — manage subu containers, namespaces, and WG attachments
-
-2.1 Core
-
- subu init <TOKEN>
- Create ./subu.db (tables: subu, wg, links, options, state).
- Requires a 6-char token (e.g., dzkq7b). Refuses if DB already exists.
-
- subu create <masu> <subu>
- Make a default subu with netns ns-<Subu_ID> containing lo only (down).
- Returns subu_N.
-
- subu list
- Columns: Subu_ID, Owner, Name, NetNS, WG_Attached?, Up/Down, Steer?
-
- subu info <Subu_ID> | subu information <Subu_ID>
- Full record + attached WG(s) + options + iface states.
-
-2.2 Loopback
-
- subu lo up <Subu_ID> | subu lo down <Subu_ID>
- Toggle loopback inside the subu’s netns.
-
-2.3 WireGuard objects (independent)
-
- subu WG global <BaseCIDR>
- e.g., 192.168.112.0/24; allocator hands out /32 peers sequentially.
- Shows current base and next free on success.
-
- subu WG create <host:port>
- Creates WG object; allocates next /32 local IP; AllowedIPs=0.0.0.0/0.
- Returns WG_M.
-
- subu WG server_provided_public_key <WG_ID> <Base64Key>
- Stores server’s pubkey.
-
- subu WG info <WG_ID> | subu WG information <WG_ID>
- Endpoint, allocated IP, pubkey set?, link state (admin/oper).
-
-2.4 Link WG ↔ subu, bring up/down
-
- subu attach WG <Subu_ID> <WG_ID>
- Creates/configures WG device inside ns-<Subu_ID>:
- - device name: subu_<M> (M from WG_ID)
- - set local /32, MTU 1420, accept_local=1
- - (no default route is added — steering uses eBPF)
- - v1: enforce one WG per Subu; error if another attached
-
- subu detach WG <Subu_ID>
- Remove WG device/config from the subu’s netns; keep WG object.
-
- subu WG up <WG_ID> | subu WG down <WG_ID>
- Toggle interface admin state in the subu’s netns (must be attached).
-
- subu network up <Subu_ID> | subu network down <Subu_ID>
- Only toggles admin state for all attached ifaces. On “up”, loopback
- is brought up first automatically. No route manipulation.
-
-2.5 Execution & (future) steering
-
- subu exec <Subu_ID> -- <cmd> …
- Run a process inside the subu’s netns.
-
- subu steer enable <Subu_ID> | subu steer disable <Subu_ID>
- (Future) Attach/detach eBPF cgroup programs to force SO_BINDTOIFINDEX=subu_<M>
- for TCP/UDP. Default: disabled.
-
-2.6 Options (persist only, for future policy)
-
- subu option list <Subu_ID>
- subu option get <Subu_ID> [name]
- subu option set <Subu_ID> <name> <value>
-
-2.7 Meta
-
- subu usage
- Short usage summary (also printed when no args are given).
-
- subu help [topic]
- This help (or per-topic help such as `subu help WG`).
-
- subu example
- A concrete end-to-end scenario.
-
- subu version
- Print version (same as -V / --version).
-++ ./subu.py help WG
-usage: subu WG [-h]
-
-options:
- -h, --help show this help message and exit
-++ ./subu.py example
-# 0) Safe init (refuses if ./subu.db exists)
-subu init dzkq7b
-# -> created ./subu.db
-
-# 1) Create Subu
-subu create Thomas US
-# -> Subu_ID: subu_7
-# -> netns: ns-subu_7 with lo (down)
-
-# 2) Define WG pool (once per host)
-subu WG global 192.168.112.0/24
-# -> base set; next free: 192.168.112.2/32
-
-# 3) Create WG object with endpoint
-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
-subu WG server_provided_public_key WG_0 ABCDEFG...xyz=
-# -> saved
-
-# 5) Attach WG to Subu (device created/configured in ns)
-subu attach WG subu_7 WG_0
-# -> device ns-subu_7/subu_0 configured (no default route)
-
-# 6) Bring network up (lo first, then attached ifaces)
-subu network up subu_7
-# -> lo up; subu_0 admin up
-
-# 7) Start the WG engine inside the netns
-subu WG up WG_0
-# -> up, handshakes should start
-
-# 8) Test from inside the subu
-subu exec subu_7 -- curl -4v https://ifconfig.me
-++ ./subu.py version
-0.1.3
-++ ./subu.py -V
-0.1.3
-++ set +x
+++ /dev/null
-#!/usr/bin/env -S python3 -B
-# developer — operations that compare/merge against developer/release branches:
-# developer branch list-new
-# developer branch pull
-
-import sys, subprocess
-from tester_lib import (
- chdir_repo_root,
- ensure_on_testing_branch_or_die,
- get_developer_ref_for_merge,
- git_diff_name_status,
- print_changes_with_exec_marker,
- fetch_remote,
- changes_outside_tester,
- prompt_yes_no,
- RELEASE_ROOT_REL,
- TESTER_ROOT_REL,
-)
-
-
-def _run_git(args):
- return subprocess.run(["git"] + list(args), check=True)
-
-
-def usage() -> int:
- print("""developer — tester-side view of developer/release branches
-
-Usage:
- developer help
- Show this message.
-
- developer branch list-new
- List changes under release/ between the current tester branch (HEAD)
- and its corresponding developer/release branch.
-
- developer branch pull
- Merge from the corresponding developer/release branch into the current
- tester branch, listing:
- - changes in release/
- - any changes under tester/ (policy warning)
- - any other changed paths and executables
- and asking for confirmation before the merge.
-""".rstrip())
- return 0
-
-
-def cmd_branch_list_new() -> int:
- repo_root = chdir_repo_root()
- info = ensure_on_testing_branch_or_die()
- dev_ref = get_developer_ref_for_merge(info.name)
- if not dev_ref:
- print(f"developer branch list-new: no corresponding developer branch for '{info.name}'.", file=sys.stderr)
- return 1
-
- fetch_remote("origin")
- changes = git_diff_name_status("HEAD", dev_ref, paths=[RELEASE_ROOT_REL])
-
- if not changes:
- print(f"release/ is up to date with {dev_ref}.")
- return 0
-
- print(f"Changes in release/ between HEAD ({info.name}) and {dev_ref}:")
- print_changes_with_exec_marker(changes, repo_root)
- return 0
-
-
-def cmd_branch_pull() -> int:
- repo_root = chdir_repo_root()
- info = ensure_on_testing_branch_or_die()
- dev_ref = get_developer_ref_for_merge(info.name)
- if not dev_ref:
- print(f"developer branch pull: no corresponding developer branch for '{info.name}'.", file=sys.stderr)
- return 1
-
- fetch_remote("origin")
-
- # release/ changes
- rel_changes = git_diff_name_status("HEAD", dev_ref, paths=[RELEASE_ROOT_REL])
- print(f"Planned release/ changes from {info.name} -> {dev_ref}:")
- print_changes_with_exec_marker(rel_changes, repo_root)
-
- # tester/ changes (should be rare)
- tester_changes = git_diff_name_status("HEAD", dev_ref, paths=[TESTER_ROOT_REL])
- if tester_changes:
- print("\nWARNING: these files under tester/ would change when merging from developer:")
- print_changes_with_exec_marker(tester_changes, repo_root)
- if not prompt_yes_no("These changes are against the usual policy. Proceed with merge?", default=False):
- print("Aborting merge.")
- return 1
-
- # all other paths (shared tools etc.)
- all_changes = git_diff_name_status("HEAD", dev_ref, paths=None)
- non_tester = changes_outside_tester(all_changes)
- other_non_release = [(s, p) for (s, p) in non_tester if not p.startswith(RELEASE_ROOT_REL + "/")]
- if other_non_release:
- print("\nOther files outside tester/ that will change (shared tools, etc.):")
- print_changes_with_exec_marker(other_non_release, repo_root)
-
- if not prompt_yes_no(f"Proceed with merge from {dev_ref} into {info.name}?", default=False):
- print("Aborting merge.")
- return 1
-
- _run_git(["merge", dev_ref])
- print(f"Merged {dev_ref} into {info.name}.")
- return 0
-
-
-def CLI(argv=None) -> int:
- if argv is None:
- argv = sys.argv[1:]
-
- if not argv or argv[0] in ("help", "-h", "--help"):
- return usage()
-
- if argv[0] != "branch":
- print(f"developer: unknown top-level command '{argv[0]}' (expected 'branch' or 'help').", file=sys.stderr)
- return 1
-
- subargs = argv[1:]
- if not subargs or subargs[0] in ("help", "-h", "--help"):
- print("developer branch commands:")
- print(" developer branch list-new")
- print(" developer branch pull")
- return 0
-
- action = subargs[0]
-
- if action == "list-new":
- return cmd_branch_list_new()
- if action == "pull":
- return cmd_branch_pull()
-
- print(f"developer branch: unknown action '{action}'.", file=sys.stderr)
- return 1
-
-
-if __name__ == "__main__":
- raise SystemExit(CLI())
+++ /dev/null
-#!/usr/bin/env bash
-script_afp=$(realpath "${BASH_SOURCE[0]}")
-
- manager="$REPO_HOME/release/manager"
- if [[ ":$PATH:" != *":$manager:"* ]]; then
- export PATH="$manager:$PATH"
- fi
+++ /dev/null
-#!/usr/bin/env -S python3 -B
-# publish — create / bump release_<major>[.<minor>] branches
-#
-# Usage:
-# publish help
-# publish minor
-# publish major [<major>] [<minor>]
-
-import sys, subprocess
-from typing import List, Tuple
-from tester_lib import (
- chdir_repo_root,
- ensure_on_testing_branch_or_die,
- RELEASE_PREFIX,
- RELEASE_TEST_PREFIX, # kept if you later want to auto-create tester branches
- get_release_branches,
- choose_latest_release,
-)
-
-
-def _run_git(args):
- return subprocess.run(["git"] + list(args), check=True)
-
-
-def usage() -> int:
- print("""publish — create or bump release_<major>[.<minor>] branches
-
-Usage:
- publish help
- Show this message.
-
- publish minor
- Create a new minor release branch for the highest existing major.
- - Finds all release_<major>[.<minor>] branches.
- - Picks the highest major M.
- - Computes next minor as (max_minor_for_M + 1) (minor=0 when absent).
- - Creates release_<M>.<next_minor> from the current tester branch.
- - Checks out the new release_<...> branch.
-
- publish major [<major>] [<minor>]
- Create a new major release branch.
- - If no major is given:
- use next integer after the highest existing major (or 0 if none).
- - If no minor is given:
- start at minor 1 → release_<major>.1
- - If minor==0 is explicitly given:
- create release_<major> (no .0 in the name).
- - Branch is created from the current tester branch.
- - Checks out the new release_<...> branch.
-""".rstrip())
- return 0
-
-
-def _list_release_branches() -> List[Tuple[str, int, int]]:
- branches = get_release_branches()
- out: List[Tuple[str, int, int]] = []
- for b in branches:
- if b.major is None:
- continue
- out.append((b.name, b.major, b.minor or 0))
- return out
-
-
-def _next_minor_for_major(major: int) -> int:
- branches = _list_release_branches()
- max_minor = 0
- found = False
- for _name, maj, minor in branches:
- if maj == major:
- found = True
- if minor > max_minor:
- max_minor = minor
- if not found:
- return 1 # first minor is 1
- return max_minor + 1
-
-
-def publish_minor() -> int:
- info = ensure_on_testing_branch_or_die()
- branches = _list_release_branches()
- if not branches:
- print("publish minor: no existing release_<major> branches. Use 'publish major' first.", file=sys.stderr)
- return 1
-
- branches_sorted = sorted(branches, key=lambda t: (t[1], t[2]))
- highest_major = branches_sorted[-1][1]
- next_minor = _next_minor_for_major(highest_major)
-
- rel_name = f"{RELEASE_PREFIX}{highest_major}.{next_minor}"
-
- print(f"Creating new minor release branch {rel_name} from {info.name}...")
- _run_git(["branch", rel_name])
- print(f"Checking out {rel_name}...")
- _run_git(["checkout", rel_name])
- return 0
-
-
-def publish_major(major_arg: str = None, minor_arg: str = None) -> int:
- info = ensure_on_testing_branch_or_die()
- branches = _list_release_branches()
-
- if major_arg is None:
- if branches:
- highest_major = max(maj for (_n, maj, _m) in branches)
- new_major = highest_major + 1
- else:
- new_major = 0
- else:
- new_major = int(major_arg)
-
- if minor_arg is None:
- new_minor = 1
- else:
- new_minor = int(minor_arg)
-
- if new_minor == 0:
- rel_name = f"{RELEASE_PREFIX}{new_major}"
- else:
- rel_name = f"{RELEASE_PREFIX}{new_major}.{new_minor}"
-
- cp = subprocess.run(["git", "branch", "--list", rel_name], text=True, stdout=subprocess.PIPE, check=True)
- if cp.stdout.strip():
- print(f"publish major: branch {rel_name} already exists.", file=sys.stderr)
- else:
- print(f"Creating new major release branch {rel_name} from {info.name}...")
- _run_git(["branch", rel_name])
-
- print(f"Checking out {rel_name}...")
- _run_git(["checkout", rel_name])
- return 0
-
-
-def CLI(argv=None) -> int:
- chdir_repo_root()
- if argv is None:
- argv = sys.argv[1:]
-
- if not argv or argv[0] in ("help", "-h", "--help"):
- return usage()
-
- sub = argv[0]
- rest = argv[1:]
-
- if sub == "minor":
- if rest:
- print("publish minor: unexpected extra arguments.", file=sys.stderr)
- return 1
- return publish_minor()
-
- if sub == "major":
- major = rest[0] if len(rest) >= 1 else None
- minor = rest[1] if len(rest) >= 2 else None
- if len(rest) > 2:
- print("publish major: too many arguments.", file=sys.stderr)
- return 1
- return publish_major(major, minor)
-
- print(f"publish: unknown subcommand '{sub}' (expected 'help', 'minor', or 'major').", file=sys.stderr)
- return 1
-
-
-if __name__ == "__main__":
- raise SystemExit(CLI())
--- /dev/null
+#!/usr/bin/env bash
+script_afp=$(realpath "${BASH_SOURCE[0]}")
+
+++ /dev/null
-#!/usr/bin/env -S python3 -B
-# tester — tester branch utilities:
-# tester branch select core
-# tester branch select release [<major>[.<minor>]]
-# tester branch show-name
-# tester branch pull
-# tester branch push
-
-import sys, subprocess
-from tester_lib import (
- chdir_repo_root,
- TEST_CORE_BRANCH,
- get_current_branch,
- is_testing_branch,
- get_upstream_ref_for_current_branch,
- git_diff_name_status,
- print_changes_with_exec_marker,
- changes_outside_tester,
- prompt_yes_no,
- get_release_branches,
- choose_latest_release,
- RELEASE_PREFIX,
- RELEASE_TEST_PREFIX,
-)
-
-
-def _run_git(args):
- return subprocess.run(["git"] + list(args), check=True)
-
-
-def usage() -> int:
- print("""tester — tester-side branch commands
-
-Usage:
- tester help
- Show this message.
-
- tester branch show-name
- Print current branch and error if not a tester branch.
-
- tester branch select core
- Checkout core_tester_branch.
-
- tester branch select release [<major>[.<minor>]]
- Select a release_tester_<major>[.<minor>] branch.
- - If no version is given: pick the highest existing release_<major>[.<minor>] by version.
- - If the corresponding release_tester_* branch does not exist yet:
- it is created from release_<major>[.<minor>] and checked out.
-
- tester branch pull
- Pull from the current branch's upstream (same tester branch on remote),
- listing incoming changes and executables and asking for confirmation.
-
- tester branch push
- Push the current tester branch to its upstream, listing outgoing changes.
- If any changes are outside $REPO_HOME/tester/, a policy warning and
- confirmation prompt is shown.
-""".rstrip())
- return 0
-
-
-# ----- branch show-name -----
-
-def cmd_branch_show_name() -> int:
- chdir_repo_root()
- name = get_current_branch()
- print(name)
- if not is_testing_branch(name):
- print(f"tester: '{name}' is not a tester branch (core_tester_branch or release_tester_*).", file=sys.stderr)
- return 1
- return 0
-
-
-# ----- branch select -----
-
-def _parse_version_arg(version: str):
- if "." in version:
- major_s, minor_s = version.split(".", 1)
- else:
- major_s, minor_s = version, None
- return major_s, minor_s
-
-
-def _select_release_specific(major_s: str, minor_s: str) -> int:
- # construct release_<...> name
- if minor_s is None:
- rel_name = f"{RELEASE_PREFIX}{major_s}"
- tester_name = f"{RELEASE_TEST_PREFIX}{major_s}"
- else:
- rel_name = f"{RELEASE_PREFIX}{major_s}.{minor_s}"
- tester_name = f"{RELEASE_TEST_PREFIX}{major_s}.{minor_s}"
-
- # ensure base release branch exists
- cp = subprocess.run(
- ["git", "branch", "--list", rel_name],
- text=True, stdout=subprocess.PIPE, check=True
- )
- if not cp.stdout.strip():
- print(f"tester: release branch '{rel_name}' does not exist.", file=sys.stderr)
- return 1
-
- # if release_tester branch does not exist, create it from release_<...>
- cp2 = subprocess.run(
- ["git", "branch", "--list", tester_name],
- text=True, stdout=subprocess.PIPE, check=True
- )
- if not cp2.stdout.strip():
- print(f"Creating tester branch {tester_name} from {rel_name}...")
- _run_git(["branch", tester_name, rel_name])
-
- # checkout tester branch
- _run_git(["checkout", tester_name])
- return 0
-
-
-def cmd_branch_select(args: list[str]) -> int:
- chdir_repo_root()
- if not args:
- print("tester branch select: missing sub-argument (core|release).", file=sys.stderr)
- return 1
- sub = args[0]
- if sub == "core":
- _run_git(["checkout", TEST_CORE_BRANCH])
- return 0
- if sub == "release":
- if len(args) == 1:
- # pick latest release_<...> by version
- rel_branches = get_release_branches()
- info = choose_latest_release(rel_branches)
- if info is None:
- print("tester: no release_<major>[.<minor>] branches found.", file=sys.stderr)
- return 1
- major = info.major
- minor = info.minor
- major_s = str(major)
- minor_s = None if minor is None or minor == 0 else str(minor)
- return _select_release_specific(major_s, minor_s)
- else:
- major_s, minor_s = _parse_version_arg(args[1])
- return _select_release_specific(major_s, minor_s or None)
-
- print(f"tester branch select: unknown mode '{sub}' (expected 'core' or 'release').", file=sys.stderr)
- return 1
-
-
-# ----- branch pull -----
-
-def cmd_branch_pull() -> int:
- repo_root = chdir_repo_root()
- branch = get_current_branch()
- upstream = get_upstream_ref_for_current_branch()
- if not upstream:
- print(f"tester pull: no upstream configured for branch '{branch}'.", file=sys.stderr)
- return 1
-
- # HEAD..upstream
- changes = git_diff_name_status("HEAD", upstream, paths=None)
- if not changes:
- print(f"{branch} is up to date with {upstream}.")
- return 0
-
- print(f"Incoming changes from {upstream} into {branch}:")
- print_changes_with_exec_marker(changes, repo_root)
-
- if not prompt_yes_no("Apply these changes with git pull?", default=False):
- print("Aborting pull.")
- return 1
-
- _run_git(["pull"])
- return 0
-
-
-# ----- branch push -----
-
-def cmd_branch_push() -> int:
- repo_root = chdir_repo_root()
- branch = get_current_branch()
- upstream = get_upstream_ref_for_current_branch()
- if not upstream:
- print(f"tester push: no upstream configured for branch '{branch}'.", file=sys.stderr)
- print("You may want to set it with: git branch --set-upstream-to origin/<branch>", file=sys.stderr)
- return 1
-
- # upstream..HEAD
- changes = git_diff_name_status(upstream, "HEAD", paths=None)
- if not changes:
- print(f"Nothing to push (HEAD is same as {upstream}).")
- return 0
-
- print(f"Changes to be pushed from {branch} to {upstream}:")
- print_changes_with_exec_marker(changes, repo_root)
-
- outside = changes_outside_tester(changes)
- if outside:
- print("\nWARNING: the following paths are outside $REPO_HOME/tester/ and are against policy:")
- print_changes_with_exec_marker(outside, repo_root)
- if not prompt_yes_no("These are against policy. Are you sure you want to push?", default=False):
- print("Aborting push.")
- return 1
-
- if not prompt_yes_no(f"Proceed with git push to {upstream}?", default=True):
- print("Aborting push.")
- return 1
-
- _run_git(["push"])
- return 0
-
-
-def CLI(argv=None) -> int:
- if argv is None:
- argv = sys.argv[1:]
-
- if not argv or argv[0] in ("help", "-h", "--help"):
- return usage()
-
- if argv[0] != "branch":
- print(f"tester: unknown top-level command '{argv[0]}' (expected 'branch' or 'help').", file=sys.stderr)
- return 1
-
- subargs = argv[1:]
- if not subargs or subargs[0] in ("help", "-h", "--help"):
- print("tester branch commands:")
- print(" tester branch show-name")
- print(" tester branch select core")
- print(" tester branch select release [<major>[.<minor>]]")
- print(" tester branch pull")
- print(" tester branch push")
- return 0
-
- action = subargs[0]
- rest = subargs[1:]
-
- if action == "show-name":
- return cmd_branch_show_name()
- if action == "select":
- return cmd_branch_select(rest)
- if action == "pull":
- return cmd_branch_pull()
- if action == "push":
- return cmd_branch_push()
-
- print(f"tester branch: unknown action '{action}'.", file=sys.stderr)
- return 1
-
-
-if __name__ == "__main__":
- raise SystemExit(CLI())
+++ /dev/null
-#!/usr/bin/env -S python3 -B
-# tester_lib.py — shared helpers for tester tools
-
-import os, sys, subprocess
-from dataclasses import dataclass
-from typing import List, Tuple, Optional
-
-
-DEV_BRANCH = "core_developer_branch"
-TEST_CORE_BRANCH = "core_tester_branch"
-RELEASE_PREFIX = "release_"
-RELEASE_TEST_PREFIX = "release_tester_"
-
-TESTER_ROOT_REL = "tester"
-DEVELOPER_ROOT_REL = "developer"
-RELEASE_ROOT_REL = "release"
-
-
-class BranchKind:
- CORE_DEV = "core_developer"
- CORE_TEST = "core_tester"
- RELEASE = "release"
- RELEASE_TEST = "release_tester"
- OTHER = "other"
-
-
-@dataclass
-class BranchInfo:
- name: str
- kind: str
- major: Optional[int] = None
- minor: Optional[int] = None
-
-
-def _run_git(args, capture_output=True, check=True) -> subprocess.CompletedProcess:
- cmd = ["git"] + list(args)
- if capture_output:
- return subprocess.run(cmd, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=check)
- return subprocess.run(cmd, text=True, check=check)
-
-
-def get_repo_root() -> str:
- rh = os.environ.get("REPO_HOME")
- if rh:
- return os.path.abspath(rh)
- try:
- cp = _run_git(["rev-parse", "--show-toplevel"])
- return cp.stdout.strip()
- except Exception as e:
- print(f"tester_lib: cannot determine repo root: {e}", file=sys.stderr)
- sys.exit(1)
-
-
-def chdir_repo_root() -> str:
- root = get_repo_root()
- os.chdir(root)
- return root
-
-
-def get_current_branch() -> str:
- cp = _run_git(["rev-parse", "--abbrev-ref", "HEAD"])
- return cp.stdout.strip()
-
-
-def _parse_version_from_name(name: str, prefix: str) -> Optional[Tuple[int, int]]:
- """
- Parse branch names like:
- prefix + "<major>" or prefix + "<major>.<minor>"
-
- Returns (major, minor) with minor defaulting to 0 if absent.
- """
- if not name.startswith(prefix):
- return None
- tail = name[len(prefix):]
- if not tail:
- return None
- if "." in tail:
- major_s, minor_s = tail.split(".", 1)
- else:
- major_s, minor_s = tail, "0"
- try:
- major = int(major_s)
- minor = int(minor_s)
- except ValueError:
- return None
- return (major, minor)
-
-
-def classify_branch(name: str) -> BranchInfo:
- if name == DEV_BRANCH:
- return BranchInfo(name=name, kind=BranchKind.CORE_DEV)
- if name == TEST_CORE_BRANCH:
- return BranchInfo(name=name, kind=BranchKind.CORE_TEST)
-
- v = _parse_version_from_name(name, RELEASE_PREFIX)
- if v is not None:
- major, minor = v
- return BranchInfo(name=name, kind=BranchKind.RELEASE, major=major, minor=minor)
-
- v = _parse_version_from_name(name, RELEASE_TEST_PREFIX)
- if v is not None:
- major, minor = v
- return BranchInfo(name=name, kind=BranchKind.RELEASE_TEST, major=major, minor=minor)
-
- return BranchInfo(name=name, kind=BranchKind.OTHER)
-
-
-def is_testing_branch(name: str) -> bool:
- info = classify_branch(name)
- return info.kind in (BranchKind.CORE_TEST, BranchKind.RELEASE_TEST)
-
-
-def get_release_tester_branches() -> List[BranchInfo]:
- cp = _run_git(["branch", "--list", f"{RELEASE_TEST_PREFIX}*"])
- branches: List[BranchInfo] = []
- for line in cp.stdout.splitlines():
- line = line.strip()
- if not line:
- continue
- if line.startswith("* "):
- line = line[2:]
- info = classify_branch(line)
- if info.kind == BranchKind.RELEASE_TEST and info.major is not None:
- branches.append(info)
- return branches
-
-
-def get_release_branches() -> List[BranchInfo]:
- cp = _run_git(["branch", "--list", f"{RELEASE_PREFIX}*"])
- branches: List[BranchInfo] = []
- for line in cp.stdout.splitlines():
- line = line.strip()
- if not line:
- continue
- if line.startswith("* "):
- line = line[2:]
- info = classify_branch(line)
- if info.kind == BranchKind.RELEASE and info.major is not None:
- branches.append(info)
- return branches
-
-
-def choose_latest_release(branches: List[BranchInfo]) -> Optional[BranchInfo]:
- if not branches:
- return None
- sorted_br = sorted(
- branches,
- key=lambda b: (b.major if b.major is not None else -1,
- b.minor if b.minor is not None else -1)
- )
- return sorted_br[-1]
-
-
-def choose_latest_release_tester(branches: List[BranchInfo]) -> Optional[BranchInfo]:
- return choose_latest_release(branches)
-
-
-def corresponding_developer_branch(test_branch: str) -> Optional[str]:
- info = classify_branch(test_branch)
- if info.kind == BranchKind.CORE_TEST:
- return DEV_BRANCH
- if info.kind == BranchKind.RELEASE_TEST and info.major is not None:
- if info.minor and info.minor != 0:
- return f"{RELEASE_PREFIX}{info.major}.{info.minor}"
- return f"{RELEASE_PREFIX}{info.major}"
- return None
-
-
-def get_developer_ref_for_merge(test_branch: str) -> Optional[str]:
- dev_branch = corresponding_developer_branch(test_branch)
- if not dev_branch:
- return None
- return f"origin/{dev_branch}"
-
-
-def get_upstream_ref_for_current_branch() -> Optional[str]:
- try:
- cp = _run_git(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"])
- except subprocess.CalledProcessError:
- return None
- return cp.stdout.strip()
-
-
-def git_diff_name_status(from_ref: str, to_ref: str, paths: Optional[List[str]] = None) -> List[Tuple[str, str]]:
- args = ["diff", "--name-status", f"{from_ref}..{to_ref}"]
- if paths:
- args.append("--")
- args.extend(paths)
- cp = _run_git(args)
- out: List[Tuple[str, str]] = []
- for line in cp.stdout.splitlines():
- if not line.strip():
- continue
- parts = line.split("\t", 1)
- if len(parts) != 2:
- continue
- status, path = parts
- out.append((status.strip(), path.strip()))
- return out
-
-
-def list_executable_flags(paths: List[str], repo_root: str) -> List[str]:
- exec_paths: List[str] = []
- for p in paths:
- fs_path = os.path.join(repo_root, p)
- if os.path.exists(fs_path) and os.access(fs_path, os.X_OK):
- exec_paths.append(p)
- return exec_paths
-
-
-def print_changes_with_exec_marker(changes: List[Tuple[str, str]], repo_root: str) -> None:
- if not changes:
- print(" (no changes)")
- return
- paths = [p for _s, p in changes]
- execs = set(list_executable_flags(paths, repo_root))
- for status, path in changes:
- mark = " [EXEC]" if path in execs else ""
- print(f" {status}\t{path}{mark}")
-
-
-def prompt_yes_no(msg: str, default: bool = False) -> bool:
- suffix = "[y/N]" if not default else "[Y/n]"
- while True:
- ans = input(f"{msg} {suffix} ").strip().lower()
- if not ans:
- return default
- if ans in ("y", "yes"):
- return True
- if ans in ("n", "no"):
- return False
- print("Please answer y or n.")
-
-
-def is_under_tester_tree(rel_path: str) -> bool:
- parts = rel_path.split(os.sep)
- return bool(parts) and parts[0] == TESTER_ROOT_REL
-
-
-def changes_outside_tester(changes: List[Tuple[str, str]]) -> List[Tuple[str, str]]:
- return [(s, p) for (s, p) in changes if not is_under_tester_tree(p)]
-
-
-def fetch_remote(remote: str = "origin") -> None:
- _run_git(["fetch", remote], capture_output=False, check=True)
-
-
-def ensure_on_testing_branch_or_die() -> BranchInfo:
- name = get_current_branch()
- info = classify_branch(name)
- if info.kind not in (BranchKind.CORE_TEST, BranchKind.RELEASE_TEST):
- print(f"Error: current branch '{name}' is not a testing branch (core_tester or release_tester_*).", file=sys.stderr)
- sys.exit(1)
- return info