Part 1 was mostly about ownership.
I wanted each service to have one clear job, the storage layout to support hardlinks, and the VPN failure domain to be as small as possible.
Once that was in place, the next question was less about architecture and more about trust.
Everything entering the download side of the stack comes from systems I do not control.
Torrent metadata is untrusted input.
The files described by that metadata are untrusted input.
A successful download only proves that the download client transferred what it was told to transfer.
It does not prove that I wanted the payload.
That changed the question from:
How do I make the stack download reliably?
to:
How many opportunities do I have to reject something before I treat it like normal media?
I did not want one oversized security component pretending to answer that question.
I wanted several narrow controls whose behavior I could test independently.
The resulting path looks like this:
network containment
|
v
filename policy
|
v
queue enforcement
|
v
authoritative import
|
v
read-only structural validation
|
v
media consumption
None of those layers proves that a file is malware-free.
That distinction matters.
The goal is to reduce exposure, catch specific classes of unwanted content early, and make every decision point visible enough to test.
Layer 1: contain the network path
The first control is the one established in Part 1.
qBittorrent shares Gluetun’s network namespace.
qBittorrent
|
v
Gluetun
|
v
VPN
If the VPN path disappears, the torrent client should lose its Internet path instead of quietly falling back to the host’s ordinary WAN connection.
That answers one specific question:
Where is this traffic allowed to go?
It does not answer:
What is inside this torrent?
A VPN kill switch is containment.
It is not content inspection.
Keeping those claims separate makes the rest of the stack easier to reason about.
Layer 2: reject obviously unwanted filenames early
The download client is one of the earliest places where the torrent’s file list becomes visible.
qBittorrent has an Excluded file names feature that can be used as a first-pass policy gate.
For a media-only workflow, an example list might include:
*.exe
*.sh
*.bat
*.cmd
*.com
*.ps1
*.vbs
*.scr
*.msi
*.lnk
That is an example, not a universal blacklist.
The useful rule is simpler:
If a file type has no legitimate reason to appear in this workflow, do not select it for transfer by default.
In a working exclusion path, a matching file should become unwanted rather than being selected for transfer.
Conceptually:
torrent metadata
|
v
inspect file names
|
+--> expected media ----> normal download
|
`--> blocked pattern ---> unwanted
That is valuable because the decision happens before import.
It is much better to reject an unexpected executable or script while the torrent is still download-side state than after it has already reached the media library.
But filename filtering has obvious limits.
A file can have a misleading extension.
Unwanted content can be packaged inside another file.
A valid media container can still contain malformed or hostile data.
So this layer should be described as a filename policy, not malware detection.
Configuration is not proof
There is another reason I do not treat an enabled checkbox as evidence that a control is working.
qBittorrent 5.2.x has had a confirmed WebUI regression where Excluded file names could be ignored when a torrent was added through the WebUI.
That distinction is important.
The upstream report is specifically about the WebUI path. It does not prove that every API, watch-folder, or automation ingestion path behaves the same way.
So the useful invariant is not:
Excluded file names = enabled
The useful invariant is:
a torrent added through the real production path
causes a matching file to become unwanted
Version matters.
Ingestion path matters.
Observed behavior matters more than configuration state.
That lesson applies to much more than qBittorrent.
Layer 3: finish the failure in the queue
Rejecting a file is only half of the workflow.
Sonarr or Radarr may already be tracking the release that produced the torrent.
If qBittorrent refuses every useful payload file, the Arr application can be left waiting for something that will never become importable.
That is where the queue supervisor becomes useful.
In this stack, that job belongs to Cleanuparr.
The failure path becomes:
Sonarr / Radarr approve release
|
v
qBittorrent receives metadata
|
v
payload violates filename policy
|
v
files become unwanted
|
v
nothing useful can be imported
|
v
Cleanuparr sees failed queue state
|
+--> remove download
+--> fail / clean queue item
`--> replacement when configured
This is a good example of why I prefer small components with explicit ownership.
qBittorrent does not need to understand Sonarr’s queue.
Sonarr does not need to implement qBittorrent’s filename policy.
Cleanuparr does not need to inspect media containers.
Each one handles the state it already understands.
The important part is that a blocked download reaches a complete workflow outcome instead of becoming a permanent queue mystery.
Safe canaries are better than real malware
I wanted to prove the blocked-download path without downloading, storing, or distributing an actual malicious executable.
There was no reason to introduce a real payload just to test plumbing.
The useful canary was torrent metadata describing a deliberately blocked synthetic filename.
Conceptually:
arr-canary/
└── blocked-test.exe
The filename is the test condition.
The file does not need to contain executable code.
That makes it possible to test:
torrent metadata parsing
|
v
filename exclusion
|
v
unwanted-file state
|
v
Arr tracked-download state
|
v
Cleanuparr classification
|
v
removal / replacement behavior
without turning a workflow test into a malware-handling exercise.
This also exposed one of the more useful lessons from rebuilding the stack.
My first instinct was to insert the test directly into qBittorrent.
That proved the qBittorrent rule.
It did not prove the stack.
The production workflow begins farther upstream.
Sonarr or Radarr creates the tracked download state that the rest of the system later observes.
A torrent inserted manually into qBittorrent therefore does not necessarily carry the same application context.
The better test begins where the real workflow begins.
If the real event originates upstream, inject the canary upstream.
That sounds obvious after the fact, but it is an easy mistake to make when testing distributed systems one component at a time.
A failed canary can still be a successful test
Not every synthetic release reached qBittorrent on the first try.
One candidate did not satisfy the active quality/profile policy.
Another fell below the application’s minimum-size rules.
Initially those looked like failed tests.
They were actually useful evidence.
The application rejected something it was supposed to reject, and it did so before creating downstream state.
A safe test should have gates:
metadata parses
|
v
correct target identified
|
v
release policy accepts candidate
|
v
only then create downstream state
If the canary stops at one of those gates, that is information.
The important part is knowing which gate stopped it.
This is another reason I prefer test evidence over a final green checkbox.
A failed canary with a clear reason tells me more than a successful canary whose path I never inspected.
Layer 4: keep Sonarr and Radarr authoritative for import
qBittorrent owns transfer state.
It should not own the media library.
Once a download is acceptable, Sonarr or Radarr remains responsible for deciding what becomes library content.
That keeps one authoritative location for:
- release tracking;
- quality and profile decisions;
- destination selection;
- renaming;
- import history;
- failed-import state.
The import boundary therefore stays:
qBittorrent
|
completed download
|
v
Sonarr / Radarr
|
authoritative import
|
v
media library
This was useful operationally in Part 1.
It is useful for security for the same reason: there is one well-defined transition between “downloaded content” and “library content.”
Hardlinks preserve that ownership model
Part 1 covered the storage layout in more detail, but hardlinks are worth mentioning again here because they let the authoritative importer do its job without requiring a second physical copy.
Conceptually:
/data/torrents/example.mkv
|
| same inode
|
/data/media/example.mkv
Sonarr or Radarr creates the library entry while the download-side path can remain available for seeding.
That gives me:
- one importer;
- one set of underlying data blocks;
- separate filesystem names for download and library roles;
- no need to let the torrent client write directly into the final library tree.
Hardlinks only work when the relevant paths are on the same filesystem and the container mounts expose that relationship correctly.
If those conditions are not true, the Arr application may fall back to copying.
The important security property is not the hardlink itself.
It is that the download client still does not become the library authority.
Layer 5: validate media with less write access
After import, I wanted one more check that did not require handing another application unrestricted write access to the library.
That is where Tdarr fits.
I am not using it here as an automatic transcoding pipeline.
Its job in this design is narrower:
imported media
|
v
structural health check
For this role, I do not need active transcode workers.
I also do not need GPU access simply because Tdarr supports it.
The mount policy can stay narrow:
volumes:
- ${MEDIA_ROOT}:/media:ro
- ${TDARR_TEMP}:/temp
The media tree is read-only.
The scratch area is writable.
That is the boundary I care about.
Tdarr can inspect the library, maintain its own application state, and write temporary data without receiving permission to rewrite the media tree.
Health checking is not quarantine
This is another place where terminology matters.
A successful media health check does not tell me:
this file is safe
It tells me something closer to:
the configured media health check
did not report structural corruption
There is also a timing issue.
In this design, Tdarr checks media after Sonarr or Radarr imports it.
That means Jellyfin can potentially see the imported item before the health check finishes.
So this is not a quarantine architecture.
A real quarantine system would need another boundary:
download
|
v
staging area
|
v
authoritative validation gate
|
PASS / FAIL
|
v
final media library
That is a different design with different operational costs.
I am not claiming to have built it here.
The read-only Tdarr check is another layer of visibility, not a pre-consumption security gate.
The complete path
Put together, the stack now has several opportunities to say no:
Sonarr / Radarr
|
v
indexer path
|
v
qBittorrent through VPN
|
+--> blocked filename?
| |
| yes
| |
| v
| unwanted
| |
| v
| Cleanuparr
| |
| +--> remove / fail
| `--> replacement
|
`--> acceptable payload
|
v
Sonarr / Radarr
authoritative import
|
v
hardlink / copy
|
v
media library
|
+--> Tdarr read-only health check
|
`--> Jellyfin
No box in that diagram gets to declare the entire result trustworthy.
That is intentional.
What these controls actually prove
I try to phrase the result narrowly.
Not:
the file passed security scanning
And definitely not:
the file is malware-free
A more defensible description is:
The download followed the expected network path, matched the configured torrent-file policy, survived queue enforcement, was imported by the authoritative Arr application, and did not produce a finding in the configured structural media check.
That sentence is less exciting.
It is also much closer to what the system actually knows.
Defense in depth works best when every layer is allowed to make only the claim it can support.
The larger lesson
The pattern here is not specific to media automation.
The same structure works anywhere an automated pipeline consumes untrusted input:
contain the network path
|
v
reject obviously invalid input early
|
v
let the workflow owner make state transitions
|
v
validate with reduced privileges
|
v
record what actually happened
The last line is where Part 3 begins.
At this point the stack has useful boundaries, but a boundary that silently stops working is not much of a boundary.
The final part of this series will turn the assumptions in Parts 1 and 2 into explicit health invariants: VPN state, forwarded-port state, authenticated management endpoints, queue behavior, read-only mounts, and stateful DOWN/RECOVERED monitoring.
The goal is to stop asking whether all the containers are merely “up” and start asking whether the system is still behaving the way it was designed to behave.