Skip to content

Commit e5e216e

Browse files
committed
Security and stability improvements
- Updated kill switch test in SETUP.md to use docker stop instead of killall - Added 50GB disk space requirement and ProtonVPN link to prerequisites - Replaced weak password generation with openssl rand -hex 12 - Extended API key timeout from 30s to 60s - Added VPN key validation loop in bootstrap.sh - Added docker compose error checking with helpful failure message - Removed duplicate log-prune launchd install - Improved log trimming with mktemp and trap for safety - Added .env.nord placeholder validation in vpn-mode.sh - Added timeout parameters to franchise-sort.py urlopen calls - Added qBittorrent password validation in download-watchdog.py - Added health checks to all services (qbittorrent, sonarr, radarr, prowlarr, bazarr, seerr, tdarr) - Added Tdarr resource limits (4 CPUs, 8GB RAM) - Added explanatory comments for cap_add and docker.sock mounts - Added SEERR_BIND_IP security warning in .env.example - Added download watchdog environment variables documentation to README
1 parent d9d4bd3 commit e5e216e

11 files changed

Lines changed: 102 additions & 16 deletions

.env.example

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ TIMEZONE=America/New_York
1717

1818
# --- WEB ACCESS ---
1919
# Seerr bind IP: keep 127.0.0.1 for local-only (recommended default).
20-
# Set to 0.0.0.0 only if you intentionally expose Seerr on your LAN.
20+
# WARNING: Setting to 0.0.0.0 exposes Seerr to your entire network. Use 127.0.0.1 for local-only access.
2121
SEERR_BIND_IP=127.0.0.1
2222

2323
# --- VPN (ProtonVPN WireGuard) ---

README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,19 @@ Then open Tidarr at `http://localhost:8484` to authenticate with your Tidal acco
8383
Franchise sorting is kept manual by default because it requires your Plex token:
8484
`PLEX_TOKEN=... python3 scripts/franchise-sort.py`
8585

86+
### Download Watchdog Configuration
87+
88+
The download watchdog reads qBittorrent credentials and behavior settings from environment variables or automatically detects them from your config files. Optional environment variables:
89+
90+
- `QBIT_USERNAME` (default: `admin`)
91+
- `QBIT_PASSWORD` (auto-detected from qBittorrent config if not set)
92+
- `WATCHDOG_STALL_SECONDS` (default: `1800` — how long a torrent must be stalled before auto-swap)
93+
- `WATCHDOG_SLOW_SECONDS` (default: `1200` — how long a torrent must be slow before auto-swap)
94+
- `WATCHDOG_MIN_SPEED_BPS` (default: `307200` — 300 KB/s minimum speed threshold)
95+
- `WATCHDOG_MAX_SWAP_PROGRESS` (default: `0.25` — never swap torrents past 25% complete)
96+
97+
Set these in `.env` or your shell environment if you need to customize watchdog behavior.
98+
8699
## One-Command Install
87100

88101
Requires OrbStack (or Docker Desktop) and Plex already installed. Handles everything else.

SETUP.md

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ Already running the basic stack and want an in-place migration? Use [UPGRADE.md]
2929
## Prerequisites
3030

3131
- A Mac (any recent macOS)
32+
- At least 50GB free disk space (media libraries will need more)
3233
- [OrbStack](https://orbstack.dev) (recommended) or [Docker Desktop](https://www.docker.com/products/docker-desktop/) installed and running
3334
- Plex installed and signed in
3435
- ProtonVPN WireGuard credentials
@@ -92,6 +93,8 @@ open -a TextEdit .env
9293

9394
Fill in `WIREGUARD_PRIVATE_KEY` and `WIREGUARD_ADDRESSES` from your ProtonVPN account.
9495

96+
Get your WireGuard private key from https://account.protonvpn.com/downloads#wireguard-configuration
97+
9598
---
9699

97100
## Step 4: Start the Stack
@@ -135,14 +138,15 @@ curl -s https://ipinfo.io/ip
135138
To test the kill switch (traffic should be blocked when the VPN drops):
136139

137140
```bash
138-
# Kill the VPN process inside the container
139-
docker exec gluetun sh -c 'killall -SIGUSR1 openvpn'
141+
# Stop the VPN container
142+
docker stop gluetun
140143

141-
# Try reaching the internet from inside the VPN namespace — should fail/timeout
142-
docker exec gluetun sh -c 'wget -qO- --timeout=5 https://ipinfo.io/ip'
144+
# Try reaching the internet from qBittorrent — should fail/timeout
145+
# (qBittorrent is routed through gluetun and should have no network when gluetun is down)
146+
docker exec qbittorrent wget -qO- --timeout=5 https://ipinfo.io/ip 2>&1 || echo "Kill switch works: qBittorrent has no network"
143147

144148
# Restore the VPN
145-
docker restart gluetun
149+
docker start gluetun
146150
```
147151

148152
If the second `wget` returns an IP instead of timing out, your kill switch isn't working. Check your Gluetun configuration.

bootstrap.sh

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -206,8 +206,17 @@ if grep -q "your_wireguard_private_key_here" .env 2>/dev/null; then
206206
else
207207
echo -e "${CYAN}VPN Configuration${NC}"
208208
echo ""
209-
read -s -p " WireGuard Private Key: " vpn_key
210-
echo ""
209+
210+
# Loop until we get a non-empty private key
211+
vpn_key=""
212+
while [[ -z "$vpn_key" ]]; do
213+
read -s -p " WireGuard Private Key: " vpn_key
214+
echo ""
215+
if [[ -z "$vpn_key" ]]; then
216+
echo -e " ${RED}Private key cannot be empty. Please enter a valid key.${NC}"
217+
fi
218+
done
219+
211220
read -p " WireGuard Address (e.g. 10.2.0.2/32): " vpn_addr
212221

213222
if [[ -n "$vpn_key" && -n "$vpn_addr" ]]; then
@@ -235,7 +244,15 @@ echo ""
235244
# Start stack
236245
echo -e "${CYAN}Starting media stack (first run downloads ~3-5 GB)...${NC}"
237246
echo ""
238-
docker compose up -d
247+
if ! docker compose up -d; then
248+
echo ""
249+
echo -e "${RED}Failed to start the stack.${NC} Check the error output above."
250+
echo "Common issues:"
251+
echo " - Missing or invalid VPN credentials in .env"
252+
echo " - Conflicting port bindings (close other apps using ports 8080, 9696, etc.)"
253+
echo " - Insufficient disk space"
254+
exit 1
255+
fi
239256

240257
echo ""
241258
echo "Waiting for core services..."

docker-compose.yml

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ services:
22
gluetun:
33
image: qmcgaw/gluetun@sha256:495cdc65ace4c110cf4de3d1f5f90e8a1dd2eb0f8b67151d1ad6101b2a02a476
44
container_name: gluetun
5+
# Grants network admin capabilities needed for VPN tunnel management
56
cap_add:
67
- NET_ADMIN
78
devices:
@@ -38,6 +39,11 @@ services:
3839
- ${MEDIA_DIR}/Downloads:/downloads
3940
- ${MEDIA_DIR}/Movies:/movies
4041
- ${MEDIA_DIR}/TV Shows:/tv
42+
healthcheck:
43+
test: ["CMD-SHELL", "curl -f http://localhost:8080 || exit 1"]
44+
interval: 30s
45+
timeout: 10s
46+
retries: 3
4147
restart: unless-stopped
4248

4349
prowlarr:
@@ -51,6 +57,11 @@ services:
5157
- ${MEDIA_DIR}/config/prowlarr:/config
5258
ports:
5359
- 127.0.0.1:9696:9696
60+
healthcheck:
61+
test: ["CMD-SHELL", "curl -f http://localhost:9696/ping || exit 1"]
62+
interval: 30s
63+
timeout: 10s
64+
retries: 3
5465
restart: unless-stopped
5566

5667
sonarr:
@@ -66,6 +77,11 @@ services:
6677
- ${MEDIA_DIR}/Downloads:/downloads
6778
ports:
6879
- 127.0.0.1:8989:8989
80+
healthcheck:
81+
test: ["CMD-SHELL", "curl -f http://localhost:8989/ping || exit 1"]
82+
interval: 30s
83+
timeout: 10s
84+
retries: 3
6985
restart: unless-stopped
7086

7187
radarr:
@@ -81,6 +97,11 @@ services:
8197
- ${MEDIA_DIR}/Downloads:/downloads
8298
ports:
8399
- 127.0.0.1:7878:7878
100+
healthcheck:
101+
test: ["CMD-SHELL", "curl -f http://localhost:7878/ping || exit 1"]
102+
interval: 30s
103+
timeout: 10s
104+
retries: 3
84105
restart: unless-stopped
85106

86107
bazarr:
@@ -96,6 +117,11 @@ services:
96117
- ${MEDIA_DIR}/TV Shows:/tv
97118
ports:
98119
- 127.0.0.1:6767:6767
120+
healthcheck:
121+
test: ["CMD-SHELL", "curl -f http://localhost:6767/ping || exit 1"]
122+
interval: 30s
123+
timeout: 10s
124+
retries: 3
99125
restart: unless-stopped
100126

101127
flaresolverr:
@@ -119,6 +145,11 @@ services:
119145
- ${MEDIA_DIR}/config/seerr:/app/config
120146
ports:
121147
- ${SEERR_BIND_IP:-127.0.0.1}:5055:5055
148+
healthcheck:
149+
test: ["CMD-SHELL", "curl -f http://localhost:5055/api/v1/status || exit 1"]
150+
interval: 30s
151+
timeout: 10s
152+
retries: 3
122153
restart: unless-stopped
123154

124155
tdarr:
@@ -143,6 +174,16 @@ services:
143174
ports:
144175
- 127.0.0.1:8265:8265
145176
- 127.0.0.1:8266:8266
177+
healthcheck:
178+
test: ["CMD-SHELL", "curl -f http://localhost:8265 || exit 1"]
179+
interval: 30s
180+
timeout: 10s
181+
retries: 3
182+
deploy:
183+
resources:
184+
limits:
185+
cpus: '4'
186+
memory: 8G
146187
restart: unless-stopped
147188

148189
unpackerr:
@@ -231,5 +272,6 @@ services:
231272
- WATCHTOWER_CLEANUP=true
232273
- WATCHTOWER_SCHEDULE=0 0 4 * * *
233274
volumes:
275+
# Allows Watchtower to manage Docker containers
234276
- /var/run/docker.sock:/var/run/docker.sock
235277
restart: unless-stopped

scripts/auto-heal.sh

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,10 @@ log() { echo "$(timestamp) $1" >> "$LOG"; }
1717

1818
# Trim log
1919
if [[ -f "$LOG" ]] && [[ $(wc -l < "$LOG") -gt 500 ]]; then
20-
tail -500 "$LOG" > "$LOG.tmp" && mv "$LOG.tmp" "$LOG"
20+
tmp=$(mktemp)
21+
trap "rm -f '$tmp'" EXIT
22+
tail -n 1000 "$LOG" > "$tmp" && mv "$tmp" "$LOG"
23+
trap - EXIT
2124
fi
2225

2326
log "--- Health check started ---"

scripts/configure.sh

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ if [[ ! -f "$SCRIPT_DIR/.env" ]]; then
4848
fi
4949
source "$SCRIPT_DIR/.env"
5050

51-
QB_PASSWORD="media$(date +%s | shasum | head -c 8)"
51+
QB_PASSWORD="media$(openssl rand -hex 12)"
5252
CREDS_FILE="$MEDIA_DIR/state/first-run-credentials.txt"
5353

5454
log() { echo -e " ${GREEN}OK${NC} $1"; }
@@ -209,7 +209,7 @@ wait_for_service() {
209209
get_api_key() {
210210
local service="$1"
211211
local config_path="$MEDIA_DIR/config/$service/config.xml"
212-
local max_attempts=15 attempt=0
212+
local max_attempts=30 attempt=0
213213
while [[ $attempt -lt $max_attempts ]]; do
214214
if [[ -f "$config_path" ]]; then
215215
local key=$(grep -o '<ApiKey>[^<]*</ApiKey>' "$config_path" 2>/dev/null | sed 's/<[^>]*>//g')

scripts/download-watchdog.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,10 @@ def main():
168168
log("[ERROR] No API keys found")
169169
return 1
170170

171+
if not QBIT_PASSWORD:
172+
log("[ERROR] QBIT_PASSWORD is not set. Check your environment or credentials file.")
173+
return 1
174+
171175
try:
172176
qbit = make_qbit_opener()
173177
except Exception as exc:

scripts/franchise-sort.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,15 +104,15 @@ def require_arg(flag: str) -> str:
104104

105105
def plex_get(path):
106106
url = f"{PLEX_URL}{path}?X-Plex-Token={PLEX_TOKEN}"
107-
resp = urllib.request.urlopen(url)
107+
resp = urllib.request.urlopen(url, timeout=30)
108108
return ET.parse(resp).getroot()
109109

110110

111111
def plex_put(path, params):
112112
params["X-Plex-Token"] = PLEX_TOKEN
113113
url = f"{PLEX_URL}{path}?{urllib.parse.urlencode(params)}"
114114
req = urllib.request.Request(url, method="PUT")
115-
return urllib.request.urlopen(req)
115+
return urllib.request.urlopen(req, timeout=30)
116116

117117

118118
def get_collections():

scripts/install-launchd-jobs.sh

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,8 +104,6 @@ install_plist "download-watchdog" 900 "$SCRIPT_DIR/download-watchdog.py" "false"
104104

105105
# Kometa one-shot (every 4 hours = 14400s)
106106
install_plist "kometa" 14400 "$SCRIPT_DIR/run-kometa.sh"
107-
# Log prune (every 24 hours)
108-
install_plist "log-prune" 86400 "$SCRIPT_DIR/log-prune.sh"
109107

110108
echo ""
111109
echo -e "${YELLOW}Optional:${NC} VPN failover (requires NordVPN as backup)"

0 commit comments

Comments
 (0)