audit: download clients for the catalog - qbittorrent + transmission measured twice, five defects fixed
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:
2026-10-12 19:38:23 +02:00
parent 7865dc9f58
commit 5148fe1e11
36 changed files with 1244 additions and 0 deletions
@@ -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.
@@ -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