audit: download clients for the catalog - qbittorrent + transmission measured twice, five defects fixed
gates / gates (push) Successful in 6m29s
gates / gates (push) Successful in 6m29s
The arr stack had no download client: radarr/sonarr/seerr decide what to fetch and hand it to one, and there was none. Both templates are written and measured on the bench (LXC 9401) and on 9202 through the product. Neither is published - the onboarding records are not complete and the gate refuses a template without one. Found by measuring, each fixed: - qBittorrent was unreachable on its own public name: WebUI\ServerDomains takes SEMICOLONS; a comma list answers 401 Invalid Host header to everything. Undocumented upstream; measured four ways through the app's own API. - Both clients asked the household's router to open a port (qBittorrent UPnP/NAT-PMP ON, Transmission port-forwarding-enabled true). The box opens no port at home. qBittorrent ignores the seeded Session\UseUPnP=false at first start but honours the same setting through its API, so it goes through after_install: ON at 17:21:51, OFF at 17:21:56. - Transmission could never go healthy (401 on every path, curl -f exit 22). - Transmission's host whitelist refused its own healthcheck. - The apparent lock-out was a measurement artefact: with stranger and household on two different addresses, the stranger is banned and the household signs in at the same instant (204). qBittorrent reads the real visitor address. Downloads work with no inbound port, measured: 34 seeds / 4 MB/s (qBittorrent), 48 peers (Transmission), both complete, port confirmed closed. 9202 is back on the live catalog with both apps removed; bench 9401 stopped as found; the live catalog's main untouched.
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
# Download clients for the catalog — qBittorrent and Transmission (2026-10-12)
|
||||
|
||||
**Why.** Radarr, Sonarr and Seerr have been in the catalog for months and **none of them downloads
|
||||
anything** — they decide what to fetch and hand it to a download client. There was no download
|
||||
client. Radarr's own template said so in its prerequisites („Letöltő kliens szükséges (pl.
|
||||
qBittorrent — külön telepítendő)") while the website promised „Automatikus film letöltő… a filmek
|
||||
automatikusan a könyvtáradba kerülnek". Operator ruling, 2026-10-12: add them.
|
||||
|
||||
**Where this run got to.** Both templates are written and **measured twice** — on the bench (LXC 9401,
|
||||
demo-hp) and on the scratch guest 9202 **through the product**. Neither is published: the onboarding
|
||||
records are not complete, and `catalog_gates.py` refuses a new template without one.
|
||||
|
||||
## What is in here
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `bench/` | qBittorrent on LXC 9401: image, telemetry, entrypoint, first admin, storage, ownership, health, memory, a real download, the config as the app rewrote it |
|
||||
| `transmission-bench/` | the same twelve questions asked of Transmission |
|
||||
| `box/` | guest 9202 through the product: the two box walks, the app logs, and `verify-fixes.json` — the proof the defects below are gone |
|
||||
| `tools/` | `boxwalk_newapps.py` (deploy + stranger + sign-in + lock-out through traefik) and `verify_fixes.py` (the re-measure). Both reuse the 2026-09-30 harness for the session, CSRF and setup gate |
|
||||
|
||||
## Five defects, each found by measuring and fixed
|
||||
|
||||
1. **qBittorrent was unreachable on its own public name.** `WebUI\ServerDomains` takes **semicolons**;
|
||||
with a comma list the app answers 401 „Invalid Host header" to everything, its own name included.
|
||||
Nothing upstream documents the separator — measured through the app's own API
|
||||
(`box/verify-fixes.json`, and the four-way test: comma 401/401, single 200/401, semicolon 200/200,
|
||||
`*` 200/200).
|
||||
2. **Both clients asked the household's router to open a port.** qBittorrent logs
|
||||
`UPnP/NAT-PMP support: ON` and tries a mapping; Transmission ships `"port-forwarding-enabled": true`.
|
||||
The box opens no port at home (`01-topology-and-trust.md` §7), so both are off now. qBittorrent's
|
||||
seeded `Session\UseUPnP=false` **is written and then ignored at first start** — the app reported
|
||||
`"upnp": true` with that very line in its config — so it goes through `after_install`, which the
|
||||
app does honour: `ON` at 17:21:51, **`OFF` at 17:21:56**.
|
||||
3. **Transmission could never become healthy.** It answers 401 on every path once authentication is
|
||||
on, so `curl -f` always failed (`exit 22`). The probe accepts 200 or 401 now, with a negative
|
||||
control that still fails.
|
||||
4. **Transmission's host whitelist refused its own healthcheck** (400 to `Host: 127.0.0.1`).
|
||||
5. **The first box walk looked like a lock-out and was not one.** Stranger and household ran from the
|
||||
same machine, so a shared ban could not be told from a per-visitor one. Re-run from two addresses:
|
||||
the stranger is banned (`401 401 401 401 401 403`, and 403 on the right password) while **the
|
||||
household signs in at the same instant (204)**. qBittorrent reads the real visitor address through
|
||||
the proxy headers; its log names `192.168.0.180`, not traefik. Transmission does not ban at all.
|
||||
|
||||
## Does a download work with no inbound port? Measured, yes
|
||||
|
||||
Operator's question. With UPnP off and the port confirmed closed (`"port-is-open": false`):
|
||||
|
||||
| | after 30 s | peers | finished |
|
||||
|---|---|---|---|
|
||||
| qBittorrent | 98 % at **4.0 MB/s** | 34 seeds | yes, inside a minute |
|
||||
| Transmission | 96 % | 48 connected, 43 sending | yes |
|
||||
|
||||
The tunnel is not involved: Cloudflare carries the app's **web page** inbound, while peer traffic
|
||||
goes out through the household's own router by NAT. What a closed port costs is incoming peers —
|
||||
slower on sparse swarms, less upload — and the app text now says so and says the household can switch
|
||||
it on themselves.
|
||||
|
||||
## What is still open before these can be published
|
||||
|
||||
- **2.5 / 2.8** — a backup and a restore through the product, data read back (the dawarich route:
|
||||
the update's per-app backup → remove keeping backups → restore).
|
||||
- **6.1–6.3** — an upgrade fixture, and a first ladder step proven on bench + 9202
|
||||
(`upgrade-test.py --write-ladder`).
|
||||
- **8.3–8.5** — logo, screenshots, the cards on both language pages, the app count 58 → 60, and the
|
||||
three *arr descriptions that promise automatic downloading.
|
||||
- **The two onboarding records**, which the gate requires complete before either template may land.
|
||||
|
||||
## State left behind
|
||||
|
||||
Guest 9202: both apps **removed** through the product (volumes gone, containers gone, the test
|
||||
downloads deleted from the scratch drive), the controller **restored to the live catalog** from this
|
||||
run's own saved copy, its cache rebuilt (60 templates, neither new app present). Bench LXC 9401:
|
||||
deployments down with their volumes, **stopped** as it was found. The live catalog's `main` is
|
||||
untouched (`3a6e930`). The two templates live in the private drill repo as work in progress.
|
||||
@@ -0,0 +1,7 @@
|
||||
# image + architectures (docker manifest inspect), 2026-10-12T15:42:37+00:00
|
||||
repo digest: qbittorrentofficial/qbittorrent-nox@sha256:92bfd78d731e254ba64f62b77b0696add6447a33642ee5feda106a26b285aa7f
|
||||
"architecture": "386", "os": "linux"
|
||||
"architecture": "amd64", "os": "linux"
|
||||
"architecture": "arm", "os": "linux",
|
||||
"architecture": "arm64", "os": "linux"
|
||||
"architecture": "riscv64", "os": "linux"
|
||||
@@ -0,0 +1,27 @@
|
||||
# preferences that reach the internet by themselves (read through the app's own API)
|
||||
dht.transmissionbt.com:6881
|
||||
"alternative_webui_enabled":false
|
||||
"alternative_webui_path":""
|
||||
"announce_ip":""
|
||||
"announce_port":0
|
||||
"announce_to_all_tiers":true
|
||||
"announce_to_all_trackers":false
|
||||
"anonymous_mode":false
|
||||
"dht":true
|
||||
"dht_bootstrap_nodes":"dht.libtorrent.org:25401
|
||||
"ip_filter_enabled":false
|
||||
"ip_filter_path":""
|
||||
"ip_filter_trackers":false
|
||||
"lsd":true
|
||||
"max_concurrent_http_announces":50
|
||||
"pex":true
|
||||
"reannounce_when_address_changed":false
|
||||
"rss_auto_downloading_enabled":false
|
||||
"rss_download_repack_proper_episodes":true
|
||||
"rss_fetch_delay":2
|
||||
"rss_max_articles_per_feed":50
|
||||
"rss_processing_enabled":false
|
||||
"rss_refresh_interval":30
|
||||
"rss_smart_episode_filters":"s(\\d+)e(\\d+)\n(\\d+)x(\\d+)\n(\\d{4}[.\\-]\\d{1
|
||||
"web_ui_https_cert_path":""
|
||||
"web_ui_https_key_path":""
|
||||
@@ -0,0 +1,113 @@
|
||||
# the running process (production binary, dropped to qbtUser)
|
||||
PID USER COMMAND
|
||||
1 root /sbin/tini -g -- /entrypoint.sh
|
||||
7 qbtUser qbittorrent-nox --confirm-legal-notice --profile=/config --torrenting-port=6881 --webui-port=8080
|
||||
93 root ps -o pid,user,args
|
||||
|
||||
# the image entrypoint, read inside the image
|
||||
#!/bin/sh
|
||||
|
||||
downloadsPath="/downloads"
|
||||
profilePath="/config"
|
||||
qbtConfigFile="$profilePath/qBittorrent/config/qBittorrent.conf"
|
||||
|
||||
isRoot="0"
|
||||
if [ "$(id -u)" = "0" ]; then
|
||||
isRoot="1"
|
||||
fi
|
||||
|
||||
if [ "$isRoot" = "1" ]; then
|
||||
if [ -n "$PUID" ] && [ "$PUID" != "$(id -u qbtUser)" ]; then
|
||||
sed -i "s|^qbtUser:x:[0-9]*:|qbtUser:x:$PUID:|g" /etc/passwd
|
||||
fi
|
||||
|
||||
if [ -n "$PGID" ] && [ "$PGID" != "$(id -g qbtUser)" ]; then
|
||||
sed -i "s|^\(qbtUser:x:[0-9]*\):[0-9]*:|\1:$PGID:|g" /etc/passwd
|
||||
sed -i "s|^qbtUser:x:[0-9]*:|qbtUser:x:$PGID:|g" /etc/group
|
||||
fi
|
||||
|
||||
if [ -n "$PAGID" ]; then
|
||||
_origIFS="$IFS"
|
||||
IFS=','
|
||||
for AGID in $PAGID; do
|
||||
AGID=$(echo "$AGID" | tr -d '[:space:]"')
|
||||
addgroup -g "$AGID" "qbtGroup-$AGID"
|
||||
addgroup qbtUser "qbtGroup-$AGID"
|
||||
done
|
||||
IFS="$_origIFS"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ ! -f "$qbtConfigFile" ]; then
|
||||
mkdir -p "$(dirname $qbtConfigFile)"
|
||||
cat << EOF > "$qbtConfigFile"
|
||||
[BitTorrent]
|
||||
Session\DefaultSavePath=$downloadsPath
|
||||
Session\Port=6881
|
||||
Session\TempPath=$downloadsPath/temp
|
||||
Session\TempPathEnabled=true
|
||||
[Meta]
|
||||
MigrationVersion=9999
|
||||
[Preferences]
|
||||
WebUI\Port=8080
|
||||
EOF
|
||||
fi
|
||||
|
||||
argLegalNotice=""
|
||||
_legalNotice=$(echo "$QBT_LEGAL_NOTICE" | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]')
|
||||
if [ "$_legalNotice" = "confirm" ]; then
|
||||
argLegalNotice="--confirm-legal-notice"
|
||||
else
|
||||
# for backward compatibility
|
||||
# TODO: remove in next major version release
|
||||
_eula=$(echo "$QBT_EULA" | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]')
|
||||
if [ "$_eula" = "accept" ]; then
|
||||
echo "QBT_EULA=accept is deprecated and will be removed soon. The replacement is QBT_LEGAL_NOTICE=confirm"
|
||||
argLegalNotice="--confirm-legal-notice"
|
||||
fi
|
||||
fi
|
||||
|
||||
argTorrentingPort=""
|
||||
if [ -n "$QBT_TORRENTING_PORT" ]; then
|
||||
argTorrentingPort="--torrenting-port=$QBT_TORRENTING_PORT"
|
||||
fi
|
||||
|
||||
argWebUIPort=""
|
||||
if [ -n "$QBT_WEBUI_PORT" ]; then
|
||||
argWebUIPort="--webui-port=$QBT_WEBUI_PORT"
|
||||
fi
|
||||
|
||||
if [ "$isRoot" = "1" ]; then
|
||||
# those are owned by root by default
|
||||
# don't change existing files owner in `$downloadsPath`
|
||||
if [ -d "$downloadsPath" ]; then
|
||||
chown qbtUser:qbtUser "$downloadsPath"
|
||||
fi
|
||||
if [ -d "$profilePath" ]; then
|
||||
chown qbtUser:qbtUser -R "$profilePath"
|
||||
fi
|
||||
fi
|
||||
|
||||
# set umask just before starting qbt
|
||||
if [ -n "$UMASK" ]; then
|
||||
umask "$UMASK"
|
||||
fi
|
||||
|
||||
if [ "$isRoot" = "1" ]; then
|
||||
exec \
|
||||
doas -u qbtUser \
|
||||
qbittorrent-nox \
|
||||
"$argLegalNotice" \
|
||||
--profile="$profilePath" \
|
||||
"$argTorrentingPort" \
|
||||
"$argWebUIPort" \
|
||||
"$@"
|
||||
else
|
||||
exec \
|
||||
qbittorrent-nox \
|
||||
"$argLegalNotice" \
|
||||
--profile="$profilePath" \
|
||||
"$argTorrentingPort" \
|
||||
"$argWebUIPort" \
|
||||
"$@"
|
||||
fi
|
||||
@@ -0,0 +1,18 @@
|
||||
# first admin: class 1, seeded at first boot. Measured 2026-10-12T15:42:46+00:00
|
||||
## 1. no temporary password anywhere in the log (the whole log, after a restart):
|
||||
0
|
||||
## 2. the generated password signs in (HTTP code):
|
||||
204
|
||||
## 3. a wrong password is refused (body):
|
||||
Unauthorized
|
||||
## 4. an unauthenticated API call (HTTP code):
|
||||
403
|
||||
## 5. lock-out after wrong tries (qBittorrent bans the address; code per try):
|
||||
try 1: 401
|
||||
try 2: 401
|
||||
try 3: 401
|
||||
try 4: 401
|
||||
try 5: 403
|
||||
try 6: 403
|
||||
## 6. and the household, right after those tries, from the same address:
|
||||
403
|
||||
@@ -0,0 +1,33 @@
|
||||
# every path the app writes, and where it is mounted
|
||||
Filesystem Size Used Available Use% Mounted on
|
||||
/dev/loop2 58.8G 6.7G 49.0G 12% /downloads
|
||||
/dev/loop2 58.8G 6.7G 49.0G 12% /downloads
|
||||
|
||||
## /config (named volume, NVMe) — settings + resume data:
|
||||
/config/qBittorrent/data/GeoDB/dbip-country-lite.mmdb
|
||||
/config/qBittorrent/data/logs/qbittorrent.log
|
||||
/config/qBittorrent/data/BT_backup/dd8255ecdc7ca55fb0bbf81323d87062db1f6d1c.fastresume
|
||||
/config/qBittorrent/data/BT_backup/dd8255ecdc7ca55fb0bbf81323d87062db1f6d1c.torrent
|
||||
/config/qBittorrent/data/BT_backup/queue
|
||||
/config/qBittorrent/config/qBittorrent-data.conf
|
||||
/config/qBittorrent/config/lockfile
|
||||
/config/qBittorrent/config/watched_folders.json
|
||||
/config/qBittorrent/config/rss/feeds.json
|
||||
/config/qBittorrent/config/categories.json
|
||||
/config/qBittorrent/config/qBittorrent.conf
|
||||
8.1M /config
|
||||
|
||||
## /downloads (the household's drive, bind mount):
|
||||
total 16
|
||||
drwxr-xr-x 4 qbtUser qbtUser 4096 Oct 12 17:39 .
|
||||
drwxr-xr-x 1 root root 4096 Oct 12 17:37 ..
|
||||
drwxr-xr-x 2 qbtUser qbtUser 4096 Oct 12 17:39 .temp
|
||||
drwxr-xr-x 2 qbtUser qbtUser 4096 Oct 12 17:39 Big Buck Bunny
|
||||
|
||||
## anything written OUTSIDE a mount (the container's own writable layer):
|
||||
A /tmp/c3
|
||||
A /tmp/ck
|
||||
A /tmp/c
|
||||
A /tmp/c2
|
||||
A /config
|
||||
A /downloads
|
||||
@@ -0,0 +1,6 @@
|
||||
# file owner on both sides of the bind mount (PUID/PGID = 1000)
|
||||
total 4
|
||||
drwxr-xr-x 2 1000 1000 4096 Oct 12 17:39 Big Buck Bunny
|
||||
## host side:
|
||||
total 4
|
||||
drwxr-xr-x 2 1000 1000 4096 Oct 12 15:39 Big Buck Bunny
|
||||
@@ -0,0 +1,9 @@
|
||||
# healthcheck
|
||||
state: healthy
|
||||
test: [CMD curl -fsS -o /dev/null http://127.0.0.1:8080/] interval=30s start_period=30s
|
||||
## the tool the healthcheck uses, inside the image:
|
||||
/usr/bin/curl
|
||||
## negative control — the probe against a port nothing serves:
|
||||
curl: (7) Failed to connect to 127.0.0.1:9999 after 0 ms: Could not connect to server
|
||||
000
|
||||
curl exit: 7
|
||||
@@ -0,0 +1,13 @@
|
||||
# memory, measured on this run
|
||||
now: 17.04MiB / 1GiB 1.66%
|
||||
cgroup peak (incl. page cache): 21 MiB
|
||||
anon: 16 MiB
|
||||
file: 0 MiB
|
||||
oom_kill:
|
||||
oom 0
|
||||
oom_kill 0
|
||||
oom_group_kill 0
|
||||
swap on the bench:
|
||||
total used free shared buff/cache available
|
||||
Mem: 10240 105 7956 0 2178 10134
|
||||
Swap: 0 0 0
|
||||
@@ -0,0 +1,28 @@
|
||||
# a real torrent, added through the app's own API and finished
|
||||
"content_path":"/downloads/Big Buck Bunny"
|
||||
"downloaded":276610512
|
||||
"name":"Big Buck Bunny"
|
||||
"progress":1
|
||||
"save_path":"/downloads"
|
||||
"state":"stalledUP"
|
||||
"total_size":276445467
|
||||
## the file on the household's drive:
|
||||
/opt/qbt-bench/userdata/media/torrents:
|
||||
total 16
|
||||
drwxr-xr-x 4 1000 1000 4096 Oct 12 15:39 .
|
||||
drwxr-xr-x 3 root root 4096 Oct 12 15:37 ..
|
||||
drwxr-xr-x 2 1000 1000 4096 Oct 12 15:39 .temp
|
||||
drwxr-xr-x 2 1000 1000 4096 Oct 12 15:39 Big Buck Bunny
|
||||
|
||||
/opt/qbt-bench/userdata/media/torrents/.temp:
|
||||
total 8
|
||||
drwxr-xr-x 2 1000 1000 4096 Oct 12 15:39 .
|
||||
drwxr-xr-x 4 1000 1000 4096 Oct 12 15:39 ..
|
||||
|
||||
/opt/qbt-bench/userdata/media/torrents/Big Buck Bunny:
|
||||
total 269984
|
||||
drwxr-xr-x 2 1000 1000 4096 Oct 12 15:39 .
|
||||
drwxr-xr-x 4 1000 1000 4096 Oct 12 15:39 ..
|
||||
-rw-r--r-- 1 1000 1000 140 Oct 12 15:38 Big Buck Bunny.en.srt
|
||||
-rw-r--r-- 1 1000 1000 276134947 Oct 12 15:39 Big Buck Bunny.mp4
|
||||
-rw-r--r-- 1 1000 1000 310380 Oct 12 15:38 poster.jpg
|
||||
@@ -0,0 +1,25 @@
|
||||
[BitTorrent]
|
||||
Session\DefaultSavePath=/downloads
|
||||
Session\Port=6881
|
||||
Session\QueueingSystemEnabled=true
|
||||
Session\SSL\Port=34909
|
||||
Session\TempPath=/downloads/.temp
|
||||
Session\TempPathEnabled=true
|
||||
|
||||
[LegalNotice]
|
||||
Accepted=true
|
||||
|
||||
[Meta]
|
||||
MigrationVersion=8
|
||||
|
||||
[Network]
|
||||
Cookies=@Invalid()
|
||||
|
||||
[Preferences]
|
||||
WebUI\Address=*
|
||||
WebUI\Password_PBKDF2=<redacted>
|
||||
WebUI\Port=8080
|
||||
WebUI\ReverseProxySupportEnabled=true
|
||||
WebUI\ServerDomains=torrent.bench.local
|
||||
WebUI\TrustedReverseProxiesList=172.16.0.0/12
|
||||
WebUI\Username=admin
|
||||
@@ -0,0 +1,17 @@
|
||||
[felhom] first boot - seeding qBittorrent config and the generated WebUI password
|
||||
WebUI will be started shortly after internal preparations. Please wait...
|
||||
|
||||
******** Information ********
|
||||
To control qBittorrent, access the WebUI at: http://localhost:8080
|
||||
Catching signal: SIGTERM
|
||||
Exiting cleanly
|
||||
WebUI will be started shortly after internal preparations. Please wait...
|
||||
|
||||
******** Information ********
|
||||
To control qBittorrent, access the WebUI at: http://localhost:8080
|
||||
Catching signal: SIGTERM
|
||||
Exiting cleanly
|
||||
WebUI will be started shortly after internal preparations. Please wait...
|
||||
|
||||
******** Information ********
|
||||
To control qBittorrent, access the WebUI at: http://localhost:8080
|
||||
@@ -0,0 +1,70 @@
|
||||
name: qbt-bench
|
||||
services:
|
||||
qbittorrent:
|
||||
container_name: qbittorrent
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: "1073741824"
|
||||
entrypoint:
|
||||
- /bin/sh
|
||||
- -c
|
||||
- |
|
||||
CONF=/config/qBittorrent/config/qBittorrent.conf
|
||||
if [ ! -f "$$CONF" ]; then
|
||||
echo "[felhom] first boot - seeding qBittorrent config and the generated WebUI password"
|
||||
mkdir -p /config/qBittorrent/config
|
||||
HASH=$$(QBT_PW="$$QBT_ADMIN_PASSWORD" python3 -c "import base64,hashlib,os,sys; s=os.urandom(16); k=hashlib.pbkdf2_hmac('sha512', os.environ['QBT_PW'].encode('utf-8'), s, 100000, 64); sys.stdout.write('@ByteArray(%s:%s)' % (base64.b64encode(s).decode(), base64.b64encode(k).decode()))")
|
||||
printf '%s\n' '[BitTorrent]' 'Session\DefaultSavePath=/downloads' 'Session\TempPath=/downloads/.temp' 'Session\TempPathEnabled=true' 'Session\Port=6881' '[Meta]' 'MigrationVersion=9999' '[Preferences]' 'WebUI\Username=admin' "WebUI\\Password_PBKDF2=\"$$HASH\"" 'WebUI\Port=8080' 'WebUI\Address=*' "WebUI\\ServerDomains=$$QBT_PUBLIC_HOST" 'WebUI\ReverseProxySupportEnabled=true' 'WebUI\TrustedReverseProxiesList=172.16.0.0/12' > "$$CONF"
|
||||
fi
|
||||
exec /sbin/tini -g -- /entrypoint.sh
|
||||
environment:
|
||||
PGID: "1000"
|
||||
PUID: "1000"
|
||||
QBT_ADMIN_PASSWORD: <redacted>
|
||||
QBT_LEGAL_NOTICE: confirm
|
||||
QBT_PUBLIC_HOST: torrent.bench.local
|
||||
QBT_TORRENTING_PORT: "6881"
|
||||
QBT_WEBUI_PORT: "8080"
|
||||
TZ: Europe/Budapest
|
||||
UMASK: "022"
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- curl
|
||||
- -fsS
|
||||
- -o
|
||||
- /dev/null
|
||||
- http://127.0.0.1:8080/
|
||||
timeout: 5s
|
||||
interval: 30s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
image: qbittorrentofficial/qbittorrent-nox:5.2.4-1
|
||||
labels:
|
||||
traefik.enable: "true"
|
||||
traefik.http.routers.qbittorrent.entrypoints: websecure
|
||||
traefik.http.routers.qbittorrent.rule: Host(`torrent.bench.local`)
|
||||
traefik.http.routers.qbittorrent.tls: "true"
|
||||
traefik.http.routers.qbittorrent.tls.certresolver: letsencrypt
|
||||
traefik.http.services.qbittorrent.loadbalancer.server.port: "8080"
|
||||
networks:
|
||||
traefik-public: null
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /opt/qbt-bench/userdata/media/torrents
|
||||
target: /downloads
|
||||
bind:
|
||||
create_host_path: true
|
||||
- type: volume
|
||||
source: qbittorrent_config
|
||||
target: /config
|
||||
volume: {}
|
||||
networks:
|
||||
traefik-public:
|
||||
name: traefik-public
|
||||
external: true
|
||||
volumes:
|
||||
qbittorrent_config:
|
||||
name: qbt-bench_qbittorrent_config
|
||||
@@ -0,0 +1,35 @@
|
||||
19:09:14 logged in to the 9202 dashboard
|
||||
19:09:14 sync -> 200 {'ok': True, 'data': {'ok': True, 'updated': ['transmission'], 'message': 'Sablonok frissítve — frissítve: transmission'
|
||||
19:09:14 rescan -> 200 {'ok': True, 'message': 'Rescan completed: 67 stacks found'}
|
||||
19:09:14 === qbittorrent ===
|
||||
19:09:14 [qbittorrent] template_visible: True
|
||||
19:09:17 [1] made the drive paths this app requires: ['/mnt/felhom-drives/scratch_hdd/userdata/qbittorrent']
|
||||
19:09:17 [1] required fields filled beyond DOMAIN/SUBDOMAIN: ['HDD_PATH', 'QBT_ADMIN_PASSWORD']
|
||||
19:09:17 [1] deploy -> 202 {'ok': True, 'message': 'Telepítés elindítva – az állapot a kártyán követhető'}
|
||||
19:09:37 [1] deployed, controller state=running, pinned={'qbittorrent': 'qbittorrentofficial/qbittorrent-nox:5.2.4-1'}
|
||||
19:09:37 [qbittorrent] deploy: True
|
||||
19:09:37 [qbittorrent] generated_password_length: 30
|
||||
19:09:38 [qbittorrent] app_answers_on_its_public_name: True
|
||||
19:09:38 [qbittorrent] stranger_root: 200
|
||||
19:09:38 [qbittorrent] stranger_api: 403
|
||||
19:09:38 [qbittorrent] household_signin_through_traefik: 204
|
||||
19:09:38 [qbittorrent] wrong_password_through_traefik: 401 Unauthorized
|
||||
19:09:38 [qbittorrent] six_wrong_tries: ['401', '401', '401', '401', '403', '403']
|
||||
19:09:38 [qbittorrent] household_right_after_the_wrong_tries: 403 Your IP address has been banned after to
|
||||
19:09:42 [qbittorrent] app_log_captured: True
|
||||
19:09:45 === transmission ===
|
||||
19:09:45 [transmission] template_visible: True
|
||||
19:09:48 [1] made the drive paths this app requires: ['/mnt/felhom-drives/scratch_hdd/userdata/transmission']
|
||||
19:09:48 [1] required fields filled beyond DOMAIN/SUBDOMAIN: ['HDD_PATH', 'TR_ADMIN_PASSWORD']
|
||||
19:09:48 [1] deploy -> 202 {'ok': True, 'message': 'Telepítés elindítva – az állapot a kártyán követhető'}
|
||||
19:10:08 [1] deployed, controller state=running, pinned={'transmission': 'lscr.io/linuxserver/transmission:version-4.1.3-r0'}
|
||||
19:10:08 [transmission] deploy: True
|
||||
19:10:08 [transmission] generated_password_length: 30
|
||||
19:10:08 [transmission] app_answers_on_its_public_name: True
|
||||
19:10:08 [transmission] stranger_root: 401
|
||||
19:10:08 [transmission] stranger_api: 401
|
||||
19:10:08 [transmission] household_signin_through_traefik: 409
|
||||
19:10:08 [transmission] wrong_password_through_traefik: 401
|
||||
19:10:09 [transmission] six_wrong_tries: ['401', '401', '401', '401', '401', '401']
|
||||
19:10:09 [transmission] household_right_after_the_wrong_tries: 409
|
||||
19:10:12 [transmission] app_log_captured: True
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"qbittorrent": {
|
||||
"template_visible": true,
|
||||
"deploy": true,
|
||||
"generated_password_length": 30,
|
||||
"app_answers_on_its_public_name": true,
|
||||
"stranger_root": "200",
|
||||
"stranger_api": "403",
|
||||
"household_signin_through_traefik": "204 ",
|
||||
"wrong_password_through_traefik": "401 Unauthorized",
|
||||
"six_wrong_tries": [
|
||||
"401",
|
||||
"401",
|
||||
"401",
|
||||
"401",
|
||||
"403",
|
||||
"403"
|
||||
],
|
||||
"household_right_after_the_wrong_tries": "403 Your IP address has been banned after to",
|
||||
"app_log_captured": true
|
||||
},
|
||||
"transmission": {
|
||||
"template_visible": true,
|
||||
"deploy": true,
|
||||
"generated_password_length": 30,
|
||||
"app_answers_on_its_public_name": true,
|
||||
"stranger_root": "401",
|
||||
"stranger_api": "401",
|
||||
"household_signin_through_traefik": "409",
|
||||
"wrong_password_through_traefik": "401",
|
||||
"six_wrong_tries": [
|
||||
"401",
|
||||
"401",
|
||||
"401",
|
||||
"401",
|
||||
"401",
|
||||
"401"
|
||||
],
|
||||
"household_right_after_the_wrong_tries": "409",
|
||||
"app_log_captured": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
[felhom] first boot - seeding qBittorrent config and the generated WebUI password
|
||||
WebUI will be started shortly after internal preparations. Please wait...
|
||||
|
||||
******** Information ********
|
||||
To control qBittorrent, access the WebUI at: http://localhost:8080
|
||||
@@ -0,0 +1,26 @@
|
||||
(N) 2026-10-12T17:21:51 - qBittorrent v5.2.4 started. Process ID: 9
|
||||
(N) 2026-10-12T17:21:51 - Using config directory: /config/qBittorrent/config
|
||||
(N) 2026-10-12T17:21:51 - Trying to listen on the following list of IP addresses: "0.0.0.0:6881,[::]:6881"
|
||||
(I) 2026-10-12T17:21:51 - Peer ID: "-qB5240-"
|
||||
(I) 2026-10-12T17:21:51 - HTTP User-Agent: "qBittorrent/5.2.4"
|
||||
(I) 2026-10-12T17:21:51 - Distributed Hash Table (DHT) support: ON
|
||||
(I) 2026-10-12T17:21:51 - Local Peer Discovery support: ON
|
||||
(I) 2026-10-12T17:21:51 - Peer Exchange (PeX) support: ON
|
||||
(I) 2026-10-12T17:21:51 - Anonymous mode: OFF
|
||||
(I) 2026-10-12T17:21:51 - Encryption support: ON
|
||||
(I) 2026-10-12T17:21:51 - Successfully listening on IP. IP: "127.0.0.1". Port: "TCP/6881"
|
||||
(I) 2026-10-12T17:21:51 - Successfully listening on IP. IP: "127.0.0.1". Port: "UDP/6881"
|
||||
(I) 2026-10-12T17:21:51 - Successfully listening on IP. IP: "172.18.0.6". Port: "TCP/6881"
|
||||
(I) 2026-10-12T17:21:51 - Successfully listening on IP. IP: "172.18.0.6". Port: "UDP/6881"
|
||||
(I) 2026-10-12T17:21:51 - Successfully listening on IP. IP: "::1". Port: "TCP/6881"
|
||||
(I) 2026-10-12T17:21:51 - Successfully listening on IP. IP: "::1". Port: "UDP/6881"
|
||||
(I) 2026-10-12T17:21:51 - UPnP/NAT-PMP support: ON
|
||||
(W) 2026-10-12T17:21:51 - Couldn't load IP geolocation database. Reason: No such file or directory
|
||||
(N) 2026-10-12T17:21:51 - Using built-in WebUI.
|
||||
(W) 2026-10-12T17:21:51 - Couldn't load WebUI translation for selected locale (C).
|
||||
(N) 2026-10-12T17:21:51 - WebUI: Now listening on IP: *, port: 8080
|
||||
(I) 2026-10-12T17:21:51 - Detected external IP. IP: "37.191.56.193"
|
||||
(I) 2026-10-12T17:21:51 - IP geolocation database loaded. Type: DBIP-Country-Lite. Build time: Thu Oct 1 01:36:12 2026.
|
||||
(I) 2026-10-12T17:21:51 - Successfully updated IP geolocation database.
|
||||
(N) 2026-10-12T17:21:56 - WebAPI login success. IP: ::ffff:127.0.0.1
|
||||
(I) 2026-10-12T17:21:56 - UPnP/NAT-PMP support: OFF
|
||||
@@ -0,0 +1,2 @@
|
||||
ls: cannot access '/mnt/felhom-drives/scratch_hdd/userdata/media/qbittorrent': No such file or directory
|
||||
qbittorrent_qbittorrent_config
|
||||
@@ -0,0 +1,20 @@
|
||||
User UID: 1000
|
||||
User GID: 1000
|
||||
───────────────────────────────────────
|
||||
Linuxserver.io version: 4.1.3-r0-ls363
|
||||
Build-date: 2026-09-29T16:34:59+00:00
|
||||
───────────────────────────────────────
|
||||
|
||||
stat: cannot statx '/downloads/complete': No such file or directory
|
||||
find: ‘/downloads/complete’: No such file or directory
|
||||
**** Permissions could not be set. This is probably because your volume mounts are remote or read-only. ****
|
||||
**** The app may not work properly and we will not provide support for it. ****
|
||||
stat: cannot statx '/downloads/incomplete': No such file or directory
|
||||
find: ‘/downloads/incomplete’: No such file or directory
|
||||
**** Permissions could not be set. This is probably because your volume mounts are remote or read-only. ****
|
||||
**** The app may not work properly and we will not provide support for it. ****
|
||||
[custom-init] No custom files found, skipping...
|
||||
Connection to localhost (::1) 9091 port [tcp/*] succeeded!
|
||||
[2026-10-12T19:09:55.432+0200] ERR watchdir-inotify.cc:103 Couldn't watch '/watch': No such file or directory (2) (watchdir-inotify.cc:103)
|
||||
[2026-10-12T19:09:55.432+0200] ERR utils.cc:145 Couldn't read '/config/queue.json': No such file or directory (2) (utils.cc:145)
|
||||
[ls.io-init] done.
|
||||
@@ -0,0 +1,4 @@
|
||||
total 8
|
||||
drwxrwsr-x 2 1000 1000 4096 Oct 12 17:09 .
|
||||
drwxrwsr-x 14 root 1000 4096 Oct 12 17:09 ..
|
||||
transmission_transmission_config
|
||||
@@ -0,0 +1,3 @@
|
||||
"dht-enabled": true,
|
||||
"peer-port": 51413,
|
||||
"port-forwarding-enabled": false,
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"qbittorrent": {
|
||||
"redeployed": true,
|
||||
"app_answers": true,
|
||||
"upnp_lines_in_the_app_log": [
|
||||
"(I) 2026-10-12T17:21:51 - UPnP/NAT-PMP support: ON",
|
||||
"(I) 2026-10-12T17:21:56 - UPnP/NAT-PMP support: OFF"
|
||||
],
|
||||
"invalid_host_header_lines": 0,
|
||||
"household_before_any_wrong_try": "204",
|
||||
"stranger_six_wrong_tries_from_192_168_0_180": [
|
||||
"401",
|
||||
"401",
|
||||
"401",
|
||||
"401",
|
||||
"401",
|
||||
"403"
|
||||
],
|
||||
"stranger_right_after": "403",
|
||||
"HOUSEHOLD_right_after_from_its_own_address": "204"
|
||||
},
|
||||
"transmission": {
|
||||
"redeployed": true,
|
||||
"app_answers": true,
|
||||
"settings_after_fix": "\"dht-enabled\": true, \"peer-port\": 51413, \"port-forwarding-enabled\": false,",
|
||||
"household_before_any_wrong_try": "409",
|
||||
"stranger_six_wrong_tries_from_192_168_0_180": [
|
||||
"401",
|
||||
"401",
|
||||
"401",
|
||||
"401",
|
||||
"401",
|
||||
"401"
|
||||
],
|
||||
"stranger_right_after": "409",
|
||||
"HOUSEHOLD_right_after_from_its_own_address": "409"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
#!/usr/bin/env python3
|
||||
"""boxwalk_newapps.py — qbittorrent and transmission installed on guest 9202 THROUGH THE PRODUCT.
|
||||
|
||||
Closes the checklist items that only a real install can close: 3.5 (the install window — what a
|
||||
stranger reaches), 3.6 (lock-out: can a stranger lock the household out), 3.9 (sign in the way each
|
||||
client does, through traefik over https), 3.10 (the visitor's address), 2.6 (remove with data / keep
|
||||
data), 9.2 (a fresh install as household and as stranger).
|
||||
|
||||
EVIDENCE, NOT PRODUCT. It presses the buttons a person presses — POST /api/stacks/<n>/deploy,
|
||||
GET /api/stacks/<n>, POST /api/stacks/<n>/remove — and reaches each app on its own public name.
|
||||
Reuses the proven harness of 2026-09-30 (`pg-last-six-2026-09-30/tools/walk.py`) for the session, the
|
||||
CSRF, the setup gate and the deploy-field filling.
|
||||
|
||||
Runs ON DOOPLEX. 9202 must already be pointed at the drill catalog (09 §6.5).
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
TOOLS = "/mnt/5_hdd/felhom.eu/git/felhom.eu/documentation/audits/pg-last-six-2026-09-30/tools"
|
||||
sys.path.insert(0, TOOLS)
|
||||
os.environ.setdefault("SC", os.environ.get("SC", "/tmp/newapps-walk"))
|
||||
os.makedirs(os.environ["SC"], exist_ok=True)
|
||||
import walk as w # noqa: E402
|
||||
|
||||
EV = os.environ.get("EV", "/mnt/5_hdd/felhom.eu/git/felhom.eu/documentation/audits/qbittorrent-2026-10-12/box")
|
||||
os.makedirs(EV, exist_ok=True)
|
||||
|
||||
APPS = [
|
||||
# (template name, subdomain, the app's own sign-in probe)
|
||||
("qbittorrent", "torrent", {
|
||||
"login_path": "/api/v2/auth/login",
|
||||
"user_field": "username", "pass_field": "password",
|
||||
"pw_env": "QBT_ADMIN_PASSWORD",
|
||||
"unauth_probe": "/api/v2/app/preferences", # must refuse a stranger
|
||||
}),
|
||||
("transmission", "transmission", {
|
||||
"login_path": "/transmission/rpc",
|
||||
"basic": True,
|
||||
"pw_env": "TR_ADMIN_PASSWORD",
|
||||
"unauth_probe": "/transmission/rpc",
|
||||
}),
|
||||
]
|
||||
|
||||
OUT = {}
|
||||
|
||||
# the scratch guest's drive, as the controller names it
|
||||
DRIVE_ROOT = os.environ.get("DRIVE_ROOT", "/mnt/felhom-drives/scratch_hdd")
|
||||
|
||||
|
||||
def rec(app, key, value):
|
||||
OUT.setdefault(app, {})[key] = value
|
||||
w.say(f" [{app}] {key}: {str(value)[:160]}")
|
||||
|
||||
|
||||
def stranger(sub, path):
|
||||
"""A stranger: the app's public name, no gate cookie, no credentials."""
|
||||
r = w.sh(["curl", "-sSk", "--max-time", "20", "-H", f"Host: {sub}.{w.DOMAIN}",
|
||||
"-o", "/dev/null", "-w", "%{http_code}", f"{w.BASE}{path}"])
|
||||
return (r.stdout or "").strip()
|
||||
|
||||
|
||||
def main():
|
||||
w.login()
|
||||
w.say("logged in to the 9202 dashboard")
|
||||
|
||||
# the box must have the drill catalog's templates
|
||||
code, d = w.ctl("POST", "/api/sync", {})
|
||||
w.say(f"sync -> {code} {str(d)[:120]}")
|
||||
code, d = w.ctl("POST", "/api/stacks/rescan", {})
|
||||
w.say(f"rescan -> {code} {str(d)[:120]}")
|
||||
|
||||
for name, sub, spec in APPS:
|
||||
w.say(f"=== {name} ===")
|
||||
st = w.stack(name)
|
||||
if not st:
|
||||
rec(name, "template_visible", False)
|
||||
continue
|
||||
rec(name, "template_visible", True)
|
||||
|
||||
# ---- 9.2 / 3.5: deploy as the household
|
||||
# HDD_PATH must be a DRIVE, not a folder on it: the controller refuses the folder with
|
||||
# „Az alkalmazas adatainak helye egy meghajto lehet, nem egy mappa a meghajton belul"
|
||||
# (409, measured 2026-10-12 19:02). walk.py's deploy_values builds <drive>/userdata/<app>
|
||||
# for a `type: path` field, which is right for an app that wants a folder and wrong here.
|
||||
ok = w.deploy(name, sub, extra_values={"HDD_PATH": DRIVE_ROOT})
|
||||
rec(name, "deploy", ok)
|
||||
if not ok:
|
||||
continue
|
||||
pw = (w.GENERATED.get(name) or {}).get(spec["pw_env"], "")
|
||||
rec(name, "generated_password_length", len(pw))
|
||||
|
||||
answered = w.wait_app(sub, "/", tries=40, delay=5)
|
||||
rec(name, "app_answers_on_its_public_name", answered)
|
||||
|
||||
# ---- 3.5: what a STRANGER reaches in the install window
|
||||
rec(name, "stranger_root", stranger(sub, "/"))
|
||||
rec(name, "stranger_api", stranger(sub, spec["unauth_probe"]))
|
||||
|
||||
# ---- 3.9: the household signs in through traefik over https
|
||||
if spec.get("basic"):
|
||||
r = w.sh(["curl", "-sSk", "--max-time", "25", "-H", f"Host: {sub}.{w.DOMAIN}",
|
||||
"-u", f"admin:{pw}", "-o", "/dev/null", "-w", "%{http_code}",
|
||||
f"{w.BASE}{spec['login_path']}"])
|
||||
rec(name, "household_signin_through_traefik", (r.stdout or "").strip())
|
||||
r = w.sh(["curl", "-sSk", "--max-time", "25", "-H", f"Host: {sub}.{w.DOMAIN}",
|
||||
"-u", "admin:definitely-not-it", "-o", "/dev/null", "-w", "%{http_code}",
|
||||
f"{w.BASE}{spec['login_path']}"])
|
||||
rec(name, "wrong_password_through_traefik", (r.stdout or "").strip())
|
||||
else:
|
||||
rc, code, body = w.app_curl(
|
||||
sub, spec["login_path"], "-H", f"Referer: https://{sub}.{w.DOMAIN}",
|
||||
"--data-urlencode", f"{spec['user_field']}=admin",
|
||||
"--data-urlencode", f"{spec['pass_field']}={pw}", method="POST")
|
||||
rec(name, "household_signin_through_traefik", f"{code} {body[:40]}")
|
||||
rc, code, body = w.app_curl(
|
||||
sub, spec["login_path"], "-H", f"Referer: https://{sub}.{w.DOMAIN}",
|
||||
"--data-urlencode", f"{spec['user_field']}=admin",
|
||||
"--data-urlencode", f"{spec['pass_field']}=definitely-not-it", method="POST")
|
||||
rec(name, "wrong_password_through_traefik", f"{code} {body[:40]}")
|
||||
|
||||
# ---- 3.6: can a stranger lock the household out? (the qBittorrent ban, through traefik)
|
||||
codes = []
|
||||
for i in range(6):
|
||||
if spec.get("basic"):
|
||||
r = w.sh(["curl", "-sSk", "--max-time", "20", "-H", f"Host: {sub}.{w.DOMAIN}",
|
||||
"-u", f"admin:wrong{i}", "-o", "/dev/null", "-w", "%{http_code}",
|
||||
f"{w.BASE}{spec['login_path']}"])
|
||||
codes.append((r.stdout or "").strip())
|
||||
else:
|
||||
_, code, _ = w.app_curl(sub, spec["login_path"],
|
||||
"-H", f"Referer: https://{sub}.{w.DOMAIN}",
|
||||
"--data-urlencode", "username=admin",
|
||||
"--data-urlencode", f"password=wrong{i}", method="POST")
|
||||
codes.append(code)
|
||||
rec(name, "six_wrong_tries", codes)
|
||||
# and the household, right after, from the same address traefik presents
|
||||
if spec.get("basic"):
|
||||
r = w.sh(["curl", "-sSk", "--max-time", "20", "-H", f"Host: {sub}.{w.DOMAIN}",
|
||||
"-u", f"admin:{pw}", "-o", "/dev/null", "-w", "%{http_code}",
|
||||
f"{w.BASE}{spec['login_path']}"])
|
||||
rec(name, "household_right_after_the_wrong_tries", (r.stdout or "").strip())
|
||||
else:
|
||||
_, code, body = w.app_curl(sub, spec["login_path"],
|
||||
"-H", f"Referer: https://{sub}.{w.DOMAIN}",
|
||||
"--data-urlencode", "username=admin",
|
||||
"--data-urlencode", f"password={pw}", method="POST")
|
||||
rec(name, "household_right_after_the_wrong_tries", f"{code} {body[:40]}")
|
||||
|
||||
# ---- 3.10: what address the app thinks the visitor has
|
||||
logs = w.guest(f"docker logs --tail 40 {name} 2>&1 | tail -20")
|
||||
open(f"{EV}/{name}-app-log.txt", "w").write(logs)
|
||||
rec(name, "app_log_captured", True)
|
||||
|
||||
# where the data landed, as the box sees it
|
||||
paths = w.guest(f"ls -la {w.DRIVE}/media/{name} 2>&1 | head -6; "
|
||||
f"docker volume ls --format '{{{{.Name}}}}' | grep -i {name} | head -4")
|
||||
open(f"{EV}/{name}-paths.txt", "w").write(paths)
|
||||
|
||||
json.dump(OUT, open(f"{EV}/boxwalk-results.json", "w"), indent=2, ensure_ascii=False)
|
||||
open(f"{EV}/boxwalk-log.txt", "w").write("\n".join(w.LOG))
|
||||
print(json.dumps(OUT, indent=2, ensure_ascii=False))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,115 @@
|
||||
#!/usr/bin/env python3
|
||||
"""verify_fixes.py — redeploy both apps on 9202 after the UPnP and host-list fixes, and prove:
|
||||
|
||||
1. neither app asks the household's router for a port any more (3.x / 01 §7)
|
||||
2. the controller's probe is no longer refused for a bad Host header
|
||||
3. the lock-out is per VISITOR, not per app: a stranger's wrong tries from ONE address do not shut
|
||||
the household out from ANOTHER (checklist 3.6 + 3.10)
|
||||
|
||||
The third one is why this file exists: the first box walk ran the stranger and the household from the
|
||||
same machine, so a shared ban could not be told apart from a per-visitor one. Here the stranger is
|
||||
DooPlex (192.168.0.180) and the household is the guest itself — two addresses, one app.
|
||||
|
||||
Runs ON DOOPLEX, after the drill catalog carries the fixed templates.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
TOOLS = "/mnt/5_hdd/felhom.eu/git/felhom.eu/documentation/audits/pg-last-six-2026-09-30/tools"
|
||||
sys.path.insert(0, TOOLS)
|
||||
os.environ.setdefault("SC", "/tmp/newapps-walk")
|
||||
import walk as w # noqa: E402
|
||||
|
||||
EV = "/mnt/5_hdd/felhom.eu/git/felhom.eu/documentation/audits/qbittorrent-2026-10-12/box"
|
||||
DRIVE_ROOT = "/mnt/felhom-drives/scratch_hdd"
|
||||
OUT = {}
|
||||
|
||||
|
||||
def rec(app, k, v):
|
||||
OUT.setdefault(app, {})[k] = v
|
||||
w.say(f" [{app}] {k}: {str(v)[:150]}")
|
||||
|
||||
|
||||
def redeploy(name, sub):
|
||||
st = w.stack(name)
|
||||
if st.get("deployed"):
|
||||
w.ctl("POST", f"/api/stacks/{name}/stop", {})
|
||||
time.sleep(10)
|
||||
code, d = w.ctl("POST", f"/api/stacks/{name}/remove",
|
||||
{"remove_hdd_data": True, "remove_backups": True})
|
||||
w.say(f" removed {name} -> {code} {str(d)[:140]}")
|
||||
time.sleep(8)
|
||||
return w.deploy(name, sub, extra_values={"HDD_PATH": DRIVE_ROOT})
|
||||
|
||||
|
||||
def main():
|
||||
w.login()
|
||||
w.ctl("POST", "/api/sync", {})
|
||||
w.ctl("POST", "/api/stacks/rescan", {})
|
||||
|
||||
for name, sub in (("qbittorrent", "torrent"), ("transmission", "transmission")):
|
||||
w.say(f"=== {name} ===")
|
||||
rec(name, "redeployed", redeploy(name, sub))
|
||||
pw = (w.GENERATED.get(name) or {}).get(
|
||||
"QBT_ADMIN_PASSWORD" if name == "qbittorrent" else "TR_ADMIN_PASSWORD", "")
|
||||
rec(name, "app_answers", w.wait_app(sub, "/", tries=40, delay=5))
|
||||
|
||||
# ---- 1. no port asked of the router
|
||||
if name == "qbittorrent":
|
||||
log = w.guest("docker exec qbittorrent sh -c "
|
||||
"'cat /config/qBittorrent/data/logs/qbittorrent.log' 2>/dev/null | head -40")
|
||||
upnp = [l for l in log.splitlines() if "UPnP" in l or "NAT-PMP" in l]
|
||||
rec(name, "upnp_lines_in_the_app_log", upnp[:3])
|
||||
badhost = [l for l in log.splitlines() if "Invalid Host header" in l]
|
||||
rec(name, "invalid_host_header_lines", len(badhost))
|
||||
open(f"{EV}/{name}-applog-after-fix.txt", "w").write(log)
|
||||
else:
|
||||
s = w.guest("docker exec transmission sh -c "
|
||||
"'grep -E \"port-forwarding-enabled|peer-port\\\"|dht-enabled\" /config/settings.json'")
|
||||
rec(name, "settings_after_fix", s.strip().replace("\n", " "))
|
||||
open(f"{EV}/{name}-settings-after-fix.txt", "w").write(s)
|
||||
|
||||
# ---- 2 + 3. the lock-out, from TWO different addresses
|
||||
login_path = ("/api/v2/auth/login" if name == "qbittorrent" else "/transmission/rpc")
|
||||
|
||||
def as_stranger(password):
|
||||
"""from DooPlex"""
|
||||
if name == "qbittorrent":
|
||||
r = w.sh(["curl", "-sSk", "--max-time", "20", "-H", f"Host: {sub}.{w.DOMAIN}",
|
||||
"-H", f"Referer: https://{sub}.{w.DOMAIN}",
|
||||
"--data-urlencode", "username=admin",
|
||||
"--data-urlencode", f"password={password}",
|
||||
"-o", "/dev/null", "-w", "%{http_code}", f"{w.BASE}{login_path}"])
|
||||
else:
|
||||
r = w.sh(["curl", "-sSk", "--max-time", "20", "-H", f"Host: {sub}.{w.DOMAIN}",
|
||||
"-u", f"admin:{password}", "-o", "/dev/null", "-w", "%{http_code}",
|
||||
f"{w.BASE}{login_path}"])
|
||||
return (r.stdout or "").strip()
|
||||
|
||||
def as_household(password):
|
||||
"""from INSIDE the guest — a different source address, through the same traefik"""
|
||||
if name == "qbittorrent":
|
||||
cmd = (f"curl -sSk --max-time 20 -H 'Host: {sub}.{w.DOMAIN}' "
|
||||
f"-H 'Referer: https://{sub}.{w.DOMAIN}' "
|
||||
f"--data-urlencode username=admin --data-urlencode password={password} "
|
||||
f"-o /dev/null -w '%{{http_code}}' https://127.0.0.1{login_path}")
|
||||
else:
|
||||
cmd = (f"curl -sSk --max-time 20 -H 'Host: {sub}.{w.DOMAIN}' "
|
||||
f"-u admin:{password} -o /dev/null -w '%{{http_code}}' "
|
||||
f"https://127.0.0.1{login_path}")
|
||||
return w.guest(cmd).strip().splitlines()[-1] if w.guest(cmd).strip() else ""
|
||||
|
||||
rec(name, "household_before_any_wrong_try", as_household(pw))
|
||||
codes = [as_stranger(f"wrong{i}") for i in range(6)]
|
||||
rec(name, "stranger_six_wrong_tries_from_192_168_0_180", codes)
|
||||
rec(name, "stranger_right_after", as_stranger(pw))
|
||||
rec(name, "HOUSEHOLD_right_after_from_its_own_address", as_household(pw))
|
||||
|
||||
json.dump(OUT, open(f"{EV}/verify-fixes.json", "w"), indent=2, ensure_ascii=False)
|
||||
print(json.dumps(OUT, indent=2, ensure_ascii=False))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,5 @@
|
||||
# image + architectures
|
||||
repo digest: lscr.io/linuxserver/transmission@sha256:4813745e2c721997d8849f5718786fd4406b3c656fbef479f87a161e51383cbd
|
||||
"architecture": "amd64",
|
||||
"architecture": "arm64",
|
||||
"architecture": "unknown",
|
||||
@@ -0,0 +1,26 @@
|
||||
# first admin: class 1, USER/PASS applied by the image's init on EVERY start. 2026-10-12T16:23:06+00:00
|
||||
## 1. authentication is required (unauthenticated RPC, want 401):
|
||||
401
|
||||
## 2. the generated password is accepted (want 409 = the CSRF challenge, i.e. past auth):
|
||||
409
|
||||
## 3. a wrong password is refused (want 401):
|
||||
401
|
||||
## 4. what the settings say (redacted):
|
||||
"download-dir": "/downloads/complete",
|
||||
"incomplete-dir": "/downloads/incomplete",
|
||||
"incomplete-dir-enabled": true,
|
||||
"peer-port": 51413,
|
||||
"rpc-authentication-required": true,
|
||||
"rpc-host-whitelist": "transmission.bench.local,localhost,127.0.0.1",
|
||||
"rpc-host-whitelist-enabled": true,
|
||||
"rpc-password": <redacted>
|
||||
"rpc-username": "admin",
|
||||
"rpc-whitelist": "127.0.0.1",
|
||||
"rpc-whitelist-enabled": false,
|
||||
## 5. five wrong tries, then the household from the same address:
|
||||
try 1: 401
|
||||
try 2: 401
|
||||
try 3: 401
|
||||
try 4: 401
|
||||
try 5: 401
|
||||
household right after: 409
|
||||
@@ -0,0 +1,74 @@
|
||||
# the init script the image runs (read inside the image)
|
||||
#!/usr/bin/with-contenv bash
|
||||
# shellcheck shell=bash
|
||||
|
||||
# copy config
|
||||
if [[ ! -f /config/settings.json ]]; then
|
||||
cp /defaults/settings.json /config/settings.json
|
||||
fi
|
||||
|
||||
if [[ -n "${USER}" ]] && [[ -n "${PASS}" ]]; then
|
||||
echo -E "$(jq -r '.["rpc-authentication-required"] = true' /config/settings.json)" >/config/settings.json
|
||||
echo -E "$(jq -r --arg user "${USER}" '.["rpc-username"] = $user' /config/settings.json)" >/config/settings.json
|
||||
echo -E "$(jq -r --arg pass "${PASS}" '.["rpc-password"] = $pass' /config/settings.json)" >/config/settings.json
|
||||
else
|
||||
echo -E "$(jq -r '.["rpc-authentication-required"] = false' /config/settings.json)" >/config/settings.json
|
||||
fi
|
||||
|
||||
if [[ -n "${WHITELIST}" ]]; then
|
||||
echo -E "$(jq -r '.["rpc-whitelist-enabled"] = true' /config/settings.json)" >/config/settings.json
|
||||
echo -E "$(jq -r --arg whitelist "${WHITELIST}" '.["rpc-whitelist"] = $whitelist' /config/settings.json)" >/config/settings.json
|
||||
else
|
||||
echo -E "$(jq -r '.["rpc-whitelist-enabled"] = false' /config/settings.json)" >/config/settings.json
|
||||
fi
|
||||
|
||||
if [[ -n "${HOST_WHITELIST}" ]]; then
|
||||
echo -E "$(jq -r '.["rpc-host-whitelist-enabled"] = true' /config/settings.json)" >/config/settings.json
|
||||
echo -E "$(jq -r --arg host_whitelist "${HOST_WHITELIST}" '.["rpc-host-whitelist"] = $host_whitelist' /config/settings.json)" >/config/settings.json
|
||||
else
|
||||
echo -E "$(jq -r '.["rpc-host-whitelist-enabled"] = false' /config/settings.json)" >/config/settings.json
|
||||
fi
|
||||
|
||||
if [[ -n "${PEERPORT}" ]]; then
|
||||
echo -E "$(jq -r --argjson peerport "${PEERPORT}" '.["peer-port"] = $peerport' /config/settings.json)" >/config/settings.json
|
||||
echo -E "$(jq -r '.["peer-port-random-on-start"] = false' /config/settings.json)" >/config/settings.json
|
||||
fi
|
||||
|
||||
if ! test -f /proc/net/if_inet6; then
|
||||
echo -E "$(jq -r '.["rpc-bind-address"] = "0.0.0.0"' /config/settings.json)" > /config/settings.json
|
||||
fi
|
||||
|
||||
if [[ -n "${UMASK}" ]]; then
|
||||
echo -E "$(jq -r --arg umask "${UMASK}" '.["umask"] = $umask' /config/settings.json)" >/config/settings.json
|
||||
fi
|
||||
|
||||
if [[ -z ${LSIO_NON_ROOT_USER} ]] && [[ -z ${LSIO_READ_ONLY_FS} ]]; then
|
||||
# Handle old theme locations
|
||||
mkdir -p {/transmissionic,/combustion-release,/flood-for-transmission,/kettu,/transmission-web-control}
|
||||
echo /transmissionic /combustion-release /flood-for-transmission /kettu /transmission-web-control | xargs -n1 ln -s /defaults/index.html
|
||||
fi
|
||||
|
||||
if [[ -z ${LSIO_NON_ROOT_USER} ]]; then
|
||||
lsiown -R abc:abc \
|
||||
/config
|
||||
|
||||
if grep -qe ' /downloads ' /proc/mounts; then
|
||||
if [[ "$(stat -c '%U' /downloads)" != "abc" ]]; then
|
||||
lsiown abc:abc /downloads
|
||||
fi
|
||||
|
||||
if [[ "$(stat -c '%U' /downloads/complete)" != "abc" ]]; then
|
||||
lsiown abc:abc /downloads/complete
|
||||
fi
|
||||
|
||||
if [[ "$(stat -c '%U' /downloads/incomplete)" != "abc" ]]; then
|
||||
lsiown abc:abc /downloads/incomplete
|
||||
fi
|
||||
fi
|
||||
|
||||
if grep -qe ' /watch ' /proc/mounts; then
|
||||
if [[ "$(stat -c '%U' /watch)" != "abc" ]]; then
|
||||
lsiown abc:abc /watch
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
@@ -0,0 +1,13 @@
|
||||
# process list
|
||||
PID USER COMMAND
|
||||
1 root /package/admin/s6/command/s6-svscan -d4 -- /run/service
|
||||
17 root s6-supervise s6-linux-init-shutdownd
|
||||
19 root /package/admin/s6-linux-init/command/s6-linux-init-shutdownd -d3 -c /run/s6/basedir -g 3000 -C -B
|
||||
36 root s6-supervise svc-cron
|
||||
37 root s6-supervise svc-transmission
|
||||
38 root s6-supervise s6rc-oneshot-runner
|
||||
39 root s6-supervise s6rc-fdholder
|
||||
47 root /package/admin/s6/command/s6-ipcserverd -1 -- /package/admin/s6/command/s6-ipcserver-access -v0 -E -l0 -i data/rules -- /package/admin/s6/command/s6-sudod -t 30000 -- /package/admin/s6-rc/command/s6-rc-oneshot-run -l ../.. --
|
||||
201 root bash ./run svc-transmission
|
||||
202 root busybox crond -f -S -l 5
|
||||
316 root ps -o pid,user,args
|
||||
@@ -0,0 +1,23 @@
|
||||
# the download, through the app's own RPC
|
||||
{"arguments":{"torrents":[{"downloadDir":"/downloads/complete","name":"Big Buck Bunny","peersConnected":0,"percentDone":1.0,"status":6,"totalSize":276445467}]},"result":"success"}
|
||||
## on the household's drive:
|
||||
/opt/tr-bench/userdata/media/transmission:
|
||||
total 16
|
||||
drwxr-xr-x 4 1000 1000 4096 Oct 12 16:29 .
|
||||
drwxr-xr-x 3 root root 4096 Oct 12 16:22 ..
|
||||
drwxr-xr-x 3 1000 1000 4096 Oct 12 16:29 complete
|
||||
drwxr-xr-x 2 1000 1000 4096 Oct 12 16:29 incomplete
|
||||
|
||||
/opt/tr-bench/userdata/media/transmission/complete:
|
||||
total 12
|
||||
drwxr-xr-x 3 1000 1000 4096 Oct 12 16:29 .
|
||||
drwxr-xr-x 4 1000 1000 4096 Oct 12 16:29 ..
|
||||
drwxr-xr-x 2 1000 1000 4096 Oct 12 16:29 Big Buck Bunny
|
||||
|
||||
/opt/tr-bench/userdata/media/transmission/complete/Big Buck Bunny:
|
||||
total 269980
|
||||
drwxr-xr-x 2 1000 1000 4096 Oct 12 16:29 .
|
||||
drwxr-xr-x 3 1000 1000 4096 Oct 12 16:29 ..
|
||||
-rw-r--r-- 1 1000 1000 140 Oct 12 16:27 Big Buck Bunny.en.srt
|
||||
-rw-r--r-- 1 1000 1000 276134947 Oct 12 16:29 Big Buck Bunny.mp4
|
||||
-rw-r--r-- 1 1000 1000 310380 Oct 12 16:27 poster.jpg
|
||||
@@ -0,0 +1,33 @@
|
||||
# where everything is written
|
||||
total 32
|
||||
drwxr-xr-x 5 abc users 4096 Oct 12 18:22 .
|
||||
drwxr-xr-x 1 root root 4096 Oct 12 18:22 ..
|
||||
-rw-r--r-- 1 abc users 2 Oct 12 18:22 bandwidth-groups.json
|
||||
drwxr-xr-x 2 abc users 4096 Oct 12 18:19 blocklists
|
||||
drwxr-xr-x 2 abc users 4096 Oct 12 18:19 resume
|
||||
-rw-r--r-- 1 abc users 3038 Oct 12 18:22 settings.json
|
||||
-rw-r--r-- 1 abc users 127 Oct 12 18:22 stats.json
|
||||
drwxr-xr-x 2 abc users 4096 Oct 12 18:19 torrents
|
||||
|
||||
28K /config
|
||||
|
||||
total 8
|
||||
drwxr-xr-x 2 abc users 4096 Oct 12 18:22 .
|
||||
drwxr-xr-x 1 root root 4096 Oct 12 18:22 ..
|
||||
## outside a mount (container writable layer):
|
||||
A /combustion-release
|
||||
A /combustion-release/index.html
|
||||
C /defaults
|
||||
A /transmissionic
|
||||
A /transmissionic/index.html
|
||||
A /tmp/tr_session_id_wCZJBbrDvP9Hfp2Rn8ATzV5cMuU6b3IAR7dtuJB9HOPE4AnS
|
||||
A /downloads
|
||||
A /kettu
|
||||
A /kettu/index.html
|
||||
C /app
|
||||
A /flood-for-transmission
|
||||
A /flood-for-transmission/index.html
|
||||
A /transmission-web-control
|
||||
A /transmission-web-control/index.html
|
||||
A /run/service
|
||||
grep: write error: Broken pipe
|
||||
@@ -0,0 +1,3 @@
|
||||
# ownership on both sides of the bind mount
|
||||
total 0
|
||||
total 0
|
||||
@@ -0,0 +1,5 @@
|
||||
# health with the probe that accepts a 401 (2026-10-12T16:32:44+00:00)
|
||||
state: healthy
|
||||
test: [CMD-SHELL curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:9091/transmission/web/ | grep -qE '^(200|401)$'] start_period=30s
|
||||
## negative control, the same probe against a dead port:
|
||||
exit: 1 (non-zero = the probe can fail)
|
||||
@@ -0,0 +1,8 @@
|
||||
# memory with the download running
|
||||
now: 5.945MiB / 512MiB 1.16%
|
||||
cgroup peak: 18 MiB
|
||||
anon: 3 MiB
|
||||
file: 2 MiB
|
||||
oom 0
|
||||
oom_kill 0
|
||||
oom_group_kill 0
|
||||
@@ -0,0 +1,9 @@
|
||||
# after a restart
|
||||
login after restart: 409 (409 = past auth)
|
||||
<HTML><HEAD>
|
||||
<TITLE>400 Bad Request</TITLE>
|
||||
</HEAD><BODY>
|
||||
<H1>Bad Request</H1>
|
||||
</BODY></HTML>
|
||||
|
||||
health: starting
|
||||
@@ -0,0 +1,90 @@
|
||||
[migrations] started
|
||||
[migrations] no migrations found
|
||||
───────────────────────────────────────
|
||||
|
||||
██╗ ███████╗██╗ ██████╗
|
||||
██║ ██╔════╝██║██╔═══██╗
|
||||
██║ ███████╗██║██║ ██║
|
||||
██║ ╚════██║██║██║ ██║
|
||||
███████╗███████║██║╚██████╔╝
|
||||
╚══════╝╚══════╝╚═╝ ╚═════╝
|
||||
|
||||
Brought to you by linuxserver.io
|
||||
───────────────────────────────────────
|
||||
|
||||
To support LSIO projects visit:
|
||||
https://www.linuxserver.io/donate/
|
||||
|
||||
───────────────────────────────────────
|
||||
GID/UID
|
||||
───────────────────────────────────────
|
||||
|
||||
User UID: 1000
|
||||
User GID: 1000
|
||||
───────────────────────────────────────
|
||||
Linuxserver.io version: 4.1.3-r0-ls363
|
||||
Build-date: 2026-09-29T16:34:59+00:00
|
||||
───────────────────────────────────────
|
||||
|
||||
stat: cannot statx '/downloads/complete': No such file or directory
|
||||
find: ‘/downloads/complete’: No such file or directory
|
||||
**** Permissions could not be set. This is probably because your volume mounts are remote or read-only. ****
|
||||
**** The app may not work properly and we will not provide support for it. ****
|
||||
stat: cannot statx '/downloads/incomplete': No such file or directory
|
||||
find: ‘/downloads/incomplete’: No such file or directory
|
||||
**** Permissions could not be set. This is probably because your volume mounts are remote or read-only. ****
|
||||
**** The app may not work properly and we will not provide support for it. ****
|
||||
[custom-init] No custom files found, skipping...
|
||||
Connection to localhost (::1) 9091 port [tcp/*] succeeded!
|
||||
[2026-10-12T18:22:26.881+0200] ERR watchdir-inotify.cc:103 Couldn't watch '/watch': No such file or directory (2) (watchdir-inotify.cc:103)
|
||||
[2026-10-12T18:22:26.881+0200] ERR utils.cc:145 Couldn't read '/config/queue.json': No such file or directory (2) (utils.cc:145)
|
||||
[ls.io-init] done.
|
||||
127.0.0.1:9091/transmission/rpc/ acknowledged notification
|
||||
Closing transmission session... done.
|
||||
[migrations] started
|
||||
[migrations] no migrations found
|
||||
usermod: no changes
|
||||
───────────────────────────────────────
|
||||
|
||||
██╗ ███████╗██╗ ██████╗
|
||||
██║ ██╔════╝██║██╔═══██╗
|
||||
██║ ███████╗██║██║ ██║
|
||||
██║ ╚════██║██║██║ ██║
|
||||
███████╗███████║██║╚██████╔╝
|
||||
╚══════╝╚══════╝╚═╝ ╚═════╝
|
||||
|
||||
Brought to you by linuxserver.io
|
||||
───────────────────────────────────────
|
||||
|
||||
To support LSIO projects visit:
|
||||
https://www.linuxserver.io/donate/
|
||||
|
||||
───────────────────────────────────────
|
||||
GID/UID
|
||||
───────────────────────────────────────
|
||||
|
||||
User UID: 1000
|
||||
User GID: 1000
|
||||
───────────────────────────────────────
|
||||
Linuxserver.io version: 4.1.3-r0-ls363
|
||||
Build-date: 2026-09-29T16:34:59+00:00
|
||||
───────────────────────────────────────
|
||||
|
||||
ln: failed to create symbolic link '/transmissionic/index.html': File exists
|
||||
ln: failed to create symbolic link '/combustion-release/index.html': File exists
|
||||
ln: failed to create symbolic link '/flood-for-transmission/index.html': File exists
|
||||
ln: failed to create symbolic link '/kettu/index.html': File exists
|
||||
ln: failed to create symbolic link '/transmission-web-control/index.html': File exists
|
||||
stat: cannot statx '/downloads/complete': No such file or directory
|
||||
find: ‘/downloads/complete’: No such file or directory
|
||||
**** Permissions could not be set. This is probably because your volume mounts are remote or read-only. ****
|
||||
**** The app may not work properly and we will not provide support for it. ****
|
||||
stat: cannot statx '/downloads/incomplete': No such file or directory
|
||||
find: ‘/downloads/incomplete’: No such file or directory
|
||||
**** Permissions could not be set. This is probably because your volume mounts are remote or read-only. ****
|
||||
**** The app may not work properly and we will not provide support for it. ****
|
||||
[custom-init] No custom files found, skipping...
|
||||
Connection to localhost (::1) 9091 port [tcp/*] succeeded!
|
||||
[2026-10-12T18:24:00.115+0200] ERR watchdir-inotify.cc:103 Couldn't watch '/watch': No such file or directory (2) (watchdir-inotify.cc:103)
|
||||
[2026-10-12T18:24:00.115+0200] ERR utils.cc:145 Couldn't read '/config/queue.json': No such file or directory (2) (utils.cc:145)
|
||||
[ls.io-init] done.
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
name: tr-bench
|
||||
services:
|
||||
transmission:
|
||||
container_name: transmission
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: "536870912"
|
||||
environment:
|
||||
HOST_WHITELIST: transmission.bench.local,localhost,127.0.0.1
|
||||
PASS: <redacted>
|
||||
PEERPORT: "51413"
|
||||
PGID: "1000"
|
||||
PUID: "1000"
|
||||
TZ: Europe/Budapest
|
||||
UMASK: "022"
|
||||
USER: admin
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- curl
|
||||
- -fsS
|
||||
- -o
|
||||
- /dev/null
|
||||
- http://127.0.0.1:9091/transmission/web/
|
||||
timeout: 5s
|
||||
interval: 30s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
image: lscr.io/linuxserver/transmission:version-4.1.3-r0
|
||||
labels:
|
||||
traefik.enable: "true"
|
||||
traefik.http.routers.transmission.entrypoints: websecure
|
||||
traefik.http.routers.transmission.rule: Host(`transmission.bench.local`)
|
||||
traefik.http.routers.transmission.tls: "true"
|
||||
traefik.http.routers.transmission.tls.certresolver: letsencrypt
|
||||
traefik.http.services.transmission.loadbalancer.server.port: "9091"
|
||||
networks:
|
||||
traefik-public: null
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /opt/tr-bench/userdata/media/transmission
|
||||
target: /downloads
|
||||
bind:
|
||||
create_host_path: true
|
||||
- type: volume
|
||||
source: transmission_config
|
||||
target: /config
|
||||
volume: {}
|
||||
networks:
|
||||
traefik-public:
|
||||
name: traefik-public
|
||||
external: true
|
||||
volumes:
|
||||
transmission_config:
|
||||
name: tr-bench_transmission_config
|
||||
Reference in New Issue
Block a user