From e5580abb6f359e11797e8cfb4b229c7097030753 Mon Sep 17 00:00:00 2001 From: Frank Date: Mon, 19 Oct 2026 16:13:47 -0700 Subject: [PATCH 1/2] feat(open-notebook): put the podcast generator behind the guard chain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 :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. --- dankclaw/Caddyfile | 29 +++++++ .../quadlet/open-notebook.container | 28 ++++--- dankclaw/pihole/addn-hosts | 1 + scripts/open-notebook.env | 26 +++--- shared/homepage/services.json | 8 +- tests/open_notebook.bats | 79 ++++++++++++++----- 6 files changed, 125 insertions(+), 46 deletions(-) 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 From 69739237c8e09ebb3cadfe32929bf9294f284e02 Mon Sep 17 00:00:00 2001 From: Frank Date: Mon, 19 Oct 2026 16:22:38 -0700 Subject: [PATCH 2/2] docs(open-notebook): record the deployed shape and what the first render proved MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The page described an undeployed service whose scriptwriter was going to be frank-zealot. Both halves are now false, and the reason is worth more than the correction. - Status, ports, coordinates: deployed 2026-10-19, guarded vhost, API published on loopback only. The first episode is the proof: 747 s of speech in 301 s of wall clock (2.5x real time, knobs at 1), download route 200 with the bearer and 401 without. - New section for the guard chain: provider-before-route, one upstream because the image rewrites /api/* itself, and the API_URL law — autodetect appends :5055, which is seerr's port on this host, so an unset API_URL yields a UI that loads and an app that cannot call itself. - Provisioned records table (credentials, model rows, dank-duo, dank-daily) and the two contract traps measured on the live API: generate takes profile NAMES, and the fleet has no /v1/embeddings, so every note save fails its embed_note command while renders from content work fine. - Why frank-archon writes the scripts: frank-zealot is reachable from this netns only at 169.254.1.2, and open-notebook's URL guard refuses to store a link-local base URL with no override. Census lands on [[Container Network Namespaces]], which also gains the root-vs-rootless publish rule — a root-published port is invisible to a rootless container, so "publish the port" was the wrong remedy as written there. - Frank TTS: the request's model field is ignored; only voice selects a pack. Tests: ./tests/run.sh, 455 passed. --- scripts/open-notebook.env | 18 +++-- .../Container Network Namespaces.md | 35 ++++++-- serverAppConfigs-docs/Dankclaw/Frank TTS.md | 2 +- .../Dankclaw/Open Notebook.md | 81 +++++++++++++------ 4 files changed, 100 insertions(+), 36 deletions(-) diff --git a/scripts/open-notebook.env b/scripts/open-notebook.env index d182e89..2c2b440 100644 --- a/scripts/open-notebook.env +++ b/scripts/open-notebook.env @@ -48,13 +48,17 @@ # # THE SCRIPTWRITER. ESPERANTO_LLM_TIMEOUT is the app's own default (180 s), kept # visible beside the TTS number because both belong to one podcast run: the -# outline call and the transcript call are the slow text work, and frank-zealot -# answers them well inside 180. Provider choice is made in the UI, not here — -# see [[Open Notebook]] for the one-time credential provisioning — and it is -# frank-zealot on purpose: script writing asks for a JSON-schema response, which -# the OpenAI-compatible llama.cpp endpoint is known to serve, while halogen's -# structured-output behaviour on frank-dragoon is UNVERIFIED. halogen remains -# the unproven alternative, not the fallback. +# outline call and the transcript call are the slow text work, and the endpoint +# provisioned today answers them well inside 180. The provider itself is chosen +# in the database, not here — [[Open Notebook]] owns that provisioning and the +# address census behind it. Short version: frank-zealot was the plan (script +# writing asks for a JSON-schema response, which llama.cpp serves and halogen's +# behaviour on frank-dragoon is UNVERIFIED), but no Dankclaw LLM is dialable from +# this container's netns, and the single address that reaches one — +# `host.containers.internal`, 169.254.1.2 — the app refuses to store at all, +# because link-local is a blocked address class. The writer is therefore +# frank-archon (`http://192.168.0.235:7770/v1`), the only fleet LLM that both +# answers from inside this netns and passes that check. # # THE DATABASE COORDINATES. SURREAL_URL addresses the sibling container BY NAME # on systemd-open-notebook (container DNS is scoped to the owning network, so diff --git a/serverAppConfigs-docs/Architecture/Container Network Namespaces.md b/serverAppConfigs-docs/Architecture/Container Network Namespaces.md index e69fa7a..954686d 100644 --- a/serverAppConfigs-docs/Architecture/Container Network Namespaces.md +++ b/serverAppConfigs-docs/Architecture/Container Network Namespaces.md @@ -88,8 +88,29 @@ Verified outcomes of that recipe on 2026-09-02: from inside `hermes-dashboard` published) and `:9119` (its own published port) connect; `:8080` and `:8787` are **refused instantly** even though both listeners are bound to `0.0.0.0` in the host netns. Probes to `host.containers.internal` (`169.254.1.2`) from that -network time out rather than refuse — the alias is an address on the *default* -`podman` network, and this container is not on it. +network time out rather than refuse — but see the 2026-10-19 re-measure below, +where the same alias answers from another `dankclaw_default` container. + +### 2026-10-19: which published ports a rootless container can actually dial + +Probed from inside `open-notebook` (rootless, on `dankclaw_default` **and** the +private `systemd-open-notebook`), each address aimed at a live listener: + +| Dial | Result | Mechanism | +|---|---|---| +| `192.168.0.143:3000` (Gitea) | **200** | published by a **rootless** container — rootless egress crosses the host netfilter where netavark installed that DNAT | +| `192.168.0.143:7770` (`frank-dragoon`) | refused | published by a **root** container. Root and rootless Podman carry separate netavark rule sets, so a rootless container's traffic never crosses root's and a root-published port is invisible to it | +| `192.168.0.143:7771` (`frank-zealot`) | refused | host-netns listener, unpublished — the rule above, reconfirmed | +| `10.89.1.1:7771` | refused | the bridge gateway address belongs to the rootlesskit netns, not to the host | +| `host.containers.internal:7771` (`169.254.1.2`) | **200** | the gateway alias is the one address that carries a rootless container to a host-netns listener. Why `hermes-dashboard` timed out on this alias in 2026-09-02 while `open-notebook` answers today is **not established**; the difference to check first is the second network and where the default route lands | +| `192.168.0.235:7770` (`frank-archon`) | **200** | a different machine: plain routing, no DNAT in the path | + +The consequence that changes designs: **a rootless container's menu of local model +endpoints is `frank-tts` (container DNS, same network) and `frank-archon` (LAN +address on another host).** Both Dankclaw endpoints are out of reach — +`frank-dragoon` behind root's publish, `frank-zealot` behind the host netns — and +the only address that does reach them, `169.254.1.2`, is link-local, which some +applications refuse to store at all ([[Open Notebook]] meets exactly that refusal). ## Operational rules @@ -98,9 +119,13 @@ network time out rather than refuse — the alias is an address on the *default* host — and never the reverse either: the deployer's health probe runs on the host, so it sees host-netns listeners that the container itself cannot dial. - To give a bridge-net container access to a host-netns listener, the options - are (a) move it to `Network=host`, or (b) publish the needed port. Option (b) - makes that port reachable from every netns and from the LAN — an exposure - change that needs operator approval before deployment, not a silent fix. + are (a) move it to `Network=host`, or (b) publish the needed port — but + publishing is advertised **only inside the Podman that owns the container**: + a root container's published port stays invisible to a rootless consumer + (2026-10-19 table above), so the fix must be made on the same root/rootless + side as the consumer. Either way it is an exposure change — a published port + also answers on the LAN — and needs operator approval before deployment, not + a silent fix. - `chrome-devtools-protocol` is intentionally `Network=host` with a loopback-only bind so the browser tool stays unreachable from the LAN; do not "tidy" it onto a bridge network — its two consumers (gateway, webui) are host-netns and reach diff --git a/serverAppConfigs-docs/Dankclaw/Frank TTS.md b/serverAppConfigs-docs/Dankclaw/Frank TTS.md index 06dabe9..878d43e 100644 --- a/serverAppConfigs-docs/Dankclaw/Frank TTS.md +++ b/serverAppConfigs-docs/Dankclaw/Frank TTS.md @@ -32,7 +32,7 @@ render needs the app installed, not this endpoint. | --- | --- | | Host | Dankclaw (`192.168.0.143`), rootless podman, user unit `frank-tts.service` | | Port | **7772**, published (`PublishPort=7772:7772`) — the rest of the `frank-*` port family is in [[Dankclaw Stack]] | -| Routes | `GET /health`, `GET /v1/audio/voices`, `POST /v1/audio/speech`; everything else 404, and speech is POST-only | +| Routes | `GET /health`, `GET /v1/audio/voices`, `POST /v1/audio/speech`; everything else 404, and speech is POST-only. The request's `model` field is **ignored** (measured 2026-10-19: `voxcpm2` and `tts-1` both synthesize the same way) — the voice comes solely from `voice`, so a consumer that must name a model may name it anything | | Image | `localhost/franksdank/frank-tts:latest`, built from `dankclaw/voiceservice/Containerfile`, `Pull=never` | | Networks | `dankclaw_default` first (it owns the default route and the consumers), then `frank-tts-engine.network` (10.89.5.0/24, the bridge to the engine) | | Voice packs | `/home/dank/voices` mounted read-only at `/voices` — host state, never repo state | diff --git a/serverAppConfigs-docs/Dankclaw/Open Notebook.md b/serverAppConfigs-docs/Dankclaw/Open Notebook.md index c9911f1..3ed3fa4 100644 --- a/serverAppConfigs-docs/Dankclaw/Open Notebook.md +++ b/serverAppConfigs-docs/Dankclaw/Open Notebook.md @@ -6,25 +6,26 @@ tags: - open-notebook - podcasts - quadlet -timestamp: 2026-10-18 +timestamp: 2026-10-19 --- # Open Notebook -open-notebook ([lfnovo/open-notebook](https://github.com/lfnovo/open-notebook)) is the notebook layer that turns existing notes and sources into a **spoken episode**: an outline, then a dialogue transcript, then one synthesized clip per speaker turn, concatenated with ffmpeg into an MP3. In the fleet it is the consumer that closes the loop Frank's audio stack was built for — the script is written by **frank-zealot**, the voices are rendered by **frank-tts**, and nothing leaves the LAN. +open-notebook ([lfnovo/open-notebook](https://github.com/lfnovo/open-notebook)) is the notebook layer that turns existing notes and sources into a **spoken episode**: an outline, then a dialogue transcript, then one synthesized clip per speaker turn, concatenated with ffmpeg into an MP3. In the fleet it is the consumer that closes the loop Frank's audio stack was built for — the script is written by **frank-archon**, the voices are rendered by **frank-tts**, and nothing leaves the LAN. -**It is NOT deployed.** This page documents repo-owned configuration — the Quadlet units, the env file, the deployer path, the homepage card — and the contract those files were written against. On 2026-10-18 no `open-notebook` or `open-notebook-surrealdb` container exists on Dankclaw (`podman ps -a` census, 2026-10-18), and the homepage card ships `wip: "down"` with `showLink: false`, so the card is hidden and reported `wip-down`. Re-censused 2026-10-19 and unchanged: no `*notebook*` unit in `systemctl --user list-unit-files`, nothing installed under `~/.config/containers/systemd/` by that name, no container in `podman ps` — the repo's three quadlet files have never been installed on the host. Deploying it is a deliberate next step, not a pending one. +**Deployed 2026-10-19.** Both units installed and `active`, both containers healthy, SurrealDB migrated to v25 on first boot, and the first episode rendered end to end: a note in, an outline, 23 dialogue turns, 23 clips, and one **747-second MP3 on the host bind, produced in 301 seconds** — about 2.5× real time with every concurrency knob at 1. The download route answers `200 audio/mpeg` with the bearer token and **401 without it**. It is guarded like every other user service — `https://notebook.franksdank.cloud`, Anubis → Authentik → the app's own login — and the API is no longer published on the LAN at all. -**Host:** Dankclaw (rootless Podman, user `dank`) — **Ports:** 8502 UI and 5056 API, published on the LAN, **no vhost and no Caddy route** — **Deployer:** `open-notebook` (one service, two units) — **Docs:** https://github.com/lfnovo/open-notebook +**Host:** Dankclaw (rootless Podman, user `dank`) — **User path:** `https://notebook.franksdank.cloud` (guarded) — **Published:** `0.0.0.0:8502` (the only upstream Caddy dials) and `127.0.0.1:5056` (the deployer's health gate, nothing else) — **Deployer:** `open-notebook` (one service, two units) — **Docs:** https://github.com/lfnovo/open-notebook -## Facts (verified 2026-10-18 against the pinned images, upstream source, and the live host) +## Facts (images and upstream source verified 2026-10-18; live-host columns re-read 2026-10-19 after deploy) | Item | Value | |------|-------| | App image | `docker.io/lfnovo/open_notebook:v1-latest@sha256:54b2025a4a4853c188f74b9b8246eca8c5480e5dd1babfa92db1038ff2f30212` (`open-notebook.container:79`) — the tag upstream's own `docker-compose.yml:27` pins for the multi-container shape. `v1-latest` is a moving tag, so the manifest-list digest (resolved from the registry 2026-10-18) is what makes this reproducible | | Database image | `docker.io/surrealdb/surrealdb:v2@sha256:e5d7387e3727bbe2f7cd6d869e48cd34777272258572ead37c9656b25f745723` (`open-notebook-surrealdb.container:66`) — that digest is **SurrealDB 2.7.0** (image built 2026-09-23; measured by running the digest: `surreal version`) | | Do NOT use | the `-single` tags (`v1-latest-single`). That variant bundles SurrealDB inside the app image and is deprecated upstream; here it would give the host a second, hidden database | -| Ports | container 8502 (Next.js UI) + 5055 (FastAPI API) → published `0.0.0.0:8502:8502` and `0.0.0.0:5056:5055` (`open-notebook.container:83-84`). **Host 5055 is taken**: seerr publishes `127.0.0.1:5055` (`dankclaw/seerr/quadlet/seerr.container`, and `ss -ltn` on Dankclaw shows `127.0.0.1:5055` live, 2026-10-18). The API therefore leaves on 5056, which is what forces `API_URL` (below) | +| Ports | container 8502 (Next.js UI) + 5055 (FastAPI API) → published `0.0.0.0:8502:8502` and `127.0.0.1:5056:5055` (`open-notebook.container`). **Host 5055 is taken**: seerr publishes `127.0.0.1:5055` (`dankclaw/seerr/quadlet/seerr.container`, and `ss -ltn` on Dankclaw shows it live). The API therefore leaves on 5056, and on loopback only: the vhost is its sole user path and the image's own Next.js layer rewrites `/api/*` to it, so nothing outside the container needs it | +| User path | `https://notebook.franksdank.cloud` → `anubis:8923` → post-auth listener `@notebook` → `192.168.0.143:8502` (`dankclaw/Caddyfile`); Authentik provider `notebook-proxy` (`external_host` the vhost with its trailing slash, `internal_host http://caddy:8017`); LAN record in `dankclaw/pihole/addn-hosts` | | Database port | 8000 inside its own namespace, **never published** (`open-notebook-surrealdb.container` has no `PublishPort`). Upstream publishes `127.0.0.1:8000` for debugging (`docker-compose.yml:18`); this unit does not, and the seam asserts that it stays that way | | Data | `open-notebook-data:/app/data` (named volume: uploads, LangGraph SQLite checkpoints, extraction caches) **plus** the interim host bind `/home/dank/media/podcasts:/app/data/podcasts` (`open-notebook.container:87-88`); database in the `open-notebook-db` named volume | | Networks | app: `dankclaw_default` first, then the private `systemd-open-notebook` (10.89.4.0/24, `open-notebook.network:33`); database: the private bridge only. Order is load-bearing — the generated `--network` flags keep file order, so the default route rides the network where `host.containers.internal` is proven | @@ -33,6 +34,17 @@ open-notebook ([lfnovo/open-notebook](https://github.com/lfnovo/open-notebook)) | Env | `scripts/open-notebook.env` (repo, knobs) then `/home/dank/Containers/open-notebook/env` (host, secrets, mode 600) — two `EnvironmentFile=` lines per unit, repo file **first** because the last file wins | | Deployer | `deploy_open_notebook` in `scripts/deploy-remote.sh`: seed secrets → ensure both digest pins are in local storage → install both units + the network → `mkdir -p /home/dank/media/podcasts` → restart database, wait for its container health → restart app → `wait_for_health open-notebook http://127.0.0.1:5056/health 180` | +## The guard chain, and the single origin + +`notebook.franksdank.cloud` follows the fleet's exposure policy in this order, and the order is the law ([[Network Architecture]]): + +1. **Provider first.** Authentik proxy provider `notebook-proxy` existed before any route pointed at the app: `external_host https://notebook.franksdank.cloud/` (exact vhost, trailing slash), `internal_host http://caddy:8017`, the three flows copied from `embedded-outpost`, Application `Open Notebook` (`notebook-guard`), and the provider attached to the embedded outpost's provider set. Flip the vhost first and authenticated traffic lands on the outpost's error page. Verify after the change that the outpost logged no `Failed to fetch outpost from API` (the null-flow wedge) and that an already-guarded vhost still answers. +2. **Then the route, in one deploy:** the guarded site block (`reverse_proxy anubis:8923` with `header_up X-Real-Ip {remote_host}` — the whole request, so no path ever reaches the app unchallenged) plus the post-auth `@notebook` handler on `http://:8017`, plus the LAN record in `dankclaw/pihole/addn-hosts`. The public Porkbun A record is a host action, not a repo file. +3. **One upstream, not two.** `reverse_proxy 192.168.0.143:8502`: the image's own Next.js layer rewrites `/api/*` to the FastAPI beside it (`next.config.ts`, `INTERNAL_API_URL` default `http://localhost:5055`), so the API needs no route and no outward face — hence `127.0.0.1:5056`. Caddy dials the published LAN port rather than a container name because this Caddy lives in root's Podman and cannot see a rootless container at all ([[Container Network Namespaces]]). SSE (`/api/search/ask`, chat) streams through the same bare proxy that carries OpenWebUI's chat at `ai.franksdank.cloud`; no `flush_interval` or timeout directive is needed, and adding a route timeout is precisely what would cut it. +4. **`API_URL` must name the vhost.** `GET /config` (the Next route) answers in priority order: `API_URL` env → autodetect from `Host` + `X-Forwarded-Proto` → `http://localhost:5055`. Autodetect appends **`:5055`**, a port that belongs to seerr on this host, so leaving `API_URL` unset hands the browser an address nothing answers and every call fails under a UI that loaded fine. `CORS_ORIGINS` is set to the same single origin only to keep the app from defaulting to `*`. `scripts/open-notebook.env` carries both. + +The app's own login stays on behind the chain. It is not redundant: publishing the API on loopback does **not** hide it from this host's other containers, which can dial `open-notebook:5055` by name on `dankclaw_default`. The chain decides *who from outside* gets in; `OPEN_NOTEBOOK_PASSWORD` decides what anything that already reached the port may do. + ## What the image actually runs One container, three processes under supervisord (`supervisord.conf` upstream): `uvicorn api.main:app --port 5055` (`:12`), the job worker (`:25`), and the Next.js frontend (`:39`). Two consequences the config depends on: @@ -44,25 +56,41 @@ Also from the image itself: `WORKDIR /app` and `EXPOSE 8502 5055` (`Dockerfile:8 `DATA_FOLDER` is hard-coded `"./data"` (`open_notebook/config.py:5`) and `PODCASTS_FOLDER` is `"./data/podcasts"` (`:19`); the database stores each episode's audio path **relative** to that folder (`episodes//audio/.mp3`), and the download route is `GET /api/podcasts/episodes/{id}/audio`. That is why covering one subdirectory with a bind mount is sufficient — nothing in the database has to change if the mount moves. -## Provisioning the providers (after first boot; UI or REST, never a unit) +## What is provisioned (REST, 2026-10-19) -Credentials are stored in the database, encrypted, and are **not expressible in env or a Quadlet file**. The sequence is one-time, in **Manage → Models**, and both entries use provider type **OpenAI Compatible** (upstream: any server that implements `/v1/audio/speech` works this way — `docs/5-CONFIGURATION/local-tts.md:5`). +Credentials live in the database, encrypted with `OPEN_NOTEBOOK_ENCRYPTION_KEY`, and are **not expressible in env or a Quadlet file** — the UI (**Manage → Models**) or the REST API are the only doors. Both use provider type `openai_compatible`, because any server that answers `/v1/audio/speech` or `/v1/chat/completions` works through it (`docs/5-CONFIGURATION/local-tts.md:5`). -1. **TTS credential** — base URL **`http://frank-tts:7772/v1`**. The `/v1` is part of the URL: the client posts to `/audio/speech`, so a base URL without `/v1` 404s on every dialogue line (`local-tts.md:72-80`, the base-URL table, always carries `/v1`). `frank-tts` resolves by container name because the app is attached to `dankclaw_default`, the network frank-tts's unit joins (`dankclaw/voiceservice/quadlet/frank-tts.container:51-52`). If frank-tts is ever moved off that network, the published-port form is `http://192.168.0.143:7772/v1` — a published port is dialable at the host's LAN address from any netns. The API-key field stays empty; frank-tts does not authenticate. -2. **LLM credential** — base URL **`http://host.containers.internal:7771/v1`**, model row `frank-zealot`. frank-zealot is a **host process** on `0.0.0.0:7771` (`ss -ltn` on Dankclaw shows the listener with no container owning it, 2026-10-18), and on this netavark host a bridge container reaches the host's LAN IP only at a *published* port — which is why Prometheus scrapes the same endpoint as `host.containers.internal:7771` (`shared/prometheus/prometheus.yml:89-111`, "not their own LAN addresses"). See [[Container Network Namespaces]]. `http://192.168.0.143:7771/v1` is the variant to try only if the gateway name ever stops resolving; it is expected to be refused from a bridge netns. -3. **Model rows are created by name.** Discovery is optional (see the next section): type the model id and confirm the add. One base URL per credential, so the TTS and LLM credentials are necessarily two separate configurations. +| Record | Value | +|--------|-------| +| Credential `frank-tts` | modality `text_to_speech`, base URL `http://frank-tts:7772/v1`, no API key (the shim does not authenticate) | +| Credential `frank-archon` | modality `language`, base URL `http://192.168.0.235:7770/v1` | +| Model row `voxcpm2` | type `text_to_speech` on the frank-tts credential. The name is decorative: frank-tts ignores the request's `model` field and synthesizes whatever `voice` names ([[Frank TTS]]) | +| Model row `frank-archon` | type `language`; `PUT /api/models/defaults` points chat, transformation and large-context at it. **The embedding default stays null — nothing in the fleet serves `/v1/embeddings`** | +| Speaker profile `dank-duo` | two speakers, Frank → `voice_id: narrator1`, Comrade → `narrator2`, `voice_model` = the TTS row | +| Episode profile `dank-daily` | `num_segments: 4`, `speaker_config` = `dank-duo`, outline and transcript LLM = the archon row, briefing that ends "Never invent numbers." | -**Verify reachability before saving**, from inside the container, because both addresses are netns-dependent: +Order that works over REST: credentials → model rows (`POST /api/models` with `credential: `) → `PUT /api/models/defaults` → speaker profile (`voice_model: `) → episode profile (`speaker_config` / `outline_llm` / `transcript_llm`). `POST /api/models/{id}/test` proves each row: the language row answered with a real completion from frank-archon; the TTS row reached frank-tts and came back `unknown voice 'alloy'; available voices: narrator1, narrator2, narrator3` — the app's own test path hard-codes an OpenAI voice, so that message *is* the proof the leg works. + +**`POST /api/podcasts/generate` takes profile NAMES, not record ids.** `episode_profile: "dank-daily"` works; the `episode_profile:…` id returns a bare `500 Failed to submit podcast generation job`, and the actual reason (`Episode profile '' not found`) appears only in `podman logs open-notebook`. + +**The scriptwriter is frank-archon, not the planned frank-zealot, because two rules collide on the address.** Measured from inside the app's netns 2026-10-19: + +- `192.168.0.143:7771` (frank-zealot, a host-netns listener) and `192.168.0.143:7770` (frank-dragoon, a **root**-published port) both refuse the connection; `host.containers.internal:7771` answers. Full census: [[Container Network Namespaces]]. +- The app refuses to **store** that one working address: its URL guard rejects any base URL that resolves inside link-local `169.254.0.0/16` (`open_notebook/utils/url_validation.py`, "Link-local addresses are not allowed (metadata endpoints)"), unconditionally — no env var touches it, and `AddHost=` cannot dodge it because the check runs on the resolved address. + +`http://192.168.0.235:7770/v1` — another machine, plain RFC1918 — is the only fleet LLM address that passes both tests. Cost: frank-archon runs `--max-num-seqs 2` and the operator session itself lives there, so a render is a slow-down for whoever else is talking to that endpoint, not a stall; `ESPERANTO_LLM_TIMEOUT=180` covers an outline or transcript call comfortably. The ways out, if the contention ever matters: move frank-zealot's listener into a rootless container (container DNS or a rootless-published port then become dialable), or measure halogen's structured-output behaviour on frank-dragoon and use it ([[frank-dragoon]] — currently UNVERIFIED for that call shape, so never the fallback by assumption). + +**No embedding model exists anywhere in the fleet, and saving a note is where that shows up.** Every note save submits an `embed_note` command that fails in the worker with `No embedding model configured. Please configure one in the Models section.` The note itself is stored and readable; semantic search, source ingestion and notebook-context questions stay dead until something serves `/v1/embeddings`. Podcast generation from `content` does not need embeddings, which is why renders work today. Closing the gap needs a new endpoint (a llama.cpp slot with `--embeddings` and a small embedding model), not a change here. + +**Secrets never go in a command line.** The API's bearer token *is* `OPEN_NOTEBOOK_PASSWORD`, and a `-H "Authorization: Bearer …"` argument is world-readable in `/proc//cmdline`; put it in a mode-600 curl config and pass `-K`: ```bash -podman exec open-notebook curl -sS http://frank-tts:7772/health -podman exec open-notebook curl -sS http://host.containers.internal:7771/v1/models +PW=$(grep -m1 '^OPEN_NOTEBOOK_PASSWORD=' /home/dank/Containers/open-notebook/env | cut -d= -f2- | tr -d '"') +cfg=$(mktemp); chmod 600 "$cfg"; printf 'header = "Authorization: Bearer %s"\n' "$PW" > "$cfg" +curl -s -K "$cfg" http://127.0.0.1:5056/api/models | head -c 400 +rm -f "$cfg" ``` -**The blocker moved; it did not vanish.** frank-tts and frank-tts-engine are **deployed** (2026-10-18) and answer on 7772, so the voice leg works end to end — `podman exec open-notebook curl -sS http://frank-tts:7772/health` is the check to run the day the app exists. What still blocks a render is open-notebook itself: not installed on this host yet. When it is, the two provider rows above are the only wiring, and the seeded speaker profiles carrying OpenAI voice names are the first trap. - -**Why frank-zealot and not frank-dragoon:** podcast scriptwriting asks the model for a JSON-schema response (with a plain-JSON fallback). The decision rests on each endpoint's documented request surface ([[frank-zealot]], [[frank-dragoon]]): zealot's is a known-good target for that call shape, dragoon's structured-output behaviour is **unverified**, so it is the unproven alternative, not the fallback. `ESPERANTO_LLM_TIMEOUT=180` (the app's own default) covers an outline or transcript call on zealot comfortably; upstream's note is to keep it under 600 so a slow call fails with a clear error instead of a UI timeout. - ## Speakers and voices: 1–4, and the IDs are free text A **speaker profile** carries one to four speakers (`docs/3-USER-GUIDE/creating-podcasts.md:27`), each with a personality and a **Voice ID that is typed exactly and passed through to the TTS request untouched** (`:29`) — there is no whitelist, no enum, no validation against a voice list. So the fleet's voice-pack names work verbatim: @@ -92,9 +120,11 @@ Defaults are `TTS_BATCH_SIZE=5` and `OPEN_NOTEBOOK_WORKER_MAX_TASKS=5` (`docs/5- The clock next to it: `ESPERANTO_TTS_TIMEOUT` (`:91`) is patience **per dialogue line**, not per episode — a three-minute episode is dozens of separate POSTs, and the tail of a long turn on a CPU engine goes past upstream's 300 s default. It is set to **660, one round-trip outside `SHIM_TIMEOUT=600`** in `scripts/frank-tts.env` (frank-tts's own per-request ceiling): nested budgets go inside-out — the same rule that file states for `LLAMA_TIMEOUT` — so a frank-tts give-up arrives labelled as frank-tts's error instead of the worker's own timeout, and re-measuring frank-tts cannot silently strand this number below it. An abandoned request does not cancel the engine process; it only strands the episode in "generating". +What the guard costs, measured on that first render (the deployed shape): 23 dialogue clips, one POST each, **10–11 s per clip** with both knobs at 1 → 301 s of wall clock for 747 s of speech, i.e. **2.5× real time**, box still responsive. That ratio is the budget to hold any future TTS engine to: halve the per-clip time, halve the episode. + ## Secrets, and the difference between blank and unset -`OPEN_NOTEBOOK_PASSWORD` unset means **authentication off** (`environment-reference.md:46`) — every API route, including the episode-audio download, answers anyone who can reach the published port. That is a real exposure on a LAN-published service with no vhost and no guard chain in front of it, which is why `deploy_open_notebook` refuses to start either unit unless the seeded host file carries a value for all four secret keys (`OPEN_NOTEBOOK_ENCRYPTION_KEY`, `OPEN_NOTEBOOK_PASSWORD`, `SURREAL_PASSWORD`, `SURREAL_PASS`). `OPEN_NOTEBOOK_ENCRYPTION_KEY` is separately load-bearing: it is **required to save any provider credential**, and changing it later makes stored keys undecryptable (`:44`). +`OPEN_NOTEBOOK_PASSWORD` unset means **authentication off** (`environment-reference.md:46`) — every API route, including the episode-audio download, answers anyone who can reach it. Moving the API to loopback did not remove that exposure, it only removed the LAN from it: any container on `dankclaw_default` dials `open-notebook:5055` by name, so an unset password is an open episode library to every container on this host, guarded or not. That is why `deploy_open_notebook` refuses to start either unit unless the seeded host file carries a value for all four secret keys (`OPEN_NOTEBOOK_ENCRYPTION_KEY`, `OPEN_NOTEBOOK_PASSWORD`, `SURREAL_PASSWORD`, `SURREAL_PASS`). `OPEN_NOTEBOOK_ENCRYPTION_KEY` is separately load-bearing: it is **required to save any provider credential**, and changing it later makes stored keys undecryptable (`:44`). The tracked file declares the secrets **empty** and the host file supplies them; podman applies `--env-file` arguments in order and the last one wins (verified against the generated `ExecStart` line, 2026-10-18). Reordering the two `EnvironmentFile=` lines in a unit would blank every credential — that is the trap the seam's ordering assertion exists for. @@ -102,7 +132,7 @@ The database has the same trap in a different spelling: the app reads `SURREAL_P ## Episode destination: interim, and reversible (issue #7) -**Episode files currently exist in two places by design.** The app's own state stays in the `open-notebook-data` named volume; finished audio additionally lands on the host, because `/home/dank/media/podcasts` is bind-mounted over `/app/data/podcasts` (`open-notebook.container:88`). That is the **interim** answer to where episodes live: the destination is an open decision tracked as **Gitea issue #7, labelled `human-only` — agents must not work it.** Until Frank settles it, episode MP3s are ordinary files in his home directory, readable without `podman unshare`, which the database volume would require (measured with the pinned SurrealDB image). +**Episode files currently exist in two places by design.** The app's own state stays in the `open-notebook-data` named volume; everything the render produces additionally lands on the host, because `/home/dank/media/podcasts` is bind-mounted over `/app/data/podcasts` (`open-notebook.container:88`). The first render confirmed the layout: `episodes//clips/0001.mp3 … 00NN.mp3` (one file per dialogue line, written as the worker goes) and `episodes//audio/.mp3` (the ffmpeg concatenation). A half-finished render is therefore visible and greppable from the host without `podman unshare`. That is the **interim** answer to where episodes live: the destination is an open decision tracked as **Gitea issue #7, labelled `human-only` — agents must not work it.** Until Frank settles it, episode MP3s are ordinary files in his home directory. Reversibility is one line: delete the `Volume=/home/dank/media/podcasts:/app/data/podcasts` line and the episodes go back to living solely inside the named volume. Nothing else in the config assumes the bind. See [[Storage Policy]] for the repo's storage conventions this interim state deliberately bends. @@ -118,10 +148,11 @@ podman pull docker.io/lfnovo/open_notebook:v1-latest@sha256:54b2025a4a4853c188f7 ## Rollback -- **Nothing deployed (today's state):** revert the commits. There is no host state to undo — no unit, no container, no volume, no `/home/dank/media/podcasts` directory beyond what the deployer would create. -- **Deployed:** `systemctl --user stop open-notebook.service open-notebook-surrealdb.service`, then move the three files (`open-notebook.container`, `open-notebook-surrealdb.container`, `open-notebook.network`) out of `~/.config/containers/systemd/` into `~/.AGENTTRASH/` — never `rm` — and `systemctl --user daemon-reload`. The generated units disappear with their sources. +- **Take it off the air, in the reverse of the deploy order:** delete the guarded site block and the `@notebook` handler from `dankclaw/Caddyfile` and deploy `caddy`; then in Authentik delete the `notebook-guard` Application and the `notebook-proxy` provider and drop it from the embedded outpost's provider set. A **route with no provider 502s** while a provider with no route is inert — that asymmetry is why the chain unwinds Caddy-first. +- **Stop the app:** `systemctl --user stop open-notebook.service open-notebook-surrealdb.service`, then move the three files (`open-notebook.container`, `open-notebook-surrealdb.container`, `open-notebook.network`) out of `~/.config/containers/systemd/` into `~/.AGENTTRASH/` — never `rm` — and `systemctl --user daemon-reload`. The generated units disappear with their sources. - **Kept on purpose:** the `open-notebook-data` and `open-notebook-db` named volumes and `/home/dank/media/podcasts`. Episode audio and the notebook database are user data; removing them is a separate, deliberate act, and episode files are host files precisely so they can outlive the container. -- **Homepage:** the card is `wip: "down"` + `showLink: false`, so retiring the service needs no card surgery beyond setting it back to that state. +- **Provider credentials are database rows**, encrypted with `OPEN_NOTEBOOK_ENCRYPTION_KEY`; no repo file mirrors them. Regenerating that key while the database volume survives makes every stored credential undecryptable and forces re-entry — so a rollback that keeps the volume keeps the key file with it. +- **Homepage:** the card test in `tests/open_notebook.bats` pins the live state (`showLink: true`, link = the vhost), so retiring the service edits that test in the same commit as the card flip back to `wip: "down"`. ## Citations @@ -130,3 +161,7 @@ podman pull docker.io/lfnovo/open_notebook:v1-latest@sha256:54b2025a4a4853c188f7 - Registry (2026-10-18): `lfnovo/open_notebook` tags (`v1-latest`, `latest`, the `-single` set) and manifest-list digests; `surrealdb/surrealdb` `v2` → 2.7.0 - Local measurements (2026-10-18, workstation, pinned digests): `surreal start --help` (credential and bind options carry `[env: SURREAL_*]`); running the pinned SurrealDB digest with env-only credentials (credential init in the log, correct login authenticates, wrong password refused, `/surreal is-ready` → `OK` exit 0, named volume owned by the mapped uid); the v1-compatible Quadlet generator dry-run of all three unit files (two `--env-file` in order, two `--network` in order, named volume + host bind, both health probes, `--pull never`) - Host evidence (Dankclaw, 2026-10-18, read-only): `ss -ltn` (`127.0.0.1:5055` held by seerr, `0.0.0.0:7771` a host listener, nothing on 7772/8502/5056/8000), `podman ps -a` (no open-notebook container, `dankclaw_default` present), subnet registry from `dankclaw/open-webui/quadlet/open-webui.network` +- Deploy + provisioning (Dankclaw, 2026-10-19): first-boot log — migrations 1→25, `Credentials were provided, and no root users were found. The root user 'open_notebook' will be created`, `api`/`worker`/`frontend` all RUNNING under supervisord; `GET :8502/config` → the `API_URL` value; `GET /api/providers` (the `openai_compatible` modality list); `GET /openapi.json` schemas for `CreateCredentialRequest` / `ModelCreate` / `SpeakerProfileCreate` / `PodcastGenerateRequest`; the POSTs listed above; the note-save worker error `No embedding model configured.` +- First render (2026-10-19): job `command:9rdojq5ne7dj9caicq20`, `execution_time: 301.21 s`, `episode:7ft03thj98sn0ek9xf29`; `episodes/d5fed29b-33e9-40ac-b46a-e0537c56c256/clips/0001.mp3 … 0023.mp3`; `…/audio/d5fed29b-….mp3` = 11,957,420 bytes, mp3 24 kHz mono, `ffprobe` duration 747.31 s; `GET /api/podcasts/episodes/{id}/audio` → 200 `audio/mpeg` with the bearer, 401 without. +- Upstream code read inside the pinned image (2026-10-19): `frontend/next.config.ts:26-43` (the `/api/*` rewrite and the one-port claim), `frontend/src/app/config/route.ts:122-173` (`/config` priority order; autodetect appends `:5055`), `frontend/src/app/api/_sse-proxy.ts` (SSE proxied with `no-transform`), `open_notebook/utils/url_validation.py` (link-local refusal, `is_blocked_ip`, no environment override anywhere in the file). +- Guard chain (2026-10-19): provider `notebook-proxy` created through `podman exec -i authentik-server ak shell` with the flows copied from `embedded-outpost` (pk 1), Application `Open Notebook`/`notebook-guard`, embedded-outpost provider set 9 → 10; afterwards the outpost logged zero `Failed to fetch outpost from API` and `seerr`/`grafana` still answered 200. Before the route existed, `notebook.franksdank.cloud` resolved to nothing (`000`).