#!/usr/bin/env python3 """svos-front-inventory: regenerate the SVOS front door's credential-free shell paths from the live release(s). 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). Two generated files, both imported by /etc/caddy/Caddyfile, both root:caddy 0640: /etc/caddy/svos-public-inventory `path ...` of the CURRENT release's INVENTORY + /sw.js /etc/caddy/svos-public-previous a matcher + handle serving the PREVIOUS release's hashed /assets/* files that the current build no longer has, from that release's own static dir. Already- open workers restore evicted inventory with credentials:'omit', so the old hashed assets must stay reachable credential-free across a rollout. Empty (comments only) when there is no previous release. Which release is "previous" is explicit state, not a guess: it is the release named in the header of the installed svos-public-inventory when current has moved on from it; otherwise the previous file's own header is kept. One previous release at most (the web contract's one current/previous build record). Every path must be a plain absolute file path and every file must exist with exactly its INVENTORY sha256, in its own release, or nothing is written. After writing, the whole Caddyfile is validated as the caddy user with the unit's EnvironmentFile (no secret on any argv); on failure both previous files are restored and caddy is not reloaded. --check: verify and diff only, change nothing. Testing: SVOS_FRONT_TEST_ROOT= re-roots /opt/svos and /etc/caddy under and skips validate/reload/chown. """ import hashlib import json import os import re import shutil import subprocess import sys from pathlib import Path TEST_ROOT = os.environ.get("SVOS_FRONT_TEST_ROOT") BASE = Path(TEST_ROOT) if TEST_ROOT else Path("/") CURRENT = BASE / "opt/svos/current" OUT = BASE / "etc/caddy/svos-public-inventory" PREV_OUT = BASE / "etc/caddy/svos-public-previous" SAFE = re.compile(r"^/[A-Za-z0-9._-]+(/[A-Za-z0-9._-]+)*$") HEADER = re.compile(r"^# GENERATED by \S+ from (\S+)/static/sw\.js", re.M) 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(release): """path -> sha256 from /static/sw.js, unchecked.""" src = (Path(release) / "static/sw.js").read_text(encoding="utf-8") m = re.search(r"^const INVENTORY = (\{.*?\});$", src, re.M) if not m: die(f"no `const INVENTORY = {{...}};` line in {release}/static/sw.js") inv = json.loads(m.group(1)) if not inv: die(f"INVENTORY is empty in {release}") return inv def verify(release, inv, paths): static = Path(release) / "static" for path in paths: if not SAFE.match(path) or ".." in path.split("/"): die(f"unsafe inventory path {path!r} in {release}") 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 != inv[path]: die(f"{path} in {release}: sha256 {actual} != INVENTORY {inv[path]}") def header_release(text): m = HEADER.search(text) return m.group(1) if m else None def shown(release): """The release path as Caddy and humans see it on the host (without the test re-root).""" return "/" + os.path.relpath(release, BASE) if TEST_ROOT else release def real(shown_path): return str(BASE / shown_path.lstrip("/")) if TEST_ROOT else shown_path def main(): check = "--check" in sys.argv[1:] cur = os.path.realpath(CURRENT) cur_inv = inventory(cur) verify(cur, cur_inv, sorted(cur_inv)) new_cur = (f"# GENERATED by /usr/local/sbin/svos-front-inventory from {shown(cur)}/static/sw.js; do not edit.\n" f"# Exact credential-free shell paths (hash-checked), plus /sw.js.\n" f"path {' '.join(sorted(cur_inv) + ['/sw.js'])}\n") old_cur = OUT.read_text(encoding="utf-8") if OUT.exists() else "" old_prev = PREV_OUT.read_text(encoding="utf-8") if PREV_OUT.exists() else "" candidates = [header_release(old_cur), header_release(old_prev)] prev = next((r for r in candidates if r and real(r) != cur and os.path.isdir(real(r))), None) if prev: prev_inv = inventory(real(prev)) keep = sorted(p for p in prev_inv if p.startswith("/assets/") and p not in cur_inv) verify(real(prev), prev_inv, keep) if prev and keep: new_prev = (f"# GENERATED by /usr/local/sbin/svos-front-inventory from {prev}/static/sw.js; do not edit.\n" f"# Previous release's hashed assets the current build no longer has (hash-checked), served\n" f"# credential-free from that release's own static dir.\n" f"@public_previous {{\n\tmethod GET HEAD\n\tpath {' '.join(keep)}\n}}\n" f"handle @public_previous {{\n\troot * {prev}/static\n\tfile_server\n}}\n") else: new_prev = ("# GENERATED by /usr/local/sbin/svos-front-inventory; do not edit.\n" "# No previous release with assets to keep: nothing is served from here.\n") if check: for name, old, new in (("current", old_cur, new_cur), ("previous", old_prev, new_prev)): print(f"{name}: " + ("installed file is current" if old == new else "installed file DIFFERS:\n" + new)) return if old_cur == new_cur and old_prev == new_prev: print("unchanged; nothing to do") return saved = {} for path, text in ((OUT, new_cur), (PREV_OUT, new_prev)): if path.exists(): saved[path] = path.read_text(encoding="utf-8") tmp = path.with_name(path.name + ".new") tmp.write_text(text, encoding="utf-8") if not TEST_ROOT: shutil.chown(tmp, "root", "caddy") os.chmod(tmp, 0o640) os.replace(tmp, path) if TEST_ROOT: print("TEST: files written, validate/reload skipped") return if subprocess.run(VALIDATE).returncode != 0: for path in (OUT, PREV_OUT): if path in saved: path.write_text(saved[path], encoding="utf-8") else: path.unlink() die("caddy validate FAILED; previous files restored, caddy not reloaded") subprocess.run(["systemctl", "reload", "caddy-svos"], check=True) print("updated and caddy-svos reloaded:\n" + new_cur + new_prev) if __name__ == "__main__": main()