feat(open-notebook): put the podcast generator behind the guard chain
The service came up internal-only: UI on the LAN at 8502, API at 5056, both published on every interface, with only its own shared password in front. That put the episode library outside the fleet's auth chain and one port scan away from anything on the network. - Caddy: guarded vhost notebook.franksdank.cloud (Anubis -> Authentik; provider notebook-proxy, internal_host caddy:8017) plus the post-auth route to the published UI port. One upstream is enough, because the image's own Next.js layer rewrites /api/* to the FastAPI beside it — so the API needs no outward face at all, and 5056 moves to loopback, where only the deployer's health gate dials it. - scripts/open-notebook.env: API_URL and CORS_ORIGINS become the single guarded origin. Left to itself the app auto-detects <host>:5055, the port seerr holds on this host; a LAN-origin API_URL would also take the browser around the guard chain, where its authenticated session does not ride. - addn-hosts: the LAN A record. The homepage card goes live and links the vhost. - Seam: the API origin is derived from whatever the Caddyfile routes to 8502, so renaming the vhost moves the test instead of leaving it blessing a stale name, and a new test proves the vhost hands the WHOLE request to Anubis.
This commit is contained in:
+59
-20
@@ -17,6 +17,7 @@ NET="$REPO_ROOT/$UNIT_DIR/open-notebook.network"
|
||||
ENVF="$REPO_ROOT/scripts/open-notebook.env"
|
||||
PLANNER="$REPO_ROOT/scripts/deploy-plan.py"
|
||||
REMOTE="$REPO_ROOT/scripts/deploy-remote.sh"
|
||||
CADDY="$REPO_ROOT/dankclaw/Caddyfile"
|
||||
# The host-side path the units read (they run on Dankclaw, not here), spelled
|
||||
# out once so the ordering test below compares against a real constant.
|
||||
HOST_TUNABLES="EnvironmentFile=/home/dank/serverAppConfigs/scripts/open-notebook.env"
|
||||
@@ -68,22 +69,58 @@ DB_PIN="sha256:e5d7387e3727bbe2f7cd6d869e48cd34777272258572ead37c9656b25f745723"
|
||||
! grep -qE '^PublishPort=.*:8000(:|$)' "$APP"
|
||||
}
|
||||
|
||||
@test "the app publishes its UI and moves the API off the host port seerr holds" {
|
||||
# onb_vhost — the host Caddy routes to the UI's 8502. Derived, never restated:
|
||||
# the API origin and the homepage link have to agree with whatever the Caddyfile
|
||||
# actually routes, so renaming the vhost there moves this seam instead of
|
||||
# leaving it blessing a stale name.
|
||||
onb_vhost() {
|
||||
awk '
|
||||
/^[[:space:]]*@[A-Za-z0-9_-]+[[:space:]]+host[[:space:]]/ { h = $3 }
|
||||
/^[[:space:]]*reverse_proxy[[:space:]]+192\.168\.0\.143:8502$/ { if (h) { print h; exit } }
|
||||
' "$CADDY"
|
||||
}
|
||||
|
||||
@test "the app publishes the UI for the guard chain and keeps the API off the LAN" {
|
||||
# 8502 is the single upstream the post-auth listener dials, so it is the
|
||||
# only port that has to answer on a LAN interface.
|
||||
grep -qxF 'PublishPort=0.0.0.0:8502:8502' "$APP"
|
||||
[ "$(grep -cE '^PublishPort=0\.0\.0\.0:' "$APP")" = "1" ] || {
|
||||
echo "the UI port is not the only wildcard publish: $(grep -E '^PublishPort=' "$APP")"; false; }
|
||||
# seerr already binds 127.0.0.1:5055 on this host, so the API cannot leave
|
||||
# the container on the host port upstream uses.
|
||||
publish="$(grep -E '^PublishPort=[^:]+:[0-9]+:5055$' "$APP")"
|
||||
[ -n "$publish" ] || { echo "no PublishPort mapping to the API's 5055"; false; }
|
||||
mapping="${publish#PublishPort=}" # e.g. 0.0.0.0:5056:5055
|
||||
host_port="${mapping#*:}"
|
||||
host_port="${host_port%%:*}"
|
||||
[ "$host_port" != "5055" ] || {
|
||||
echo "the API is published on host 5055, which seerr already holds"; false; }
|
||||
# The frontend auto-detects the API as <host it was opened on>:5055, so a
|
||||
# remap is only correct if API_URL names the published port.
|
||||
# the container on the port upstream uses; and it needs no outward face at
|
||||
# all, because the image's Next.js layer rewrites /api/* to it. Loopback is
|
||||
# where the deployer's health gate dials it.
|
||||
grep -qxF 'PublishPort=127.0.0.1:5056:5055' "$APP"
|
||||
# One origin serves UI and API, so API_URL names the guarded vhost rather
|
||||
# than a host:port. Left to itself the frontend auto-detects `<host>:5055`;
|
||||
# a LAN-origin API_URL takes the browser around the guard chain, where its
|
||||
# authenticated session does not ride.
|
||||
api_url="$(sed -n 's/^API_URL=//p' "$ENVF")"
|
||||
[ "$api_url" = "http://192.168.0.143:${host_port}" ] || {
|
||||
echo "API_URL ($api_url) does not name the published API port ($host_port)"; false; }
|
||||
vhost="$(onb_vhost)"
|
||||
[ -n "$vhost" ] || { echo "no Caddy route to the UI port was found"; false; }
|
||||
[ "$api_url" = "https://${vhost}" ] || {
|
||||
echo "API_URL ($api_url) is not the guarded origin https://${vhost}"; false; }
|
||||
# Same-origin traffic needs no CORS at all, but while CORS_ORIGINS is unset
|
||||
# the app warns and defaults to `*` in front of every episode's audio.
|
||||
cors="$(sed -n 's/^CORS_ORIGINS=//p' "$ENVF")"
|
||||
[ "$cors" = "https://${vhost}" ] || {
|
||||
echo "CORS_ORIGINS ($cors) is not the single guarded origin https://${vhost}"; false; }
|
||||
}
|
||||
|
||||
@test "the vhost is guarded and the app login stays behind it" {
|
||||
vhost="$(onb_vhost)"
|
||||
# The vhost itself proxies the WHOLE request to Anubis — an unmatched path
|
||||
# must never reach the app unchallenged.
|
||||
awk -v v="$vhost" '
|
||||
$0 == v " {" { inblock = 1; seen = 0 }
|
||||
inblock && /reverse_proxy anubis:8923/ { seen = 1 }
|
||||
inblock && /^}/ { exit }
|
||||
END { exit !seen }
|
||||
' "$CADDY" || { echo "$vhost does not proxy everything through Anubis"; false; }
|
||||
# And the app's own password is still required, so a route that ever bypasses
|
||||
# the chain does not hand out the episode library: the deployer refuses to
|
||||
# start the units while the secret file leaves it blank.
|
||||
grep -qF 'OPEN_NOTEBOOK_PASSWORD' "$ENVF"
|
||||
}
|
||||
|
||||
@test "the pair shares one network and the app finds the database by container name" {
|
||||
@@ -245,21 +282,23 @@ DB_PIN="sha256:e5d7387e3727bbe2f7cd6d869e48cd34777272258572ead37c9656b25f745723"
|
||||
grep -qF 'dankclaw:open-notebook|' "$REPO_ROOT/scripts/deploy.sh"
|
||||
}
|
||||
|
||||
@test "the homepage card exists and stays hidden until the service is deployed" {
|
||||
python3 - "$REPO_ROOT/shared/homepage/services.json" <<'PY'
|
||||
@test "the homepage card links the guarded vhost now that the service is live" {
|
||||
python3 - "$REPO_ROOT/shared/homepage/services.json" "$(onb_vhost)" <<'PY'
|
||||
import json, os, sys
|
||||
data = json.load(open(sys.argv[1]))
|
||||
vhost = sys.argv[2]
|
||||
cards = [s for s in data if s["name"] == "open-notebook"]
|
||||
assert len(cards) == 1, f"expected exactly one open-notebook card, got {len(cards)}"
|
||||
card = cards[0]
|
||||
assert card["category"] == "user" and card["section"] == "AI", card
|
||||
assert card["probe"] == "127.0.0.1:8502", card["probe"]
|
||||
assert card["method"] == "http"
|
||||
# Not deployed yet: the card is reported wip-down and carries no link, so the
|
||||
# page shows no dead button (the Seerr precedent).
|
||||
assert card.get("wip") == "down", card.get("wip")
|
||||
assert card.get("showLink") is False, card.get("showLink")
|
||||
assert not card.get("url"), "a hidden card must not carry a url"
|
||||
# Deployed and guarded: the card is live and its link is the vhost, so the
|
||||
# button on the page reaches the app through the guard chain. Card links carry
|
||||
# the fleet's http:// form everywhere; Caddy answers the redirect to HTTPS.
|
||||
assert not card.get("wip"), f"card should be live, got wip={card.get('wip')!r}"
|
||||
assert card.get("showLink") is True, card.get("showLink")
|
||||
assert card.get("url") == f"http://{vhost}", card.get("url")
|
||||
art = card["art"].lstrip("/")
|
||||
assert os.path.exists(os.path.join(os.path.dirname(sys.argv[1]), art)), art
|
||||
PY
|
||||
|
||||
Reference in New Issue
Block a user