diff options
| author | Danilo M. <danix@danix.xyz> | 2026-10-19 20:53:28 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-10-19 20:53:28 +0200 |
| commit | d29fc1cd128b7d6a18e3e50d8ff4d6262f7e88ae (patch) | |
| tree | 1d65e63f4ad94b2dcd040f46dd281e0679d61606 /remote | |
| parent | 8afb50ff9a3a499d8952dfc54378df29161cde0e (diff) | |
| download | sb-builder-d29fc1cd128b7d6a18e3e50d8ff4d6262f7e88ae.tar.gz sb-builder-d29fc1cd128b7d6a18e3e50d8ff4d6262f7e88ae.zip | |
sbb-remote 0.2.0: inventory, slackrepo and publish jobs, signing key
inventory lists every package with its effective version (hint over
.info), build state from the slackrepo DB, summary, and the parsed
hint found by slackrepo's search order; hints are parsed with shlex,
never sourced. Job kinds now return argv plus env: slackrepo
build/rebuild/update/remove (packages validated against the tree,
--preview, --no-publish sets SBB_PUBLISH=n) and publish (sb-publish).
gpg-status and gpg-unlock let the GUI unlock the package signing key
in gpg-agent; the passphrase comes on stdin only, every other
subprocess gets stdin=DEVNULL. A real slackrepo run is refused while
the key is locked.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'remote')
| -rw-r--r-- | remote/config.example.toml | 4 | ||||
| -rwxr-xr-x | remote/sbb-remote | 244 |
2 files changed, 233 insertions, 15 deletions
diff --git a/remote/config.example.toml b/remote/config.example.toml index d7fcd7f..e9b76d0 100644 --- a/remote/config.example.toml +++ b/remote/config.example.toml @@ -7,3 +7,7 @@ upstream_branch = "current" slackrepo_db = "/var/lib/slackrepo/SBo-danix/database_SBo-danix_x86_64.sqlite3" sbpull_script = "/usr/local/share/slackrepo_setup/sbpull.bash" overlays = ["personal", "pentesting"] +slackrepo_repo = "SBo-danix" +hintdir = "/etc/slackrepo/SBo-danix/hintfiles" +default_hintdir = "" # searched after hintdir when set +genreprc = "/root/.genreprc" # REPOSOWNERGPG names the signing key diff --git a/remote/sbb-remote b/remote/sbb-remote index 2d877d6..b26fa9f 100755 --- a/remote/sbb-remote +++ b/remote/sbb-remote @@ -18,7 +18,7 @@ """Helper run over ssh by the sb-builder GUI. Every command prints one JSON object on stdout, except job-follow, which -streams raw log bytes. Jobs run detached (setsid, stdin=/dev/null), one at a +streams raw log bytes. gpg-unlock reads the passphrase from stdin. Jobs run detached (setsid, stdin=/dev/null), one at a time under a flock, from a fixed table of kinds: the GUI sends a kind and its arguments, this file validates them and builds the argv itself. """ @@ -28,15 +28,17 @@ import glob import json import os import re +import shlex import shutil import signal import sqlite3 import subprocess import sys +import tempfile import time import tomllib -VERSION = "0.1.0" +VERSION = "0.2.0" DEFAULTS = { "state_dir": "/var/lib/sb-builder", "repo_dir": "/var/lib/sbopkg/SBo-danix", @@ -44,10 +46,17 @@ DEFAULTS = { "slackrepo_db": "/var/lib/slackrepo/SBo-danix/database_SBo-danix_x86_64.sqlite3", "sbpull_script": "/usr/local/share/slackrepo_setup/sbpull.bash", "overlays": ["personal", "pentesting"], + "slackrepo_repo": "SBo-danix", + "hintdir": "/etc/slackrepo/SBo-danix/hintfiles", + "default_hintdir": "", + "genreprc": "/root/.genreprc", } FLAGS = ("--yes", "--no") JOB_ID = re.compile(r"^\d{8}-\d{6}-[a-z]+(-\d+)?$") PKG = re.compile(r"^[^/]+/([^/]+)/\1\.SlackBuild$") +PKGNAME = re.compile(r"^[A-Za-z0-9._+-]+$") +ACTIONS = ("build", "rebuild", "update", "remove") +PRESET = "/usr/libexec/gpg-preset-passphrase" ACTIVE = ("starting", "running") START_GRACE = 10 # seconds a job may sit in "starting" before it counts as lost @@ -77,7 +86,7 @@ def load_config(path=None): return cfg -# ── job kinds: (cfg, args) -> argv, raising Error on bad args ─────────────── +# ── job kinds: (cfg, args) -> (argv, env), raising Error on bad args ──────── def _flag(args): if len(args) != 1 or args[0] not in FLAGS: @@ -89,14 +98,49 @@ def kind_sbpull(cfg, args): if not args or args[0] not in cfg["overlays"]: raise Error(f"sbpull: overlay must be one of {cfg['overlays']}") return ["bash", "-c", '. "$1" && sbpull "$2" "$3"', "_", - cfg["sbpull_script"], args[0], _flag(args[1:])] + cfg["sbpull_script"], args[0], _flag(args[1:])], {} def kind_sbshadow(cfg, args): - return ["sbshadow", _flag(args)] + return ["sbshadow", _flag(args)], {} -KINDS = {"sbpull": kind_sbpull, "sbshadow": kind_sbshadow} +def kind_slackrepo(cfg, args): + if not args or args[0] not in ACTIONS: + raise Error(f"slackrepo: action must be one of {', '.join(ACTIONS)}") + action, rest = args[0], args[1:] + opts = [a for a in rest if a.startswith("--")] + for o in opts: + if o not in ("--preview", "--no-publish"): + raise Error(f"slackrepo: unknown option {o}") + known = {os.path.basename(d) for d in _pkgdirs(cfg["repo_dir"], "*/*")} + pkgs = [] + for p in (a for a in rest if not a.startswith("--")): + if not PKGNAME.match(p) or p not in known: + raise Error(f"slackrepo: no such package: {p}") + if p not in pkgs: + pkgs.append(p) + if not pkgs and action != "update": + raise Error(f"slackrepo: {action} needs at least one package") + argv = ["slackrepo", f"--repo={cfg['slackrepo_repo']}", action] + if "--preview" in opts: + argv.append("--preview") + return argv + pkgs, {"SBB_PUBLISH": "n"} if "--no-publish" in opts else {} + + +def kind_publish(cfg, args): + if args: + raise Error("publish takes no arguments") + return ["sb-publish"], {} + + +KINDS = {"sbpull": kind_sbpull, "sbshadow": kind_sbshadow, + "slackrepo": kind_slackrepo, "publish": kind_publish} + + +def signs(kind, args): + """A real slackrepo run ends in gen_repos_files.sh, which signs.""" + return kind == "slackrepo" and "--preview" not in args # ── job state ─────────────────────────────────────────────────────────────── @@ -173,7 +217,9 @@ def cmd_jobs(cfg): def cmd_job_start(cfg, kind=None, *args): if kind not in KINDS: raise Error(f"unknown kind: {kind}") - argv = KINDS[kind](cfg, list(args)) + argv, env = KINDS[kind](cfg, list(args)) + if signs(kind, args) and gpg_unlocked(cfg) is False: + raise Error("signing key locked") base = time.strftime("%Y%m%d-%H%M%S-") + kind jid, n = base, 1 while True: @@ -185,7 +231,7 @@ def cmd_job_start(cfg, kind=None, *args): n += 1 jid = f"{base}-{n}" write_meta(d, {"id": jid, "kind": kind, "args": list(args), "argv": argv, - "status": "starting", "created": time.time()}) + "env": env, "status": "starting", "created": time.time()}) open(os.path.join(d, "log"), "wb").close() subprocess.Popen([sys.executable, os.path.abspath(__file__), "_run", jid], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, @@ -215,7 +261,8 @@ def cmd_run(cfg, jid=None): meta["start"] = time.time() try: p = subprocess.Popen(meta["argv"], stdin=subprocess.DEVNULL, stdout=log, - stderr=subprocess.STDOUT, start_new_session=True) + stderr=subprocess.STDOUT, start_new_session=True, + env={**os.environ, **meta.get("env", {})}) except OSError as e: log.write(f"sbb-remote: {e}\n".encode()) rc = 127 @@ -268,20 +315,24 @@ def _try(fn): def _git(cfg, *args): - return subprocess.run(["git", "-C", cfg["repo_dir"], *args], + return subprocess.run(["git", "-C", cfg["repo_dir"], *args], stdin=subprocess.DEVNULL, capture_output=True, text=True, check=True).stdout -def _pkgs(root, pattern): - """Names of the <pkg>/<pkg>.SlackBuild dirs matching pattern under root.""" +def _pkgdirs(root, pattern): + """Paths of the <pkg>/<pkg>.SlackBuild dirs matching pattern under root.""" out = [] for d in glob.glob(os.path.join(root, pattern, "")): - name = os.path.basename(os.path.dirname(d)) - if os.path.isfile(os.path.join(d, name + ".SlackBuild")): - out.append(name) + d = os.path.dirname(d) + if os.path.isfile(os.path.join(d, os.path.basename(d) + ".SlackBuild")): + out.append(d) return out +def _pkgs(root, pattern): + return [os.path.basename(d) for d in _pkgdirs(root, pattern)] + + def _packages(cfg): r = cfg["repo_dir"] mine = {o: _pkgs(r, o + "/*") for o in cfg["overlays"]} @@ -320,6 +371,166 @@ def cmd_stats(cfg): } +# ── inventory ─────────────────────────────────────────────────────────────── + +def parse_assignments(path): + """KEY=value assignments of a shell-style file, without running it. + + Values with a $ are returned as {"value": raw, "shell": True}: the shell + would expand them, this parser does not. + """ + with open(path, errors="replace") as f: + text = f.read().replace("\\\n", " ") + out = {} + for line in text.splitlines(): + try: + tokens = shlex.split(line, comments=True) + except ValueError: + continue # unbalanced quotes: slackrepo would choke too, skip the line + raw = line.split("=", 1)[1] if "=" in line else "" + for t in tokens: + name, eq, value = t.partition("=") + if eq and re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", name): + out[name] = {"value": raw.strip(), "shell": True} if "$" in raw else value + return out + + +def _value(v): + return v["value"] if isinstance(v, dict) else v + + +def find_hint(cfg, path): + """slackrepo's search order (parsefunctions.sh): first <pkg>.hint found.""" + pkg = os.path.basename(path) + dirs = [os.path.join(cfg["repo_dir"], path), cfg["hintdir"], + os.path.join(cfg["hintdir"], path)] + if cfg["default_hintdir"]: + dirs += [cfg["default_hintdir"], os.path.join(cfg["default_hintdir"], path)] + for d in dirs: + f = os.path.join(d, pkg + ".hint") + if os.path.isfile(f): + return f + return None + + +def _summary(pkgdir, pkg): + try: + with open(os.path.join(pkgdir, "slack-desc"), errors="replace") as f: + for line in f: + m = re.match(rf"^{re.escape(pkg)}: {re.escape(pkg)} \((.*)\)\s*$", line) + if m: + return m.group(1) + except OSError: + pass + return None + + +def _builds(cfg): + """{path: (built_version, built_time)}, {path: result} from the slackrepo DB.""" + try: + db = sqlite3.connect(f"file:{cfg['slackrepo_db']}?mode=ro", uri=True) + try: + built = {i: (v, int(t)) for i, v, t in + db.execute("select itemid, version, built from revisions where dep = '/'")} + results = dict(db.execute("select itemid, result from buildresults")) + finally: + db.close() + except sqlite3.Error: + return {}, {} + return built, results + + +COLUMNS = ["path", "version", "info_version", "hint_version", "built_version", "built_time", + "result", "maintainer", "requires", "summary", "hint_path", "hints"] + + +def cmd_inventory(cfg): + built, results = _builds(cfg) + rows = [] + for d in sorted(_pkgdirs(cfg["repo_dir"], "*/*")): + path = os.path.relpath(d, cfg["repo_dir"]) + pkg = os.path.basename(d) + try: + info = parse_assignments(os.path.join(d, pkg + ".info")) + except OSError: + info = {} + hint_path = find_hint(cfg, path) + hints = parse_assignments(hint_path) if hint_path else None + info_version = _value(info.get("VERSION")) + hint_version = _value(hints.get("VERSION")) if hints else None + bv, bt = built.get(path, (None, None)) + rows.append([path, hint_version or info_version, info_version, hint_version, bv, bt, + results.get(path), _value(info.get("MAINTAINER")), + (_value(info.get("REQUIRES")) or "").split(), _summary(d, pkg), + hint_path, hints]) + return {"columns": COLUMNS, "rows": rows} + + +# ── signing key ───────────────────────────────────────────────────────────── + +def _signing_key(cfg): + try: + with open(cfg["genreprc"]) as f: + m = re.search(r'^\s*REPOSOWNERGPG=["\']?([^"\'\s]+)', f.read(), re.M) + except OSError: + return None + return m and m.group(1) + + +def _keygrips(key): + """Keygrips of the signing-capable (sub)keys of key.""" + out = subprocess.run(["gpg", "--batch", "--with-colons", "--with-keygrip", + "--list-secret-keys", key], stdin=subprocess.DEVNULL, + capture_output=True, text=True).stdout + grips, can_sign = [], False + for line in out.splitlines(): + f = line.split(":") + if f[0] in ("sec", "ssb"): + can_sign = "s" in f[11] + elif f[0] == "grp" and can_sign: + grips.append(f[9]) + return grips + + +def gpg_unlocked(cfg): + """True/False, or None when no signing key is configured or found.""" + key = _signing_key(cfg) + grips = _keygrips(key) if key else [] + if not grips: + return None + for g in grips: + out = subprocess.run(["gpg-connect-agent", f"keyinfo {g}", "/bye"], + stdin=subprocess.DEVNULL, capture_output=True, text=True).stdout + info = next((ln.split() for ln in out.splitlines() if ln.startswith("S KEYINFO")), []) + if len(info) < 7 or info[6] != "1": + return False + return True + + +def cmd_gpg_status(cfg): + return {"unlocked": gpg_unlocked(cfg), "key": _signing_key(cfg)} + + +def cmd_gpg_unlock(cfg, stdin=None): + key = _signing_key(cfg) + grips = _keygrips(key) if key else [] + if not grips: + raise Error("no signing key configured") + passphrase = (stdin or sys.stdin.buffer).read().rstrip(b"\n") + for g in grips: + subprocess.run([PRESET, "--preset", g], input=passphrase, check=True, + capture_output=True) + with tempfile.NamedTemporaryFile() as t: + ok = subprocess.run(["gpg", "--batch", "--pinentry-mode", "error", "-u", key, + "-bas", "-o", "-", t.name], stdin=subprocess.DEVNULL, + capture_output=True).returncode == 0 + if not ok: + for g in grips: + subprocess.run([PRESET, "--forget", g], stdin=subprocess.DEVNULL, capture_output=True) + raise Error("bad passphrase") + return {"unlocked": True} + + COMMANDS = { "version": cmd_version, "stats": cmd_stats, @@ -327,6 +538,9 @@ COMMANDS = { "job-start": cmd_job_start, "job-follow": cmd_job_follow, "job-kill": cmd_job_kill, + "inventory": cmd_inventory, + "gpg-status": cmd_gpg_status, + "gpg-unlock": cmd_gpg_unlock, "_run": cmd_run, } |
