DEV Community

Cover image for qBittorrent Stuck at 0 KB/s? Your Synced VPN Port Is Lying
Josh Hall
Josh Hall

Posted on Originally published at peira.dev

qBittorrent Stuck at 0 KB/s? Your Synced VPN Port Is Lying

If qBittorrent is sitting at 0 KB/s while your port-sync tool reports success, the sync is not the thing to check. A forwarded port that matches is a statement about your configuration, not about your traffic, and the two can disagree for days without anything raising its voice. Mine disagreed for four.

Cross-post from Peira Labs. Full version: peira.dev/articles/qbittorrent-stalled-vpn-port-check

The alert that fired once, then went quiet

My VPN container failed its own health check and restarted itself. On the way through, its port-forwarding service stopped, cleared the file holding the forwarded port, and then failed to start again. That file sat empty for four days. qBittorrent knew none of this and carried on listening on the last port it had been handed, so nothing outside could open a connection to it.

The part that actually cost me the four days: a monitor did notice, and it did notify me — on the day it broke. Two alerts reached my phone, I half-read them, and then it went quiet. It only ever spoke on a change of state. It fired once on the transition from working to broken and never again, because nothing changed after that. Four days of silence read exactly like four days of fine.

A notification is an event: it happens once, at a transition. Health is a state: it is true or false continuously. When the only thing carrying your signal is an event, a failure that stays failed makes exactly as much noise as a system that is working perfectly.

What a port syncer actually proves

There are at least half a dozen open-source tools that sync gluetun's forwarded port into qBittorrent, and they do it well: they poll gluetun's control server for the current port and write it into qBittorrent's listening-port setting. Their health check confirms they can reach each API. It does not confirm that the tunnel is carrying traffic, that the forwarded port answers from outside, or that a single byte has arrived.

So on the day my port forward died, the correct behaviour for every one of those tools was to report success. They were not broken. They were answering a different question than the one I needed answered.

The five checks I ended up with

I wrote a small thing called deadair to answer the other question. It is a single Rust binary, it reads only, and it changes nothing about your setup.

Check Fails when Where the answer comes from
probe gluetun or qBittorrent cannot be reached at all both APIs
tunnel gluetun reports no public address, or a private one gluetun /v1/publicip/ip
port_agreement the forwarded port and the listening port disagree both
reachability qBittorrent reports firewalled or disconnected qBittorrent connection_status
traffic torrents want data and zero bytes arrived in the window qBittorrent dl_info_data

The one I am most pleased with is reachability, because it costs nothing. qBittorrent already knows whether anything from the outside world can open a connection to it, and it will tell you in one field. Its connection_status is exactly one of connected, firewalled, or disconnected. firewalled is the client saying, in effect, "I can call out, but nobody can call in." Behind a VPN with port forwarding, that is the dead-port symptom stated plainly — no external port-checking service, no trusting a third party with your address.

One thing that caught me while building it: current gluetun versions make every control-server route private by default. You authenticate with an API key in an X-API-Key header or with HTTP basic auth. If you are following an older guide that curls /v1/portforward with no credentials, that is why you are getting nothing back.

Here is the same stack with a dead port forward. This is real output, not an illustration:

$ deadair check
[ok]   probe           all probes succeeded
[ok]   tunnel          203.0.113.42
[fail] port_agreement  forwarded 51413, listening 6881
[fail] reachability    firewalled
[ok]   traffic         watching: not enough history yet; 1 torrents have no seeds
[fail] overall
Enter fullscreen mode Exit fullscreen mode

The exit code is the part that makes it useful to something else: 0 for healthy, 1 for a warning, 2 for a failure, so Uptime Kuma, healthchecks.io, or a plain cron line can consume it without parsing anything.

Why an idle queue must never page you

Download speed is zero most of the time in a healthy homelab. The queue empties, everything finishes. If your monitor pages you every time nothing is downloading, you will mute it within a week — and then it is worth nothing on the day it is right.

So the traffic check gates itself. Before it complains that no data arrived, it asks whether anything wanted data. It counts torrents whose state is one of downloading, metaDL, forcedDL, or stalledDL, and if that count is zero, the verdict is simply "idle" and everything is fine. Only when something genuinely wants bytes does it compare the session's downloaded-byte counter against the value from the start of the window. A counter going backwards means qBittorrent restarted, which is not a stall, so that is treated as progress.

Running it next to gluetun

The deployment detail that matters: run it inside gluetun's network namespace, exactly as qBittorrent does. That is what lets it reach both control servers on localhost, and it means the checker sees the same network the client sees.

services:
  gluetun:
    image: qmcgaw/gluetun
    cap_add: [NET_ADMIN]
    ports:
      - "9113:9113"   # deadair's metrics, published through gluetun

  qbittorrent:
    image: lscr.io/linuxserver/qbittorrent
    network_mode: "service:gluetun"

  deadair:
    image: ghcr.io/peiralabs/deadair:0.1.1
    network_mode: "service:gluetun"
    environment:
      DEADAIR_GLUETUN_APIKEY: CHANGE_ME
    depends_on: [gluetun, qbittorrent]
Enter fullscreen mode Exit fullscreen mode

The image is published for linux/amd64 and linux/arm64, so it runs on an x86 box or an ARM NAS unchanged. It is a static binary in a scratch image and pulls at 3.47 MB. If you would rather not run another container, each release ships a static binary that runs one-shot from cron and returns the exit code above.

The habit worth keeping

The thing you measure has to be the system's output rather than its configuration, because configuration agrees with itself right up until the moment it stops meaning anything. And the signal has to be a state you can re-read whenever you like, not an event that fires once and trusts you to be paying attention on the day. That second half is the one that actually cost me the four days.

Honesty about something this new: this is version 0.1.1, tested against mocked gluetun and qBittorrent endpoints, which is a long way from "running in a hundred homelabs for a year." It does not fix anything — keep your port syncer — and it only knows gluetun and qBittorrent.

Written by Peira Labs — full version, with the Prometheus metrics and the Grafana wiring, at peira.dev.

Top comments (0)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.