* @license https://www.gnu.org/licenses/agpl-3.0.en.html/ GNU Affero General Public License v3.0 * @credits Rhilip Roardom */ declare(strict_types=1); namespace App\Http\Controllers; use App\DTO\AnnounceGroupDTO; use App\DTO\AnnounceQueryDTO; use App\DTO\AnnounceTorrentDTO; use App\DTO\AnnounceUserDTO; use App\Exceptions\TrackerException; use App\Jobs\ProcessAnnounce; use App\Models\BlacklistClient; use App\Models\Group; use App\Models\Peer; use App\Models\Scopes\ApprovedScope; use App\Models\Torrent; use App\Models\User; use Illuminate\Http\Request; use Illuminate\Http\Response; use Illuminate\Support\Facades\DB; use Random\RandomException; use Throwable; use Exception; use Illuminate\Support\Facades\Redis; final class AnnounceController extends Controller { // Torrent Moderation Codes protected const int PENDING = 0; protected const int REJECTED = 2; protected const int POSTPONED = 3; // Announce Intervals private const int MIN = 1_800; private const int MAX = 3_600; // Port Blacklist private const array BLACK_PORTS = [ // Hyper Text Transfer Protocol (HTTP) - port used for web traffic 8080, 8081, // Kazaa - peer-to-peer file sharing, some known vulnerabilities, and at least one worm (Benjamin) targeting it. 1214, // IANA registered for Microsoft WBT Server, used for Windows Remote Desktop and Remote Assistance connections 3389, // eDonkey 2000 P2P file sharing service. http://www.edonkey2000.com/ 4662, // Gnutella (FrostWire, Limewire, Shareaza, etc.), BearShare file sharing app 6346, 6347, // Port used by p2p software, such as WinMX, Napster. 6699, ]; private const array HEADERS = [ 'Content-Type' => 'text/plain; charset=utf-8', 'Cache-Control' => 'private, no-cache, no-store, must-revalidate, max-age=0', 'Pragma' => 'no-cache', 'Expires' => 0, 'Connection' => 'close' ]; /** * Announce Code. * * @throws Exception * @throws Throwable */ public function index(Request $request, string $passkey): ?Response { try { // Check client. $this->checkClient($request); // Check passkey. $this->checkPasskey($passkey); // Check and then get Announce queries. $queries = $this->checkAnnounceFields($request); // Check user via supplied passkey. $user = $this->checkUser($passkey, $queries); // Check users group. $group = $this->checkGroup($user); // Get Torrent Info Array from queries and judge if user can reach it. $torrent = $this->checkTorrent($queries->getInfoHash()); // Check if a user is announcing a torrent as completed but no peer is in db. $this->checkPeer($torrent, $queries, $user); // Lock Min Announce Interval. $this->checkMinInterval($torrent, $queries, $user); // Check User Max Connections Per Torrent. $this->checkMaxConnections($torrent, $user); // Check Download Slots. if (($user->is_lifetime === false || $user->group->download_slots !== null) && config('announce.slots_system.enabled')) { $visible = $this->checkDownloadSlots($queries, $torrent, $user, $group); } else { $visible = true; } // Process Annnounce Job. $this->processAnnounceJob($queries, $user, $group, $torrent, $visible); if ($visible) { // Generate A Response For The Torrent Client. $response = $this->generateSuccessAnnounceResponse($queries, $torrent, $user); } else { $response = $this->generateWarningAnnounceResponse($torrent, new TrackerException(164, [':max' => $group->download_slots])); } } catch (TrackerException $exception) { $response = $this->generateFailedAnnounceResponse($exception); } catch (Exception) { $response = 'd14:failure reason21:Internal Server Error8:intervali5400e12:min intervali5400ee'; } return $this->sendFinalAnnounceResponse($response); } /** * Check Client Is Valid. * * @throws TrackerException * @throws Throwable */ private function checkClient(Request $request): void { // Query count check if ($request->query->count() < 6) { throw new TrackerException(129); } // Miss Header User-Agent is not allowed. if (!$request->header('User-Agent')) { throw new TrackerException(120); } // Block Other Browser, Crawler (May Cheater or Faker Client) by check Requests headers if ( $request->header('accept-language') || $request->header('referer') || $request->header('accept-charset') /** * This header check may block Non-bittorrent client `Aria2` to access tracker, * Because they always add this header which other clients don't have. * * @see https://blog.rhilip.info/archives/1010/ ( in Chinese ) */ || $request->header('want-digest') ) { throw new TrackerException(122); } $userAgent = $request->header('User-Agent'); // Should also block User-Agent strings that are too long. (For Database reasons) if (\strlen((string) $userAgent) > 64) { throw new TrackerException(123); } // Block Browser by checking the User-Agent if (preg_match('/(Mozilla|Browser|Chrome|Safari|AppleWebKit|Opera|Links|Lynx|Bot|Unknown)/i', (string) $userAgent)) { throw new TrackerException(121); } // Block Blacklisted Clients $blacklistedPeerIdPrefixes = cache()->rememberForever('client_blacklist', fn () => BlacklistClient::pluck('peer_id_prefix')->toArray()); $peerId = $request->query->getString('peer_id'); foreach ($blacklistedPeerIdPrefixes as $blacklistedPeerIdPrefix) { if (str_starts_with($peerId, $blacklistedPeerIdPrefix)) { throw new TrackerException(128, [':ua' => $request->header('User-Agent')]); } } } /** * Check Passkey Exist and Valid. * * @throws TrackerException * @throws Throwable */ private function checkPasskey(string $passkey): void { // If Passkey Is Not Provided Return Error to Client if ($passkey === '') { throw new TrackerException(130, [':attribute' => 'passkey']); } // If Passkey Length Is Wrong if (\strlen($passkey) !== 32) { throw new TrackerException(132, [':attribute' => 'passkey', ':rule' => 32]); } // If Passkey Format Is Wrong if (strspn(strtolower($passkey), 'abcdef0123456789') !== 32) { throw new TrackerException(131, [':attribute' => 'passkey', ':reason' => 'Passkey format is incorrect']); } } /** * Extract and validate Announce fields. * * @throws TrackerException * @throws Throwable */ private function checkAnnounceFields(Request $request): AnnounceQueryDTO { $queries = []; // Part.1 Validate required announce fields foreach (['info_hash', 'peer_id', 'port', 'uploaded', 'downloaded', 'left'] as $item) { $itemData = $request->query->get($item); if (null !== $itemData) { $queries[$item] = $itemData; } else { throw new TrackerException(130, [':attribute' => $item]); } } foreach (['info_hash', 'peer_id'] as $item) { if (\strlen((string) $queries[$item]) !== 20) { throw new TrackerException(133, [':attribute' => $item, ':rule' => 20]); } } foreach (['port', 'uploaded', 'downloaded', 'left'] as $item) { $itemData = $queries[$item]; if (!is_numeric($itemData) || $itemData < 0) { throw new TrackerException(134, [':attribute' => $item]); } } // Part.2 Extract optional announce fields foreach ([ 'event' => '', 'numwant' => 25, 'corrupt' => 0, 'key' => '', ] as $item => $value) { $queries[$item] = $request->query->get($item, $value); } foreach (['numwant', 'corrupt'] as $item) { if (!is_numeric($queries[$item]) || $queries[$item] < 0) { throw new TrackerException(134, [':attribute' => $item]); } } $queries['event'] = strtolower((string) $queries['event']); if (!\in_array($queries['event'], ['started', 'completed', 'stopped', 'paused', ''])) { throw new TrackerException(136, [':event' => $queries['event']]); } // Part.3 check Port is Valid and Allowed if ( !ctype_digit($queries['port']) // Block system-reserved ports since 99.9% of the time they're fake and thus not connectable // Some clients will send port of 0 on 'stopped' events. Let them through as they won't receive peers anyway. || ($queries['port'] < 1024 && $queries['event'] !== 'stopped') || $queries['port'] > 0xFFFF || \in_array($queries['port'], self::BLACK_PORTS, true) ) { throw new TrackerException(135, [':port' => $queries['port']]); } // Part.4 Get request ip and convert it to packed form $ip = $request->getClientIp(); if ($ip === null) { throw new TrackerException(130, [':attribute' => 'ip']); } $ip = inet_pton($ip); if ($ip === false) { throw new TrackerException(130, [':attribute' => 'ip']); } return new AnnounceQueryDTO( (int) $queries['port'], (int) $queries['uploaded'], (int) $queries['downloaded'], (int) $queries['left'], (int) $queries['corrupt'], (int) $queries['numwant'], $queries['event'], (string) $queries['key'], $request->headers->get('user-agent') ?? '', (string) $queries['info_hash'], (string) $queries['peer_id'], $ip, ); } /** * Get User Via Validated Passkey. * * @throws TrackerException * @throws Throwable */ private function checkUser(string $passkey, AnnounceQueryDTO $queries): User { // Check Passkey Against Users Table $user = cache()->remember('user:'.$passkey, 8 * 3600, fn () => User::query() ->select(['id', 'group_id', 'can_download', 'is_donor', 'is_lifetime']) ->where('passkey', '=', $passkey) ->first()); // If User Doesn't Exist Return Error to Client if ($user === null) { throw new TrackerException(140); } // If User Download Rights Are Disabled Return Error to Client if ($user->can_download === false && $queries->left !== 0) { throw new TrackerException(142); } return $user; } /** * Get Users Group. * * @throws TrackerException * @throws Throwable */ private function checkGroup(User $user): Group { $deniedGroups = cache()->remember('denied_groups', 8 * 3600, fn () => DB::table('groups') ->selectRaw("min(case when slug = 'banned' then id end) as banned_id") ->selectRaw("min(case when slug = 'validating' then id end) as validating_id") ->selectRaw("min(case when slug = 'disabled' then id end) as disabled_id") ->first()); // Get The Users Group $group = cache()->remember('group:'.$user->group_id, 8 * 3600, fn () => Group::query() ->select(['id', 'download_slots', 'is_immune', 'is_freeleech', 'is_double_upload']) ->find($user->group_id)); // If User Account Is Unactivated/Validating Return Error to Client if ($user->group_id === $deniedGroups->validating_id) { throw new TrackerException(141, [':status' => 'Unactivated/Validating']); } // If User Is Banned Return Error to Client if ($user->group_id === $deniedGroups->banned_id) { throw new TrackerException(141, [':status' => 'Banned']); } // If User Is Disabled Return Error to Client if ($user->group_id === $deniedGroups->disabled_id) { throw new TrackerException(141, [':status' => 'Disabled']); } return $group; } /** * Check If Torrent Exist In Database. * * @throws TrackerException * @throws Throwable */ private function checkTorrent(string $infoHash): Torrent { $torrent = cache()->remember( 'announce-torrents:by-infohash:'.$infoHash, 8 * 3600, fn () => Torrent::withoutGlobalScope(ApprovedScope::class) ->select(['id', 'free', 'doubleup', 'seeders', 'leechers', 'times_completed', 'status']) ->where('info_hash', '=', $infoHash) ->firstOr(fn (): string => '-1') ); // If Torrent Doesn't Exsist Return Error to Client if ($torrent === '-1') { throw new TrackerException(150); } // If Torrent Is Pending Moderation Return Error to Client if ($torrent->status === self::PENDING) { throw new TrackerException(151, [':status' => 'PENDING In Moderation']); } // If Torrent Is Rejected Return Error to Client if ($torrent->status === self::REJECTED) { throw new TrackerException(151, [':status' => 'REJECTED In Moderation']); } // If Torrent Is Postponed Return Error to Client if ($torrent->status === self::POSTPONED) { throw new TrackerException(151, [':status' => 'POSTPONED In Moderation']); } // Don't use eager loading so that we can make use of mysql prepared statement caching. // If we use eager loading, then laravel will use `where torrent_id in (123)` instead of `where torrent_id = ?` $torrent->setRelation( 'peers', Peer::select(['torrent_id', 'peer_id', 'user_id', 'downloaded', 'uploaded', 'left', 'seeder', 'active', 'visible', 'ip', 'port', 'updated_at']) ->where('torrent_id', '=', $torrent->id) ->get() ); return $torrent; } /** * Check If Peer Exist In Database. * * @throws TrackerException * @throws Throwable */ private function checkPeer(Torrent $torrent, AnnounceQueryDTO $queries, User $user): void { if ( $queries->event === 'completed' && $torrent->peers ->where('peer_id', '=', $queries->getPeerId()) ->where('user_id', '=', $user->id) ->isEmpty() ) { throw new TrackerException(152); } } /** * Check A Peers Min Annnounce Interval. * * @throws TrackerException * @throws Exception * @throws Throwable */ private function checkMinInterval(Torrent $torrent, AnnounceQueryDTO $queries, User $user): void { $event = match ($queries->event) { 'started' => 'started', 'completed' => 'completed', 'stopped' => 'stopped', default => 'empty', }; $now = (int) now()->timestamp; // Detect broken (namely qBittorrent) clients sending duplicate announces // and eliminate them from screwing up stats. $duplicateAnnounceKey = config('cache.prefix').'announce-lock:'.$user->id.'-'.$torrent->id.'-'.$queries->getPeerId().'-'.$event; $lastAnnouncedAt = Redis::connection('announce')->command('SET', [$duplicateAnnounceKey, $now, ['NX', 'GET', 'EX' => 30]]); if ($lastAnnouncedAt !== false) { throw new TrackerException(162, [':elapsed' => $now - $lastAnnouncedAt]); } // Block clients disrespecting the min interval $lastAnnouncedKey = config('cache.prefix').'peer-last-announced:'.$user->id.'-'.$torrent->id.'-'.$queries->getPeerId(); $randomMinInterval = random_int(intdiv(self::MIN * 85, 100), intdiv(self::MIN * 95, 100)); $lastAnnouncedAt = Redis::connection('announce')->command('SET', [$lastAnnouncedKey, $now, ['NX', 'GET', 'EX' => $randomMinInterval]]); // Delete the timer if the user paused the torrent, and it's been at // least 5 minutes since they last announced. if ($event === 'stopped' && $lastAnnouncedAt < $now - 5 * 60) { Redis::connection('announce')->command('DEL', [$lastAnnouncedKey]); } elseif ($lastAnnouncedAt !== false && !\in_array($event, ['completed', 'stopped'])) { throw new TrackerException(162, [':elapsed' => $now - $lastAnnouncedAt]); } } /** * Check A Users Max Connections. * * @throws TrackerException * @throws Throwable */ private function checkMaxConnections(Torrent $torrent, User $user): void { // Pull Count On Users Peers Per Torrent For Rate Limiting $connections = $torrent->peers ->where('user_id', '=', $user->id) ->where('active', '=', true) ->count(); // If Users Peer Count On A Single Torrent Is Greater Than X Return Error to Client if ($connections > config('announce.rate_limit')) { throw new TrackerException(138, [':limit' => config('announce.rate_limit')]); } } /** * Check A Users Download Slots. * * @throws TrackerException * @throws Throwable */ private function checkDownloadSlots(AnnounceQueryDTO $queries, Torrent $torrent, User $user, Group $group): bool { $max = $group->download_slots; $peer = $torrent->peers ->where('peer_id', '=', $queries->getPeerId()) ->where('user_id', '=', $user->id) ->first(); $cacheKey = 'user-leeching-count:'.$user->id; $count = cache()->get($cacheKey, 0); $isNewPeer = $peer === null || !$peer->visible; $isDeadPeer = $queries->event === 'stopped'; $isSeeder = $queries->left === 0; $newLeech = $isNewPeer && !$isDeadPeer && !$isSeeder; $stoppedLeech = !$isNewPeer && $isDeadPeer && !$isSeeder; $leechBecomesSeed = !$isNewPeer && !$isDeadPeer && $isSeeder && $peer->left > 0; $seedBecomesLeech = !$isNewPeer && !$isDeadPeer && !$isSeeder && $peer->left === 0; if ($max !== null && $max >= 0 && ($newLeech || $seedBecomesLeech) && $count >= $max) { return false; } if ($newLeech || $seedBecomesLeech) { cache()->increment($cacheKey); } elseif ($stoppedLeech || $leechBecomesSeed) { cache()->decrement($cacheKey); } return true; } /** * Generate A Successful Announce Response For Client. * * @throws RandomException */ private function generateSuccessAnnounceResponse(AnnounceQueryDTO $queries, Torrent $torrent, User $user): string { $peersIpv4 = ''; $peersIpv6 = ''; $peerCount = 0; $seederCount = 0; $leecherCount = 0; /** * For non `stopped` event only where either the torrent has at least one leech, or the user is a leech. * We query peers from database and send peerlist, otherwise just quick return. */ if ($queries->event !== 'stopped') { $limit = (min($queries->numwant, 25)); // Get Torrents Peers (Only include leechers in a seeder's peerlist) if ($queries->left === 0) { foreach ($torrent->peers as $peer) { if ($peer->active && $peer->seeder) { $seederCount++; } if ($peer->active && ! $peer->seeder) { $leecherCount++; } // Don't include other seeders, inactive peers, invisible peers nor other peers belonging to the same user if ($peer->seeder || !$peer->active || !$peer->visible || $peer->user_id === $user->id) { continue; } switch (\strlen((string) $peer['ip'])) { case 4: $peersIpv4 .= $peer['ip'].pack('n', (int) $peer['port']); $peerCount++; break; case 16: $peersIpv6 .= $peer['ip'].pack('n', (int) $peer['port']); $peerCount++; } if ($peerCount >= $limit) { break; } } } else { foreach ($torrent->peers as $peer) { if ($peer->active && $peer->seeder) { $seederCount++; } if ($peer->active && ! $peer->seeder) { $leecherCount++; } // Don't include inactive peers, invisible peers, nor other peers belonging to the same user if (!$peer->active || !$peer->visible || $peer->user_id === $user->id) { continue; } switch (\strlen((string) $peer['ip'])) { case 4: $peersIpv4 .= $peer['ip'].pack('n', (int) $peer['port']); $peerCount++; break; case 16: $peersIpv6 .= $peer['ip'].pack('n', (int) $peer['port']); $peerCount++; } if ($peerCount >= $limit) { break; } } } } // Build Response For Bittorrent Client // Keys must be ordered alphabetically $response = 'd8:completei' .$seederCount .'e10:downloadedi' .$torrent->times_completed .'e10:incompletei' .$leecherCount .'e8:intervali' .random_int(self::MIN, self::MAX) .'e12:min intervali' .random_int(intdiv(self::MIN * 95, 100), self::MIN) .'e'; if ($peersIpv6 === '') { return $response.'5:peers'.\strlen($peersIpv4).':'.$peersIpv4.'e'; } return $response.'5:peers' .\strlen($peersIpv4).':'.$peersIpv4 .'6:peers6' .\strlen($peersIpv6).':'.$peersIpv6.'e'; } /** * Generate A Warning Announce Response For Client. */ private function generateWarningAnnounceResponse(Torrent $torrent, TrackerException $trackerException): string { $message = $trackerException->getMessage(); return 'd8:completei' .$torrent->seeders .'e10:downloadedi' .$torrent->times_completed .'e10:incompletei' .$torrent->leechers .'e8:intervali' .random_int(self::MIN, self::MAX) .'e12:min intervali' .random_int(intdiv(self::MIN * 95, 100), self::MIN) .'e15:warning message' .\strlen($message) .':' .$message .'5:peers0:e'; } /** * Process Announce Database Queries. */ private function processAnnounceJob(AnnounceQueryDTO $queries, User $user, Group $group, Torrent $torrent, bool $visible): void { $groupDto = new AnnounceGroupDTO((bool) $group->is_freeleech, (bool) $group->is_double_upload, (bool) $group->is_immune); $userDto = new AnnounceUserDTO($user->id, $user->is_donor, $groupDto); $torrentDto = new AnnounceTorrentDTO($torrent->id, $torrent->free, $torrent->doubleup); ProcessAnnounce::dispatch($queries, $userDto, $torrentDto, $visible); } private function generateFailedAnnounceResponse(TrackerException $trackerException): string { $message = $trackerException->getMessage(); if ($trackerException->getCode() === 151) { // If the torrent status is pending, postponed, or rejected, reduce the interval to 30 seconds. // This allows the uploader to start seeding sooner when the torrent is approved. return 'd14:failure reason'.\strlen($message).':'.$message.'8:intervali30e12:min intervali30ee'; } return 'd14:failure reason'.\strlen($message).':'.$message.'8:intervali'.self::MIN.'e12:min intervali'.self::MIN.'ee'; } /** * Send Final Announce Response. */ private function sendFinalAnnounceResponse(string $response): Response { return response($response, headers: self::HEADERS); } }