arr fleet: FrankBook-Pro is the sole deployment, all egress VPN-gated

FrankBook-Pro becomes the only *arr/qBittorrent deployment. Every fleet
container (qB + five arrs) now shares gluetun's netns, so all egress —
torrent, indexer APIs, metadata lookups — exits the WireGuard tunnel by
construction (AllowedIPs=0.0.0.0/0); the arrs' own ports publish on the
gluetun unit, none on the arr units themselves.

- dankserver/compose.yaml: remove the six arr services (the Aug 20 compose
  copy — never healthy, superseded; config dirs trashed on the host)
- dankclaw/Caddyfile + shared/homepage/services.json: arr vhosts and
  probes repoint 192.168.0.235 -> 192.168.0.112 (FrankBook-Pro)
- frankbook-pro units: five arrs join gluetun netns (Wants/After/Network);
  gluetun publishes 9696/8989/7878/8686/8787 alongside 8080/6881
- tests/frankbook_quadlet.bats: new VPN egress seam — every arr+qB unit
  must share gluetun's netns and publish no port; gluetun must publish the
  whole fleet's ports
- deploy-plan.py: gluetun changes carry the whole netns family (a gluetun
  recreation kills all six joiners); dankserver compose set loses the arrs
- AGENTS.md: deployment is complete only when unit tests AND the Playwright
  E2E suite both pass
This commit is contained in:
Frank
2026-09-04 12:54:28 -07:00
parent 8a6362401a
commit 4c7465d00d
14 changed files with 101 additions and 163 deletions
+12
View File
@@ -130,6 +130,18 @@ Set `FRANK_SUDO_PW` for headless runs on a host without passwordless sudo
(remote sudo reads it from stdin via `sudo -S`). The value is never stored
in this repo.
### Deployment completeness (MANDATORY)
A deployment is **not complete** when `deploy.sh` exits green. It is complete
only when BOTH of the following pass:
1. **Unit/config tests** — `./tests/run.sh` (all seams green), and
2. **Playwright end-to-end tests** — `tests/e2e/run.sh` drives the LIVE fleet
(homepage, Anubis/Authentik guard chain, guarded vhosts) in a real
browser. The E2E suite is deliberately excluded from `./tests/run.sh`
because it needs live hosts and credentials, so it MUST be invoked
explicitly after every behavior-affecting deploy.
```mermaid
graph LR
A[Edit locally] --> B[Tests pass]
+12 -12
View File
@@ -241,27 +241,27 @@ jelly.franksdank.cloud {
# --- *arr Stack (exempt by construction: own auth) ---
prowlarr.franksdank.cloud {
reverse_proxy 192.168.0.235:9696
reverse_proxy 192.168.0.112:9696
}
radarr.franksdank.cloud {
reverse_proxy 192.168.0.235:7878
reverse_proxy 192.168.0.112:7878
}
sonarr.franksdank.cloud {
reverse_proxy 192.168.0.235:8989
reverse_proxy 192.168.0.112:8989
}
lidarr.franksdank.cloud {
reverse_proxy 192.168.0.235:8686
reverse_proxy 192.168.0.112:8686
}
readarr.franksdank.cloud {
reverse_proxy 192.168.0.235:8787
reverse_proxy 192.168.0.112:8787
}
qbittorrent.franksdank.cloud {
reverse_proxy 192.168.0.235:8080
reverse_proxy 192.168.0.112:8080
}
# --- Internal listener: auth decision layer ---------------------------------
@@ -354,32 +354,32 @@ http://:8017 {
@prowlarr host prowlarr.franksdank.cloud
handle @prowlarr {
reverse_proxy 192.168.0.235:9696
reverse_proxy 192.168.0.112:9696
}
@radarr host radarr.franksdank.cloud
handle @radarr {
reverse_proxy 192.168.0.235:7878
reverse_proxy 192.168.0.112:7878
}
@sonarr host sonarr.franksdank.cloud
handle @sonarr {
reverse_proxy 192.168.0.235:8989
reverse_proxy 192.168.0.112:8989
}
@lidarr host lidarr.franksdank.cloud
handle @lidarr {
reverse_proxy 192.168.0.235:8686
reverse_proxy 192.168.0.112:8686
}
@readarr host readarr.franksdank.cloud
handle @readarr {
reverse_proxy 192.168.0.235:8787
reverse_proxy 192.168.0.112:8787
}
@qbittorrent host qbittorrent.franksdank.cloud
handle @qbittorrent {
reverse_proxy 192.168.0.235:8080
reverse_proxy 192.168.0.112:8080
}
}
-2
View File
@@ -20,5 +20,3 @@ COPAY_PASS=changeme
# Jellyfin
JELLYFIN_PUBLISH_SERVER_URL=http://192.168.0.235:8096
# *arr Stack
ARR_TZ=America/New_York
-103
View File
@@ -146,109 +146,6 @@ services:
timeout: 10s
retries: 3
# ═══════════════════════════════════════════════
# *arr Stack — automated media download
# ═══════════════════════════════════════════════
prowlarr:
image: lscr.io/linuxserver/prowlarr:latest
container_name: prowlarr
restart: unless-stopped
environment:
- PUID=0
- PGID=0
- TZ=America/Los_Angeles
- UMASK_SET=022
volumes:
- /mnt/entropy/downloads:/downloads:ro
- /opt/prowlarr/config:/config
networks: [appnet]
ports:
- "9696:9696"
qbittorrent:
image: lscr.io/linuxserver/qbittorrent:latest
container_name: qbittorrent
restart: unless-stopped
environment:
- PUID=0
- PGID=0
- TZ=America/Los_Angeles
- UMASK_SET=022
volumes:
- /opt/qbittorrent/config:/config
- /mnt/entropy/downloads:/downloads
- /mnt/entropy/downloads/incomplete:/downloads/incomplete
ports:
- "8080:8080"
networks: [appnet]
radarr:
image: lscr.io/linuxserver/radarr:latest
container_name: radarr
restart: unless-stopped
environment:
- PUID=0
- PGID=0
- TZ=America/Los_Angeles
- UMASK_SET=022
volumes:
- /opt/radarr/config:/config
- /mnt/entropy/Movies:/movies
- /mnt/entropy/downloads:/downloads
ports:
- "7878:7878"
networks: [appnet]
sonarr:
image: lscr.io/linuxserver/sonarr:latest
container_name: sonarr
restart: unless-stopped
environment:
- PUID=0
- PGID=0
- TZ=America/Los_Angeles
- UMASK_SET=022
volumes:
- /opt/sonarr/config:/config
- /mnt/entropy/TVShows:/tv
- /mnt/entropy/downloads:/downloads
ports:
- "8989:8989"
networks: [appnet]
lidarr:
image: lscr.io/linuxserver/lidarr:latest
container_name: lidarr
restart: unless-stopped
environment:
- PUID=0
- PGID=0
- TZ=America/Los_Angeles
- UMASK_SET=022
volumes:
- /opt/lidarr/config:/config
- /mnt/entropy/Music:/music
- /mnt/entropy/downloads:/downloads
ports:
- "8686:8686"
networks: [appnet]
readarr:
image: lscr.io/linuxserver/readarr:amd64-1.0.980
container_name: readarr
restart: unless-stopped
environment:
- PUID=0
- PGID=0
- TZ=America/Los_Angeles
- UMASK_SET=022
volumes:
- /opt/readarr/config:/config
- /mnt/entropy/eBooks:/books
- /mnt/entropy/downloads:/downloads
ports:
- "8787:8787"
networks: [appnet]
# ═══════════════════════════════════════════════
# Node Exporter — host metrics
@@ -5,11 +5,12 @@
# (installed by the frankbook-pro handler in scripts/deploy-remote.sh —
# install_quadlet targets the Linux hosts' systemctl --user, not the VM).
#
# WireGuard tunnel to ProtonVPN. qBittorrent shares this container's network
# namespace (Network=gluetun.container) so torrent egress never touches the
# residential IP. qBittorrent's WebUI and BitTorrent ports publish HERE — a
# netns-sharer publishes no ports of its own (same invariant as the dankclaw
# searxng/gluetun pair). Credentials live in
# WireGuard tunnel to ProtonVPN. qBittorrent and all five *arrs share this
# container's network namespace (Network=gluetun.container) so every fleet
# container's egress — torrent traffic, indexer APIs, metadata lookups —
# exits ONLY through the tunnel (AllowedIPs=0.0.0.0/0). Every fleet port
# publishes HERE; a netns-sharer publishes no ports of its own (same
# invariant as the dankclaw searxng/gluetun pair). Credentials live in
# /home/core/Containers/gluetun/env inside the VM (mode 0600, never
# committed); the private key is Mac-tunnel-specific and MUST NOT be copied
# from dankclaw — one WireGuard peer key cannot serve two machines at once.
@@ -33,6 +34,11 @@ Environment=WIREGUARD_PERSISTENT_KEEPALIVE_INTERVAL=${WIREGUARD_PERSISTENT_KEEPA
PublishPort=8080:8080
PublishPort=6881:6881
PublishPort=6881:6881/udp
PublishPort=9696:9696
PublishPort=8989:8989
PublishPort=7878:7878
PublishPort=8686:8686
PublishPort=8787:8787
[Service]
EnvironmentFile=/home/core/Containers/gluetun/env
@@ -3,10 +3,15 @@
# Install path is INSIDE the machine VM:
# /home/core/.config/containers/systemd/lidarr.container
#
#
# Joins gluetun's network namespace: all egress exits through the WireGuard
# tunnel; 8686 publishes on the gluetun unit.
# Grab-only: no media binds; config migrated from dankserver's retired
# bare container.
[Unit]
Description=Lidarr (FrankBook-Pro)
Wants=gluetun.service
After=gluetun.service
[Container]
ContainerName=lidarr
@@ -16,7 +21,7 @@ Environment=PGID=0
Environment=TZ=America/Los_Angeles
Environment=UMASK_SET=022
Volume=/Volumes/COMPLETE/lidarr-data:/config
PublishPort=8686:8686
Network=gluetun.container
[Service]
TimeoutStartSec=900
@@ -3,6 +3,10 @@
# Install path is INSIDE the machine VM:
# /home/core/.config/containers/systemd/prowlarr.container
#
#
# Joins gluetun's network namespace: all egress (indexer APIs, metadata
# lookups) exits through the WireGuard tunnel; 9696 publishes on the
# gluetun unit. Same-netns targets (qB, the arrs) are loopback addresses.
# Arr on the Mac is grab-only: indexer + app-sync management lives here, the
# arrs never auto-import media (the user reviews completed torrents in
# /Volumes/COMPLETE/"Finished Torrents" and moves files manually into the
@@ -11,9 +15,12 @@
# Config is migrated from dankserver's retired bare container
# (tar-archived first, per the Phase 4 cutover note) — indexers and app
# settings survive; stale host pointers (old qbit at 192.168.0.235:8080)
# are re-pointed to the Mac's qbit via its API after first boot.
# are re-pointed to loopback qBittorrent (127.0.0.1:8080, same netns) via
# its API after first boot.
[Unit]
Description=Prowlarr (FrankBook-Pro)
Wants=gluetun.service
After=gluetun.service
[Container]
ContainerName=prowlarr
@@ -23,7 +30,7 @@ Environment=PGID=0
Environment=TZ=America/Los_Angeles
Environment=UMASK_SET=022
Volume=/Volumes/COMPLETE/prowlarr-data:/config
PublishPort=9696:9696
Network=gluetun.container
[Service]
TimeoutStartSec=900
@@ -3,10 +3,15 @@
# Install path is INSIDE the machine VM:
# /home/core/.config/containers/systemd/radarr.container
#
#
# Joins gluetun's network namespace: all egress exits through the WireGuard
# tunnel; 7878 publishes on the gluetun unit.
# Grab-only: no media binds; config migrated from dankserver's retired
# bare container.
[Unit]
Description=Radarr (FrankBook-Pro)
Wants=gluetun.service
After=gluetun.service
[Container]
ContainerName=radarr
@@ -16,7 +21,7 @@ Environment=PGID=0
Environment=TZ=America/Los_Angeles
Environment=UMASK_SET=022
Volume=/Volumes/COMPLETE/radarr-data:/config
PublishPort=7878:7878
Network=gluetun.container
[Service]
TimeoutStartSec=900
@@ -3,6 +3,9 @@
# Install path is INSIDE the machine VM:
# /home/core/.config/containers/systemd/readarr.container
#
#
# Joins gluetun's network namespace: all egress exits through the WireGuard
# tunnel; 8787 publishes on the gluetun unit.
# Grab-only: no media binds; config migrated from dankserver's retired
# bare container.
#
@@ -11,6 +14,8 @@
# of a broken 1.x nightly for Readarr; the pin avoids surprises.
[Unit]
Description=Readarr (FrankBook-Pro)
Wants=gluetun.service
After=gluetun.service
[Container]
ContainerName=readarr
@@ -20,7 +25,7 @@ Environment=PGID=0
Environment=TZ=America/Los_Angeles
Environment=UMASK_SET=022
Volume=/Volumes/COMPLETE/readarr-data:/config
PublishPort=8787:8787
Network=gluetun.container
[Service]
TimeoutStartSec=900
@@ -3,11 +3,16 @@
# Install path is INSIDE the machine VM:
# /home/core/.config/containers/systemd/sonarr.container
#
#
# Joins gluetun's network namespace: all egress exits through the WireGuard
# tunnel; 8989 publishes on the gluetun unit.
# Grab-only: no media binds (the TV library lives on dankserver's entropy
# disk, which the Mac never mounts); config migrated from dankserver's
# retired bare container.
[Unit]
Description=Sonarr (FrankBook-Pro)
Wants=gluetun.service
After=gluetun.service
[Container]
ContainerName=sonarr
@@ -17,7 +22,7 @@ Environment=PGID=0
Environment=TZ=America/Los_Angeles
Environment=UMASK_SET=022
Volume=/Volumes/COMPLETE/sonarr-data:/config
PublishPort=8989:8989
Network=gluetun.container
[Service]
TimeoutStartSec=900
+5 -9
View File
@@ -147,12 +147,6 @@ DANKSERVER_COMPOSE_SERVICES = {
"copyparty",
"jellyfin",
"calibre-web",
"prowlarr",
"qbittorrent",
"radarr",
"sonarr",
"lidarr",
"readarr",
}
COMPOSE_NAME_MAP = {"mariadb": "romm-mariadb", "redis": "romm-redis"}
@@ -341,9 +335,11 @@ def services_for_path(path: str, target: str) -> set[str]:
if len(parts) >= 3 and parts[2] == "quadlet":
name = parts[3].removesuffix(".container")
if name == "gluetun":
# qBittorrent shares gluetun's network namespace; a gluetun
# recreation requires both units to be restarted.
_add(services, "gluetun", "qbittorrent")
# Every *arr + qBittorrent container shares gluetun's
# network namespace; a gluetun recreation kills all of
# them, so the plan restarts the whole netns family.
_add(services, "gluetun", "qbittorrent",
"prowlarr", "sonarr", "radarr", "lidarr", "readarr")
elif name in FRANKBOOK_SERVICES:
_add(services, name)
return services
+6 -6
View File
@@ -104,7 +104,7 @@
"description": "TV series library and automation.",
"url": "http://sonarr.franksdank.cloud",
"docs": "",
"probe": "192.168.0.235:8989",
"probe": "192.168.0.112:8989",
"method": "http",
"wip": false,
"showLink": true
@@ -259,7 +259,7 @@
"description": "Indexer manager.",
"url": "http://prowlarr.franksdank.cloud",
"docs": "",
"probe": "192.168.0.235:9696",
"probe": "192.168.0.112:9696",
"method": "http",
"wip": false,
"showLink": true
@@ -270,7 +270,7 @@
"description": "Movie library and automation.",
"url": "http://radarr.franksdank.cloud",
"docs": "",
"probe": "192.168.0.235:7878",
"probe": "192.168.0.112:7878",
"method": "http",
"wip": false,
"showLink": true
@@ -281,7 +281,7 @@
"description": "Book library and automation.",
"url": "http://readarr.franksdank.cloud",
"docs": "",
"probe": "192.168.0.235:8787",
"probe": "192.168.0.112:8787",
"method": "http",
"wip": false,
"showLink": true
@@ -292,7 +292,7 @@
"description": "Music library and automation.",
"url": "http://lidarr.franksdank.cloud",
"docs": "",
"probe": "192.168.0.235:8686",
"probe": "192.168.0.112:8686",
"method": "http",
"wip": false,
"showLink": true
@@ -303,7 +303,7 @@
"description": "Torrent client.",
"url": "http://qbittorrent.franksdank.cloud",
"docs": "",
"probe": "192.168.0.235:8080",
"probe": "192.168.0.112:8080",
"method": "http",
"wip": false,
"showLink": true
+2 -2
View File
@@ -111,11 +111,11 @@ REMOTE_DEPLOY="$REPO_ROOT/scripts/deploy-remote.sh"
[ "$output" = $'gitea-postgres\ngitea-server' ]
}
@test "frankbook-pro gluetun changes carry qbittorrent; arr changes stay scoped" {
@test "frankbook-pro gluetun changes carry the whole netns family; arr changes stay scoped" {
run python3 "$PLANNER" --target frankbook-pro --paths \
frankbook-pro/gluetun/quadlet/gluetun.container
[ "$status" -eq 0 ]
[ "$output" = $'gluetun\nqbittorrent' ]
[ "$output" = $'gluetun\nprowlarr\nqbittorrent\nradarr\nsonarr\nlidarr\nreadarr' ]
run python3 "$PLANNER" --target frankbook-pro --paths \
frankbook-pro/sonarr/quadlet/sonarr.container
[ "$status" -eq 0 ]
+20 -18
View File
@@ -2,9 +2,12 @@
# Seam: the FrankBook-Pro quadlet units (gluetun, qbittorrent, five *arrs).
# These units are installed INSIDE the Mac's podman machine VM by the
# frankbook-pro handler in deploy-remote.sh. The contract pinned here:
# boot persistence, self-healing, the gluetun netns invariant (qBittorrent
# publishes no port of its own; gluetun publishes both), the verbatim
# plan-locked data-root mounts, and zero literal secrets in tracked files.
# boot persistence, self-healing, the VPN egress invariant (the whole
# *arr fleet + qBittorrent share gluetun's netns and publish no ports of
# their own, so ALL egress exits through the WireGuard tunnel —
# AllowedIPs=0.0.0.0/0 — by construction; gluetun publishes every fleet
# port), the verbatim plan-locked data-root mounts, and zero literal
# secrets in tracked files.
load helpers
@@ -31,18 +34,22 @@ FRANKBOOK_UNITS=(
done
}
@test "qbittorrent joins gluetun's netns and publishes no port of its own" {
local unit="$REPO_ROOT/frankbook-pro/qbittorrent/quadlet/qbittorrent.container"
grep -q '^Network=gluetun.container$' "$unit" || return 1
grep -q '^Wants=gluetun.service$' "$unit" || return 1
grep -q '^After=gluetun.service$' "$unit" || return 1
! grep -q '^PublishPort=' "$unit" || return 1
@test "every arr and qbittorrent joins gluetun's netns and publishes no port" {
local svc unit
for svc in qbittorrent prowlarr sonarr radarr lidarr readarr; do
unit="$REPO_ROOT/frankbook-pro/$svc/quadlet/$svc.container"
grep -q '^Network=gluetun.container$' "$unit" || return 1
grep -q '^Wants=gluetun.service$' "$unit" || return 1
grep -q '^After=gluetun.service$' "$unit" || return 1
! grep -q '^PublishPort=' "$unit" || return 1
done
}
@test "gluetun publishes qbittorrent's WebUI and torrent ports and needs NET_ADMIN+tun" {
@test "gluetun publishes the whole fleet's ports and needs NET_ADMIN+tun" {
local unit="$REPO_ROOT/frankbook-pro/gluetun/quadlet/gluetun.container"
grep -q '^PublishPort=8080:8080$' "$unit" || return 1
grep -q '^PublishPort=6881:6881$' "$unit" || return 1
for port in 8080 6881 9696 8989 7878 8686 8787; do
grep -q "^PublishPort=$port:$port\$" "$unit" || return 1
done
grep -q '^PublishPort=6881:6881/udp$' "$unit" || return 1
grep -q '^AddCapability=NET_ADMIN$' "$unit" || return 1
grep -q '^AddDevice=/dev/net/tun:/dev/net/tun$' "$unit" || return 1
@@ -69,12 +76,7 @@ FRANKBOOK_UNITS=(
grep -q '^Volume=/Volumes/INCOMPLETE:/Volumes/INCOMPLETE$' "$unit" || return 1
}
@test "every arr serves its standard port and readarr keeps its pinned image" {
grep -q '^PublishPort=9696:9696$' "$REPO_ROOT/frankbook-pro/prowlarr/quadlet/prowlarr.container" || return 1
grep -q '^PublishPort=8989:8989$' "$REPO_ROOT/frankbook-pro/sonarr/quadlet/sonarr.container" || return 1
grep -q '^PublishPort=7878:7878$' "$REPO_ROOT/frankbook-pro/radarr/quadlet/radarr.container" || return 1
grep -q '^PublishPort=8686:8686$' "$REPO_ROOT/frankbook-pro/lidarr/quadlet/lidarr.container" || return 1
grep -q '^PublishPort=8787:8787$' "$REPO_ROOT/frankbook-pro/readarr/quadlet/readarr.container" || return 1
@test "readarr keeps its pinned image" {
grep -q '^Image=lscr.io/linuxserver/readarr:amd64-1.0.980$' \
"$REPO_ROOT/frankbook-pro/readarr/quadlet/readarr.container" || return 1
}