diff --git a/dankclaw/Caddyfile b/dankclaw/Caddyfile index 4d05768..e2bdbdb 100644 --- a/dankclaw/Caddyfile +++ b/dankclaw/Caddyfile @@ -162,6 +162,25 @@ marreta.franksdank.cloud { } } +# --- Open Notebook (guarded: Anubis → Authentik; notebook-proxy provider, internal_host caddy:8017) --- +# The podcast generator. ONE upstream carries the whole app: the image's own +# Next.js layer rewrites /api/* to the FastAPI beside it (INTERNAL_API_URL, +# default http://localhost:5055 inside the container), so a browser on this +# vhost never needs the API port. That is why scripts/open-notebook.env sets +# API_URL to exactly this origin — left unset, the app auto-detects +# `:5055`, and 5055 on this host belongs to seerr — and why +# the published API port sits on loopback. App-level login +# (OPEN_NOTEBOOK_PASSWORD) stays on behind the chain: two owners, edge identity +# and the app's own session. +# Dialled by published LAN port, not container name: open-notebook is a +# rootless quadlet and this Caddy lives in root's storage, so root's DNS does +# not see the container (the same reason marreta and SearXNG are dialled by IP). +notebook.franksdank.cloud { + reverse_proxy anubis:8923 { + header_up X-Real-Ip {remote_host} + } +} + # --- Pi-hole (quadlet on slirp4netns; published 8083) --- pihole.franksdank.cloud { basicauth { @@ -420,6 +439,16 @@ http://:8017 { handle @marreta { reverse_proxy 192.168.0.143:8082 } + + @notebook host notebook.franksdank.cloud + handle @notebook { + # Bare proxy, like ai.franksdank.cloud: the app's SSE answers + # (/api/search/ask, chat sessions) stream for minutes through this + # same listener with no flush or timeout tuning, so nothing here + # needs it — a read/write timeout on the route would be the thing + # that cuts them. + reverse_proxy 192.168.0.143:8502 + } } # --- Optional: Jackett --- diff --git a/dankclaw/open-notebook/quadlet/open-notebook.container b/dankclaw/open-notebook/quadlet/open-notebook.container index 11b0aa5..7e7ae47 100644 --- a/dankclaw/open-notebook/quadlet/open-notebook.container +++ b/dankclaw/open-notebook/quadlet/open-notebook.container @@ -33,17 +33,21 @@ # and 5055 is exactly the port seerr already holds on this host # (`PublishPort=127.0.0.1:5055:5055`; `ss -ltn` on Dankclaw 2026-10-18 shows # 127.0.0.1:5055 taken). So the API leaves the container on host port 5056. -# That forces one setting: the frontend auto-detects the API as -# `:5055`, so API_URL in scripts/open-notebook.env must name -# the remapped port explicitly, or the UI loads and every call fails. Both -# ports are published on all interfaces because the consumer is a browser on -# the LAN — the fleet's browser-facing services (open-webui, comfyui, gitea) -# bind the same way, while loopback-only is reserved for services whose user -# path is the guard chain. This service has NO vhost and no Caddy route: it is -# internal-only until it is guarded, and its own password -# (OPEN_NOTEBOOK_PASSWORD) is what stands in front of it. That password is not -# decoration: with it unset the API answers everyone on the LAN, including the -# unauthenticated episode-audio download. +# Only 8502 is published on all interfaces, because it is the single upstream +# the guard chain dials: notebook.franksdank.cloud → anubis → authentik → +# http://:8017 → 192.168.0.143:8502 (dankclaw/Caddyfile), and this Caddy lives +# in root's storage, so a published port is the only way it can reach a rootless +# quadlet. The API needs no outward face at all — the image's own Next.js layer +# rewrites /api/* to the FastAPI beside it, so one origin serves UI and API — +# and the fleet's rule is that loopback-only is for services whose user path is +# the guard chain, which this became on 2026-10-19; 5056 stays published on +# loopback because the deployer's health gate dials it there. That is also why +# API_URL in scripts/open-notebook.env names the vhost rather than a port: left +# to itself the frontend auto-detects `:5055`, and the UI loads +# while every call fails. OPEN_NOTEBOOK_PASSWORD is not made redundant by the +# chain: it is what keeps the API honest for everything else on this host, and +# with it unset the API answers anyone, including the unauthenticated +# episode-audio download. # # VOLUMES. `/app/data` is a named volume: uploads, the LangGraph SQLite # checkpoints and the extraction caches the image keeps under it @@ -81,7 +85,7 @@ Pull=never Network=dankclaw_default Network=open-notebook.network PublishPort=0.0.0.0:8502:8502 -PublishPort=0.0.0.0:5056:5055 +PublishPort=127.0.0.1:5056:5055 EnvironmentFile=/home/dank/serverAppConfigs/scripts/open-notebook.env EnvironmentFile=/home/dank/Containers/open-notebook/env Volume=open-notebook-data:/app/data diff --git a/dankclaw/pihole/addn-hosts b/dankclaw/pihole/addn-hosts index 99ae03f..327082d 100644 --- a/dankclaw/pihole/addn-hosts +++ b/dankclaw/pihole/addn-hosts @@ -45,3 +45,4 @@ 192.168.0.143 pihole.franksdank.cloud 192.168.0.143 seerr.franksdank.cloud 192.168.0.143 marreta.franksdank.cloud +192.168.0.143 notebook.franksdank.cloud diff --git a/scripts/open-notebook.env b/scripts/open-notebook.env index ae8c764..d182e89 100644 --- a/scripts/open-notebook.env +++ b/scripts/open-notebook.env @@ -1,5 +1,6 @@ # open-notebook.env — configuration for **open-notebook**, the fleet's podcast -# generator on Dankclaw (UI host port **8502**, API host port **5056**). Read by +# generator on Dankclaw (user path https://notebook.franksdank.cloud; UI host +# port **8502**, API host port **5056 loopback-only**). Read by # BOTH Quadlet units (dankclaw/open-notebook/quadlet/) as the FIRST # EnvironmentFile= line, which is why every path and host name below is the # value the CONTAINER must see. @@ -73,15 +74,20 @@ SURREAL_PASS= OPEN_NOTEBOOK_ENCRYPTION_KEY= OPEN_NOTEBOOK_PASSWORD= -# WHERE THE BROWSER FINDS THE API. The UI auto-detects `:5055`, -# and 5055 on this host belongs to seerr, so the API leaves the container on 5056 -# and this line has to say so. Both origins are listed in CORS_ORIGINS because -# the API is called cross-origin from the UI's own port: the LAN origin is the -# normal one, the localhost one covers a browser opened on Dankclaw itself. The -# app logs a warning at start while CORS_ORIGINS is unset and defaults to `*`, -# which is a wildcard in front of an endpoint that serves every episode's audio. -API_URL=http://192.168.0.143:5056 -CORS_ORIGINS=http://192.168.0.143:8502,http://localhost:8502 +# WHERE THE BROWSER FINDS THE API. The guarded vhost +# (notebook.franksdank.cloud → Caddy → 8502) is the only user path, and the +# image's own Next.js layer rewrites `/api/*` to the FastAPI running beside it, +# so ONE origin serves UI and API and API_URL has to name that vhost. This is +# not cosmetic: unset, the app auto-detects `:5055` — the port +# upstream ships, and the one seerr holds on this host — and hands the browser +# an address nothing answers; pointed at the LAN `:5056` instead, every call +# leaves the guarded origin and an authenticated browser session does not ride +# it. Same-origin traffic needs no CORS at all, but CORS_ORIGINS stays set to +# the one origin because the app warns at start while it is unset and defaults +# to `*` — a wildcard in front of an endpoint that serves every episode's +# audio. No trailing slash: the UI concatenates `${API_URL}/api/...`. +API_URL=https://notebook.franksdank.cloud +CORS_ORIGINS=https://notebook.franksdank.cloud # Concurrency: see "THE CPU-SHUTDOWN GUARD" above. Defaults are 5 and 5. TTS_BATCH_SIZE=1 diff --git a/shared/homepage/services.json b/shared/homepage/services.json index fca56b0..229ef15 100644 --- a/shared/homepage/services.json +++ b/shared/homepage/services.json @@ -32,13 +32,13 @@ "name": "open-notebook", "category": "user", "section": "AI", - "description": "Podcast generator — notes in, spoken episode out: script written by frank-zealot, voices rendered by frank-tts. No public route; LAN UI only once it is deployed.", - "url": "", + "description": "Podcast generator — notes in, spoken episode out: script written by frank-zealot, voices rendered by frank-tts. Guarded twice: Authentik at the edge, then its own login.", + "url": "http://notebook.franksdank.cloud", "docs": "", "probe": "127.0.0.1:8502", "method": "http", - "wip": "down", - "showLink": false, + "wip": false, + "showLink": true, "art": "/img/apps/mono-on.svg" }, { diff --git a/tests/open_notebook.bats b/tests/open_notebook.bats index f105ebc..87a084f 100755 --- a/tests/open_notebook.bats +++ b/tests/open_notebook.bats @@ -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 :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 `: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