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:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Executable
+47
@@ -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"
|
||||
Executable
+91
@@ -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()
|
||||
Reference in New Issue
Block a user