Self-hosted BitTorrent + arr-stack + catalog-update pipeline targeting
nullstone (Debian 13). Replaces the legacy onyx -> rsync -> import
round-trip.
Contents:
- README.md headline + ASCII architecture diagram + quickstart
- CLAUDE.md project rules (mirrors beta-flix style)
- .gitignore secrets dirs (.env, gluetun, qbt config, ssh keys)
- .gitleaksignore allowlist nullstone LAN addr + Tailscale CGNAT
- docs/architecture.md the plan in detail (gluetun + qbt + arr + catalog)
- docs/migration.md onyx-qbt -> nullstone-qbt runbook (3 phases)
- docs/trackers.md tracker schema + IP-pinning + ratio notes (user-curated)
- compose/docker-compose.yml gluetun v3.40 + qbt 5.0.5 (netns=gluetun) +
sonarr/radarr/prowlarr (hotio) + betaflix-catalog
- compose/.env.example documented env-var template (no secrets)
- compose/traefik/arr.yml file-provider for qbt/sonarr/radarr/prowlarr
.s8n.ru subdomains, LAN+TS only via
trusted-only@file + authentik-forwardauth@file
- catalog/catalog.py Flask service, ~340 LoC, /sonarr + /radarr +
/healthz; pulls beta-flix, inserts alphabetic
row into MEDIA-LIST.md, writes run log, commits
+ pushes as obsidian-ai. Idempotent via
payload-hash cache.
- catalog/Dockerfile python:3.12-slim + git + tini
- catalog/requirements.txt flask + jinja2 + requests + gitpython + pyyaml (pinned)
- catalog/templates/*.j2 run log + catalog row Jinja templates
- catalog/README.md service docs
- scripts/migrate-onyx.sh phase-2 helper (rsync + .torrent ship, dry-run by default)
- scripts/add-tracker.sh Prowlarr API helper
- scripts/killswitch-test.sh gluetun kill-switch verification (3 steps)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
96 lines
3.2 KiB
Bash
Executable file
96 lines
3.2 KiB
Bash
Executable file
#!/usr/bin/env bash
|
|
# scripts/killswitch-test.sh — verify gluetun blocks traffic when VPN drops.
|
|
#
|
|
# Test plan (per docs/architecture.md §b):
|
|
#
|
|
# 1. With gluetun UP, qbt MUST resolve api.ipify.org and return an IP
|
|
# that is NOT nullstone's WAN IP (i.e. Proton exit).
|
|
# 2. Stop gluetun. qbt's container MUST NOT be able to reach the internet
|
|
# (curl hangs / fails fast). If it succeeds → kill-switch leak → ABORT.
|
|
# 3. Restart gluetun, re-verify step 1.
|
|
#
|
|
# Run from anywhere with `docker` access on the host. Idempotent.
|
|
|
|
set -euo pipefail
|
|
|
|
GLUETUN="${GLUETUN_CONTAINER:-gluetun}"
|
|
QBT="${QBT_CONTAINER:-qbittorrent}"
|
|
TIMEOUT="${TIMEOUT:-8}"
|
|
|
|
red() { printf '\033[31m%s\033[0m\n' "$*"; }
|
|
green() { printf '\033[32m%s\033[0m\n' "$*"; }
|
|
yellow(){ printf '\033[33m%s\033[0m\n' "$*"; }
|
|
|
|
require_container() {
|
|
if ! docker inspect "$1" >/dev/null 2>&1; then
|
|
red "FAIL: container '$1' not found"; exit 1
|
|
fi
|
|
}
|
|
|
|
require_container "$GLUETUN"
|
|
require_container "$QBT"
|
|
|
|
# --- Step 0: discover nullstone's WAN IP (so we can detect leaks).
|
|
WAN_IP="$(curl -sf --max-time "$TIMEOUT" https://api.ipify.org || true)"
|
|
if [ -z "$WAN_IP" ]; then
|
|
yellow "WARN: could not fetch host WAN IP — leak detection will be best-effort"
|
|
else
|
|
echo "Host WAN IP: $WAN_IP"
|
|
fi
|
|
|
|
# --- Step 1: VPN-up check
|
|
echo
|
|
echo "Step 1: VPN-up — qbt should exit via Proton."
|
|
if ! docker inspect -f '{{.State.Running}}' "$GLUETUN" | grep -q true; then
|
|
yellow "gluetun not running — starting…"
|
|
docker start "$GLUETUN" >/dev/null
|
|
sleep 5
|
|
fi
|
|
VPN_IP="$(docker exec "$QBT" curl -sf --max-time "$TIMEOUT" https://api.ipify.org || true)"
|
|
if [ -z "$VPN_IP" ]; then
|
|
red "FAIL: qbt could not reach api.ipify.org even with gluetun up. Check VPN config."
|
|
exit 1
|
|
fi
|
|
echo "qbt sees IP: $VPN_IP"
|
|
if [ -n "$WAN_IP" ] && [ "$VPN_IP" = "$WAN_IP" ]; then
|
|
red "FAIL: qbt's IP == host WAN IP. Traffic is NOT going through VPN."
|
|
exit 1
|
|
fi
|
|
green "OK: qbt egressing via VPN."
|
|
|
|
# --- Step 2: kill-switch check
|
|
echo
|
|
echo "Step 2: kill-switch — stop gluetun, qbt MUST fail to reach internet."
|
|
docker stop "$GLUETUN" >/dev/null
|
|
sleep 2
|
|
set +e
|
|
LEAK_IP="$(docker exec "$QBT" curl -sf --max-time "$TIMEOUT" https://api.ipify.org 2>/dev/null)"
|
|
RC=$?
|
|
set -e
|
|
docker start "$GLUETUN" >/dev/null
|
|
if [ $RC -eq 0 ] && [ -n "$LEAK_IP" ]; then
|
|
red "FAIL: kill-switch broken — qbt reached the internet with VPN down."
|
|
red " Leaked IP: $LEAK_IP"
|
|
red " Tear down the stack and investigate before adding any torrents."
|
|
exit 1
|
|
fi
|
|
green "OK: qbt could not reach internet with gluetun stopped."
|
|
|
|
# --- Step 3: re-verify VPN comes back
|
|
echo
|
|
echo "Step 3: VPN restart — wait for gluetun to be healthy again."
|
|
for i in $(seq 1 30); do
|
|
if [ "$(docker inspect -f '{{.State.Health.Status}}' "$GLUETUN" 2>/dev/null || echo none)" = "healthy" ]; then
|
|
break
|
|
fi
|
|
sleep 2
|
|
done
|
|
RECOVERY_IP="$(docker exec "$QBT" curl -sf --max-time "$TIMEOUT" https://api.ipify.org || true)"
|
|
if [ -z "$RECOVERY_IP" ]; then
|
|
yellow "WARN: gluetun did not recover within 60s. Check 'docker logs $GLUETUN'."
|
|
exit 1
|
|
fi
|
|
green "OK: VPN recovered. qbt sees IP $RECOVERY_IP."
|
|
|
|
echo
|
|
green "Kill-switch test PASSED. Safe to seed."
|