docs(arr): fleet consolidated on FrankBook-Pro, VPN egress proven; E2E counts derived from registry
This commit is contained in:
1 parent
4c7465d00d
commit
df4339a82a
5 files changed
+103
-76
No files matched your search
@@ -2,17 +2,22 @@
|
|||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
This repository is the single source of truth for the two homelab servers. It
|
This repository is the single source of truth for the two homelab servers
|
||||||
tracks both the Podman container stacks and the LLM inference fleet. One repo,
|
and the FrankBook-Pro Mac mini. It tracks the Podman container stacks and
|
||||||
one checkout per host, one deploy script.
|
the LLM inference fleet. One repo, one checkout per host, one deploy script.
|
||||||
|
|
||||||
- **Dankserver** (192.168.0.235) — media/library stack (RomM, Copyparty,
|
- **Dankserver** (192.168.0.235) — media/library stack (RomM, Copyparty,
|
||||||
Calibre-Web, the *arr stack, Jellyfin) as bare containers, plus the
|
Calibre-Web, Jellyfin, Plex) as bare containers, plus the `frank-small`
|
||||||
`frank-small` LLM service.
|
LLM service.
|
||||||
- **Dankclaw** (192.168.0.143) — AI/search/development/observability stack
|
- **Dankclaw** (192.168.0.143) — AI/search/development/observability stack
|
||||||
(SearXNG via Gluetun, ComfyUI, OpenWebUI, Hermes, Gitea, Grafana, Prometheus,
|
(SearXNG via Gluetun, ComfyUI, OpenWebUI, Hermes, Gitea, Grafana,
|
||||||
Loki, Alloy, Pi-hole) plus the `frank-large` LLM service. Caddy on
|
Prometheus, Loki, Alloy, Pi-hole) plus the `frank-large` LLM service.
|
||||||
Dankclaw reverse-proxies both hosts.
|
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,
|
**2026-08-22 merge:** the former `vllmScripts` repository (LLM fleet scripts,
|
||||||
units, env tunables, and runbook) is merged into this repo. Its full git
|
units, env tunables, and runbook) is merged into this repo. Its full git
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
type: reference
|
type: reference
|
||||||
title: *arr Stack
|
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]
|
tags: [arr-stack, prowlarr, sonarr, radarr, lidarr, readarr, qbittorrent, frankbook-pro, dankserver]
|
||||||
timestamp: 2026-09-04T00:00:00Z
|
timestamp: 2026-09-04T00:00:00Z
|
||||||
---
|
---
|
||||||
@@ -9,13 +9,14 @@ timestamp: 2026-09-04T00:00:00Z
|
|||||||
# *arr Stack
|
# *arr Stack
|
||||||
|
|
||||||
The *arr suite is a collection of automated media management tools for Usenet
|
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
|
that fronts this household's downloads — as quadlet units inside its podman
|
||||||
machine (the documented wiring below), and on **Dankserver** as a compose
|
machine, with every container (the five arrs AND qBittorrent) sharing the
|
||||||
stack behind six Caddy vhosts (reactivated 2026-09-04; see below). It provides
|
[[Gluetun]] WireGuard tunnel's network namespace so **all egress exits
|
||||||
automatic downloading and library management that Copyparty alone does not
|
through the VPN**. A short-lived Dankserver compose copy was removed on
|
||||||
cover: quality profile enforcement, series/movie discovery, metadata
|
2026-09-04 (see below). The stack provides automatic downloading and library
|
||||||
enrichment, and automated file organization.
|
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) →
|
The core chain is: **Prowlarr** (indexer API aggregator) →
|
||||||
**Sonarr/Radarr/Lidarr/Readarr** (media-specific managers) → **qBittorrent**
|
**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
|
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.
|
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`,
|
A copy of the stack lived in `dankserver/compose.yaml` (`/opt/<svc>/config`
|
||||||
`/opt/<svc>/config` data roots) was committed 2026-08-29 but **never booted**:
|
data roots) from 2026-08-20 to 2026-09-04 and never ran healthy: the config
|
||||||
the config dirs were created root-owned (`root:root 755`), and every app
|
dirs were created root-owned (`root:root 755`), so every app died at logger
|
||||||
crashed at logger init (`UnauthorizedAccessException: Access to the path
|
init (`UnauthorizedAccessException: Access to the path '/config/Sentry' is
|
||||||
'/config/Sentry' is denied`) before binding any port — which read as a dead
|
denied`) before binding any port — which read as dead ports, not a boot
|
||||||
port, not a boot crash, until `podman logs <svc>` was checked.
|
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
|
### Rootless identity model on Dankserver (verified, keep for reference)
|
||||||
/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)
|
|
||||||
|
|
||||||
- On Dankserver's podman 5.7.0 rootless, a container's default user namespace
|
- 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
|
maps **container root (PUID=0) → host uid 1000 (dank)** and container uid
|
||||||
*N* → host `100000 + N - 1`. Verified via `/proc/<pid>/uid_map`
|
*N* → host `100000 + N - 1`. Verified via `/proc/<pid>/uid_map`
|
||||||
(`0 1000 1` / `1 100000 65536`) across plex, tautulli, jellyfin,
|
(`0 1000 1` / `1 100000 65536`) across plex, tautulli, jellyfin,
|
||||||
calibre-web, copyparty, romm, and the arr stack. This is why PUID=0
|
calibre-web, copyparty, romm. This is why PUID=0 services write as `dank`
|
||||||
services write as `dank` and why data roots are owned `1000:1000`.
|
and why data roots are owned `1000:1000` on that host.
|
||||||
- **Never add `userns_mode: host` to compose services here.** podman-compose
|
- **Never add `userns_mode: host` to a podman-compose service.**
|
||||||
1.5.0 runs every compose service inside a pod, and podman rejects the
|
podman-compose 1.5.0 runs every compose service inside a pod, and podman
|
||||||
combination (`--userns and --pod cannot be set together`) — the stack then
|
rejects the combination (`--userns and --pod cannot be set together`) —
|
||||||
fails to recreate with no recourse. The default mapping already gives the
|
the stack then fails to recreate with no recourse. The default mapping
|
||||||
fleet identity; PUID=0 + `1000:1000`-owned data roots is the whole recipe.
|
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
|
- qBittorrent's first boot (PUID=0, empty config) prints a one-time WebUI
|
||||||
admin password in `podman logs qbittorrent` — persist it via the PBKDF2
|
admin password in `podman logs qbittorrent` — persist it via the PBKDF2
|
||||||
recipe in [[qBittorrent]] before restarting the container.
|
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
|
## Access / auth posture
|
||||||
|
|
||||||
No *arr app sits behind the Anubis guard chain — the stack is exempt by
|
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
|
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
|
vhost. Six TLS vhosts (`<svc>.franksdank.cloud`) terminate on Caddy on
|
||||||
(`http://<frankbook-pro>:8989` etc.); the Dankserver stack additionally has
|
Dankclaw and route to FrankBook-Pro's published ports
|
||||||
six TLS vhosts (`<svc>.franksdank.cloud`) that terminate on Caddy on Dankclaw
|
(`192.168.0.112:<port>`, all exposed by the gluetun unit since the netns
|
||||||
and route to Dankserver's published ports.
|
consolidation); the same ports are reachable directly on the LAN.
|
||||||
|
|
||||||
Auth-config history (verified live 2026-08-29): all five arrs shipped with
|
Auth-config history (verified live 2026-08-29): all five arrs shipped with
|
||||||
`AuthenticationMethod=None` **plus** `AuthenticationRequired=Enabled`. On the
|
`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).
|
into any config — it was generated at first boot, not a default).
|
||||||
- Per-arr QBittorrent download clients exist on sonarr/radarr/lidarr
|
- Per-arr QBittorrent download clients exist on sonarr/radarr/lidarr
|
||||||
(enabled) and readarr (**parked**: its qB proxy fails auth live even with
|
(enabled) and readarr (**parked**: its qB proxy fails auth live even with
|
||||||
correct creds — known defect of the abandoned upstream; do not enable until
|
correct creds — known defect of the abandoned upstream; retest needed
|
||||||
the image changes). Each was **tested before enabling**; failures stay parked
|
after the 2026-09-04 netns move). All four clients were re-pointed to
|
||||||
with a reason, never enabled blind.
|
**loopback** (`127.0.0.1:8080`, same netns) on 2026-09-04 when the arrs
|
||||||
- Prowlarr runs v2.5.2 where apps are first-class rows (`Applications` table):
|
joined gluetun's netns — the old `10.88.0.1:8080` gateway hack routes
|
||||||
four Application rows (sonarr/radarr/lidarr/readarr) now carry each arr's
|
into the VPN tunnel (AllowedIPs=0.0.0.0/0) and is dead from inside the
|
||||||
real baseUrl+apiKey with `SyncLevel=FullSync`. Before this, the table was
|
netns. Verified live: `podman exec sonarr curl` reaches qB with 200, and
|
||||||
empty and every sync attempt threw `NullConfig` in `GetIndexerMappings()` —
|
a real qB login from inside the netns returns 200.
|
||||||
visible in `podman logs prowlarr`, not in any UI.
|
- Prowlarr runs v2.5.2 where apps are first-class rows (`Applications`
|
||||||
- Indexers are **test-before-enable**: EZTV and 1337x stay disabled with reason
|
table): four Application rows (sonarr/radarr/lidarr/readarr) carried each
|
||||||
"blocked by CloudFlare Protection" (this host's egress ASN is CF-blocked at
|
arr's real baseUrl+apiKey with `SyncLevel=FullSync` as of 2026-08-30.
|
||||||
those trackers); Nyaa.si, sukebei, RuTor, Byrutor, YTS passed live tests and
|
**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.
|
are enabled. A failed test = parked with reason, never enabled blind.
|
||||||
- Prowlarr v2 sync model: indexers stay in Prowlarr; arrs search *through* it
|
- Prowlarr v2 sync model: indexers stay in Prowlarr; arrs search *through* it
|
||||||
and hand off to their own qB download client (category routing). Mappings
|
and hand off to their own qB download client (category routing). Mappings
|
||||||
|
|||||||
@@ -11,6 +11,31 @@ timestamp: 2026-09-14
|
|||||||
|
|
||||||
# Bundle Change Log
|
# 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
|
## 2026-09-04 — dankserver *arr stack reactivated; rootless identity model documented
|
||||||
- ARR-Stack: the Dankserver compose deployment (committed 2026-08-29, never
|
- ARR-Stack: the Dankserver compose deployment (committed 2026-08-29, never
|
||||||
booted — root-owned `/opt/<svc>/config` dirs crashed every app at logger
|
booted — root-owned `/opt/<svc>/config` dirs crashed every app at logger
|
||||||
|
|||||||
@@ -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("admin open links reach their apps through SSO", async ({ page }) => {
|
||||||
test.setTimeout(240_000);
|
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 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) =>
|
const hrefs = await page.locator("#grid a.open").evaluateAll((els) =>
|
||||||
els.map((el) => (el as HTMLAnchorElement).href),
|
els.map((el) => (el as HTMLAnchorElement).href),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -22,9 +22,14 @@ async function settleThroughGuard(page: Page, targetHost: string): Promise<void>
|
|||||||
|
|
||||||
test("every user open link reaches its app", async ({ page }) => {
|
test("every user open link reaches its app", async ({ page }) => {
|
||||||
test.setTimeout(420_000);
|
test.setTimeout(420_000);
|
||||||
await page.goto("/");
|
// Expected link count comes from the live registry, mirroring the
|
||||||
await expect(page.locator("#grid a.open")).toHaveCount(9);
|
// 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
|
const hrefs = await page
|
||||||
.locator("#grid a.open")
|
.locator("#grid a.open")
|
||||||
.evaluateAll((els) => els.map((el) => ({ href: (el as HTMLAnchorElement).href, label: el.textContent ?? "" })));
|
.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)}`);
|
failures.push(`${link.label}: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
}
|
}
|
||||||
await page.goto("/");
|
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([]);
|
expect(failures, failures.join("\n")).toEqual([]);
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user