docs(arr): fleet consolidated on FrankBook-Pro, VPN egress proven; E2E counts derived from registry

This commit is contained in:
Frank
2026-09-04 13:17:39 -07:00
parent 4c7465d00d
commit df4339a82a
5 changed files with 103 additions and 76 deletions
+13 -8
View File
@@ -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
+51 -64
View File
@@ -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/<svc>/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 <svc>` was checked.
A copy of the stack lived in `dankserver/compose.yaml` (`/opt/<svc>/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 <svc>` 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/<pid>/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://<frankbook-pro>:8989` etc.); the Dankserver stack additionally has
six TLS vhosts (`<svc>.franksdank.cloud`) that terminate on Caddy on Dankclaw
and route to Dankserver's published ports.
vhost. Six TLS vhosts (`<svc>.franksdank.cloud`) terminate on Caddy on
Dankclaw and route to FrankBook-Pro's published ports
(`192.168.0.112:<port>`, 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:<arr port>` (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
+25
View File
@@ -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/<svc>/config` dirs crashed every app at logger
+6 -1
View File
@@ -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),
);
+8 -3
View File
@@ -22,9 +22,14 @@ async function settleThroughGuard(page: Page, targetHost: string): Promise<void>
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([]);
});