A torrent detector for Remnawave: catching what the built-in blocker misses

The built-in Torrent Blocker in a Remnawave node relies on the xray sniffer, and that sniffer only sees a small share of torrent traffic: modern clients encrypt their peer connections, leaving nothing to recognise BitTorrent by. In this article you'll install the open-source fsnt-torrent-blocker detector, which closes that gap with two independent signals — first watching what it catches without banning anyone, then switching it to enforcing mode.

The detector supplements the standard setup rather than replacing it. All it does is spot the offender and hand a report to the node; the banning, the duration and the notifications still come from the Torrent Blocker plugin. If the groundwork isn't in place — sniffing, routing rules, the outbound and the plugin itself — the detector has nobody to report to and does nothing.

What you'll need

  • A ForestsNet Cloud VPS with SSH access as root or a user with sudo.
  • Standard blocking already set up by following How to block torrents on your VPN or proxy: sniffing enabled, routing rules in place, the outbound defined and the Torrent Blocker plugin switched on in the node. Without that the detector is of no use.
  • Remnawave Node 2.7.0 or newer, with the node container started with cap_add: NET_ADMIN.
  • The xray access log left on: log.access in the config must not be "none". With no log there is nothing for the detector to work with.
  • systemd and python3 3.6 or newer. If python3 isn't installed, the installer will add it — the detector has no external dependencies, the standard library is enough.

Why the built-in blocker isn't enough

The Remnawave documentation says it plainly: the xray sniffer identifies BitTorrent in roughly 10–30% of cases. Three classes of traffic slip past it.

Encrypted peers. In qBittorrent and most other clients, MSE/PE protocol encryption is on by default. There is no plaintext BitTorrent protocol handshake in the TCP stream at all — nothing to match.

DHT. The distributed table a client uses to find peers without a tracker runs over UDP. xray has no sniffer for it whatsoever.

Rules based on tracker ports and domains. Peers connect to bare IP addresses on random high ports. Neither geosite:category-public-tracker nor a port list catches them — those only cover tracker requests, a small fraction of the exchange.

Measured on a live node. A client with an active upload opened connections to 102 addresses on 91 different ports in 75 seconds. All of it went out through DIRECT, and the built-in blocker never fired once.

For how torrent blocking works in general, and what to do if you're running WireGuard or OpenVPN rather than Remnawave, see that same article — How to block torrents on your VPN or proxy.

How the detector finds torrents

Signal 1. Connection fan-out

The detector reads the xray access log and, for each user, counts how many distinct destination addresses and ports they reached within a sliding window — 60 seconds by default.

A torrent client opens connections to dozens of peers, each on its own port. Nothing else produces that fan-out: not a browser, not a messenger, not a video call. An ordinary user accumulates up to a couple of dozen addresses on high ports per minute; someone torrenting reaches 50–150.

Connections made by domain name, to private addresses, and to web, DNS, mail, messaging and STUN/TURN ports are left out of the count. This is exactly how encrypted MSE/PE peers get caught despite having no signature at all.

Signal 2. btguard — the UDP control plane

An nft table called btguard is installed alongside the detector. It drops, on egress, what the xray sniffer misses over UDP:

  • DHT (BEP 5) — matched on the opening bytes of the bencode dictionary in queries and responses;
  • the uTP handshake (BEP 29) — matched on the header fields of an ST_SYN packet;
  • UDP tracker connect (BEP 15) — matched on the protocol magic number and a zero action code.

Every drop lands in the kernel log together with the peer's address. The detector reads that log and works out who was torrenting: the same flow appears in the access log as udp:<peer>:<port> email:<client>, so the address-port pair identifies the client.

The signatures here are strict, and that matters. A naive uTP rule matching 0x4100 also catches TURN and WebRTC media traffic — 176 false packets in 90 seconds during testing. A rule that checks the full ST_SYN header fields produces none. The logic is cross-checked against Jolymmiles' Xray-core fork, where these checks are broken down field by field.

What happens when it fires

The detector posts a report to the node's internal webhook — the same one xray itself reports to. From there the node behaves normally: it bans the client's IP in nftables for blockDuration, honouring ignoreLists, drops their current connections, and passes the report to the panel, which forwards it to Telegram and to the torrent_blocker.report webhook.

The detector reads the webhook address from the running xray config every time, so it survives node restarts with no reconfiguration. Both node generations are supported — abstract sockets and file sockets alike.

Installation

Install it in observation mode first. It will write its findings to the journal and ban nobody, so you can see who it catches on your own traffic at no risk.

curl -fsSL https://raw.githubusercontent.com/forestsnet/torrent-blocker/main/install.sh | sudo bash -s -- --dry-run
journalctl -u fsnt-torrent-blocker -f

The installer puts the detector in /usr/local/sbin/fsnt-torrent-blocker, its settings in /etc/default/fsnt-torrent-blocker, and sets up the fsnt-torrent-blocker.service unit along with the btguard rules (/etc/nftables.d/btguard.nft plus a oneshot btguard.service). The --no-btguard flag installs only the log-based fan-out detection, without the nft rules.

Before enabling the service, the installer runs a selftest: it locates xray, the webhook and the log, sends a probe report with no user attached (the node checks the format but bans nobody), and then a deliberately malformed report the node is expected to reject. If anything doesn't line up, the service is not enabled.

Once you've reviewed the findings and confirmed there are no false positives, switch it to enforcing mode:

curl -fsSL https://raw.githubusercontent.com/forestsnet/torrent-blocker/main/install.sh | sudo bash -s -- --live

Running the installer again updates the detector and keeps your settings.

If you have several nodes, install on all of them from your own machine. The detector is embedded in install.sh in full, so the server needs no access to GitHub:

for h in 10.0.0.1 10.0.0.2 10.0.0.3; do ssh deploy@$h 'sudo bash -s' < install.sh; done

Tuning the thresholds

Thresholds are set by installer flags and can be edited afterwards in /etc/default/fsnt-torrent-blocker — restart the service after editing. The installer flag and the config variable mean the same thing:

  • --window N / FTB_WINDOW, default 60 — the observation window, in seconds.
  • --min-hosts N / FTB_MIN_HOSTS, default 25 — the threshold for distinct destination addresses per window.
  • --min-ports N / FTB_MIN_PORTS, default 20 — the threshold for distinct destination ports per window. It only fires when both conditions are met at once.
  • --cooldown N / FTB_COOLDOWN, default 300 — don't send another report for the same IP more often than this, in seconds.
  • --max-reports N / FTB_MAX_REPORTS_PER_MIN, default 10 — a safety catch: above this many reports per minute, sending pauses for 10 minutes.
  • --ignore-ports L / FTB_IGNORE_PORTS, empty by default — extra destination ports, comma-separated, to leave out of the fan-out count.
  • --no-btguard / FTB_BTGUARD, default auto — auto enables btguard correlation if the nft table exists, off leaves only the fan-out signal, on forces it.
  • --dry-run and --live / FTB_DRY_RUN, default 0 — observation mode versus enforcing mode.

The defaults come from measurements on real traffic, and there's a wide gap between legitimate traffic and torrents: the most fan-out-heavy legitimate services — peer-to-peer update delivery, WebRTC calls, games — reach about fifteen distinct ports per window, while a torrent client reaches 140–250. A threshold of 20 ports sits squarely in that gap.

The decisive part is the "both addresses and ports" condition. A service that hits dozens of addresses on a narrow set of ports never trips the detector — which is how both Windows update delivery and a voice call behave.

Ban duration and exclusions are not set here. blockDuration and ignoreLists live in the panel, in the Torrent Blocker plugin config. The detector doesn't duplicate them: the node applies them to its reports exactly as it does to its own.

Which ports don't count as fan-out

Reaching many addresses on these ports is normal behaviour, so the detector skips them. The list was built from typical legitimate traffic, checking real destination addresses and rDNS for each port:

  • 22, 53, 80, 443, 8080, 8443 — SSH, DNS, HTTP and HTTPS, including over QUIC.
  • 123 and 2002 — time synchronisation: NTP and Roughtime.
  • 465, 587, 993, 995, 853 — mail and DNS-over-TLS.
  • 554 and 1935 — video streaming: RTSP from cameras and RTMP.
  • 3478–3497, 5349, 16384–16403, 19302–19309 — STUN, TURN and RTP: setup and media for peer-to-peer calls in WebRTC, messengers and Google Meet.
  • 5222, 5223, 5228 — XMPP and push notifications, including Google FCM.
  • 7680 — Windows update delivery between machines, dozens of residential addresses on a single port.

Ports where fan-out is exactly what you want to see were deliberately left out — 3333 and other mining pool ports, for instance.

If some legitimate service on your node does spread across many ports and trips the detector, add its ports to FTB_IGNORE_PORTS rather than editing the shared list for the sake of one node.

Telling detector hits apart from built-in ones

Reports from the detector arrive through the same path as native ones, so tell them apart by the protocol label:

  • In Telegram. The built-in one reads Protocol: bittorrent (tcp), the detector reads Protocol: fsnt-torrent-blocker (tcp).
  • In the panel, under Torrent Blocker reports — filter on Protocol with the value bittorrent versus fsnt-torrent-blocker.
  • In the torrent_blocker.report webhook — the built-in one has xrayReport.outboundTag set to RW_TB_OUTBOUND_BLOCK, while the detector sets both protocol and outboundTag to fsnt-torrent-blocker.

The numbers behind the finding are in xrayReport.routeTarget and look like this: fanout 73 hosts / 58 ports / 86 conns in 60s (tcp 85, udp 1). The destination field carries one of the peers.

Checking it works

sudo fsnt-torrent-blocker --selftest
systemctl status fsnt-torrent-blocker
journalctl -u fsnt-torrent-blocker -f

A healthy selftest looks like this:

ok    xray pid 1464055, API ноды — файловый сокет
ok    вебхук Torrent Blocker найден
ok    лог /proc/18868/root/var/log/remnanode/access.log
ok    вебхук принимает отчёты (HTTP 200)
ok    обработчик ноды разбирает формат детектора
итог: OK

And a finding in the journal looks like this:

2026-09-23 10:45:55Z user=1042 ip=203.0.113.7 fanout 73 hosts / 58 ports / 86 conns in 60s (tcp 85, udp 1) -> принят нодой

Limitations

Worth knowing before you install, rather than after the first complaint.

Bans are applied by client IP — the same as with the built-in plugin. A mobile carrier address behind CGNAT may cover several subscribers.

Each node decides on its own traffic. The panel learns about the ban and sends a notification, but the ban doesn't carry over to other nodes — that's how the plugin works. To cut an offender off everywhere, handle the torrent_blocker.report webhook yourself: switch the user to DISABLED, for example.

The detector talks to the node's internal API, not the public one. After major Remnawave Node upgrades, run --selftest first.

Traffic that never reaches the access log stays invisible. With log.access set to "none" there is nothing to work from.

Removing it

curl -fsSL https://raw.githubusercontent.com/forestsnet/torrent-blocker/main/install.sh | sudo bash -s -- --uninstall

Your settings are kept — add --purge to remove those as well.

Check that ordinary traffic still works. After switching to enforcing mode, open a few sites through your VPN, make a video call and download a large file. If anything legitimate trips the detector, you'll see it in the journal as a finding against an IP you recognise — and you can clear it with a port in FTB_IGNORE_PORTS.

Sources