torrent/src/core/tracker/HttpTracker.ts

228 lines
7.5 KiB
TypeScript

import { type BencodeDict, type BencodeList, decode } from "../bencode/decoder.js";
import type { AnnounceParams, ITracker, ScrapeResult } from "./ITracker.js";
import type { TrackerPeer, TrackerResponse } from "./TrackerResponse.js";
/** Maximum response body size from trackers (1 MB) */
const MAX_RESPONSE_SIZE = 1024 * 1024;
/** Tracker HTTP request timeout (15 seconds) */
const REQUEST_TIMEOUT_MS = 15_000;
/**
* HTTP/HTTPS tracker client.
*
* Key correctness concern: info_hash and peer_id are raw binary — they must be
* percent-encoded byte-by-byte (not hex, not base64). We build the query string
* manually instead of using URLSearchParams to avoid double-encoding.
*/
export class HttpTracker implements ITracker {
readonly url: string;
private trackerId?: string;
private userAgent = "";
constructor(url: string) {
this.url = url;
}
async announce(params: AnnounceParams): Promise<TrackerResponse> {
this.userAgent = params.userAgent;
const qs = buildQueryString(params, this.trackerId);
const sep = this.url.includes("?") ? "&" : "?";
const fullUrl = `${this.url}${sep}${qs}`;
const res = await fetch(fullUrl, {
headers: {
"User-Agent": params.userAgent,
"Accept-Encoding": "gzip",
},
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
if (!res.ok) {
throw new Error(`HTTP tracker returned ${res.status} for ${this.url}`);
}
const arrayBuf = await res.arrayBuffer();
if (arrayBuf.byteLength > MAX_RESPONSE_SIZE) {
throw new Error(`Tracker response too large (${arrayBuf.byteLength} bytes)`);
}
const body = Buffer.from(arrayBuf);
const decoded = decode(body);
if (!decoded || typeof decoded !== "object" || Array.isArray(decoded)) {
throw new Error("Tracker returned invalid bencode (expected dictionary)");
}
const dict = decoded as BencodeDict;
if (dict["failure reason"]) {
const raw = dict["failure reason"];
const reason = Buffer.isBuffer(raw) ? raw.toString("utf8") : String(raw);
throw new Error(`Tracker failure: ${reason}`);
}
if (dict["tracker id"]) {
const raw = dict["tracker id"];
this.trackerId = Buffer.isBuffer(raw) ? raw.toString("utf8") : String(raw);
}
return parseResponse(dict);
}
async stop(params: Omit<AnnounceParams, "event">): Promise<void> {
try {
await this.announce({ ...params, event: "stopped" });
} catch {
// best-effort
}
}
/**
* Scrape the tracker for current seeder/leecher counts without announcing.
* Derives the scrape URL by replacing the last `/announce` path segment with `/scrape`.
* Returns null if the tracker doesn't support scrape or the request fails.
*/
async scrape(infoHash: Buffer): Promise<ScrapeResult | null> {
// Only supported if announce URL contains /announce in the path
const scrapeUrl = this.url.replace(/(\/announce)([^/]*)$/, "/scrape$2");
if (scrapeUrl === this.url) return null;
try {
const scrapeQSep = scrapeUrl.includes("?") ? "&" : "?";
const fullUrl = `${scrapeUrl}${scrapeQSep}info_hash=${percentEncode(infoHash)}`;
const res = await fetch(fullUrl, {
headers: { "User-Agent": this.userAgent || "Mozilla/5.0" },
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
if (!res.ok) return null;
const body = Buffer.from(await res.arrayBuffer());
const decoded = decode(body);
if (!decoded || typeof decoded !== "object" || Array.isArray(decoded)) return null;
const dict = decoded as BencodeDict;
if (!dict.files || typeof dict.files !== "object") return null;
const entries = Object.values(dict.files as BencodeDict);
if (entries.length === 0) return null;
const entry = entries[0];
if (!entry || typeof entry !== "object" || Array.isArray(entry)) return null;
const e = entry as BencodeDict;
const complete = e.complete;
const incomplete = e.incomplete;
const downloaded = e.downloaded;
return {
seeders: typeof complete === "number" ? complete : 0,
leechers: typeof incomplete === "number" ? incomplete : 0,
completed: typeof downloaded === "number" ? downloaded : 0,
};
} catch {
return null;
}
}
}
function buildQueryString(params: AnnounceParams, trackerId?: string): string {
const parts: string[] = [
`info_hash=${percentEncode(params.infoHash)}`,
`peer_id=${percentEncode(params.peerId)}`,
`port=${params.port}`,
`uploaded=${params.uploaded}`,
`downloaded=${params.downloaded}`,
`left=${params.left}`,
`compact=${params.compact !== false ? 1 : 0}`,
`no_peer_id=1`,
];
if (params.event) {
parts.push(`event=${params.event}`);
}
if (params.numWant !== undefined) {
parts.push(`numwant=${params.numWant}`);
}
if (params.key) {
parts.push(`key=${encodeURIComponent(params.key)}`);
}
if (trackerId) {
parts.push(`trackerid=${encodeURIComponent(trackerId)}`);
}
return parts.join("&");
}
/**
* Percent-encode each byte of a Buffer individually.
* e.g. 0x1a → %1A (uppercase hex, safe chars left as-is... but for binary data
* we encode everything non-alphanumeric to be safe).
*/
function percentEncode(buf: Buffer): string {
let result = "";
for (let i = 0; i < buf.length; i++) {
const byte = buf[i];
// safe chars: A-Z a-z 0-9 - _ . ~
if (
(byte >= 0x41 && byte <= 0x5a) || // A-Z
(byte >= 0x61 && byte <= 0x7a) || // a-z
(byte >= 0x30 && byte <= 0x39) || // 0-9
byte === 0x2d || // -
byte === 0x5f || // _
byte === 0x2e || // .
byte === 0x7e // ~
) {
result += String.fromCharCode(byte);
} else {
result += `%${byte.toString(16).padStart(2, "0").toUpperCase()}`;
}
}
return result;
}
function parseResponse(dict: BencodeDict): TrackerResponse {
const rawInterval = dict.interval;
const interval = typeof rawInterval === "number" ? rawInterval : 1800;
const rawMinInterval = dict["min interval"];
const minInterval = typeof rawMinInterval === "number" ? rawMinInterval : undefined;
const rawSeeders = dict.complete;
const seeders = typeof rawSeeders === "number" ? rawSeeders : undefined;
const rawLeechers = dict.incomplete;
const leechers = typeof rawLeechers === "number" ? rawLeechers : undefined;
const rawWarning = dict["warning message"];
const warning = Buffer.isBuffer(rawWarning) ? rawWarning.toString("utf8") : undefined;
const rawTrackerId = dict["tracker id"];
const trackerId = Buffer.isBuffer(rawTrackerId) ? rawTrackerId.toString("utf8") : undefined;
const peers = parsePeers(dict.peers);
return { interval, minInterval, seeders, leechers, peers, warning, trackerId };
}
function parsePeers(raw: unknown): TrackerPeer[] {
if (!raw) return [];
// Compact format: 6 bytes per peer (4 IP + 2 port)
if (Buffer.isBuffer(raw)) {
const peers: TrackerPeer[] = [];
for (let i = 0; i + 6 <= raw.length; i += 6) {
const ip = `${raw[i]}.${raw[i + 1]}.${raw[i + 2]}.${raw[i + 3]}`;
const port = raw.readUInt16BE(i + 4);
peers.push({ ip, port });
}
return peers;
}
// Dict format (older trackers)
if (Array.isArray(raw)) {
return (raw as BencodeList)
.filter((p): p is BencodeDict => !!p && typeof p === "object" && !Array.isArray(p))
.map((pd) => ({
ip: Buffer.isBuffer(pd.ip) ? pd.ip.toString("ascii") : String(pd.ip ?? "0.0.0.0"),
port: typeof pd.port === "number" ? pd.port : 0,
}));
}
return [];
}