diff options
Diffstat (limited to 'remote/sbb-remote')
| -rwxr-xr-x | remote/sbb-remote | 244 |
1 files changed, 229 insertions, 15 deletions
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, } |
