marque / bloodhound
BitTorrent tracker (announce/scrape) for Marque platform
Requires
- php: ^8.3
- illuminate/queue: ^13.0
- illuminate/redis: ^13.0
- illuminate/routing: ^13.0
- illuminate/support: ^13.0
- marque/threepio: ^3.0
- marque/trove: ^3.0|^4.0
Requires (Dev)
- mockery/mockery: ^1.6
- orchestra/testbench: ^11.0
- pestphp/pest: ^4.7
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
BitTorrent tracker for the Marque platform. Handles announce/scrape with Redis-backed peer storage, client validation, and anti-cheat detection.
Installation
Requires marque/trove.
composer require marque/bloodhound
Publish the config and run migrations:
php artisan vendor:publish --tag=bloodhound-config php artisan migrate
How It Works
Bloodhound registers two endpoints:
| Endpoint | Purpose |
|---|---|
GET /announce/{announce_key} |
Peer announces (start, stop, complete) |
GET /scrape/{announce_key?} |
Swarm statistics |
Users authenticate via their announce key (auto-generated by Trove's HasTrackerStats trait). Peer data is stored in Redis for performance - no database queries on the announce hot path.
Announce Flow
- Announce key validated, user looked up
- Torrent identified by info_hash
- Anti-cheat checks run
- Client validated against whitelist
- Peer upserted in Redis, stats calculated
- Upload/download deltas queued for database update
- Bencoded peer list returned to client
Peer Storage
All peer data lives in Redis with configurable key prefix. Peers auto-expire after the configured TTL (default: 1 hour). Seeder/leecher counts are maintained as atomic counters.
Swarm Counts
Redis holds the live swarm, but a catalogue needs to filter and sort on it —
"hide dead torrents", "sort by seeders" — which SQL cannot do against Redis. So
each announce also writes seeders and leechers onto the torrents row
(skipping the write when nothing changed).
That projection alone is not enough. A peer that vanishes without sending
stopped — client killed, machine off, network gone — expires quietly in Redis,
and nothing announces afterwards to correct the row, which would sit there
advertising a swarm that no longer exists.
bloodhound:sync-swarm-counts closes that gap. It sweeps expired peers and
writes the settled counts back, and is scheduled hourly:
php artisan bloodhound:sync-swarm-counts
php artisan bloodhound:sync-swarm-counts --chunk=1000 # tune batch size
It is registered on the scheduler automatically; you only need Laravel's
scheduler running. Torrent listings are unaffected unless you also set
trove.hide_dead_torrents.
Configuration
Published to config/bloodhound.php:
Timing
| Key | Default | Description |
|---|---|---|
announce_interval |
1800 |
Seconds between announces (sent to clients) |
min_announce_interval |
300 |
Minimum allowed interval |
peer_expiry |
3600 |
Seconds before inactive peers are removed |
Redis
| Key | Default | Description |
|---|---|---|
redis.connection |
default |
Laravel Redis connection name |
redis.prefix |
bloodhound: |
Key namespace |
Peer Response
| Key | Default | Description |
|---|---|---|
max_peers_per_announce |
50 |
Max peers returned per announce |
peer_response_format |
auto |
auto, compact, or dictionary |
Client Validation
Bloodhound validates BitTorrent clients by peer ID. Default mode is whitelist with 17 pre-configured clients including qBittorrent, Deluge, Transmission, rTorrent, libtorrent, Vuze, and others.
| Key | Default | Description |
|---|---|---|
client_validation.enabled |
true |
Enable client checks |
client_validation.mode |
whitelist |
whitelist or blacklist |
client_validation.whitelist |
(see config) | Allowed clients with version ranges |
client_validation.blacklist |
(see config) | Blocked clients (Xunlei, etc.) |
Each whitelist entry specifies a peer ID pattern, version format, and allowed version range. You can add custom clients or adjust version requirements.
Anti-Cheat
| Key | Default | Description |
|---|---|---|
anti_cheat.enabled |
true |
Master switch |
anti_cheat.max_upload_speed |
104857600 |
100 MB/s cap |
anti_cheat.max_download_speed |
104857600 |
100 MB/s cap |
anti_cheat.min_announce_gap |
60 |
Min seconds between announces |
anti_cheat.max_connections_per_torrent |
3 |
Per user, per torrent |
anti_cheat.max_connections_per_ip |
10 |
Per IP address |
Anti-cheat runs these checks on every announce:
- Port blacklist - Blocks known P2P/ISP-blocked ports
- Announce frequency - Prevents tracker hammering
- Connection limits - Per user and per IP
- Speed checks - Flags impossibly fast transfers
- Data consistency - Validates reported download vs torrent size
- Swarm consistency - Samples 5% of announces for coordinated inflation
Violations fire a CheatDetected event and are logged to Redis for admin review.
Stats Queue — deprecated
| Key | Default | Description |
|---|---|---|
queue.enabled |
true |
No longer read |
queue.connection |
null |
No longer read |
queue.queue |
tracker |
No longer read |
Byte counts no longer travel through a queue. They are written to the ledger on the
announce path and folded into totals by bloodhound:aggregate-ledger. These keys are
kept for one release so an existing published config does not break, and are removed in
the next major.
A note on your queue connection. Bloodhound requires Redis for peer storage, so most deployments have one, and Laravel's path of least resistance is to point
QUEUE_CONNECTIONat the same instance. That used to couple two failure modes into one: a Redis restart lost both the pending byte count and the baseline it was derived from. That is no longer possible — the queue carries no data — but pointing your queue at a separate backend is still the safer arrangement.
Announce Log — the ledger
On by default, and this is deliberate. A full-detail, permanent history of every announce — each started, regular-interval, completed, and stopped request, with the cumulative totals the client reported, the delta credited, and the baseline that delta was computed against.
This table is the source of truth for ratio. User totals and per-torrent totals are projections rebuilt from it, and a wrong number can only be detected — let alone corrected — by comparing it against this. A source of truth cannot be opt-in: an install running without one has no way to know its ratios are wrong, and ratio is what gets people banned.
Turning it off is supported, but it disables reconciliation, the rebuild command, and the arithmetic audit along with it. You are choosing to accumulate numbers nothing can verify.
Turn this on if you want to investigate a cheating report or settle a disputed ratio after the fact. It is off by default because full-detail logging on a busy tracker is real, ongoing storage growth, and that shouldn't be imposed on every install.
| Key | Default | Description |
|---|---|---|
announce_log.enabled |
false |
Master switch |
announce_log.connection |
null |
Database connection (null = app default) |
announce_log.retention_days |
null |
Days to keep rows (null = forever) |
BLOODHOUND_ANNOUNCE_LOG=true BLOODHOUND_ANNOUNCE_LOG_CONNECTION=announce_log BLOODHOUND_ANNOUNCE_LOG_RETENTION_DAYS=90
The row is written synchronously, before the announce response goes back to the client. This is deliberate: the ledger is the durable record everything else is rebuilt from, so it has to exist the moment the announce completes. Putting it behind a queue would mean the only copy of a byte count lived in a job payload, and a lost job would be a lost credit with nothing left to re-derive it from.
What gets logged
One row per announce: user, torrent, peer ID, event, IP, port, user agent, the cumulative uploaded/downloaded/left the client reported, the calculated upload_delta/download_delta since that peer's last announce, the
prior_up/prior_down baseline those deltas were computed against, and whether
anti-cheat flagged it (with the reason when it did).
Storing the baseline alongside the delta is what lets a row be checked on its own:
delta == reported - prior holds per row, and each row's prior should equal the
previous row's reported value for that peer. A break in that chain is the signature of
a lost baseline — which is how a Redis outage becomes visible after the fact instead
of silently costing users their credit.
Both the cumulative totals and the deltas are kept deliberately. The delta is what actually happened this announce; the cumulative is the client's own claim. Storing both lets you cross-check a client's arithmetic against its own delta history - a client whose claimed total doesn't match the sum of its reported deltas is lying about something.
The table is append-only. Rows are never modified after they're written, so there is no updated_at.
Isolating it on a separate database
announce_log.connection takes any connection name from config/database.php. Point it at a second database - same engine or not - and all reads and writes for this one table go there, with no other change. Migrations follow it too.
This is the mechanism for keeping a high-write-volume table off your main database. Note that user_id and torrent_id carry no foreign key constraints precisely so this works: a real FK would require those tables to live on the same connection, which is the thing you're trying to avoid.
Retention and pruning
retention_days is null by default, which means keep everything forever. With logging enabled and no retention set, this table grows without bound on a busy tracker. That default is deliberate - once you've opted into logging, how long to keep it is your call, not ours - but it is your job to make it.
Set retention_days and the scheduled bloodhound:prune-announce-log command keeps the table bounded:
php artisan bloodhound:prune-announce-log
It's registered on Laravel's scheduler to run daily, so it needs the scheduler running. It no-ops safely when no retention is configured, and reports what it did either way.
Private trackers only
This is a Bloodhound feature and has no equivalent in marque/hound. Public trackers are deliberately anonymous - hound records no user against an announce at all - so ratio verification and cheat investigation aren't concepts that apply there.
The existing anti-cheat Redis suspicious list keeps working exactly as before whether or not you enable this, since it has no toggle of its own and operators who haven't opted in still need it.
Querying the log
AnnounceLogServiceInterface is the read side. Resolve it from the container:
use Marque\Bloodhound\Contracts\AnnounceLogServiceInterface; public function __construct( private readonly AnnounceLogServiceInterface $log, ) {}
| Method | Answers |
|---|---|
forUser(int $userId, ?Carbon $since = null) |
What has this user been doing? |
forTorrent(int $torrentId, ?Carbon $since = null) |
What happened on this torrent? |
forUserAndTorrent(int $userId, int $torrentId) |
This user's session history on one torrent - the ratio-dispute query |
flagged(?Carbon $since = null) |
Everything anti-cheat rejected |
byIp(string $ip, ?Carbon $since = null) |
Multi-account / IP correlation |
All return a Collection of AnnounceLog models, newest first.
$recent = $this->log->forUser($user->id, now()->subDays(7)); $disputed = $this->log->forUserAndTorrent($user->id, $torrent->id);
Results aren't paginated. The log is queried deliberately - an investigation, a dispute - not rendered on a hot path, and $since is the intended way to bound a result set on a tracker with real volume. Each method's query is shaped to use the table's indexes, so passing $since is cheap rather than a post-filter.
Bloodhound ships no UI for this. Browsing the log is left to your application.
Verifying the numbers
Three commands, and the reason they exist: before the ledger, a byte count could be lost or corrupted and nothing anywhere would know. The totals were accumulators with nothing behind them, so a wrong number stayed wrong forever and the first anyone heard of it was a user disputing a ban.
php artisan bloodhound:aggregate-ledger # fold pending rows into totals (scheduled, every minute) php artisan bloodhound:reconcile-ledger # do the totals match the ledger? (scheduled, daily) php artisan bloodhound:audit-ledger # is the ledger itself coherent? php artisan bloodhound:rebuild-totals # recompute totals from the ledger
Reconciliation reports; it does not repair. You should learn a number went wrong before anything changes it back. Rows not yet aggregated are reported as a backlog rather than as drift — an alert that cries wolf is an alert nobody reads.
The audit is the interesting one. Every ledger row stores the baseline its delta was computed against, so two things are checkable that were not before: that each row's arithmetic holds, and that each peer's chain of baselines runs unbroken. A chain break means a baseline went missing between two announces — which is what a Redis outage looks like after the fact. Those bytes were never credited and cannot be recovered, but you find out it happened instead of never knowing.
Rebuild recomputes user and per-torrent totals by replaying the ledger, for everyone
or --user=. It deliberately does not move the aggregation watermark, and it restores
byte columns only — completion dates are not derivable from deltas.
Pruning and the floor
bloodhound:prune-announce-log deletes rows past retention_days, but only rows the
aggregator has already consumed. Age alone is not sufficient grounds for deletion:
pruning below the reconciliation watermark would make the totals derived from those rows
permanently unverifiable, which is a cleanup job quietly destroying the thing the ledger
exists to protect. The command says so when it withholds rows.
Upgrading an existing install
The migration writes one opening_balance ledger row per user, carrying their pre-ledger
uploaded/downloaded forward, then folds it immediately so nobody reads as having zero
ratio.
That row asserts the old total was correct as of migration. It is not evidence that it ever was — the per-announce history was never kept, which is exactly why the ledger now exists. Per-torrent history likewise starts empty, so hit-and-run enforcement is only meaningful for torrents grabbed after the upgrade.
Port Blacklist
Default blocked ports include Direct Connect (411-413), Kazaa (1214), eMule (4662), Gnutella (6346-6347), and legacy BitTorrent defaults (6881-6889).
Events
| Event | Fired When | Properties |
|---|---|---|
TorrentCompleted |
Peer finishes download | userId, torrentId, ip, userAgent |
CheatDetected |
Anti-cheat violation | type, userId, torrentId, reason |
TorrentCompleted automatically records a snatch (completion record) via the built-in listener.
Listen to these in your app for custom behaviour:
use Marque\Bloodhound\Events\CheatDetected; Event::listen(CheatDetected::class, function ($event) { // Ban user, notify admin, etc. });
Middleware
Bloodhound applies BlockBrowsers middleware to tracker endpoints. This rejects requests from web browsers (detected by cookies, Accept-Language headers, and user agent strings) - only BitTorrent clients should hit these endpoints.
Requirements
- PHP 8.3+
- Laravel 13+
- Redis
- marque/trove
License
MIT