#!/usr/bin/env python3 """svos-front-inventory: regenerate the SVOS front door's credential-free shell paths from the live release. eshpfi configs/svos-front/, installed at /usr/local/sbin/svos-front-inventory on nh3-svos. Run as root after every SVOS release (after /opt/svos/current moves), or the service worker's install breaks: its INVENTORY names hashed asset files, and only those exact paths (plus /sw.js) are served before Basic auth (svos-dev, 2026-10-05). What it does: 1. Reads INVENTORY from /opt/svos/current/static/sw.js (a JSON object of path -> sha256). 2. Refuses unless every path is a plain absolute file path and every file exists with exactly that sha256. 3. Writes /etc/caddy/svos-public-inventory (one Caddyfile `path` line, root:caddy 0640) atomically. 4. Validates the whole Caddyfile as the caddy user with the unit's EnvironmentFile (no secret on any argv), restores the previous inventory file if validation fails, and otherwise reloads caddy-svos. --check: steps 1-2 only, plus a diff against the installed file; changes nothing. """ import hashlib import json import os import re import shutil import subprocess import sys from pathlib import Path STATIC = Path("/opt/svos/current/static") OUT = Path("/etc/caddy/svos-public-inventory") SAFE = re.compile(r"^/[A-Za-z0-9._-]+(/[A-Za-z0-9._-]+)*$") VALIDATE = ["systemd-run", "--quiet", "--wait", "--pipe", "--collect", "-p", "User=caddy", "-p", "Group=caddy", "-p", "EnvironmentFile=/etc/caddy/env", "-p", "Environment=XDG_DATA_HOME=/var/lib/caddy XDG_CONFIG_HOME=/var/lib/caddy", "/usr/local/bin/caddy", "validate", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"] def die(msg): sys.exit(f"svos-front-inventory: {msg}") def inventory(): src = (STATIC / "sw.js").read_text(encoding="utf-8") m = re.search(r"^const INVENTORY = (\{.*?\});$", src, re.M) if not m: die("no `const INVENTORY = {...};` line in sw.js") inv = json.loads(m.group(1)) if not inv: die("INVENTORY is empty") for path, digest in inv.items(): if not SAFE.match(path) or ".." in path.split("/"): die(f"unsafe inventory path {path!r}") f = STATIC / path.lstrip("/") if not f.is_file() or f.is_symlink(): die(f"{path} is not a regular file in {STATIC}") actual = hashlib.sha256(f.read_bytes()).hexdigest() if actual != digest: die(f"{path} sha256 {actual} != INVENTORY {digest}") return sorted(inv) def render(paths): release = os.path.realpath(STATIC.parent) return (f"# GENERATED by /usr/local/sbin/svos-front-inventory from {release}/static/sw.js; do not edit.\n" f"# Exact credential-free shell paths (hash-checked), plus /sw.js.\n" f"path {' '.join(paths + ['/sw.js'])}\n") def main(): new = render(inventory()) old = OUT.read_text(encoding="utf-8") if OUT.exists() else "" if "--check" in sys.argv[1:]: print("inventory ok;", "installed file is current" if old == new else "installed file DIFFERS:\n" + new) return if old == new: print("unchanged; nothing to do") return tmp = OUT.with_name(OUT.name + ".new") tmp.write_text(new, encoding="utf-8") shutil.chown(tmp, "root", "caddy") os.chmod(tmp, 0o640) backup = OUT.with_name(OUT.name + ".prev") if OUT.exists(): shutil.copy2(OUT, backup) os.replace(tmp, OUT) if subprocess.run(VALIDATE).returncode != 0: if backup.exists(): os.replace(backup, OUT) die("caddy validate FAILED; previous inventory restored, caddy not reloaded") subprocess.run(["systemctl", "reload", "caddy-svos"], check=True) print("inventory updated and caddy-svos reloaded:\n" + new) if __name__ == "__main__": main()