#!/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()