Search by

marque / bloodhound

lomsoftware

BitTorrent tracker (announce/scrape) for Marque platform

Package info

github.com/letterofmarque/bloodhound

Issues

pkg:composer/marque/bloodhound

Statistics

Installs: 188

Dependents: 0

Suggesters: 0

Stars: 0

v11.3.0 2026-11-07 13:06 UTC

README

BitTorrent tracker for the Marque platform. Handles announce/scrape with Redis-backed peer storage, client validation, anti-cheat, and a ledger that every member's ratio and seedtime is summed from.

Starting from scratch?

Bloodhound is the tracker half only — no frontend, no auth, no upload UI. For a complete private tracker, install the set:

composer require marque/trove marque/bloodhound marque/guise marque/usarrs marque/cennad

That resolves marque/threepio and marque/deck for you.

Prefer a public, no-login tracker? Use marque/hound and marque/disguise instead of bloodhound and guise.

Requirements

  • Redis, configured to keep what it holds: appendonly yes and maxmemory-policy noeviction, on a database of its own. See below; in production bloodhound refuses announces until it is.
  • PHP 8.3+
  • Laravel 13+
  • marque/trove
  • MySQL, MariaDB, PostgreSQL, or SQLite 3.33 or later.

Redis

Bloodhound keeps every peer's last reported counters in Redis and credits each announce against them. Redis is therefore not a cache here: it holds the baseline your members' ratio and seedtime are measured from. A Redis that restarts empty, or evicts a key when memory runs out, silently costs whichever members' records it dropped.

So run the tracker's Redis with:

appendonly yes
appendfsync everysec
maxmemory-policy noeviction

and give it a database (or an instance) of its own: point THREEPIO_REDIS_CONNECTION at a connection in config/database.php that the cache, queue and sessions don't use. A FLUSHDB aimed at your cache would otherwise take the swarm with it. On a managed service, set the same in its parameter group.

Then check it:

php artisan bloodhound:check-redis

It reads aof_enabled from INFO persistence and maxmemory_policy from INFO memory, and prints what it found, what is needed and the lines to set. It exits 1 when the check fails. It reads INFO rather than CONFIG GET because managed services generally allow INFO where they disable or rename CONFIG. It also warns, without failing, when the tracker's Redis connection points at the same host, port and database as the cache, queue or session store.

In production, announces are refused until the check passes. Each one is answered Tracker storage is not configured for durability; the operator has been notified, and the failure is logged at critical. No ledger row is written for these, since the refusal is the tracker's, not the member's. A passing verdict is cached in your application cache for five minutes and a failing one for 15 seconds, so a fix takes effect within 15 seconds. Announces log the failure at most once every five minutes; the scheduled check below logs it on every run. This is deliberately a hard stop: a tracker that credits ratio against a store that can forget is worse than one that is briefly down, and putting it right is a two-line change. In any other environment (APP_ENV other than production) a warning is logged instead and announces go ahead, so development machines and CI need nothing.

bloodhound:aggregate-ledger, scheduled every minute, runs the same check and logs a failure (critical in production, warning elsewhere), so a tracker nobody is announcing to still reports it.

If your provider makes the data durable another way (replication, snapshots) and offers no AOF, set BLOODHOUND_REDIS_DURABILITY=attested. That satisfies the persistence check. It covers the eviction policy only when the server refuses INFO altogether: when INFO reports a policy other than noeviction, the check fails whatever you attest, because every provider lets you set the policy and an evicted peer record is a lost baseline.

marque:install runs the same check when it installs a private tracker, and stops before installing anything until it passes. BLOODHOUND_REDIS_DURABILITY=attested in .env covers persistence there too; the eviction policy still has to be noeviction.

If Redis is lost anyway, bloodhound notices and reads baselines from the ledger for a while: see Recovery mode. The audit then names every peer whose session the loss cut.

You also need ext-redis or predis/predis; see threepio's README.

Database

Bloodhound runs on MySQL, MariaDB, PostgreSQL and SQLite. The aggregator's batched writes are spelled for those four (UPDATE ... JOIN on MySQL and MariaDB, UPDATE ... FROM on the others) and refuse any other driver. SQLite needs 3.33 or later for UPDATE ... FROM; below it you get a SQL syntax error from the aggregator, not a clear refusal, so check sqlite3 --version before upgrading. The suite is tested on MySQL 8.0, MariaDB 11, PostgreSQL 17 and SQLite.

Installation

composer require marque/bloodhound

Publish the config and run migrations:

php artisan vendor:publish --tag=bloodhound-config
php artisan migrate

Bloodhound schedules its own commands (see Commands), so it needs Laravel's scheduler running. Without it nobody is credited for anything: totals move only when bloodhound:aggregate-ledger folds the ledger. Announces notice this. At most once every five minutes, an announce checks whether the oldest row not yet folded is more than ten minutes old, and if it is, logs an error naming the scheduler.

How It Works

Bloodhound registers two endpoints:

Endpoint Purpose
GET /announce/{announce_key} Peer announces (start, stop, complete)
GET /scrape/{announce_key?} Swarm statistics

Those are the defaults — the URL shape is configurable, which matters if you are migrating an existing tracker. See Announce URL shape.

Users authenticate via their announce key. Keys live in bloodhound's own announce_keys table: HasTrackerStats issues one when a user is created, and other code reads or regenerates it through trove's TrackerStatsInterface, which bloodhound binds — see trove's README.

If your User implements MustVerifyEmail, the key waits for a verified address. An unverified sign-up gets no key, and bloodhound issues one when Laravel's Verified event fires. Without this, someone could sign up with another person's address and announce before anyone proved the inbox. Taking the key back afterwards would mean rotating it, and every rotation makes the user re-download their .torrent files. A user who already has a key keeps it when their address is verified. Users created already verified, and apps that don't verify addresses, get a key at creation as before. So does a user verified by setting email_verified_at directly (an admin screen, a seeder), which fires no Verified event.

Importing users? A row inserted without Eloquent (a bulk insert(), raw SQL, a migration from another tracker) passes through none of those, so it has no key and cannot announce. Run php artisan bloodhound:issue-keys after the import. It issues a key to every verified user without one (every user, if your model doesn't verify addresses) and never touches an existing key. --dry-run reports the count first. If you are bringing legacy keys across instead, insert them into announce_keys before running it, and those users keep their old keys. The legacy users.announce_key column is deprecated and never read. Peer data is stored in Redis; the announce path makes one query to resolve the user.

Announce URL shape

If you are migrating an existing tracker, read this first. Every .torrent file already in circulation announces to your old URL, and those files are on strangers' disks — you cannot reissue them. So bloodhound lets you keep serving the URL they expect:

'routes' => [
    'announce_path' => 'announce',     // e.g. 'announce.php'
    'scrape_path'   => 'scrape',
    'key_source'    => 'path',         // 'path' | 'query'
    'key_parameter' => 'announce_key', // e.g. 'passkey'
    'key_pattern'   => '[0-9a-zA-Z]{32}',
],

Coming from TBDev or one of its many forks, where the key is a query parameter, that is:

BLOODHOUND_ANNOUNCE_PATH=announce.php
BLOODHOUND_SCRAPE_PATH=scrape.php
BLOODHOUND_KEY_SOURCE=query
BLOODHOUND_KEY_PARAMETER=passkey

...giving you GET /announce.php?passkey=<key>.

This is configurable routing, not a legacy compatibility mode. There are no per-tracker special cases in here and there should never be any — the config says "your URLs, your choice" and stays neutral about where you came from.

Four things worth knowing:

Change scrape too. Changing announce and leaving scrape at the default is a half-migration, and clients will scrape the wrong URL.

Route names never change. They are tracker.announce and tracker.scrape whatever you configure, so route('tracker.announce', ...) keeps working.

key_pattern is a security surface. It gates what reaches a database lookup. The default matches what bloodhound itself mints — Str::random(32), i.e. 32 alphanumeric characters. Keep it as tight as your existing keys allow; widening it is your risk to take. Don't anchor it yourself, and don't add delimiters — it is used directly as a Laravel route constraint and anchored for you when matching.

A malformed key gets a reason, not a 404. A BitTorrent client shows a bencoded failure reason. A 4xx, or your app's HTML 404 page, reads to it as an unreachable tracker or a garbled error, not a message worth showing the user. With the key in the query, a malformed one is answered Invalid announce key at HTTP 200. With it in the path, the route constraint vets the key before any lookup. A request that fails it (wrong length, extra segments, no key at all) reaches a fallback route under the same path, which answers the same bencoded Invalid announce key without touching the database. This is the default from 9.0, and a published config with no malformed_key in its routes block gets it too. Before 9.0 it was the router's 404. To keep that 404, set routes.malformed_key to not_found (BLOODHOUND_MALFORMED_KEY=not_found). With a published config, add the key to its routes block first; a published block replaces the shipped one whole (see Configuration). On announce a missing key is refused the same way. Scrape accepts a missing key, since a public scrape is legitimate, and refuses only a malformed one. Only the configured paths are covered. A legacy URL at a different path (/announce.php when you serve /announce) is still the app's 404 until you configure it, as an additional endpoint if the new URL stays.

An nginx rewrite is the right fix for an outage, not for a migration

If your announce URLs are 404ing right now, a rewrite at the web server is the fastest way out and you should do it. As the permanent answer it is weak: it puts a routing concern in deployment config where the application cannot see it, it is easy to lose on a server rebuild, it cannot be covered by this package's tests, and it does nothing for anyone on a host where they do not control the web server.

Serving the old URL and the new one at once

A migration doesn't cut over. The .torrent files issued years ago announce to the old URL for as long as they're seeded, while the ones you issue now should carry the native one. So serve both: routes above is the primary endpoint, and endpoints lists more, each with the same keys, all live together.

'routes' => [ /* the native /announce/{key}, unchanged */ ],

'endpoints' => [
    'legacy' => [
        'announce_path' => 'announce.php',
        'scrape_path'   => 'scrape.php',
        'key_source'    => 'query',
        'key_parameter' => 'passkey',
        'key_pattern'   => '[0-9a-f]{32}',
    ],
],

Coming from TBDev or one of its forks, that serves announce.php?passkey=<key> beside /announce/<key>, each checking keys against its own pattern. bloodhound routes a key after the path or in the query; a tracker whose key comes before the path (Gazelle's Ocelot announces to /<key>/announce) needs a web-server rewrite to one of those.

  • Names. The primary keeps tracker.announce and tracker.scrape; the others are tracker.announce.<name> and tracker.scrape.<name>. A name is lowercase letters, digits, - and _.
  • Downloads carry the primary endpoint's URL. Only old files use the others.
  • A key in the path gets the same bencoded "Invalid announce key" fallback on every endpoint, per its own malformed_key.
  • No two endpoints may share a path, including one's announce path as another's scrape path, and no path may sit under the path of an endpoint that takes its key in the path (announce/legacy.php under /announce/{key}): that endpoint would answer it. Laravel would let one route win without a word, so bloodhound refuses to boot instead, naming both.

Reshaping old requests: transforms

Sometimes the old URL isn't just a different place: a parameter has another name, or keys were stored in another case. An endpoint can name a transform, a class that reshapes each request before announce and scrape handle it:

'legacy' => [
    // ...
    'transform' => App\Tracker\OldTrackerTransform::class,
],
use Illuminate\Http\Request;
use Marque\Bloodhound\Contracts\AnnounceTransform;
use Marque\Bloodhound\Support\ReshapedRequest;
use Marque\Trove\Contracts\UserInterface;

class OldTrackerTransform implements AnnounceTransform
{
    public function apply(Request $request): Request
    {
        // The old links say ?pk=; this endpoint reads ?passkey=.
        $query = preg_replace('/(^|&)pk=/', '$1passkey=', (string) $request->server('QUERY_STRING'));

        return ReshapedRequest::withQueryString($request, $query);
    }

    public function resolveUser(Request $request): ?UserInterface
    {
        return null; // see Lazy migration, below
    }
}
  • It's a class, from the container, never a file. The announce URL is the most probed, least authenticated address a tracker has; a config value naming code to run there would be remote code execution. Anything but a namespaced class implementing AnnounceTransform refuses to boot.
  • Build the new query with ReshapedRequest::withQueryString(). bloodhound reads info_hash and peer_id, which are binary, from the raw query string, not the parsed one, and this keeps the two in step.
  • It reshapes; it can't decline a request. A transform that returns a malformed request is refused by the normal checks with a bencoded error; one that throws is answered with an HTTP error, which clients retry. Either way the tracker keeps answering.
  • Scrape uses the same transform as its endpoint's announce. A legacy scrape URL has the same shape.
  • Key case on MySQL and MariaDB. Their default collation compares announce keys without regard to case, so lower-casing keys changes nothing there; on PostgreSQL and SQLite it does.

Lazy migration: members arrive when they first announce

Importing every member of the old tracker means 100,000 rows of whom perhaps 10,000 ever come back. Instead, a transform's resolveUser() can find the announcing member itself, and provision one the old tracker knows but Marque doesn't yet:

use Marque\Bloodhound\Services\AnnounceKeyProvisioner;

public function __construct(private AnnounceKeyProvisioner $provisioner) {}

public function resolveUser(Request $request): ?UserInterface
{
    $key = (string) $request->query('passkey');

    return $this->provisioner->provision($key, function () use ($key) {
        $old = DB::connection('old_tracker')->table('users')->where('passkey', $key)->first();

        if ($old === null) {
            return null; // not one of ours: nothing is created
        }

        return User::create(['name' => $old->username, 'email' => $old->email]);
    });
}

The contract:

  • What resolveUser() returns replaces the native lookup. When the transform finds the member, that's the only lookup. Null means "not mine", and the native lookup by key runs as usual.
  • Null never means "create one". The old tracker is the gate: a key it never issued creates nothing and is answered "Unknown announce key", like any other unknown key.
  • A key Marque already knows returns its member without running your creator, so the old tracker is asked once per member.
  • Exactly one member per key, under concurrency. A client announcing to twenty torrents sends twenty first announces at once. The member and their key are written in one transaction, and the unique indexes refuse all but one; the others roll back and get the winner's member.
  • The member keeps their old key as their Marque key, so their old .torrent files and the native URL both work.
  • A provisioned member is checked like anyone: a disabled account is refused.
  • If the old tracker can't be reached, that's your transform's call. An exception from the creator rolls back anything it created and answers the client with an HTTP error, which clients retry. Return null instead and the client is told "Unknown announce key".

Bring their old ratio with them. Provisioned at zero, a member can be restricted by your ratio rules for being new. Carry their old figures over on the record (see Correcting the numbers), in provision()'s third argument, which runs once the member is committed and only in the request that created them:

return $this->provisioner->provision($key, $create, function (UserInterface $user) use ($key) {
    $old = DB::connection('old_tracker')->table('users')->where('passkey', $key)->first();

    // adjust() refuses an adjustment that moves nothing.
    if ((int) $old->uploaded === 0 && (int) $old->downloaded === 0) {
        return;
    }

    app(LedgerAdjuster::class)->adjust((int) $user->getAuthIdentifier(), 0,
        ['uploaded' => (int) $old->uploaded, 'downloaded' => (int) $old->downloaded],
        by: $operatorId, reason: 'Carried over from the old tracker');
});

$operatorId is the user id of an existing admin, recorded as the one who carried the figures over.

Not in the creator: the ledger can live on its own database, where an adjustment commits straight away, and a request that loses the race rolls its member back, which would leave the adjustment behind. If the adjustment fails, the member stays without their old figures: the adjuster's exception answers that announce with an HTTP error and is logged, and bloodhound:adjust-totals can add the figures afterwards.

Prove the key formats are disjoint first. bloodhound mints its own keys, 32 random letters and digits. If an old key could equal a key bloodhound minted for someone else, the old member's announces would credit that someone else: account takeover, not a data bug. A 32-character hex key colliding with a random one is vanishingly unlikely, but if your old keys are short, guessable or reused, don't put them in announce_keys at all. Keep them in a mapping table of your own, have resolveUser() look them up there, and issue the member a fresh key with TrackerStatsInterface::regenerateAnnounceKey(). That path is yours to make race-safe.

Not verified: route:cache. Endpoint routes are ordinary named routes and should cache like any others, but a package test can't prove it: route:cache re-bootstraps the app without the providers the test harness injects. After caching, check php artisan route:list on your own app.

Announce Flow

  1. Announce key validated, user looked up (a disabled account is refused); torrent identified by info_hash; parameters and IP validated. An announce over an address family that's off (address_families) is answered here with a warning, and nothing after this step runs for it.
  2. The tracker's Redis checked for durability (cached; see Redis). In production a failing check refuses the announce here.
  3. Client validated against the whitelist or blacklist.
  4. The peer's record read from Redis, once, along with the torrent's live peers. A peer ID live in the swarm for another member is refused here: two members cannot share one, and letting the second through would overwrite or remove the first's record.
  5. A leech from a member whose downloading is paused, by the ratio rule or by hit-and-run strikes, is refused here, unless it's a torrent they're already in (see Ratio enforcement and Hit-and-run).
  6. Anti-cheat judges the announce against that record.
  7. The credit computed from the same record, and the ledger row written.
  8. Only then Redis updated: the peer's record, the swarm counters and sets.
  9. Bencoded peer list returned to the client. Within a minute or two bloodhound:aggregate-ledger adds the row to the member's totals.

A refusal at step 3, 4, 5 or 6 writes a flagged ledger row and stops: nothing is credited and Redis is not touched. A refusal at step 1 (a bad announce key, an unknown torrent, missing or invalid parameters, an out-of-range port) or step 2 writes no row: there is no member and torrent to record it against, or the tracker itself is refusing.

IPv6 and dual-stack peers

IPv6 peers are handed out in peers6 (BEP 7) beside the IPv4 ones in peers. A dual-stack client, which announces over IPv4 and IPv6 in turn under one peer ID, keeps an address in each family and is handed out in both in compact responses (see threepio).

A client can also say what address it has in the other family, with ipv4= and ipv6=. bloodhound hands that out too (trust_announced_addresses, on by default), held to the same rule as the address the announce came from: the right family, and public in production. Anything else is ignored, not refused. An announced address only fills the family the announce didn't arrive over. An ipv4= sent over IPv4 never replaces the address the client connected from, and an announced address is never used for location (see Location limits). They're trusted because the announcer is a member, and every ledger row, refusals included, records the announced addresses that passed vetting (announced_ipv4, announced_ipv6). Turn it off and a peer is handed out only where it announced from (addresses already claimed age out within peer_expiry).

An IPv6-only peer can only reach IPv6-capable peers. Behind Cloudflare, which publishes AAAA records for proxied hosts, expect many members to announce over IPv6.

Accepting one family only. address_families (BLOODHOUND_ADDRESS_FAMILIES) is ipv4,ipv6 by default; set it to ipv4 or ipv6 to take announces over that family only. That's the simplest answer to dual-stack ambiguity in the location limits (see Location limits), at the cost of the other family's members. An announce over the family that's off gets an ordinary response: no peers, the torrent's stored counts, and a warning message ("This tracker accepts IPv4 announces only", or IPv6). It isn't a failure, so a dual-stack client shows no error and carries on over the family that's on, and a client that only has the other family is told why it finds nobody. Nothing is recorded for it, in the ledger or the swarm. Its counters are cumulative, so the next announce over the family that's on credits every byte. An event (completed, stopped) sent only over the family that's off is lost; clients send events to every endpoint, so in practice it arrives over the other. An announced address in a family that's off is ignored too. A setting with no recognisable family means both.

Peer Storage

Peers live in Redis, under threepio's key prefix (threepio.redis.prefix, marque: by default): a hash of peers per torrent, seeder and leecher counters, per-member and per-IP sets, and swarm byte totals. threepio's README lists the keys. Bloodhound adds a suspicious list (the last 1,000 anti-cheat events), a durability_sentinel key and, while one is open, recovery_until.

A peer that has not announced for threepio.peer_expiry (an hour by default) is treated as gone: it is left out of peer lists and counts as a new peer if it comes back. Its record stays in Redis until the hourly sweep (bloodhound:sync-swarm-counts) removes it. That sweep is the only way an expired peer leaves. A stopped announce removes its peer at once.

Retained baselines

When the sweep removes an expired peer, it keeps the record for baseline_retention (7 days by default) after its last announce, in a separate gone:{torrent} hash. A member whose client comes back under the same peer ID without having sent stopped (network lost, machine asleep, client killed) is then credited what it moved in between, measured against that record, instead of nothing. The record is used once and deleted. A stopped peer is never retained, because stopped closes the session.

Recovery mode

If Redis does lose its data, the next peer it has no record of finds bloodhound's durability_sentinel key gone while the ledger shows announces from the last peer_expiry. That means the swarm those announces built is not there. Bloodhound logs a warning and opens a recovery window for twice peer_expiry (two hours by default): long enough for every peer that was in the swarm to announce again. During it, a peer that Redis has no record of is looked up in the ledger instead: the last accepted announce from that member, on that torrent, under that peer ID, and no older than baseline_retention. A refused announce is never a baseline, and if the last row is a stopped, or ended, the session is over and there is none.

Outside the window the announce path never reads the ledger. php artisan bloodhound:check-redis --recover opens a window by hand.

An install upgrading from 7.x has no sentinel yet; the upgrade's migrations set it while the swarm is known to be in Redis, so the first announces after upgrading do not read as a lost Redis.

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 (keeping their records as baselines, above) 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.

How an announce is credited

Every announce bloodhound accepts is credited once, at the moment it arrives, against the peer's record in Redis: the counters and time of that peer's last accepted announce (its baseline). The bytes and the seconds are both written on the announce's ledger row, and every total is a sum of those rows. Nothing pairs one announce with another afterwards.

Bytes are the reported uploaded and downloaded minus the baseline's. A counter that has gone backwards means the client restarted under the same peer ID and is counting from zero again, so that direction is credited the reported figure.

Seconds are the time since the baseline's announce, capped at threepio.peer_expiry (an hour by default). A client that goes quiet without saying so gets the hour, the point at which the swarm already treats it as gone, rather than the whole night its laptop was shut. The interval goes to the state the peer was in when it began: nothing left to download means seeding, otherwise leeching. The interval in which a peer finishes downloading is leech time, all of it, because that is what it was doing.

Each kind of announce:

  • First announce of a session (no baseline: a new peer ID, the first after a stop, or a peer ID another member's record holds): credits nothing. The row has no priors.
  • Regular, started and completed: credited as above. started does not close a session: a client that restarts quickly under the same peer ID carries on from its baseline. completed also records the completion.
  • stopped: credits the bytes and seconds since the previous announce, against the record it removes. The protocol sends final counters on a stop, so they count. The session then ends; nothing is kept for it.
  • A refused announce (client validation, the ratio rule or hit-and-run, or anti-cheat): credits nothing and changes nothing in Redis. The next accepted announce is measured from the last accepted one, so its bytes are credited in full; its seconds are capped as usual.
  • A speed forfeit: the direction over the cap credits 0, the other direction and the seconds are credited, and the baseline moves on (see Anti-Cheat).

The ledger row is written first, then Redis. If the insert fails, the client gets a tracker error and retries against an untouched baseline, so nothing is lost. If Redis fails after the insert, the next announce is measured against the same baseline and one interval is credited twice; the audit names that row.

Seedtime and leechtime

Bloodhound records how long each member spent seeding and leeching every torrent, on the member (seedtime, leechtime) and per torrent (torrent_user.seedtime, torrent_user.leechtime), in seconds, summed from the ledger like the byte totals.

A member's time is the sum over their clients: two clients seeding one torrent for an hour earn two hours.

  • Time is counted in every ratio_mode. It is a record of what happened, not a policy; switching mode later finds the history already there.
  • hound counts nothing. A public tracker has no members to credit.

Expect lower figures than some other trackers report. Trackers that credit a fixed announce interval per announce, or the whole gap however long, count time a client wasn't there. Bloodhound counts only time it can stand behind: real elapsed time between accepted announces, capped, never across a stop, and none for a refused announce.

Anti-Cheat

Anti-cheat judges an announce against the same peer record the credit uses, so nothing is checked against one baseline and credited against another. It refuses only what a legitimate client doesn't do, and never refuses a client that is already in the swarm on a count.

Check Applies to Outcome
Port Every announce Refused: a port in threepio.blacklisted_ports, or outside 1–65535
Minimum gap Regular announces (no event) from a peer in the swarm, at the address it last announced from Refused when sooner than min_announce_gap after its last accepted announce
Location limits A new peer, or one that moved or turned from seeding to leeching; never stopped Refused at a new location past the seeding or leeching limit
Clients per torrent A peer not in the swarm, other than stopped Refused when the member already has max_clients_per_torrent live clients on the torrent
Speed Every announce with a baseline The direction over the cap is forfeited; the announce is accepted
Data anomaly Every announce More left than the torrent's size: reported, never refused
Swarm consistency A 5% sample of announces Swarm downloads well beyond uploads plus seeders' copies: reported, never refused

Minimum gap. A hammer guard, 60 seconds by default, below threepio.min_announce_interval (300, what clients are told) so a client that re-announces early is not refused. started, completed and stopped are never refused for timing: they report something that happened, and refusing them loses a completion or leaves a ghost peer in the swarm. The gap is measured from the peer's record and a refusal changes nothing, so a client retrying every 30 seconds is accepted every minute rather than locked out.

The gap is from the same address only. A dual-stack client announces over IPv4 and IPv6 seconds apart with one peer ID; the second is accepted and credited only what moved since the first.

One announce per peer at a time. A peer's announces are read, credited and written under a short Redis lock, so two at once (the dual-stack case again) cannot both credit the interval the first opens. An announce that cannot get the lock within two seconds is refused and recorded; the client retries.

Peer IDs are any 20 bytes (BEP 3). One that isn't UTF-8 text, or holds a NUL, is stored in the ledger as x: and its hex, the same way every time, so its rows still chain and recovery still finds them. The user agent is stored scrubbed to valid UTF-8.

Location limits. A location is an IP address; for IPv6 it is the /64, because a connection is usually handed a whole /64 and clients pick addresses from it freely. A peer is located where its last announce came from. An address it announced (ipv4=, ipv6=) never locates it, because a client can announce any address, including the account owner's. Neither does the other family's address it keeps for handing out: one peer ID can announce from two places, so a peer's two families are never taken as one machine. A dual-stack client is still one location, wherever it last announced from. Seeding and leeching are limited separately: max_seeding_locations_per_torrent (3) and max_leeching_locations_per_torrent (1). The leeching limit is what stops one account being shared to download. Only a new location is counted against a limit:

  • a peer already in the swarm, announcing from where it was, is never refused by a count, so a leecher that finishes becomes a seeder without being refused;
  • a peer is counted again (not against itself) when it moves to another location, or turns from seeding to leeching, so starting as a seeder and then reporting bytes left does not get round the leeching limit;
  • a client at a location the member already uses on that torrent, in the same state, is not a new location;
  • a new peer is classed as seeding or leeching by the left it reports, and counted against the member's live peers in the same state.

The counts come from the torrent's live peers, which the response reads anyway.

Ambiguous locations. One case can't be decided from a single announce. qBittorrent and other libtorrent clients announce over IPv4 and IPv6 in turn under one peer ID. So in a house with two leeching clients, one dual-stack and one IPv4-only, the dual-stack client's IPv6 announce reads as a second place. One peer ID announcing from two different places looks exactly the same. bloodhound calls a refusal ambiguous when all of these hold:

  • the peer moved, from its last announce over the other address family;
  • that last announce was within ambiguous_window seconds (an hour by default; it has to span an announce interval, because a client's first family each cycle comes an interval after its last announce, over the second);
  • the place it came from is one where the member has another live client in the same state (leeching, or seeding);
  • it hasn't been seen on another network in the family it now announces over. A dual-stack machine comes back to a family on its own address, so a new network there is a move.

A new client's refusal, or one turning from seeding to leeching where it is, is ambiguous when it lands on another of the member's clients' address in that client's other family, and that client was seen on both families within dual_stack_gap seconds (120). That's an IPv4-only laptop starting beside a dual-stack desktop. The desktop's IPv6 announce follows its IPv4 one each cycle, so its place is almost always its IPv6 /64, and before 11.1 the laptop was refused on every try. The new client's row is flagged ("a new client at another of the member's clients' address") and usually has no baseline, the client being new. Ledger readers still count it as accepted: a refusal's reason never starts "Ambiguous location", in any case. "Lands on" means the same IPv4 address, or the same IPv6 /64, and only clients in the same state (both leeching, or both seeding) count. dual_stack_gap of 0 turns this off.

ambiguous_locations says what happens to one:

Setting A dual-stack household One account used at two places
flag (default) Not refused for switching families. The announce is credited and its ledger row flagged ("Ambiguous location") What refuse would stop gets in only by way of a flagged row and a CheatDetected (location_ambiguous) event. Some sharing gets in under every setting; see below for that, and for what the detector reports
refuse The dual-stack client's announces over its second family are refused, so it shows a tracker error for that endpoint (10.x behaviour) Refused
allow Not refused, nothing flagged Can get in silently

Under flag, every ambiguous row is flagged but CheatDetected fires at most once a day per peer per torrent: a household is ambiguous on every announce over its second family, and an event each time would bury everything else. Anything other than the three values means flag.

Some household cases are still refused under every setting:

  • A dual-stack desktop that has so far announced only over IPv6 has no IPv4 address to pair, so an IPv4-only laptop starting then is a new location. It clears at the desktop's first IPv4 announce, which libtorrent sends within seconds. A desktop client that only ever announces over IPv6, and doesn't claim an IPv4 address with ipv4=, never pairs: the laptop stays refused until the desktop announces over IPv4 or claims an IPv4 address.
  • A desktop whose two families were last seen more than dual_stack_gap apart, such as one endpoint failing and backing off. The laptop clears once both announce close together again.
  • A dual-stack laptop starting over IPv6 in the few seconds between the desktop's IPv4 and IPv6 announces. It clears on its next try.
  • A dual-stack client starting (or restarting) over IPv6 beside an IPv4-only one. Its first announce is refused: nothing pairs the IPv4-only client with an IPv6 address. It clears at its IPv4 announce, seconds later, which lands on the other client's place; its next IPv6 announce is then ambiguous. libtorrent's endpoint order varies, so this can happen on any start. The same goes for a dual-stack client turning from seeding to leeching with an IPv6 announce beside an IPv4-only one.

And one rule that isn't about dual-stack at all: a household whose address changes has moved, as far as a tracker can tell. That covers a new public IPv4 address, or the ISP renumbering its IPv6 prefix. Each client is refused at the new address while the member has another client still recorded at the old one, until the old records age out (threepio.peer_expiry, an hour by default) or the member ends those sessions from their profile. Every version has worked this way. The rule that stops a client counting a place it has left is the same one that stops this.

What flag lets through, and what the detector can see. Under flag, a refusal that might be a household is accepted, and the flagged row and its event are the trace. Staff who want to know should listen for location_ambiguous. bloodhound:detect-location-sharing runs daily and reads the ledger for two patterns:

  • An unsustained link. An ambiguous row in the last week linked two places, the peer that linked them has switched between them fewer than three times since (a real dual-stack client switches every announce interval, and keeps that credit after it stops or restarts), and in the last day the member has had other clients at both. This is a client at home moving to a friend's to set up the link, then clients at each. It's reported every day the member keeps both places, for a week after the setup (--link-days).
  • A peer that keeps going back to a network it left, within one family. That's one peer ID shared between two places on IPv4 (or two IPv6 /64s). It also looks like a VPN switched on and off, a phone moving between Wi-Fi and mobile data, a dual-WAN router, or a carrier whose CGNAT doesn't keep one IPv4 address per customer.

Findings are logged and fired as CheatDetected (location_sharing_suspected). They're evidence for staff, not verdicts, and the command never refuses or changes a credit.

What it can't see, because nothing on a tracker can tell these from a real household:

  • One peer ID shared across families, with one place on IPv4 and the other on IPv6, and no second client. It looks exactly like one dual-stack client. 10.x accepted it too, and no ambiguous row is ever written.
  • A linking peer that keeps switching between the two places, driven by a script. It looks like a household of two dual-stack clients.
  • A linking peer that settles at the far place, with no other client of the member there. It looks like a dual-stack client whose IPv4 went away. Its setup row is still flagged.
  • A new client at a paired address. One peer ID announces from two places within dual_stack_gap seconds, or a client claims the other address with ipv4=/ipv6=, and then a new client joins at that address. It looks like a laptop beside a dual-stack desktop. The new client's first row is flagged, and nothing links it to a second place afterwards. (Under refuse it's refused.)

It can also report a real household: one whose dual-stack client switched fewer than three times after linking the places before it stopped or restarted, while the member has other clients at both.

An address a client announces (ipv4=, ipv6=) never counts as a place. It can make a new client's refusal ambiguous (see "A new client at a paired address"). If none of that suits, take announces over one family only (address_families). Or judge locations yourself: bind your own Marque\Bloodhound\Contracts\LocationJudge in a service provider. It's asked a LocationQuestion (the member, the peer, its record, the torrent's live peers) on every announce that reaches the location check (not stopped, not one anti-cheat has already refused, and never with anti_cheat.enabled off), and answers LocationVerdict::pass(), refuse($reason) or ambiguous($reason). The policy above applies to its ambiguous verdicts too. Better evidence than timing, such as matching IPv4 and IPv6 networks by ASN from GeoIP data you hold, belongs there.

Clients per torrent. A member may run max_clients_per_torrent (5) live peer IDs on one torrent; only a new one past that is refused. Many peer IDs on one torrent pollute its swarm and inflate its seeder count, and that is the abuse a cap exists for. How many torrents a member, or an address, seeds is never limited: the members who seed thousands of torrents around the clock from one seedbox are the ones keeping a site alive. (8.0 removed max_connections_per_ip, which counted across torrents and refused exactly them.) An approved seedbox is not exempt from this or the location limits.

Speed. The bytes since the baseline over the real time elapsed, per direction, against max_upload_speed / max_download_speed (100 MiB/s each), or the seedbox caps (1,250,000,000 bytes/s, 10 Gbit/s) when the announce comes from one of the member's approved seedboxes. A direction over its cap is forfeited: credited 0, and the baseline moves on, so those bytes never land later. The other direction and the seconds are still credited, and the row is flagged with a reason that names the direction, the claimed rate, the cap and the bytes not credited. Refusing instead would leave the baseline where it was, and the next announce would credit the same bytes over a longer window. Lower the ordinary caps to what your members' home connections actually do. A forfeit is not re-credited automatically, so a cap set too low costs honest members their bytes.

Events. Only the checks that accept the announce fire CheatDetected, with type speed_violation, data_anomaly, swarm_anomaly (the last with no user) or location_ambiguous, and add an entry to the Redis suspicious list. The detector fires location_sharing_suspected. Refusals (port, gap, location, clients per torrent, and client validation) fire nothing: each is a flagged ledger row with its reason, which AnnounceLogServiceInterface::flagged() returns along with forfeits and ambiguous locations.

anti_cheat.enabled = false turns off every check above: nothing is refused, forfeited or reported. Client validation has its own switch.

Port Blacklist

Default blocked ports include Direct Connect (411-413), Kazaa (1214), eMule (4662), Gnutella (6346-6347), and legacy BitTorrent defaults (6881-6889).

Seedboxes

A seedbox is an address or range a member registers as theirs. Announces from inside one of a member's approved seedboxes are held to the seedbox speed caps instead of the ordinary ones; that is all a seedbox changes. The point is to let you set the ordinary caps to what home connections do without forfeiting a gigabit seedbox's bytes.

  • Registering. A member registers an IPv4 or IPv6 address (stored as a /32 or /128) or a CIDR range, with an optional label. A range may be at most a /24 for IPv4 or a /48 for IPv6 (seedbox.min_prefix_v4, seedbox.min_prefix_v6), so it can't take in other customers of the same provider. Each network is registered once per member.
  • Approval. With seedbox.require_approval (the default) a new seedbox is pending until an admin approves it. Without it, registering approves it. Approving and revoking record who did it and when. A revoked seedbox's announces go back to the ordinary cap. A member can remove their own.
  • On the announce path a member's approved networks come from the cache (five minutes) and are matched in PHP, so an announce costs no query. Every change made through bloodhound clears that member's cache, so it applies to their next announce.
  • Events: SeedboxRegistered, SeedboxApproved and SeedboxRevoked, each with the seedbox (a trove Seedbox) and by, the deciding admin's user id (null on registration). Removing one fires nothing.

Bloodhound has no screens. It implements trove's SeedboxesInterface, and usarrs renders the member screen (/profile/seedboxes) and the admin screen (/admin/seedboxes) through it. Without usarrs, use the command:

php artisan bloodhound:seedbox list --status=pending
php artisan bloodhound:seedbox approve 12 --by=1    # --by: the admin's user id
php artisan bloodhound:seedbox revoke 12 --by=1

Ending a session

A session is one client announcing one torrent. A client restarted under a new peer ID somewhere else (a laptop moved to another network, a VPN switched on) is a new location, and the old session stays live until it expires (peer_expiry, an hour). With the leeching limit at one location, the restarted client is refused until then. So a member can end the old session themselves; the refusal tells them to.

Ending one removes the peer from the swarm, and any retained baseline, and writes an ended ledger row carrying the counters the session last reported. It credits nothing: whatever the session moved since its last announce is lost, which is the price of not waiting. Like a stopped, it closes the session, so if the old client announces again under the same peer ID it starts a new one, the audit expects no baseline after it, and recovery mode offers none. A member can end only their own sessions. It runs under the peer's lock, as an announce does, so an announce from that session cannot land halfway through.

Bloodhound implements trove's TrackerSessionsInterface (sessionsFor(), a member's live sessions, leeching first; endSession()), and usarrs renders it at /profile/sessions.

Ratio enforcement

Under ratio_mode full (the default), min_ratio is enforced. A member who falls below it goes on ratio watch. If they're still below when the watch runs out, new downloading is paused until they recover. Seeding is never paused, because seeding is how a member recovers.

Standing When What it means
ok at or above min_ratio, or under the grace allowance nothing
watch below min_ratio, having downloaded at least enforcement.grace_gb a deadline enforcement.watch_days away (14). Nothing is refused yet
restricted still below at the deadline, or hit_and_run.max_strikes active hit-and-run strikes new leeching refused (below)
exempt role at or above enforcement.exempt_role (moderator), or exempted by an admin never watched or restricted

Restricted refuses a leech announce (left > 0) on a torrent the member isn't already in, with a bencoded reason that names whichever rule applies and the way out. The ledger records it like any refusal. Their .torrent download for such a torrent is refused with the same words, through trove's DownloadGateInterface, which guise and disguise 5.3 answer with a 403.

Restricted never refuses:

  • seeding (left 0);
  • stopped;
  • a torrent the member is already in, meaning a live peer of theirs, or a torrent_user row once the ledger has folded it. They can finish it, and download its .torrent again.

It's decided hourly, by bloodhound:enforce-ratio, from the folded totals. The announce path and the download only read what was decided: one indexed lookup. A member who seeds back above min_ratio is restored within the hour. Switching ratio_mode to off stops refusals at once, and the next run clears every watch and restriction. Switching to seedtime does that for the ratio rule only: hit-and-run restrictions stay. The figures are recorded the same way in every mode.

Exemptions. Staff at or above enforcement.exempt_role are exempt. An admin can exempt one member, with the reason and the admin on record:

php artisan bloodhound:standing exempt 42 --reason="Donor" --by=1
php artisan bloodhound:standing show 42
php artisan bloodhound:standing unexempt 42 --by=1     # judged again at the next run

Exempting lifts a watch or restriction at once. --by, the deciding admin's user id, is required for exempt and unexempt, so the audit trail names who decided. A --by that names no user is refused. Both are recorded as staff actions through trove's StaffActionsInterface, as is approving or revoking a seedbox.

Telling members. Each change fires an event, and the member's standing is in statsFor() (trove's TrackerStats::$standing), which usarrs shows on the dashboard. Listen for the events to email or notify:

Event Carries
RatioWatchStarted userId, ratio, required, deadline
MemberRestricted userId, ratio, required, strikes (0 when it was the ratio alone)
StandingRestored userId, previous (watch or restricted)

Choosing the numbers. Bloodhound's figures are conservative, which is worth knowing before copying thresholds from another tracker:

  • the credited interval is capped at threepio.peer_expiry;
  • nothing is counted across a stopped;
  • anti-cheat forfeits over-cap bytes.

The grace allowance (enforcement.grace_gb, BLOODHOUND_RATIO_GRACE_GB) is how many GB a member downloads before the ratio rule applies to them at all. Until then their ratio is recorded and shown on their dashboard as usual, but nothing is enforced, so a new member's first downloads can't put them on watch.

Set it for what your tracker carries; no single default suits every tracker. The 5 GB default is dozens of albums on a music tracker, but on an HD film or sports tracker it can be less than one torrent, so the grace is gone before a new member has finished their first download. A few typical torrents' worth is a reasonable start: BLOODHOUND_RATIO_GRACE_GB=20.

It's GB as the tracker shows sizes to members (1024 MB, the same unit as a torrent's size and a member's downloaded figure), so 20 here is what a member sees as 20 GB downloaded. Decimals are fine (2.5), and 0 enforces from the first byte. A value that isn't a number falls back to 5.

Before 9.2.0 it was set in bytes, as enforcement.grace_bytes (BLOODHOUND_RATIO_GRACE_BYTES). A config published on 9.0 or 9.1 still has that line, at its 5 GiB default. That default isn't a choice anyone made, so it's ignored: grace_gb's default is the same 5 GB, and BLOODHOUND_RATIO_GRACE_GB works without editing the file. Any other grace_bytes value is one you set, so it still wins over grace_gb and your number doesn't move on upgrade. It's logged as deprecated once a day, with the GB it amounts to. Move it to grace_gb and remove the line.

Hit-and-run

Per torrent, the other rule private trackers run: a member who downloads a torrent owes it some seeding. It's off by default, so upgrading changes nothing for an existing tracker. Turn it on with BLOODHOUND_HIT_AND_RUN=true beside the ratio rule, or set ratio_mode to seedtime to run it without one. marque:install turns it on for a private tracker it builds from nothing.

The obligation. A torrent a member downloaded more than hit_and_run.buffer_percent (10%) of is owed min_seedtime (72 hours) of seeding in all, or a ratio of hit_and_run.ratio (1.0) on it, whichever comes first. A few files of a pack don't count. Set ratio to null (BLOODHOUND_HIT_AND_RUN_RATIO=) to accept time only.

Warnings and strikes. A member away from a torrent they still owe gets:

Away for Then
prewarn_days (1) a pre-warning (HitAndRunPrewarned)
grace_days (3), with the pre-warning at least prewarn_days old a strike (HitAndRunStrike), lapsing after expire_days (14)

"Away" means no live peer of theirs on the torrent, and last seen on it before then. Coming back before the grace runs out forgets the pre-warning. A member seeding it right now is never struck. Seeding the torrent to the requirement clears its strike (HitAndRunCleared). A torrent is struck once, ever.

hit_and_run.max_strikes (3) active strikes pause new downloading, through the same restricted standing as the ratio rule: the same refusal at announce, the same download gate, and seeding still never paused. Restricted by both rules, a member stays restricted until both clear, and the reason names whichever applies. The same exemptions apply: enforcement.exempt_role and above, and bloodhound:standing exempt.

It never reaches back. It counts only torrents first active after it's switched on, so turning it on doesn't strike everyone's history at once. Switched off and on again, it starts afresh: only torrents first active from then are counted, and a strike from before no longer counts, lapsed or not.

Where the defaults come from. The mechanics are UNIT3D's: the buffer, pre-warning, grace, expiry and three-strike limit, from its config/hitrun.php. UNIT3D asks for 7 days of seeding. Bloodhound asks 72 hours, the commonest figure trackers publish, because its seedtime is counted conservatively (above). The 1:1 alternative is common on trackers too. Every number is configurable.

Showing members. trove's SeedingObligationsInterface, which bloodhound binds, lists what a member owes: strikes first, with when each lapses. usarrs shows it on /profile/stats and counts it on the dashboard.

It's decided hourly by bloodhound:enforce-hit-and-run, just before bloodhound:enforce-ratio, so a new strike restricts in the same run.

Configuration

Published to config/bloodhound.php. Bloodhound's config is merged with the published file at the top level only. A top-level key missing from your file comes from the shipped config, environment variable and all. A published block, such as anti_cheat, replaces the shipped one whole: a key missing from it falls back to the default in code, which is the same as the shipped default, but its environment variable does nothing until the key is in your file. enforcement is the exception, from 9.2.0: keys missing from a published enforcement block are filled from the shipped config, environment variables included, so grace_gb reaches a config published before it existed.

Uploads: the private flag

Key Default Description
uploads.private_flag require require, disallow, warn_if_public, warn_if_private or allow

Bloodhound binds trove's TorrentFilePolicyInterface, so every upload (guise, disguise or cennad) is checked against this. With require, a torrent without the private flag is refused, and the uploader is told to recreate it with "private" ticked. Without the flag, clients share the swarm over DHT and peer exchange, outside the tracker's accounting. The flag is inside the info dictionary, so Marque never sets it for them: that would change the info_hash. An unrecognised value is treated as require. See trove's README for the modes.

Downloads carry the member's announce URL

When a member downloads a torrent, the .torrent they get has announce set to their own URL: this tracker's announce route with their key, in the path or the query exactly as Announce URL shape configures it. Its comment links back to the torrent's page. Of the rest of the top level, only encoding is kept; everything else the uploader's client wrote, announce-list included, is dropped. The info dictionary is untouched, so the info_hash is the same for everyone. A member with no key yet (for example, before verifying their address) is refused with that reason rather than given a file their client can't announce with.

Routing

Covered in full under Announce URL shape above.

Key Default Description
routes.announce_path announce Announce path, no leading slash
routes.scrape_path scrape Scrape path
routes.key_source path path or query
routes.key_parameter announce_key Route segment or query parameter name
routes.key_pattern [0-9a-zA-Z]{32} Regex the key must match — no delimiters, no anchors
routes.malformed_key failure A malformed key in the path: failure (bencoded reason) or not_found (the router's 404, the default before 9.0)
endpoints [] More announce URLs served beside routes, by name, each taking the keys above plus an optional transform class (see Serving the old URL and the new one at once)

Redis and baselines

Key Default Description
redis.durability required attested when your provider makes Redis durable without AOF (BLOODHOUND_REDIS_DURABILITY); the eviction policy is still checked
baseline_retention 604800 Seconds an expired peer's record is kept as its baseline (7 days)
address_families ipv4,ipv6 Families announces are accepted over: ipv4, ipv6, or both (BLOODHOUND_ADDRESS_FAMILIES); see IPv6 and dual-stack peers
trust_announced_addresses true Hand out the ipv4= / ipv6= addresses a client announces (BLOODHOUND_TRUST_ANNOUNCED_ADDRESSES); see IPv6 and dual-stack peers

See Redis and Retained baselines.

Timing

Timing, Redis and the peer response are threepio's settings, in config/threepio.php (env vars THREEPIO_*). bloodhound keeps its peers through threepio, so it uses them; publish threepio's config to change them.

Key Default Description
announce_interval 1800 Seconds between announces (sent to clients)
min_announce_interval 300 Minimum interval clients are told to respect
peer_expiry 3600 Seconds before an inactive peer is treated as gone; also the cap on one announce's credited interval

Threepio's Redis settings

From config/threepio.php, as above.

Key Default Description
redis.connection default Laravel Redis connection name: give the tracker its own
redis.prefix marque: Key namespace

Peer Response

From config/threepio.php, as above.

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 9 pre-configured clients: qBittorrent, Deluge, Transmission, libtorrent, rTorrent, uTorrent, BitTorrent, Vuze and BiglyBT. A refused client gets a bencoded failure and a flagged ledger row.

Key Default Description
client_validation.enabled true Enable client checks
client_validation.mode whitelist whitelist or blacklist
whitelist (see config) Allowed clients with version ranges, a top-level key
blacklist (see config) Blocked clients (Xunlei, etc.), a top-level key

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

Described under Anti-Cheat and Seedboxes. Speeds are bytes per second.

Key Default Description
anti_cheat.enabled true Master switch
anti_cheat.max_upload_speed 104857600 Ordinary cap, 100 MiB/s; over it is forfeited
anti_cheat.max_download_speed 104857600 Ordinary cap, 100 MiB/s
anti_cheat.seedbox.max_upload_speed 1250000000 Cap for an approved seedbox, 10 Gbit/s
anti_cheat.seedbox.max_download_speed 1250000000 Cap for an approved seedbox, 10 Gbit/s
anti_cheat.seedbox.require_approval true A new seedbox waits for an admin
anti_cheat.seedbox.min_prefix_v4 24 Widest IPv4 range a seedbox may be
anti_cheat.seedbox.min_prefix_v6 48 Widest IPv6 range a seedbox may be
anti_cheat.min_announce_gap 60 Min seconds between two regular announces from one peer
anti_cheat.max_seeding_locations_per_torrent 3 Locations a member may seed one torrent from
anti_cheat.max_leeching_locations_per_torrent 1 Locations a member may leech one torrent from
anti_cheat.ambiguous_locations flag flag, refuse or allow an ambiguous location (BLOODHOUND_AMBIGUOUS_LOCATIONS)
anti_cheat.ambiguous_window 3600 Seconds since the peer's last announce, over the other family, for a refusal to be ambiguous (BLOODHOUND_AMBIGUOUS_WINDOW)
anti_cheat.dual_stack_gap 120 Seconds between a client's IPv4 and IPv6 announces for a new client at its other address to be ambiguous; an address announced (ipv4=/ipv6=) on the client's latest announce always counts; 0 turns it off (BLOODHOUND_DUAL_STACK_GAP)
anti_cheat.max_clients_per_torrent 5 A member's live clients (peer IDs) on one torrent; only a new one is refused
anti_cheat.block_datacenter_ips false Not built yet; setting it does nothing

Completions

Key Default Description
completion_cooldown 86400 Seconds after a completion before another completed from the same member on the same torrent counts as new

event=completed is client-supplied, and a client restart or a second machine reports it again for the same download, so time decides what counts as a new completion.

Ratio

Key Default Description
ratio_mode full full enforces min_ratio, plus hit-and-run when hit_and_run.enabled. seedtime enforces hit-and-run alone. off enforces nothing
min_ratio 0.5 The ratio full enforces
min_seedtime 259200 (72h) Hit-and-run: the seeding each torrent is owed
enforcement.watch_days 14 How long a member below min_ratio has to recover before new downloading is paused
enforcement.grace_gb 5 Nothing is enforced until a member has downloaded this many GB (1024-based, as sizes are shown)
enforcement.grace_bytes null Deprecated since 9.2.0: the same allowance in bytes. Wins over grace_gb when set to anything but the old 5 GiB default
enforcement.exempt_role moderator This trove role and above are never watched or restricted, by either rule
hit_and_run.enabled false Run hit-and-run beside the ratio rule under full
hit_and_run.ratio 1.0 A ratio on the torrent that also meets it; null for time only
hit_and_run.buffer_percent 10 It counts only past this share of the torrent downloaded
hit_and_run.prewarn_days 1 Away this long while short: a pre-warning
hit_and_run.grace_days 3 Away this long while short: a strike
hit_and_run.expire_days 14 A strike lapses after this
hit_and_run.max_strikes 3 This many active strikes pause new downloading

Bytes and time are recorded in every mode; the mode decides only what is enforced. See Ratio enforcement and Hit-and-run.

Announce Log

Key Default Description
announce_log.connection null Database connection (null = app default)
announce_log.retention_days null Days to keep rows (null = forever)
BLOODHOUND_ANNOUNCE_LOG_CONNECTION=announce_log
BLOODHOUND_ANNOUNCE_LOG_RETENTION_DAYS=90

See The ledger.

Removed in 8.0

Removed key What happens if your config still has it Use instead
announce_log.enabled Set to false, bloodhound refuses to boot, naming the key. true is harmless Nothing: the ledger is always on
queue.enabled, queue.connection, queue.queue Ignored, as they already were in 7.x (#10816); from 8.1.0 a warning is logged once a day Nothing: byte counts never travel through a queue
anti_cheat.max_connections_per_torrent Ignored; a warning is logged once a day naming the replacements max_seeding_locations_per_torrent and max_leeching_locations_per_torrent
anti_cheat.max_connections_per_ip Ignored; a warning is logged once a day max_clients_per_torrent, a member's clients on one torrent. Nothing limits how many torrents one address seeds

The ledger can't be turned off because every credit is written to it and nowhere else: a tracker without it would credit nobody anything.

Moved or removed earlier

A config published before these changes still holds the old keys. They are ignored, and from 8.1.0 a warning is logged once a day naming each one. For a moved key, the warning gives both the value your config sets and the value actually in effect.

Key in config/bloodhound.php Since Now
announce_interval (BLOODHOUND_ANNOUNCE_INTERVAL) 2.0.0 threepio.announce_interval (THREEPIO_ANNOUNCE_INTERVAL)
min_announce_interval (BLOODHOUND_MIN_ANNOUNCE_INTERVAL) 2.0.0 threepio.min_announce_interval (THREEPIO_MIN_ANNOUNCE_INTERVAL)
max_peers_per_announce (BLOODHOUND_MAX_PEERS) 2.0.0 threepio.max_peers_per_announce (THREEPIO_MAX_PEERS)
peer_expiry (BLOODHOUND_PEER_EXPIRY) 2.0.0 threepio.peer_expiry (THREEPIO_PEER_EXPIRY)
redis.connection (BLOODHOUND_REDIS_CONNECTION) 2.0.0 threepio.redis.connection (THREEPIO_REDIS_CONNECTION)
redis.prefix (BLOODHOUND_REDIS_PREFIX) 2.0.0 threepio.redis.prefix (THREEPIO_REDIS_PREFIX)
logging 4.1.0 announce_log

redis.durability is still bloodhound's. Only connection and prefix moved.

The ledger

announce_log is a full-detail, permanent history of every announce from a known member on a known torrent: each started, regular, completed and stopped request, accepted or refused. It is the source of truth for ratio and seedtime. User totals and per-torrent totals are projections summed from it, and a wrong number can only be detected — let alone corrected — by comparing it against this. Ratio is what gets people banned.

The row is written synchronously, before Redis and before the response. A queue would mean the only copy of a credit lived in a job payload, and a lost job would be a lost credit with nothing left to re-derive it from.

What a row holds

Column Meaning
user_id, torrent_id, peer_id Who, on what, from which client session
event started, regular, completed, stopped; ended for a session the member ended; or opening_balance for a migration's carried total
ip, port, user_agent Where from
announced_ipv4, announced_ipv6 The address the client announced (ipv4=, ipv6=) for the family it didn't connect over, when trusted and valid; else null
uploaded, downloaded, left The cumulative counters the client reported
upload_delta, download_delta Bytes this row credits
seed_seconds, leech_seconds Seconds this row credits (at most peer_expiry, and only one of the two)
prior_up, prior_down, prior_at The baseline those were measured against: its counters and the time of its announce. Null when there was none
anti_cheat_flagged, anti_cheat_reason A refusal or a forfeit, and why
created_at When the credit was measured, which is also the peer's new last announce

Every row carries its own credit. A refusal or a first announce credits 0 bytes and 0 seconds, so it adds nothing to any sum by construction. Storing the baseline beside the credit is what lets each row be checked on its own, and each peer's rows checked as a chain (see Audit). Both the client's cumulative claim and the credited delta are kept: a client whose claimed totals don't match its own delta history is lying about something.

The table is append-only. Rows are never modified after they're written, so there is no updated_at. Credit that is not a row of its own (what pruning removed, and what the upgrade carried over) lives in ledger_carry: one row per member and torrent, with uploaded, downloaded, seedtime and leechtime, on the same connection as announce_log. Corrections an operator made on the record live in ledger_adjustments beside it (see Correcting the numbers). The ledger is its rows, its carry and its adjustments, and everything below reads it that way.

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 announce_log and ledger_carry go there, with no other change. ledger_adjustments follows it as well. 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.

Aggregation

bloodhound:aggregate-ledger runs every minute and folds new rows into torrent_user and the users table: per batch of 1,000 rows, the sums of each member-and-torrent's bytes and seconds, written in a fixed handful of statements. A row that credits nothing writes nothing. A fold is a sum, so a batch boundary splits nothing.

  • Totals trail the ledger by a run or two. A scheduled run folds only up to the highest id the previous run saw (the horizon, on ledger_cursors). An id is handed out when an insert starts, not when it commits, so a later id can become visible first; folding to the head could step over a row still committing, and it would never be counted. Everything below the horizon has had a whole minute to commit. A backlog after an outage drains in two runs.
  • Two folds can't count a batch twice. Each batch locks the cursor row first and reads its position under the lock, so a second fold (a manual run beside the scheduler, two hosts without a shared cache lock) waits, then starts from where the first left off. The advance is a compare-and-swap on the position read: if the cursor moved anyway, the batch rolls back and is redone. withoutOverlapping() on the schedule is a courtesy, not the control.
  • Crash-safety is the transaction. The totals and the cursor advance commit together or not at all, so a redone batch is idempotent.

Measured on a development machine across the four engines, folding 200,000 rows took 26 to 37 seconds, depending on the engine. bloodhound:reconcile-ledger reports how many rows are waiting; watch that number at your busiest hour.

Verifying the numbers

The 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.

Reconcile

bloodhound:reconcile-ledger (scheduled daily) compares every member's and every member-and-torrent's bytes, seedtime and leechtime against the ledger: the sum of the rows the aggregator has folded, plus carry and adjustments. It reports; it does not repair. You should learn a number went wrong before anything changes it back. Rows not yet folded are reported as a backlog rather than as drift — an alert that cries wolf is an alert nobody reads. Drift is logged as a warning and the command exits 1.

Reconcile can't tell which side is wrong. If the ledger is right, rebuild. If the stored totals are right and the ledger never saw where they came from, accept them (see Correcting the numbers).

Audit

bloodhound:audit-ledger checks the ledger against itself.

Arithmetic, per row. Bytes: each direction is the reported counter minus the prior, or the reported counter when it went backwards. A forfeit may have zeroed a direction, so a flagged row may show 0 instead. Seconds: seed_seconds + leech_seconds equals created_at - prior_at, capped at peer_expiry, all in one of the two. A row with no baseline credits nothing. The cap is today's peer_expiry, so if you change it, rows written under the old one report as differences.

The chain, per member, torrent and peer ID, in order. Each row's baseline should be what the previous row reported, and its prior_at the previous row's created_at. After a stopped, or a session the member ended (ended, which has no baseline itself), the next row starts a new session with no baseline. Refusals and opening balances are not part of the chain, and stops and ended sessions end it cleanly, so none of them is ever reported as a break. What is reported, with the reason:

  • Redis lost this peer: a row with no baseline that should have had one. Not reported when the gap is longer than baseline_retention, since no baseline is kept that long.
  • Duplicate interval: two rows measured against the same baseline: one interval credited twice (a Redis write that failed after its row, or two announces from one peer at once).
  • The baseline does not continue the previous announce, a baseline after the session stopped (or ended), and an ended session with a baseline.

With a persistent Redis a break should not happen; when one does, you learn which row.

Rows written by 7.x, before rows carried their own time, are held to the rules they were written under: bytes only, with a counter that went backwards credited 0, and they are not checked as a chain. The upgrade records where they end.

Rebuild

bloodhound:rebuild-totals recomputes user and per-torrent totals from the ledger, its carry and its adjustments, for everyone or --user=: bytes, seedtime and leechtime. It takes the aggregator's cursor lock first, so a fold can't commit between its reset and its write; a fold that was waiting carries on from the same position afterwards. On MySQL a fold waiting through a long rebuild may hit innodb_lock_wait_timeout and fail; the next run retries. A rebuild leaves the cursor where it is, and leaves completion dates and counts alone, because they are not derivable from the rows.

A per-torrent row the rebuild has to re-create is dated from the ledger, not from today, so hit-and-run doesn't take old activity for new. Its creation time is the pair's first ledger row, and when the member was last seen is its newest. A pair with carried (pruned) history can't be dated exactly: its creation time is bounded by the oldest row left in the ledger, and placed before hit-and-run began, so the rule never judges carried activity. An adjusted pair is dated the same way.

On a development machine, rebuilding from 200,000 rows took 29 to 95 seconds depending on the engine.

Correcting the numbers

Some totals are real and no ledger row explains them: figures imported from an earlier tracker, history from before the ledger, a speed forfeit you decide was wrong. Reconcile reports the first two as drift every day, and the rebuild it recommends would erase them. The third never reached the totals at all.

Two commands correct a member's totals on the record. Each writes rows to ledger_adjustments (accept-totals one per torrent it records, adjust-totals one) with the amounts (signed), the torrent (0 for the member as a whole), who decided (--by, a user id, required) and why (--reason, required). Each shows what it will write and asks first (--force for a script), logs a warning, and records a staff action (bloodhound.ledger.accepted or bloodhound.ledger.adjusted) that bosun's Audit log shows. Reconcile and rebuild read adjustments with the rest of the ledger, so reconcile is clean afterwards and a rebuild keeps the result.

bloodhound:accept-totals: the stored totals are right. It records the gap between what's stored and what the ledger explains: per torrent from torrent_user, and what the member holds beyond their torrents on torrent 0. Either way round: a stored total below its ledger is accepted too. The member's totals don't change; a torrent_user row on torrent 0, where there is one, is brought into line with the member-level part, as a rebuild would bring it. --user= for one member, or --all for everyone reconcile reports.

php artisan bloodhound:accept-totals --user=42 --by=1 --reason="Imported from the old tracker"

It refuses a member whose own totals are below what the ledger explains for their torrents, by more than torrent 0 holds. Those totals contradict each other, so a rebuild is the fix. Under --all such a member is listed and skipped, and everyone else is accepted.

bloodhound:adjust-totals: give or take an amount. The adjustment and the totals move together, the way the aggregator moves them. Amounts are whole bytes or seconds, signed; --torrent= defaults to 0, the member as a whole.

php artisan bloodhound:adjust-totals 42 --torrent=17 --uploaded=8000000000 --by=1 --reason="Speed forfeit was a false positive"

Nothing goes below zero, on either side: not the stored totals, and not what the ledger explains for the torrent, which is what a rebuild would write. Take credit back from the torrent it's on: torrent 0 holds only what's on the member as a whole. If a stored total is more than the ledger explains, accept it first, then debit it.

Adjustments are never edited or deleted. A wrong one is answered by another, signed the other way, so the record of what was decided and by whom survives the correction. bloodhound:ledger-adjustments lists them, newest first (--user=, --limit=).

Retention, pruning and carry

retention_days is null by default, which means keep everything forever, and the table grows without bound on a busy tracker. That default is deliberate - how long to keep the source of truth for ratio is your call, not ours - but it is your job to make it.

Set retention_days and the scheduled bloodhound:prune-announce-log command (daily) keeps the table bounded:

php artisan bloodhound:prune-announce-log

It no-ops when no retention is configured, and reports what it did either way.

Only rows the aggregator has folded are pruned. A row still waiting is credit not yet in the totals, so it is kept whatever its age, and the command says how many it withheld.

What it deletes, it carries. Each row carries its own credit, so what a deleted row contributed is exactly its own bytes and seconds. The prune works in chunks of 1,000 rows by id; each chunk's bytes and seconds are added to ledger_carry per member and torrent, and the rows deleted, in one transaction. If anything fails, the delete rolls back with the carry. No row is kept to protect another, so the table stops at your retention window, and reconcile and rebuild stay exact: a prune followed by a rebuild reproduces the pre-prune totals, which was measured on all four engines with 200,000 rows. That prune took 34 to 69 seconds, depending on the engine.

Carry is signed, but neither a prune nor the upgrade ever writes a negative. An operator's correction is an adjustment, kept apart from carry (see Correcting the numbers), and never pruned.

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) Every refusal and forfeit
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.

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.

Commands

Command Scheduled Does
bloodhound:aggregate-ledger Every minute Folds new ledger rows into the totals (up to the horizon), and checks Redis durability
bloodhound:reconcile-ledger Daily Reports totals that disagree with the ledger
bloodhound:detect-location-sharing Daily Reports two patterns of one account used from more than one place; --hours= (24), --link-days= (7)
bloodhound:prune-announce-log Daily Deletes rows past retention_days, carrying what they credited
bloodhound:sync-swarm-counts Hourly Sweeps expired peers, keeping their baselines; settles swarm counts
bloodhound:enforce-hit-and-run Hourly Applies hit-and-run: pre-warns, strikes, clears strikes once seeded
bloodhound:enforce-ratio Hourly Decides each member's standing from both rules: starts watches, restricts, restores
bloodhound:standing — show, exempt (--reason=, --by=) or unexempt (--by=) a member
bloodhound:check-redis — Checks AOF and the eviction policy; --recover opens a recovery window
bloodhound:issue-keys — Issues a key to every verified user without one, after an import; --dry-run
bloodhound:audit-ledger — Checks every row's arithmetic and every peer's chain
bloodhound:rebuild-totals — Recomputes totals from the ledger, carry and adjustments; --user= for one member
bloodhound:accept-totals — Records stored totals the ledger can't explain as correct (--user= or --all, --reason=, --by=)
bloodhound:adjust-totals — Gives or takes an amount from a member's totals (--torrent=, --uploaded= etc., --reason=, --by=)
bloodhound:ledger-adjustments — Lists the adjustments operators have made, with who and why
bloodhound:seedbox — list (--status=), approve or revoke a seedbox (--by=)
bloodhound:backfill-ledger — Once, after upgrading from 7.x: credits pre-8.0 history its time and stopped bytes
bloodhound:record-pruned-history — Once, if needed: accounts for rows a pre-8.0 prune deleted without carrying them

Upgrading to 11.2

Run php artisan migrate: it creates ledger_adjustments, on announce_log's connection. Nothing else changes until you use the new commands (see Correcting the numbers).

Upgrading from 10.x to 11.0

One change in default behaviour, which is why it's a major: an ambiguous location is accepted and flagged (ambiguous_locations=flag) instead of refused. A dual-stack client is no longer refused for switching families. From 11.1, a new client at another client's paired address is ambiguous too; see Ambiguous locations for the cases still refused. To keep 10.x's refusals, set BLOODHOUND_AMBIGUOUS_LOCATIONS=refuse; the detector still runs and reports either way. With a published config/bloodhound.php (every tracker marque:install built has one), that works from 11.1.2, which fills the anti_cheat keys 11.0 added from the shipped config; on 11.0 to 11.1.1, add them to the file's anti_cheat block yourself.

App code that reads announce_log and takes "flagged, with no baseline" for a refusal should ask AnnounceLog::refused() or use the accepted() scope instead: from 11.1 an admitted new client's row is flagged and usually has no baseline.

Nothing else is needed beyond 10.1's migration if you're coming from 10.0, and nothing in Redis to clear. bloodhound:detect-location-sharing is scheduled daily with the rest. Its findings arrive as CheatDetected events with type location_sharing_suspected, and as warnings in the log. Listen for them, and for location_ambiguous, if you want staff told. If you'd set BLOODHOUND_MAX_LEECHING_LOCATIONS=2 to work around dual-stack refusals, you can put it back to 1, knowing the few household cases still refused once (listed under Ambiguous locations).

Upgrading to 10.1

Run php artisan migrate: it adds two nullable columns to announce_log (announced_ipv4, announced_ipv6), with no default, which is an instant change on MySQL 8, MariaDB and PostgreSQL. It requires marque/threepio ^4.2.

IPv6 peers are handed out from the first announce after the upgrade; nothing in Redis needs clearing. ipv4= and ipv6= are honoured by default. Set BLOODHOUND_TRUST_ANNOUNCED_ADDRESSES=false to hand a peer out only where it announced from. See IPv6 and dual-stack peers.

Upgrading from 9.x to 10.0

Two breaking changes, both small:

  • It requires marque/trove ^5.0. Upgrade it with the rest of Marque 11.0: trove 5, usarrs 9, and the frontend releases that accept trove 5 (guise and disguise 5.4, cennad 4.5). Composer won't install bloodhound 10 beside trove 4.
  • bloodhound:standing exempt and unexempt require --by, the deciding admin's user id, as 9.x warned. A script that exempts members without it now fails, so add --by=<admin id>.

Exemptions and seedbox decisions are now recorded as staff actions. Install marque/bosun to keep and read them; without it nothing changes.

Upgrading to 9.2

Nothing to do. The ratio grace allowance is now set in GB, as enforcement.grace_gb (BLOODHOUND_RATIO_GRACE_GB, default 5), and nobody's number moves:

  • A config published on 9.0 or 9.1 has grace_bytes at its 5 GiB default. It's ignored, and grace_gb is filled in for you, so the env var works without editing the file.
  • A grace_bytes you set to anything else still wins, read as 9.1 read it (an empty value is 0), and is logged once a day as deprecated. Replace it with grace_gb (in GB, as sizes are shown on the tracker) when convenient.
  • If you cache config, run php artisan config:cache again after upgrading. A cache built on 9.1 has no grace_gb, so BLOODHOUND_RATIO_GRACE_GB isn't read until it's rebuilt.

See Ratio enforcement for how to size it for what your tracker carries.

Upgrading from 8.x to 9.0

Two defaults change, and both are things your members will notice.

1. The ratio is enforced. Under ratio_mode full, the default, which 8.x documented as planned, members below min_ratio (0.5) go on watch at the first hourly run after you upgrade. Fourteen days later, any still below have new downloading paused until they recover. Decide before you upgrade:

  • Tell your members what the rule is and when the first watch starts. The dashboard shows each member's standing (usarrs 8.5), and the RatioWatchStarted event is there to email from.
  • Check the numbers. min_ratio, enforcement.watch_days (14), enforcement.grace_gb (5; grace_bytes before 9.2) and enforcement.exempt_role (moderator) are all configurable; see Ratio enforcement.
  • Exempt anyone who should be: bloodhound:standing exempt <id> --reason=... --by=<your id>.
  • Or don't enforce at all: set BLOODHOUND_RATIO_MODE=off. The figures are still recorded.

2. A malformed announce key gets a bencoded reason, not a 404. To keep the 404, set routes.malformed_key to not_found. See Announce URL shape.

The frontends must handle a refused download. bloodhound 9 refuses a restricted member's .torrent through trove's download gate. guise and disguise before 5.3 don't catch that refusal and would answer 500, so bloodhound 9 declares a conflict with them. Upgrade together:

composer require marque/bloodhound:^9.0 marque/trove:^4.9 marque/guise:^5.3 marque/usarrs:^8.5 -W
php artisan migrate        # creates member_standings

Use marque/disguise:^5.3 in place of guise where that is your frontend. bloodhound:enforce-ratio is scheduled for you, so it needs the scheduler, like everything else here. To see who it will watch before it runs, ask the database for members whose uploaded is below min_ratio times downloaded, with downloaded at or above the grace allowance and role below enforcement.exempt_role.

Upgrading from 7.x to 8.0

Before upgrading:

  1. Make Redis durable. Point THREEPIO_REDIS_CONNECTION at a database or instance of its own and set appendonly yes, appendfsync everysec and maxmemory-policy noeviction (see Redis). You can confirm it on 7.x with redis-cli INFO persistence (look for aof_enabled:1) and redis-cli INFO memory (maxmemory_policy:noeviction), with your tracker's connection flags. If your provider offers no AOF and makes the data durable another way, set BLOODHOUND_REDIS_DURABILITY=attested. In production, 8.0 refuses announces until this is right.
  2. If you have ever pruned the announce log, don't run rebuild-totals on 7.x. 7.x's prune kept nothing of what it deleted, and a rebuild there would cut those members' ratios.
  3. Update a published config/bloodhound.php. Delete announce_log.enabled (with it set to false, 8.0 won't boot) and the queue block. Replace anti_cheat.max_connections_per_torrent with max_seeding_locations_per_torrent and max_leeching_locations_per_torrent. Add the new keys you want to set by environment: redis.durability, baseline_retention and the anti_cheat.seedbox block. Simplest is to re-publish and re-apply your changes.
  4. Delete max_connections_per_ip from a published config: it is gone, and nothing limits how many torrents one address seeds. max_clients_per_torrent caps a member's clients on one torrent instead.
  5. Review the speed caps. Over the cap now forfeits the bytes instead of refusing the announce. Lower the ordinary caps to suit your members, and have seedbox users register their seedboxes (and approve them) so they are held to the seedbox cap.

Upgrade bloodhound to ^8.0 and threepio to ^4.0 in one composer require (with hound's matching release, if a hound site shares the install), then php artisan migrate. The migrations:

  • add seed_seconds, leech_seconds and prior_at to announce_log, and leechtime to the users table and torrent_user;
  • create ledger_carry and seedboxes, and add the horizon to ledger_cursors, set to the head of the ledger so folding carries on;
  • record the last id written by 7.x, so the audit holds earlier rows to 7.x's rules;
  • record time opening balances: stored seedtime and leechtime that no ledger row explains (7.x always wrote seedtime as 0, so this is imported or hand-entered time) as carry, per torrent, then the rest of each member's total on torrent 0. Without it reconcile would report that time as drift and a rebuild would erase it. Running it again records nothing;
  • if retention_days is set, record pruned history (below), and say how much it covered.

Nothing rewrites an existing row, and no rebuild is needed. Reconcile is clean afterwards, unless you pruned in the past and retention is now off (step 9).

After upgrading:

  1. Run php artisan bloodhound:check-redis. The aggregator also runs the check every minute.

  2. Delete the old Redis keys once, with your tracker's connection flags (-h, -p, -n) on both redis-cli calls, and your redis.prefix in place of marque::

    redis-cli --scan --pattern '*marque:ip:*:peers' | xargs -r redis-cli del
    redis-cli --scan --pattern '*marque:announce_time:*' | xargs -r redis-cli del

    The per-IP sets are replaced by ip:{ip}:sessions. The announce_time keys expire on their own within two minutes; the gap is now read from the peer's record. The leading * matches the prefix Laravel adds to Redis keys.

  3. Optional, recommended: php artisan bloodhound:backfill-ledger. 7.x credited no time, and credited nothing for the bytes a stopped announce reported. The ledger proves both, and this credits them: each 7.x session's seconds between consecutive accepted announces (capped, attributed by state, never across a stop), and each stop's bytes against the announce before it. It writes the result to ledger_carry and adds it to the totals in one transaction under the aggregator's lock, so no rebuild is needed. It rewrites no row, runs once (a second run does nothing), and refuses until the aggregator has folded all of 7.x history, which the first scheduled run after migrating normally does. Members' seedtime and ratio move when you run it. Without it, time starts at the upgrade and nothing is wrong.

  4. If you pruned in the past but retention is now off, run php artisan bloodhound:record-pruned-history (below).

If you pruned on an earlier version

Before 8.0, every prune deleted credited rows and kept nothing. Those members' totals are still right, but the ledger no longer explains them: reconciliation reports them as drift, and running rebuild-totals would cut their ratios. Don't run a rebuild until this is done.

The upgrade migration does it if retention_days is set. If you pruned in the past but have since turned retention off, run it yourself:

php artisan bloodhound:record-pruned-history

It records each gap as carry, and only what pruning could have caused, so real drift stays visible:

  • per member and torrent, because every pair that moved bytes has its own total, and pruned credit always leaves a gap there. A gap only on a member's own total means the two totals disagree with each other, which pruning can't cause.
  • only where the stored total is above the ledger, because pruning removes credit and never adds it.
  • bytes only, because 7.x recorded no time.

Like the opening balance, what it records asserts the totals were correct as of the upgrade, not that they were ever right. Running it again changes nothing.

Upgrading from before the ledger (bloodhound before 5.0)

The migration writes one opening_balance ledger row per member, carrying their pre-ledger uploaded/downloaded forward, then folds it immediately so nobody reads as having zero ratio. An install migrating from before 5.0 straight to 8.0 has the fold done by the 8.0 migration that adds the time columns, once the columns the aggregator needs exist.

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. Hit-and-run counts only torrents first active after it's switched on anyway.

If you ran 4.1.0 with its queued announce log turned on, the migration found rows already there and wrote no opening balance. Your pre-ledger totals have no row behind them, so reconcile reports them as drift, and a rebuild would reset them to what the ledger holds. Accept them once, before any rebuild:

php artisan bloodhound:accept-totals --all --by=<your user id> --reason="Pre-ledger totals (4.1.0 queued log)"

Events

Event Fired When Properties
TorrentCompleted Peer finishes download userId, torrentId, ip, userAgent
CheatDetected A speed forfeit, data anomaly, swarm anomaly or ambiguous location (not a refusal), or a detector finding type, userId, torrentId, reason
SeedboxRegistered A member registers a seedbox seedbox, by (null)
SeedboxApproved An admin approves one seedbox, by
SeedboxRevoked An admin revokes one seedbox, by
RatioWatchStarted A member falls below min_ratio and goes on watch userId, ratio, required, deadline
MemberRestricted A watch runs out with the member still below, or they reach the strike limit userId, ratio, required, strikes
StandingRestored A watch or restriction clears (recovered, exempted, or enforcement off) userId, previous
HitAndRunPrewarned A member is away from a torrent they owe userId, torrentId, seededSeconds, requiredSeconds
HitAndRunStrike Still away at the grace period userId, torrentId, expiresAt, activeStrikes
HitAndRunCleared A struck torrent is seeded to the requirement userId, torrentId

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.

License

MIT. See LICENSE.