feat(open-notebook): guarded vhost, single origin, live homepage card #41
No files matched your search
@@ -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
|
||||
# `<host-you-opened>: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 ---
|
||||
|
||||
@@ -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
|
||||
# `<host-you-opened>: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 `<host-you-opened>: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
|
||||
|
||||
@@ -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
|
||||
+16
-10
@@ -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 `<host-you-opened>: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 `<host-you-opened>: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
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
{
|
||||
|
||||
+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