- configs/svos-apps/nh3-dev/: user units for svos-terminal (running), svos-postoffice, svos-miranda, svos-agent (installed, not yet started). - configs/svos-front/Caddyfile: svos-terminal.phasefinal.com upstreams to https://svos-seathost.nh3.phasefinal.com now that app-terminal runs on nh3-dev. - configs/fv-ml1-sol/: an nh3-dev user unit holding fv-ml1's BMC SOL session into the journal; fv-ml1's kernel console goes to ttyS1 (the SOL port, marker-tested) from its next boot (ZFSBootMenu commandline on zroot/ROOT).
294 lines
12 KiB
Caddyfile
294 lines
12 KiB
Caddyfile
# SVOS front door on nh3-svos (eshpfi configs/svos-front/), 2026-10-05. Prime: mesh-only, svos.phasefinal.com.
|
|
# - Listens on the tailnet address (100.64.0.9:443) AND, since 2026-10-08 (Prime: "serve it on the LAN too"), on the
|
|
# LAN address (10.100.50.43:443), so non-mesh internal clients work; the internal AdGuards answer 10.100.50.43, the
|
|
# headscale MagicDNS records still answer 100.64.0.9. No internet listener and no :80 redirect server.
|
|
# - Cert: Let's Encrypt via Cloudflare DNS-01 with a phasefinal.com-only token (env CF_API_TOKEN, /etc/caddy/env).
|
|
# - Identity: HTTP Basic auth (Prime 2026-10-05: "Basic auth is fine, saved in browser is fine"; mesh-only because we
|
|
# don't host externally). One account, `Vuong` (Prime is how SVOS addresses him). argon2id hash in
|
|
# /etc/caddy/svos-basic-auth.accounts (root:caddy 0640, NOT in git); plaintext vaulted nh3-svos/basic-auth-vuong.
|
|
# The WhoIs/forward_auth gate that preceded this was retired the same day (every mesh node is login `pfi`).
|
|
# - Client-supplied identity/forwarding headers are deleted BEFORE auth. After auth, Tailscale-User-Login is set to
|
|
# the authenticated user ({http.auth.user.id}) and Authorization is deleted: the kernel's human path (INV-U1-06)
|
|
# refuses any request that carries an Authorization header, and the app has no business seeing the password.
|
|
# reverse_proxy sets X-Forwarded-Proto=https and X-Forwarded-For itself; Host passes through; Origin untouched.
|
|
# - App side (svos-dev): kernel 127.0.0.1:8400 (/api/* with the prefix stripped, /api/health is the kernel's),
|
|
# connector 127.0.0.1:8403 (/health, /verbs/ping|pins|pin|unpin, /openapi.json), static UI /opt/svos/current/static.
|
|
{
|
|
email lkraven@lkraven.com
|
|
auto_https disable_redirects
|
|
admin localhost:2019
|
|
# Hosting bounds (svos-dev 2026-10-05, svoperatingsystem web/README.md Hosting contract): request headers within 5 s,
|
|
# the whole request body within 15 s. NO write/response deadline: /api/stream (SSE) must stay open. Measured on a
|
|
# replica 2026-10-05 before deploy: SSE 25 s and a 20 s slow response survive on h1 and h2; a stalled body is cut at
|
|
# 15 s (h1 504, h2 502); unfinished headers are closed at 5 s.
|
|
servers {
|
|
timeouts {
|
|
read_header 5s
|
|
read_body 15s
|
|
}
|
|
}
|
|
}
|
|
|
|
https://svos.phasefinal.com:443 {
|
|
bind 100.64.0.9 10.100.50.43
|
|
tls {
|
|
dns cloudflare {env.CF_API_TOKEN}
|
|
resolvers 1.1.1.1 8.8.8.8
|
|
}
|
|
route {
|
|
# Whole families by prefix (Caddy header-delete wildcard; tested 2026-10-05: case-insensitive, and
|
|
# X-Forwardedness / Tailscale-Other survive), plus the named strays outside them.
|
|
request_header -Tailscale-User-*
|
|
request_header -X-Forwarded-*
|
|
request_header -Tailscale-Headers-Info
|
|
request_header -X-Real-IP
|
|
request_header -Forwarded
|
|
request_header -X-SVOS-Peer
|
|
|
|
# Request bodies capped at exactly 1 MiB (1,048,576 B, = the kernel dispatcher's own cap) on every route;
|
|
# only /api/* and the connector read bodies at all. Over the cap -> 413. Responses are not limited.
|
|
request_body {
|
|
max_size 1MiB
|
|
}
|
|
|
|
# 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
|
|
}
|
|
# Voice (U12, app-voice on 127.0.0.1:8402; Prime 2026-10-08 "voice on"). BEFORE basic_auth on purpose: the browser
|
|
# calls /voice/* with `Authorization: Bearer <lease secret>` (web/src/lib/voice.ts), minted by a signed kernel open
|
|
# that itself goes through /api behind Basic auth. Basic auth here would reject the bearer, and the kernel's
|
|
# Authorization strip below would drop it. The lease IS the auth; the app refuses /voice without it. The /voice
|
|
# prefix is preserved upstream, Authorization passes untouched, no buffering (streamed audio), cancellation
|
|
# reaches the listener (client disconnect closes the upstream). Engines stay private (fv-ml1 :8300, irv-ml1 :8198).
|
|
@voice path /voice /voice/*
|
|
handle @voice {
|
|
reverse_proxy 127.0.0.1:8402 {
|
|
flush_interval -1
|
|
}
|
|
}
|
|
|
|
# Previous release's hashed assets across a rollout (svos-dev 2026-10-05): open workers restore evicted inventory
|
|
# credential-free. Generated by the same tool: a matcher + handle rooted in THAT release's static dir, or
|
|
# comments only when there is no previous release.
|
|
import /etc/caddy/svos-public-previous
|
|
|
|
basic_auth argon2id SVOS {
|
|
import /etc/caddy/svos-basic-auth.accounts
|
|
}
|
|
request_header Tailscale-User-Login {http.auth.user.id}
|
|
request_header -Authorization
|
|
|
|
handle_path /api/* {
|
|
reverse_proxy 127.0.0.1:8400
|
|
}
|
|
@connector path /health /verbs/ping /verbs/pins /verbs/pin /verbs/unpin /openapi.json
|
|
handle @connector {
|
|
reverse_proxy 127.0.0.1:8403
|
|
}
|
|
# 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
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
# app-terminal's own origin (svos-dev 2026-10-07, U8 + U10c "a pane needs its own origin"). The interface embeds this
|
|
# page in a cross-origin iframe; the browser derives its URL from the registry's base_url
|
|
# (instance.toml: base_url = "https://svos-terminal.phasefinal.com"), so the kernel's signed calls (/health, /verbs/*)
|
|
# also arrive here, from the box itself, and pass to the connector on 127.0.0.1:8404.
|
|
# - NO Basic auth and no identity header: Chromium cancels Basic prompts for cross-origin frames and subresources, so a
|
|
# gate here kills the pane. The lease is the page's auth (GET / without one is a waiting page by ruling A105;
|
|
# /terminal/session|stream|close refuse without the bearer); /verbs/* and /health refuse without the kernel's
|
|
# Ed25519 signature. As defence in depth, /verbs/* and /health are refused unless they come from the box itself.
|
|
# - The app sets Content-Security-Policy: frame-ancestors https://svos.phasefinal.com (its frame_ancestors config);
|
|
# it passes through unmodified. /terminal/stream is a long-lived ndjson stream: no buffering (flush_interval -1)
|
|
# and no write/idle deadline (none is set anywhere in this file). Same 1 MiB body cap.
|
|
# - Mesh-only like the main site: headscale MagicDNS extra record -> 100.64.0.9; nh3-svos resolves it via /etc/hosts.
|
|
# U13 (2026-10-11): app-terminal now runs on nh3-dev, loopback-only. This origin reaches it through nh3-dev's seat-host
|
|
# origin (fleet Caddy, allowlisted to this box's 10.100.50.43); Host must be the upstream's name or the fleet Caddy's
|
|
# host matcher misses it. The old upstream was 127.0.0.1:8404 here.
|
|
(svos_terminal_upstream) {
|
|
reverse_proxy https://svos-seathost.nh3.phasefinal.com {
|
|
header_up Host {upstream_hostport}
|
|
flush_interval -1
|
|
}
|
|
}
|
|
|
|
https://svos-terminal.phasefinal.com:443 {
|
|
bind 100.64.0.9 10.100.50.43
|
|
tls {
|
|
dns cloudflare {env.CF_API_TOKEN}
|
|
resolvers 1.1.1.1 8.8.8.8
|
|
}
|
|
route {
|
|
request_header -Tailscale-User-*
|
|
request_header -X-Forwarded-*
|
|
request_header -Tailscale-Headers-Info
|
|
request_header -X-Real-IP
|
|
request_header -Forwarded
|
|
request_header -X-SVOS-Peer
|
|
|
|
request_body {
|
|
max_size 1MiB
|
|
}
|
|
|
|
# The kernel's signed calls come from the box itself (its source on the mesh address); anything else is refused.
|
|
@not_from_box {
|
|
path /verbs/* /health
|
|
not remote_ip 100.64.0.9 10.100.50.43 127.0.0.1
|
|
}
|
|
handle @not_from_box {
|
|
respond 403
|
|
}
|
|
|
|
# Exactly the paths the connector serves (svos-dev 2026-10-07), method-pinned; everything else is 404.
|
|
@page {
|
|
method GET HEAD
|
|
path / /assets/*
|
|
}
|
|
handle @page {
|
|
import svos_terminal_upstream
|
|
}
|
|
@lease_get {
|
|
method GET
|
|
path /terminal/session /terminal/stream
|
|
}
|
|
handle @lease_get {
|
|
import svos_terminal_upstream
|
|
}
|
|
@lease_close {
|
|
method POST
|
|
path /terminal/close
|
|
}
|
|
handle @lease_close {
|
|
import svos_terminal_upstream
|
|
}
|
|
@verbs {
|
|
method POST
|
|
path /verbs/*
|
|
}
|
|
handle @verbs {
|
|
import svos_terminal_upstream
|
|
}
|
|
@health {
|
|
method GET
|
|
path /health
|
|
}
|
|
handle @health {
|
|
import svos_terminal_upstream
|
|
}
|
|
handle {
|
|
respond 404
|
|
}
|
|
}
|
|
}
|
|
|
|
# The kernel's app origin (U13 B2, 2026-10-10, Prime-approved; svoperatingsystem U9a and docs/operations/u13-svos-edit.md
|
|
# "Before the edit" 1). Apps that run OFF this box reach the kernel here: from the U13 edit, app-terminal,
|
|
# app-postoffice (the bridge) and app-high-seat, all on nh3-dev. LAN only, because nh3-dev is not on the mesh; the
|
|
# internal AdGuards answer svos-kernel.phasefinal.com -> 10.100.50.43 and no MagicDNS record exists.
|
|
# - Upstream is the kernel's SECOND listener, `serve --app-listen 127.0.0.1:8410` (configs/svos-kernel/20-app-listen.conf):
|
|
# a request there is never a human and loopback trust never applies, so every call authenticates with its own bearer.
|
|
# NO Basic auth, and Authorization passes through untouched.
|
|
# - Source allowlisted to nh3-dev (10.100.10.50); anything else gets 403 from Caddy.
|
|
# - Paths, method-pinned, exactly svoperatingsystem docs/operations/u13-svos-edit.md item 1 at dc241f8 (every caller on
|
|
# nh3-dev, read at source): the apps (bridge, terminal, app-miranda, app-high-seat) GET /auth/kernel-key, POST
|
|
# /auth/enroll, POST /auth/proofs, POST /registry/apps/*/manifest, /verbs/*, POST /seats/enroll; Miranda's agent
|
|
# connector GET /auth/whoami, /registry/apps, /registry/apps/*, /principals/*/grants, /seats, /events,
|
|
# /artifacts/resolve (and /verbs/*). Anything else 404. B2's check: GET /auth/kernel-key without a credential is a
|
|
# 401 from the KERNEL, not from Caddy.
|
|
# - Client identity/forwarding headers are deleted as on the other sites; the same 1 MiB body cap; nothing streams.
|
|
https://svos-kernel.phasefinal.com:443 {
|
|
bind 10.100.50.43
|
|
tls {
|
|
dns cloudflare {env.CF_API_TOKEN}
|
|
resolvers 1.1.1.1 8.8.8.8
|
|
}
|
|
route {
|
|
request_header -Tailscale-User-*
|
|
request_header -X-Forwarded-*
|
|
request_header -Tailscale-Headers-Info
|
|
request_header -X-Real-IP
|
|
request_header -Forwarded
|
|
request_header -X-SVOS-Peer
|
|
|
|
request_body {
|
|
max_size 1MiB
|
|
}
|
|
|
|
@not_nh3_dev not remote_ip 10.100.10.50
|
|
handle @not_nh3_dev {
|
|
respond 403
|
|
}
|
|
@app_get {
|
|
method GET
|
|
path /auth/kernel-key /auth/whoami /registry/apps /registry/apps/* /seats /events /artifacts/resolve
|
|
}
|
|
handle @app_get {
|
|
reverse_proxy 127.0.0.1:8410
|
|
}
|
|
@app_grants {
|
|
method GET
|
|
path_regexp ^/principals/[^/]+/grants$
|
|
}
|
|
handle @app_grants {
|
|
reverse_proxy 127.0.0.1:8410
|
|
}
|
|
@app_post {
|
|
method POST
|
|
path /auth/enroll /auth/proofs /seats/enroll
|
|
}
|
|
handle @app_post {
|
|
reverse_proxy 127.0.0.1:8410
|
|
}
|
|
@app_manifest {
|
|
method POST
|
|
path_regexp ^/registry/apps/[^/]+/manifest$
|
|
}
|
|
handle @app_manifest {
|
|
reverse_proxy 127.0.0.1:8410
|
|
}
|
|
@app_verbs path /verbs/*
|
|
handle @app_verbs {
|
|
reverse_proxy 127.0.0.1:8410
|
|
}
|
|
handle {
|
|
respond 404
|
|
}
|
|
}
|
|
}
|