Search by

marque / bloodhound

lomsoftware

BitTorrent tracker (announce/scrape) for Marque platform

v5.1.0 2026-09-04 05:13 UTC

This package is auto-updated.

Last update: 2026-09-04 18:03:48 UTC


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

  1. Announce key validated, user looked up
  2. Torrent identified by info_hash
  3. Anti-cheat checks run
  4. Client validated against whitelist
  5. Peer upserted in Redis, stats calculated
  6. Upload/download deltas queued for database update
  7. 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:

  1. Port blacklist - Blocks known P2P/ISP-blocked ports
  2. Announce frequency - Prevents tracker hammering
  3. Connection limits - Per user and per IP
  4. Speed checks - Flags impossibly fast transfers
  5. Data consistency - Validates reported download vs torrent size
  6. 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_CONNECTION at 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

License

MIT