aboutsummaryrefslogtreecommitdiffstats
path: root/remote
diff options
context:
space:
mode:
Diffstat (limited to 'remote')
-rw-r--r--remote/config.example.toml4
-rwxr-xr-xremote/sbb-remote244
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,
}