Files
esh-pfi-infrastructure/configs/svos-front/svos-front-inventory
T
vh e369b1c769 feat(nh3-svos): credential-free SVOS offline shell before Basic auth; contract static routes; backup drill results
svos-dev: the service worker fetches its hash-pinned INVENTORY with credentials omitted. Exactly those files plus /sw.js are now served before basic_auth (GET/HEAD, exact files). The list is generated from the release's sw.js by svos-front-inventory, which hash-checks each file and validates before reload. Gated static routes follow the web/README hosting contract: missing assets 404 instead of index.html, other document requests get index.html, everything else 404, unversioned files no-cache. acceptance-matrix.sh: 35/35 live; 16 FAIL against the previous config (positive control). git added to the nh3-svos playbook (kernel's remote check). Live backup drill: quiesced backup ~1-2 s outage; a refused freeze does NOT abort vzdump (unfrozen backup, job reported successful); both images restore-mounted read-only and verified.
2026-10-05 19:58:19 -07:00

92 lines
3.8 KiB
Python
Executable File

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