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
rootor a user withsudo. - 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.accessin the config must not be"none". With no log there is nothing for the detector to work with. systemdandpython33.6 or newer. Ifpython3isn'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.
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_SYNpacket; - 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.
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 -fThe 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 -- --liveRunning 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; doneTuning 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, default60— the observation window, in seconds.--min-hosts N/FTB_MIN_HOSTS, default25— the threshold for distinct destination addresses per window.--min-ports N/FTB_MIN_PORTS, default20— the threshold for distinct destination ports per window. It only fires when both conditions are met at once.--cooldown N/FTB_COOLDOWN, default300— don't send another report for the same IP more often than this, in seconds.--max-reports N/FTB_MAX_REPORTS_PER_MIN, default10— 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, defaultauto—autoenables btguard correlation if the nft table exists,offleaves only the fan-out signal,onforces it.--dry-runand--live/FTB_DRY_RUN, default0— 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.
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.123and2002— time synchronisation: NTP and Roughtime.465,587,993,995,853— mail and DNS-over-TLS.554and1935— 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 readsProtocol: fsnt-torrent-blocker (tcp). - In the panel, under Torrent Blocker reports — filter on
Protocolwith the valuebittorrentversusfsnt-torrent-blocker. - In the
torrent_blocker.reportwebhook — the built-in one hasxrayReport.outboundTagset toRW_TB_OUTBOUND_BLOCK, while the detector sets bothprotocolandoutboundTagtofsnt-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 -fA 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 обработчик ноды разбирает формат детектора
итог: OKAnd 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 -- --uninstallYour settings are kept — add --purge to remove those as well.
Sources
- forestsnet/torrent-blocker — the detector's source and the nft rules.
- Remnawave — node plugins, Torrent Blocker
- Jolymmiles/Xray-core — the fork the DHT, uTP and UDP tracker signatures were cross-checked against.