diff --git a/AGENTS.md b/AGENTS.md index 65331e2..ca5ca2b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,17 +2,22 @@ ## Overview -This repository is the single source of truth for the two homelab servers. It -tracks both the Podman container stacks and the LLM inference fleet. One repo, -one checkout per host, one deploy script. +This repository is the single source of truth for the two homelab servers +and the FrankBook-Pro Mac mini. It tracks the Podman container stacks and +the LLM inference fleet. One repo, one checkout per host, one deploy script. - **Dankserver** (192.168.0.235) — media/library stack (RomM, Copyparty, - Calibre-Web, the *arr stack, Jellyfin) as bare containers, plus the - `frank-small` LLM service. + Calibre-Web, Jellyfin, Plex) as bare containers, plus the `frank-small` + LLM service. - **Dankclaw** (192.168.0.143) — AI/search/development/observability stack - (SearXNG via Gluetun, ComfyUI, OpenWebUI, Hermes, Gitea, Grafana, Prometheus, - Loki, Alloy, Pi-hole) plus the `frank-large` LLM service. Caddy on - Dankclaw reverse-proxies both hosts. + (SearXNG via Gluetun, ComfyUI, OpenWebUI, Hermes, Gitea, Grafana, + Prometheus, Loki, Alloy, Pi-hole) plus the `frank-large` LLM service. + Caddy on Dankclaw reverse-proxies both hosts. +- **FrankBook-Pro** (192.168.0.112) — macOS mini running the *arr stack + (Prowlarr, Sonarr, Radarr, Lidarr, Readarr, qBittorrent) as quadlets + inside its podman machine; every container rides the Gluetun WireGuard + netns, so all *arr and qBittorrent egress is VPN-tunneled. Deployed via + the `frankbook-pro` handler. **2026-08-22 merge:** the former `vllmScripts` repository (LLM fleet scripts, units, env tunables, and runbook) is merged into this repo. Its full git diff --git a/serverAppConfigs-docs/Dankserver/ARR-Stack.md b/serverAppConfigs-docs/Dankserver/ARR-Stack.md index 2498c1c..8ff1931 100644 --- a/serverAppConfigs-docs/Dankserver/ARR-Stack.md +++ b/serverAppConfigs-docs/Dankserver/ARR-Stack.md @@ -1,7 +1,7 @@ --- type: reference title: *arr Stack -description: The *arr automation stack (Prowlarr, Sonarr, Radarr, Lidarr, Readarr, qBittorrent) — FrankBook-Pro quadlets and the Dankserver compose deployment (reactivated 2026-09-04): topology, wiring, auth posture, and gotchas. +description: The *arr automation stack (Prowlarr, Sonarr, Radarr, Lidarr, Readarr, qBittorrent) — FrankBook-Pro only, every container on the gluetun WireGuard netns (all egress VPN): topology, wiring, auth posture, and gotchas. tags: [arr-stack, prowlarr, sonarr, radarr, lidarr, readarr, qbittorrent, frankbook-pro, dankserver] timestamp: 2026-09-04T00:00:00Z --- @@ -9,13 +9,14 @@ timestamp: 2026-09-04T00:00:00Z # *arr Stack The *arr suite is a collection of automated media management tools for Usenet -and BitTorrent. The stack runs in two places: **FrankBook-Pro** — the Mac mini +and BitTorrent. It runs in exactly one place: **FrankBook-Pro** — the Mac mini that fronts this household's downloads — as quadlet units inside its podman -machine (the documented wiring below), and on **Dankserver** as a compose -stack behind six Caddy vhosts (reactivated 2026-09-04; see below). It provides -automatic downloading and library management that Copyparty alone does not -cover: quality profile enforcement, series/movie discovery, metadata -enrichment, and automated file organization. +machine, with every container (the five arrs AND qBittorrent) sharing the +[[Gluetun]] WireGuard tunnel's network namespace so **all egress exits +through the VPN**. A short-lived Dankserver compose copy was removed on +2026-09-04 (see below). The stack provides automatic downloading and library +management that Copyparty alone does not cover: quality profile enforcement, +series/movie discovery, metadata enrichment, and automated file organization. The core chain is: **Prowlarr** (indexer API aggregator) → **Sonarr/Radarr/Lidarr/Readarr** (media-specific managers) → **qBittorrent** @@ -51,67 +52,45 @@ qBittorrent is special: it shares the [[Gluetun]] container's network namespace IP. A netns-sharer publishes no ports of its own — qB's WebUI (8080) and BitTorrent port (6881 TCP+UDP) are published on the gluetun unit instead. -## Dankserver compose deployment (reactivated 2026-09-04) +## Dankserver compose copy (removed 2026-09-04) -The original Dankserver compose stack (`dankserver/compose.yaml`, -`/opt//config` data roots) was committed 2026-08-29 but **never booted**: -the config dirs were created root-owned (`root:root 755`), and every app -crashed at logger init (`UnauthorizedAccessException: Access to the path -'/config/Sentry' is denied`) before binding any port — which read as a dead -port, not a boot crash, until `podman logs ` was checked. +A copy of the stack lived in `dankserver/compose.yaml` (`/opt//config` +data roots) from 2026-08-20 to 2026-09-04 and never ran healthy: the config +dirs were created root-owned (`root:root 755`), so every app died at logger +init (`UnauthorizedAccessException: Access to the path '/config/Sentry' is +denied`) before binding any port — which read as dead ports, not a boot +crash, until `podman logs ` was checked. After a one-day revival it was +removed by operator decision: **FrankBook-Pro is the sole *arr deployment.** +Config roots moved to `~/.AGENTTRASH/20260904-dankserver-arr-teardown/` on +Dankserver (recoverable until purged). -Fix and restart (2026-09-04): `sudo chown -R 1000:1000 -/opt/{prowlarr,qbittorrent,radarr,sonarr,lidarr,readarr}/config`, then -`podman-compose -f dankserver/compose.yaml up -d` for the six services. All -six now serve 200 on their ports and through the Caddy vhosts. - -### Rootless identity model (verified, do not "fix" it) +### Rootless identity model on Dankserver (verified, keep for reference) - On Dankserver's podman 5.7.0 rootless, a container's default user namespace maps **container root (PUID=0) → host uid 1000 (dank)** and container uid *N* → host `100000 + N - 1`. Verified via `/proc//uid_map` (`0 1000 1` / `1 100000 65536`) across plex, tautulli, jellyfin, - calibre-web, copyparty, romm, and the arr stack. This is why PUID=0 - services write as `dank` and why data roots are owned `1000:1000`. -- **Never add `userns_mode: host` to compose services here.** podman-compose - 1.5.0 runs every compose service inside a pod, and podman rejects the - combination (`--userns and --pod cannot be set together`) — the stack then - fails to recreate with no recourse. The default mapping already gives the - fleet identity; PUID=0 + `1000:1000`-owned data roots is the whole recipe. + calibre-web, copyparty, romm. This is why PUID=0 services write as `dank` + and why data roots are owned `1000:1000` on that host. +- **Never add `userns_mode: host` to a podman-compose service.** + podman-compose 1.5.0 runs every compose service inside a pod, and podman + rejects the combination (`--userns and --pod cannot be set together`) — + the stack then fails to recreate with no recourse. The default mapping + already gives the fleet identity; PUID=0 + `1000:1000`-owned data roots is + the whole recipe. (The compose file briefly carried `userns_mode: host` on + all six arr services on 2026-09-04 — reverted in `e454159`, same day.) - qBittorrent's first boot (PUID=0, empty config) prints a one-time WebUI admin password in `podman logs qbittorrent` — persist it via the PBKDF2 recipe in [[qBittorrent]] before restarting the container. -### Differences from the FrankBook-Pro deployment - -- **No gluetun on Dankserver:** this qBittorrent's torrent egress leaves via - the residential IP (the FrankBook qB is the VPN-gated one). Route - privacy-sensitive grabs through FrankBook-Pro. -- **Empty config:** the Dankserver arrs have no indexers, no qB download - clients, and no quality profiles configured — the Chain wiring section - below describes FrankBook-Pro. Configure test-first per that section if - wiring this stack up. -- **Six vhosts:** `prowlarr/sonarr/radarr/lidarr/readarr/qbittorrent - .franksdank.cloud` are exempt from the Anubis chain (own auth) with a - documented exemption reason per the guard policy. -- Homepage: Sonarr sits in the user section; Prowlarr/Radarr/Readarr/Lidarr/ - qBittorrent sit in admin. - -## Historical note - -The compose file briefly carried `userns_mode: host` on all six services -(2026-09-04, reverted same day in `e454159`) — an incorrect fix for the boot -crash that podman-compose 1.5.0 could not apply anyway (pod/userns conflict -above). The real fix was the chown; see the identity model section. - ## Access / auth posture No *arr app sits behind the Anubis guard chain — the stack is exempt by construction (its own auth), with an exemption comment in the Caddyfile per -vhost. FrankBook-Pro's stack is reached directly by IP:port on the LAN -(`http://:8989` etc.); the Dankserver stack additionally has -six TLS vhosts (`.franksdank.cloud`) that terminate on Caddy on Dankclaw -and route to Dankserver's published ports. +vhost. Six TLS vhosts (`.franksdank.cloud`) terminate on Caddy on +Dankclaw and route to FrankBook-Pro's published ports +(`192.168.0.112:`, all exposed by the gluetun unit since the netns +consolidation); the same ports are reachable directly on the LAN. Auth-config history (verified live 2026-08-29): all five arrs shipped with `AuthenticationMethod=None` **plus** `AuthenticationRequired=Enabled`. On the @@ -137,17 +116,25 @@ per-arr qBittorrent clients (category = app name) → import**. into any config — it was generated at first boot, not a default). - Per-arr QBittorrent download clients exist on sonarr/radarr/lidarr (enabled) and readarr (**parked**: its qB proxy fails auth live even with - correct creds — known defect of the abandoned upstream; do not enable until - the image changes). Each was **tested before enabling**; failures stay parked - with a reason, never enabled blind. -- Prowlarr runs v2.5.2 where apps are first-class rows (`Applications` table): - four Application rows (sonarr/radarr/lidarr/readarr) now carry each arr's - real baseUrl+apiKey with `SyncLevel=FullSync`. Before this, the table was - empty and every sync attempt threw `NullConfig` in `GetIndexerMappings()` — - visible in `podman logs prowlarr`, not in any UI. -- Indexers are **test-before-enable**: EZTV and 1337x stay disabled with reason - "blocked by CloudFlare Protection" (this host's egress ASN is CF-blocked at - those trackers); Nyaa.si, sukebei, RuTor, Byrutor, YTS passed live tests and + correct creds — known defect of the abandoned upstream; retest needed + after the 2026-09-04 netns move). All four clients were re-pointed to + **loopback** (`127.0.0.1:8080`, same netns) on 2026-09-04 when the arrs + joined gluetun's netns — the old `10.88.0.1:8080` gateway hack routes + into the VPN tunnel (AllowedIPs=0.0.0.0/0) and is dead from inside the + netns. Verified live: `podman exec sonarr curl` reaches qB with 200, and + a real qB login from inside the netns returns 200. +- Prowlarr runs v2.5.2 where apps are first-class rows (`Applications` + table): four Application rows (sonarr/radarr/lidarr/readarr) carried each + arr's real baseUrl+apiKey with `SyncLevel=FullSync` as of 2026-08-30. + **Status 2026-09-04: the table is EMPTY again** (live API check — 8 rows, + all Cardigann/Torznab, zero Application rows; lost with the dankserver + config migration). Prowlarr sync is broken until the four rows are + rebuilt with baseUrl `127.0.0.1:` (same netns). +- Indexers are **test-before-enable**: EZTV and 1337x stay disabled with + reason "blocked by CloudFlare Protection" (this host's egress ASN was + CF-blocked at those trackers). **2026-09-04: arr egress moved onto the + ProtonVPN tunnel** — the parked reason may no longer hold; retest before + enabling. Nyaa.si, sukebei, RuTor, Byrutor, YTS passed live tests and are enabled. A failed test = parked with reason, never enabled blind. - Prowlarr v2 sync model: indexers stay in Prowlarr; arrs search *through* it and hand off to their own qB download client (category routing). Mappings diff --git a/serverAppConfigs-docs/log.md b/serverAppConfigs-docs/log.md index f68383f..f850316 100644 --- a/serverAppConfigs-docs/log.md +++ b/serverAppConfigs-docs/log.md @@ -11,6 +11,31 @@ timestamp: 2026-09-14 # Bundle Change Log +## 2026-09-04 — *arr fleet consolidated on FrankBook-Pro; all egress VPN-gated +- Operator decision: **FrankBook-Pro is the sole *arr deployment.** The + Dankserver compose copy (booted earlier the same day) was removed; + `/opt` config roots trashed on the host; compose/services pruned. +- All six fleet containers (qB + five arrs) now share gluetun's network + namespace — **all egress exits the ProtonVPN tunnel by construction** + (AllowedIPs=0.0.0.0/0). The arrs publish no ports of their own; their UI + ports (9696/8989/7878/8686/8787) moved onto the gluetun unit. Proven live: + residential `71.227.227.172` vs netns-wide `51.179.177.14`. +- New test seam `tests/frankbook_quadlet.bats`: every arr+qB unit must join + gluetun's netns and publish no port; gluetun must publish the whole + fleet's port set. deploy-plan.py: gluetun changes carry the whole netns + family. +- arr→qB download clients re-pointed `10.88.0.1:8080` (old bridge-gateway + hack, dead inside the tunnel) → `127.0.0.1:8080`; verified by real qB + login from inside the netns. Caddy vhosts + homepage probes repoint to + 192.168.0.112; homepage all green. +- Defects discovered: Prowlarr's four Application rows are GONE (empty + table; sync broken until rebuilt with loopback baseUrls) and readarr's + qB proxy auth defect needs a retest post-move. EZTV/1337x CF-block + parking may be stale now that egress is VPN — retest before enabling. +- E2E specs (11-admin/12-crawl) de-hardcoded: expected open-link counts now + derive from the live services.json registry. AGENTS.md: deployments are + complete only when unit tests AND Playwright E2E both pass. + ## 2026-09-04 — dankserver *arr stack reactivated; rootless identity model documented - ARR-Stack: the Dankserver compose deployment (committed 2026-08-29, never booted — root-owned `/opt//config` dirs crashed every app at logger diff --git a/tests/e2e/specs/11-admin.spec.ts b/tests/e2e/specs/11-admin.spec.ts index ac3bb25..7f8ab24 100644 --- a/tests/e2e/specs/11-admin.spec.ts +++ b/tests/e2e/specs/11-admin.spec.ts @@ -23,8 +23,13 @@ test("admin panel renders admin-category services", async ({ page }) => { test("admin open links reach their apps through SSO", async ({ page }) => { test.setTimeout(240_000); + // Expected link count comes from the live registry (admin showLink=true + // entries, now including the *arr vhosts that use their own auth). + const registry: { name: string; category: string; showLink: boolean }[] = + await (await page.request.get("/services.json")).json(); + const expectedAdminLinks = registry.filter((s) => s.category === "admin" && s.showLink).length; await page.goto("/admin/"); - await expect(page.locator("#grid a.open")).toHaveCount(3); // grafana, prometheus, loki + await expect(page.locator("#grid a.open")).toHaveCount(expectedAdminLinks); const hrefs = await page.locator("#grid a.open").evaluateAll((els) => els.map((el) => (el as HTMLAnchorElement).href), ); diff --git a/tests/e2e/specs/12-crawl.spec.ts b/tests/e2e/specs/12-crawl.spec.ts index 88297cb..f2cafd7 100644 --- a/tests/e2e/specs/12-crawl.spec.ts +++ b/tests/e2e/specs/12-crawl.spec.ts @@ -22,9 +22,14 @@ async function settleThroughGuard(page: Page, targetHost: string): Promise test("every user open link reaches its app", async ({ page }) => { test.setTimeout(420_000); - await page.goto("/"); - await expect(page.locator("#grid a.open")).toHaveCount(9); + // Expected link count comes from the live registry, mirroring the + // showLink=true user entries — a registry edit cannot stale this test. + const registry: { name: string; category: string; showLink: boolean }[] = + await (await page.request.get("/services.json")).json(); + const expectedUserLinks = registry.filter((s) => s.category === "user" && s.showLink).length; + await page.goto("/"); + await expect(page.locator("#grid a.open")).toHaveCount(expectedUserLinks); const hrefs = await page .locator("#grid a.open") .evaluateAll((els) => els.map((el) => ({ href: (el as HTMLAnchorElement).href, label: el.textContent ?? "" }))); @@ -43,7 +48,7 @@ test("every user open link reaches its app", async ({ page }) => { failures.push(`${link.label}: ${err instanceof Error ? err.message : String(err)}`); } await page.goto("/"); - await expect(page.locator("#grid a.open")).toHaveCount(9); + await expect(page.locator("#grid a.open")).toHaveCount(expectedUserLinks); } expect(failures, failures.join("\n")).toEqual([]); });