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:
@@ -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) ---
|
# --- Pi-hole (quadlet on slirp4netns; published 8083) ---
|
||||||
pihole.franksdank.cloud {
|
pihole.franksdank.cloud {
|
||||||
basicauth {
|
basicauth {
|
||||||
@@ -420,6 +439,16 @@ http://:8017 {
|
|||||||
handle @marreta {
|
handle @marreta {
|
||||||
reverse_proxy 192.168.0.143:8082
|
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 ---
|
# --- Optional: Jackett ---
|
||||||
|
|||||||
@@ -33,17 +33,21 @@
|
|||||||
# and 5055 is exactly the port seerr already holds on this host
|
# 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
|
# (`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.
|
# 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
|
# Only 8502 is published on all interfaces, because it is the single upstream
|
||||||
# `<host-you-opened>:5055`, so API_URL in scripts/open-notebook.env must name
|
# the guard chain dials: notebook.franksdank.cloud → anubis → authentik →
|
||||||
# the remapped port explicitly, or the UI loads and every call fails. Both
|
# http://:8017 → 192.168.0.143:8502 (dankclaw/Caddyfile), and this Caddy lives
|
||||||
# ports are published on all interfaces because the consumer is a browser on
|
# in root's storage, so a published port is the only way it can reach a rootless
|
||||||
# the LAN — the fleet's browser-facing services (open-webui, comfyui, gitea)
|
# quadlet. The API needs no outward face at all — the image's own Next.js layer
|
||||||
# bind the same way, while loopback-only is reserved for services whose user
|
# rewrites /api/* to the FastAPI beside it, so one origin serves UI and API —
|
||||||
# path is the guard chain. This service has NO vhost and no Caddy route: it is
|
# and the fleet's rule is that loopback-only is for services whose user path is
|
||||||
# internal-only until it is guarded, and its own password
|
# the guard chain, which this became on 2026-10-19; 5056 stays published on
|
||||||
# (OPEN_NOTEBOOK_PASSWORD) is what stands in front of it. That password is not
|
# loopback because the deployer's health gate dials it there. That is also why
|
||||||
# decoration: with it unset the API answers everyone on the LAN, including the
|
# API_URL in scripts/open-notebook.env names the vhost rather than a port: left
|
||||||
# unauthenticated episode-audio download.
|
# 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
|
# VOLUMES. `/app/data` is a named volume: uploads, the LangGraph SQLite
|
||||||
# checkpoints and the extraction caches the image keeps under it
|
# checkpoints and the extraction caches the image keeps under it
|
||||||
@@ -81,7 +85,7 @@ Pull=never
|
|||||||
Network=dankclaw_default
|
Network=dankclaw_default
|
||||||
Network=open-notebook.network
|
Network=open-notebook.network
|
||||||
PublishPort=0.0.0.0:8502:8502
|
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/serverAppConfigs/scripts/open-notebook.env
|
||||||
EnvironmentFile=/home/dank/Containers/open-notebook/env
|
EnvironmentFile=/home/dank/Containers/open-notebook/env
|
||||||
Volume=open-notebook-data:/app/data
|
Volume=open-notebook-data:/app/data
|
||||||
|
|||||||
@@ -45,3 +45,4 @@
|
|||||||
192.168.0.143 pihole.franksdank.cloud
|
192.168.0.143 pihole.franksdank.cloud
|
||||||
192.168.0.143 seerr.franksdank.cloud
|
192.168.0.143 seerr.franksdank.cloud
|
||||||
192.168.0.143 marreta.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
|
# 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
|
# BOTH Quadlet units (dankclaw/open-notebook/quadlet/) as the FIRST
|
||||||
# EnvironmentFile= line, which is why every path and host name below is the
|
# EnvironmentFile= line, which is why every path and host name below is the
|
||||||
# value the CONTAINER must see.
|
# value the CONTAINER must see.
|
||||||
@@ -73,15 +74,20 @@ SURREAL_PASS=
|
|||||||
OPEN_NOTEBOOK_ENCRYPTION_KEY=
|
OPEN_NOTEBOOK_ENCRYPTION_KEY=
|
||||||
OPEN_NOTEBOOK_PASSWORD=
|
OPEN_NOTEBOOK_PASSWORD=
|
||||||
|
|
||||||
# WHERE THE BROWSER FINDS THE API. The UI auto-detects `<host-you-opened>:5055`,
|
# WHERE THE BROWSER FINDS THE API. The guarded vhost
|
||||||
# and 5055 on this host belongs to seerr, so the API leaves the container on 5056
|
# (notebook.franksdank.cloud → Caddy → 8502) is the only user path, and the
|
||||||
# and this line has to say so. Both origins are listed in CORS_ORIGINS because
|
# image's own Next.js layer rewrites `/api/*` to the FastAPI running beside it,
|
||||||
# the API is called cross-origin from the UI's own port: the LAN origin is the
|
# so ONE origin serves UI and API and API_URL has to name that vhost. This is
|
||||||
# normal one, the localhost one covers a browser opened on Dankclaw itself. The
|
# not cosmetic: unset, the app auto-detects `<host-you-opened>:5055` — the port
|
||||||
# app logs a warning at start while CORS_ORIGINS is unset and defaults to `*`,
|
# upstream ships, and the one seerr holds on this host — and hands the browser
|
||||||
# which is a wildcard in front of an endpoint that serves every episode's audio.
|
# an address nothing answers; pointed at the LAN `:5056` instead, every call
|
||||||
API_URL=http://192.168.0.143:5056
|
# leaves the guarded origin and an authenticated browser session does not ride
|
||||||
CORS_ORIGINS=http://192.168.0.143:8502,http://localhost:8502
|
# 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.
|
# Concurrency: see "THE CPU-SHUTDOWN GUARD" above. Defaults are 5 and 5.
|
||||||
TTS_BATCH_SIZE=1
|
TTS_BATCH_SIZE=1
|
||||||
|
|||||||
@@ -32,13 +32,13 @@
|
|||||||
"name": "open-notebook",
|
"name": "open-notebook",
|
||||||
"category": "user",
|
"category": "user",
|
||||||
"section": "AI",
|
"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.",
|
"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": "",
|
"url": "http://notebook.franksdank.cloud",
|
||||||
"docs": "",
|
"docs": "",
|
||||||
"probe": "127.0.0.1:8502",
|
"probe": "127.0.0.1:8502",
|
||||||
"method": "http",
|
"method": "http",
|
||||||
"wip": "down",
|
"wip": false,
|
||||||
"showLink": false,
|
"showLink": true,
|
||||||
"art": "/img/apps/mono-on.svg"
|
"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"
|
ENVF="$REPO_ROOT/scripts/open-notebook.env"
|
||||||
PLANNER="$REPO_ROOT/scripts/deploy-plan.py"
|
PLANNER="$REPO_ROOT/scripts/deploy-plan.py"
|
||||||
REMOTE="$REPO_ROOT/scripts/deploy-remote.sh"
|
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
|
# 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.
|
# out once so the ordering test below compares against a real constant.
|
||||||
HOST_TUNABLES="EnvironmentFile=/home/dank/serverAppConfigs/scripts/open-notebook.env"
|
HOST_TUNABLES="EnvironmentFile=/home/dank/serverAppConfigs/scripts/open-notebook.env"
|
||||||
@@ -68,22 +69,58 @@ DB_PIN="sha256:e5d7387e3727bbe2f7cd6d869e48cd34777272258572ead37c9656b25f745723"
|
|||||||
! grep -qE '^PublishPort=.*:8000(:|$)' "$APP"
|
! 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 -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
|
# seerr already binds 127.0.0.1:5055 on this host, so the API cannot leave
|
||||||
# the container on the host port upstream uses.
|
# the container on the port upstream uses; and it needs no outward face at
|
||||||
publish="$(grep -E '^PublishPort=[^:]+:[0-9]+:5055$' "$APP")"
|
# all, because the image's Next.js layer rewrites /api/* to it. Loopback is
|
||||||
[ -n "$publish" ] || { echo "no PublishPort mapping to the API's 5055"; false; }
|
# where the deployer's health gate dials it.
|
||||||
mapping="${publish#PublishPort=}" # e.g. 0.0.0.0:5056:5055
|
grep -qxF 'PublishPort=127.0.0.1:5056:5055' "$APP"
|
||||||
host_port="${mapping#*:}"
|
# One origin serves UI and API, so API_URL names the guarded vhost rather
|
||||||
host_port="${host_port%%:*}"
|
# than a host:port. Left to itself the frontend auto-detects `<host>:5055`;
|
||||||
[ "$host_port" != "5055" ] || {
|
# a LAN-origin API_URL takes the browser around the guard chain, where its
|
||||||
echo "the API is published on host 5055, which seerr already holds"; false; }
|
# authenticated session does not ride.
|
||||||
# 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.
|
|
||||||
api_url="$(sed -n 's/^API_URL=//p' "$ENVF")"
|
api_url="$(sed -n 's/^API_URL=//p' "$ENVF")"
|
||||||
[ "$api_url" = "http://192.168.0.143:${host_port}" ] || {
|
vhost="$(onb_vhost)"
|
||||||
echo "API_URL ($api_url) does not name the published API port ($host_port)"; false; }
|
[ -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" {
|
@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"
|
grep -qF 'dankclaw:open-notebook|' "$REPO_ROOT/scripts/deploy.sh"
|
||||||
}
|
}
|
||||||
|
|
||||||
@test "the homepage card exists and stays hidden until the service is deployed" {
|
@test "the homepage card links the guarded vhost now that the service is live" {
|
||||||
python3 - "$REPO_ROOT/shared/homepage/services.json" <<'PY'
|
python3 - "$REPO_ROOT/shared/homepage/services.json" "$(onb_vhost)" <<'PY'
|
||||||
import json, os, sys
|
import json, os, sys
|
||||||
data = json.load(open(sys.argv[1]))
|
data = json.load(open(sys.argv[1]))
|
||||||
|
vhost = sys.argv[2]
|
||||||
cards = [s for s in data if s["name"] == "open-notebook"]
|
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)}"
|
assert len(cards) == 1, f"expected exactly one open-notebook card, got {len(cards)}"
|
||||||
card = cards[0]
|
card = cards[0]
|
||||||
assert card["category"] == "user" and card["section"] == "AI", card
|
assert card["category"] == "user" and card["section"] == "AI", card
|
||||||
assert card["probe"] == "127.0.0.1:8502", card["probe"]
|
assert card["probe"] == "127.0.0.1:8502", card["probe"]
|
||||||
assert card["method"] == "http"
|
assert card["method"] == "http"
|
||||||
# Not deployed yet: the card is reported wip-down and carries no link, so the
|
# Deployed and guarded: the card is live and its link is the vhost, so the
|
||||||
# page shows no dead button (the Seerr precedent).
|
# button on the page reaches the app through the guard chain. Card links carry
|
||||||
assert card.get("wip") == "down", card.get("wip")
|
# the fleet's http:// form everywhere; Caddy answers the redirect to HTTPS.
|
||||||
assert card.get("showLink") is False, card.get("showLink")
|
assert not card.get("wip"), f"card should be live, got wip={card.get('wip')!r}"
|
||||||
assert not card.get("url"), "a hidden card must not carry a url"
|
assert card.get("showLink") is True, card.get("showLink")
|
||||||
|
assert card.get("url") == f"http://{vhost}", card.get("url")
|
||||||
art = card["art"].lstrip("/")
|
art = card["art"].lstrip("/")
|
||||||
assert os.path.exists(os.path.join(os.path.dirname(sys.argv[1]), art)), art
|
assert os.path.exists(os.path.join(os.path.dirname(sys.argv[1]), art)), art
|
||||||
PY
|
PY
|
||||||
|
|||||||
Reference in New Issue
Block a user