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.
This commit is contained in:
vh
2026-10-05 19:58:19 -07:00
parent 7c3eee877a
commit e369b1c769
6 changed files with 186 additions and 7 deletions
+39 -2
View File
@@ -33,6 +33,21 @@ https://svos.phasefinal.com:443 {
request_header -Forwarded
request_header -X-SVOS-Peer
# Credential-free offline shell (svos-dev 2026-10-05): the service worker fetches its hash-pinned INVENTORY
# with credentials:'omit', so exactly those files plus /sw.js are served BEFORE Basic auth, GET/HEAD only,
# exact files (no fallback). Public build bytes only; no API or connector path. The path list is GENERATED
# from the live release's sw.js by /usr/local/sbin/svos-front-inventory, which must run after every release.
@public_shell {
method GET HEAD
import /etc/caddy/svos-public-inventory
}
handle @public_shell {
@revalidate path /sw.js /index.html /manifest.webmanifest /favicon.svg /icons/*
header @revalidate Cache-Control no-cache
root * /opt/svos/current/static
file_server
}
basic_auth argon2id SVOS {
import /etc/caddy/svos-basic-auth.accounts
}
@@ -46,10 +61,32 @@ https://svos.phasefinal.com:443 {
handle @connector {
reverse_proxy 127.0.0.1:8403
}
handle {
# Static routes per svoperatingsystem web/README.md "Hosting contract" (gated):
# / and /status -> index.html; the static route space -> exact files, a missing file is 404 (never index.html);
# other HTML document requests -> index.html (the app renders Page not found); everything else -> 404.
@shell_doc path / /status
handle @shell_doc {
header Cache-Control no-cache
root * /opt/svos/current/static
try_files {path} /index.html
rewrite * /index.html
file_server
}
@static path /assets/* /icons/* /favicon.svg /manifest.webmanifest /index.html /sw.js
handle @static {
@revalidate path /sw.js /index.html /manifest.webmanifest /favicon.svg /icons/*
header @revalidate Cache-Control no-cache
root * /opt/svos/current/static
file_server
}
@other_doc expression header({'Sec-Fetch-Dest': 'document'}) || header({'Accept': '*text/html*'})
handle @other_doc {
header Cache-Control no-cache
root * /opt/svos/current/static
rewrite * /index.html
file_server
}
handle {
respond 404
}
}
}
+47
View File
@@ -0,0 +1,47 @@
#!/bin/bash
# SVOS front-door acceptance matrix (eshpfi configs/svos-front/), 2026-10-05.
# Run from a MESH peer. Feed the variables on stdin ahead of this file so no credential touches an argv:
# pw=$(secret get nh3-svos/basic-auth-vuong)
# { printf 'BASE=https://svos.phasefinal.com\nCURLX="--resolve svos.phasefinal.com:443:100.64.0.9"\nCRED=%s\nINV=%q\nIDX_SHA=%s\n' \
# "Vuong:$pw" "$(<lines 'path sha256' from the release sw.js INVENTORY>)" "$(<sha256 of static/index.html>)"
# cat configs/svos-front/acceptance-matrix.sh; } | ssh infra-ops@<mesh-peer> 'bash -s'
# Positive control (2026-10-05): against the previous Caddyfile (no public shell, try_files fallback) it reports
# 16 FAIL, so it can see the behaviours it checks.
# expects: BASE, CURLX (extra curl args), CRED (user:pass), INV (lines "path sha256"), IDX_SHA (sha256 of index.html)
pass=0; fail=0
ok() { pass=$((pass+1)); printf 'PASS %s\n' "$1"; }
bad() { fail=$((fail+1)); printf 'FAIL %s :: %s\n' "$1" "$2"; }
req() { # $1 name, $2 auth(y/n), then curl args; sets CODE REALM CT CC SHA
local name=$1 auth=$2; shift 2
local hdr=$(mktemp) body=$(mktemp)
if [ "$auth" = y ]; then CODE=$(curl -s $CURLX -K <(printf 'user = "%s"\n' "$CRED") -D "$hdr" -o "$body" -w '%{http_code}' "$@")
else CODE=$(curl -s $CURLX -D "$hdr" -o "$body" -w '%{http_code}' "$@"); fi
REALM=$(grep -ci '^www-authenticate: Basic realm="SVOS"' "$hdr"); CT=$(grep -i '^content-type:' "$hdr" | tr -d '\r' | cut -d' ' -f2-)
CC=$(grep -i '^cache-control:' "$hdr" | tr -d '\r' | cut -d' ' -f2-); SHA=$(sha256sum "$body" | cut -d' ' -f1); rm -f "$hdr" "$body"
}
gated() { req "$1" n "${@:2}"; [ "$CODE" = 401 ] && [ "$REALM" = 1 ] && ok "$1 -> 401 realm" || bad "$1" "code=$CODE realm=$REALM"; }
echo "== unauthenticated: exact inventory is public, hash-preserved"
while read -r p h; do req "U1 GET $p" n "$BASE$p"; [ "$CODE" = 200 ] && [ "$SHA" = "$h" ] && ok "U1 GET $p 200 sha ok cc=[$CC]" || bad "U1 GET $p" "code=$CODE sha=$SHA"; done <<< "$INV"
req "U1 GET /sw.js" n "$BASE/sw.js"; [ "$CODE" = 200 ] && echo "$CT" | grep -q javascript && [ "$CC" = no-cache ] && ok "U1 GET /sw.js 200 ct=[$CT] cc=[$CC]" || bad "U1 /sw.js" "code=$CODE ct=$CT cc=$CC"
req "U2 HEAD /index.html" n -I "$BASE/index.html"; [ "$CODE" = 200 ] && ok "U2 HEAD /index.html 200" || bad "U2" "code=$CODE"
gated "U3 POST /index.html" -X POST "$BASE/index.html"
gated "U4 GET /assets/missing.js" "$BASE/assets/missing.js"
gated "U5 GET /" "$BASE/"
gated "U5 GET /status" "$BASE/status"
for p in /api/health /health /openapi.json /verbs/ping /verbs/pins; do gated "U6 GET $p" "$BASE$p"; done
gated "U7 GET /assets/../api/health (path-as-is)" --path-as-is "$BASE/assets/../api/health"
gated "U8 GET /index.html/" "$BASE/index.html/"
req "U9 GET /INDEX.HTML" n "$BASE/INDEX.HTML"; [ "$CODE" = 404 ] || [ "$CODE" = 401 ] && ok "U9 /INDEX.HTML -> $CODE (no bytes served)" || bad "U9" "code=$CODE"
gated "U10 GET /%2e%2e/etc/passwd" --path-as-is "$BASE/%2e%2e/etc/passwd"
gated "U11 GET /icons/" "$BASE/icons/"
echo "== authenticated"
for p in / /status; do req "A1 GET $p" y -H 'Sec-Fetch-Dest: document' "$BASE$p"; [ "$CODE" = 200 ] && [ "$SHA" = "$IDX_SHA" ] && [ "$CC" = no-cache ] && ok "A1 GET $p -> index.html cc=[$CC]" || bad "A1 $p" "code=$CODE sha=$SHA cc=$CC"; done
req "A3 GET /assets/missing.js" y "$BASE/assets/missing.js"; [ "$CODE" = 404 ] && [ "$SHA" != "$IDX_SHA" ] && ok "A3 missing asset -> 404 (not index)" || bad "A3" "code=$CODE"
req "A3b GET /assets/missing.js as document" y -H 'Sec-Fetch-Dest: document' "$BASE/assets/missing.js"; [ "$CODE" = 404 ] && ok "A3b missing asset as document -> 404" || bad "A3b" "code=$CODE"
req "A4 GET /icons/" y "$BASE/icons/"; [ "$CODE" = 404 ] && ok "A4 /icons/ -> 404" || bad "A4" "code=$CODE"
req "A5 GET /nowhere (no doc headers)" y "$BASE/nowhere"; [ "$CODE" = 404 ] && ok "A5 /nowhere -> 404" || bad "A5" "code=$CODE"
req "A6 GET /nowhere Sec-Fetch-Dest" y -H 'Sec-Fetch-Dest: document' "$BASE/nowhere"; [ "$CODE" = 200 ] && [ "$SHA" = "$IDX_SHA" ] && ok "A6 /nowhere document -> index.html" || bad "A6" "code=$CODE"
req "A7 GET /nowhere Accept html" y -H 'Accept: text/html,application/xhtml+xml' "$BASE/nowhere"; [ "$CODE" = 200 ] && [ "$SHA" = "$IDX_SHA" ] && ok "A7 /nowhere Accept html -> index.html" || bad "A7" "code=$CODE"
for p in /api/health /health; do req "A8 GET $p" y "$BASE$p"; [ "$REALM" = 0 ] && ok "A8 GET $p passes Caddy auth (no Basic challenge) -> upstream $CODE" || bad "A8 $p" "code=$CODE realm=$REALM"; done
req "A9 POST /index.html" y -X POST "$BASE/index.html"; [ "$CODE" = 405 ] || [ "$CODE" = 404 ] && ok "A9 POST /index.html authed -> $CODE" || bad "A9" "code=$CODE"
echo "== $pass pass, $fail fail"
+91
View File
@@ -0,0 +1,91 @@
#!/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()