import { EventEmitter } from "node:events"; import type { Config } from "@config/Config"; import type { ClientProfile } from "../client/ClientProfile.js"; import type { TorrentFileInfo } from "../torrent/TorrentFile.js"; import { HttpTracker } from "../tracker/HttpTracker.js"; import type { ITracker } from "../tracker/ITracker.js"; import type { TrackerResponse } from "../tracker/TrackerResponse.js"; import { UdpTracker } from "../tracker/UdpTracker.js"; import { SpeedSimulator } from "./SpeedSimulator.js"; export type SeederStatus = "idle" | "running" | "paused" | "stopping" | "stopped" | "error"; export interface SeederState { infoHashHex: string; name: string; trackerUrl: string; uploaded: number; downloaded: number; left: number; status: SeederStatus; lastAnnounce: Date | null; lastInterval: number; seeders: number; leechers: number; peers: number; error: string | null; } export interface AnnounceRecord { timestamp: Date; trackerUrl: string; uploaded: number; seeders?: number; leechers?: number; peers: number; event: string; } export class FakeSeeder extends EventEmitter { // Typed on() overloads — avoids unsafe declaration merging on(event: "announce", listener: (record: AnnounceRecord) => void): this; on(event: "stopped", listener: () => void): this; on(event: "stateChange", listener: (state: SeederState) => void): this; on(event: string, listener: (...args: unknown[]) => void): this { return super.on(event, listener) as this; } private readonly torrent: TorrentFileInfo; private readonly profile: ClientProfile; private readonly config: Config; private readonly trackers: ITracker[]; private readonly speedSim: SpeedSimulator; private status: SeederStatus = "idle"; private uploaded = 0; private readonly downloaded = 0; private left: number; private lastAnnounce: Date | null = null; private lastInterval = 1800; private lastSeeder = 0; private lastLeecher = 0; private lastPeers = 0; private error: string | null = null; private announceTimer: ReturnType | null = null; private tickTimer: ReturnType | null = null; private lastTickTime = Date.now(); constructor(torrent: TorrentFileInfo, profile: ClientProfile, config: Config) { super(); this.torrent = torrent; this.profile = profile; this.config = config; this.left = 0; // We're a seeder — we have the full file this.speedSim = new SpeedSimulator({ minUploadRateBytesPerSec: config.minUploadRateBytesPerSec, maxUploadRateBytesPerSec: config.maxUploadRateBytesPerSec, }); this.trackers = buildTrackers(torrent.announceList); } get infoHashHex(): string { return this.torrent.infoHashHex; } getState(): SeederState { return { infoHashHex: this.torrent.infoHashHex, name: this.torrent.name, trackerUrl: this.torrent.announce, uploaded: this.uploaded, downloaded: this.downloaded, left: this.left, status: this.status, lastAnnounce: this.lastAnnounce, lastInterval: this.lastInterval, seeders: this.lastSeeder, leechers: this.lastLeecher, peers: this.lastPeers, error: this.error, }; } async start(): Promise { if (this.status === "running") return; this.setStatus("running"); // Start speed tick this.startTickTimer(); // First announce: started await this.doAnnounce("started"); this.scheduleNextAnnounce(); } async pause(): Promise { if (this.status !== "running") return; if (this.announceTimer) { clearTimeout(this.announceTimer); this.announceTimer = null; } if (this.tickTimer) { clearInterval(this.tickTimer); this.tickTimer = null; } // Notify trackers we're leaving the swarm (best-effort) await Promise.allSettled( this.trackers.map((t) => t.stop({ infoHash: this.torrent.infoHash, peerId: this.profile.peerId, port: this.config.announcePort, uploaded: this.uploaded, downloaded: this.downloaded, left: this.left, userAgent: this.profile.userAgent, key: this.profile.key, }) ) ); this.setStatus("paused"); } async resume(): Promise { if (this.status !== "paused") return; this.setStatus("running"); // Restart speed tick this.startTickTimer(); // Re-announce as started then resume loop await this.doAnnounce("started"); this.scheduleNextAnnounce(); } async stop(): Promise { if (this.status === "stopped" || this.status === "stopping") return; const wasPaused = this.status === "paused"; this.setStatus("stopping"); if (this.announceTimer) { clearTimeout(this.announceTimer); this.announceTimer = null; } if (this.tickTimer) { clearInterval(this.tickTimer); this.tickTimer = null; } // If already paused, trackers were already notified — skip the stopped announce if (!wasPaused) { await Promise.allSettled( this.trackers.map((t) => t.stop({ infoHash: this.torrent.infoHash, peerId: this.profile.peerId, port: this.config.announcePort, uploaded: this.uploaded, downloaded: this.downloaded, left: this.left, userAgent: this.profile.userAgent, key: this.profile.key, }) ) ); } this.setStatus("stopped"); this.emit("stopped"); } /** Starts the 5-second speed simulation tick timer */ private startTickTimer(): void { this.lastTickTime = Date.now(); this.tickTimer = setInterval(() => { try { const now = Date.now(); const delta = now - this.lastTickTime; this.lastTickTime = now; this.uploaded += this.speedSim.tick(delta); this.emit("stateChange", this.getState()); } catch (err) { console.error("[seeder] Tick error:", err); } }, 5_000); } private scheduleNextAnnounce(): void { const intervalMs = this.lastInterval * 1000; this.announceTimer = setTimeout(async () => { await this.doAnnounce(""); if (this.status === "running") { this.scheduleNextAnnounce(); } }, intervalMs); } private async doAnnounce(event: "started" | "stopped" | "completed" | ""): Promise { const params = { infoHash: this.torrent.infoHash, peerId: this.profile.peerId, port: this.config.announcePort, uploaded: this.uploaded, downloaded: this.downloaded, left: this.left, event, userAgent: this.profile.userAgent, compact: true, numWant: 50, key: this.profile.key, }; // Announce to all trackers, collect results const results = await Promise.allSettled(this.trackers.map((t) => t.announce(params))); let bestInterval = this.lastInterval; for (let i = 0; i < results.length; i++) { const result = results[i]; const tracker = this.trackers[i]; if (result.status === "fulfilled") { const res: TrackerResponse = result.value; this.lastAnnounce = new Date(); this.lastSeeder = res.seeders ?? this.lastSeeder; this.lastLeecher = res.leechers ?? this.lastLeecher; this.lastPeers = res.peers.length; bestInterval = Math.min(bestInterval, res.interval); this.error = null; // clear any previous error const record: AnnounceRecord = { timestamp: this.lastAnnounce, trackerUrl: tracker.url, uploaded: this.uploaded, seeders: res.seeders, leechers: res.leechers, peers: res.peers.length, event, }; this.emit("announce", record); } else { this.error = result.reason instanceof Error ? result.reason.message : String(result.reason); // Fallback: scrape to at least get current seeder/leecher counts if (tracker.scrape && this.status === "running") { tracker .scrape(this.torrent.infoHash) .then((stats) => { if (stats && this.status === "running") { this.lastSeeder = stats.seeders; this.lastLeecher = stats.leechers; this.emit("stateChange", this.getState()); } }) .catch((err) => { console.warn(`[seeder] Scrape fallback failed for ${tracker.url}:`, err); }); } } } this.lastInterval = bestInterval; this.emit("stateChange", this.getState()); } private setStatus(s: SeederStatus): void { this.status = s; this.emit("stateChange", this.getState()); } } /** * Reject tracker URLs that point to private/internal networks (SSRF protection). * Checks the hostname against RFC 1918, loopback, link-local, and metadata ranges. */ function isPrivateHost(hostname: string): boolean { // Strip IPv6 brackets const host = hostname.replace(/^\[|\]$/g, ""); // Loopback if (host === "localhost" || host === "::1" || host.startsWith("127.")) return true; // Cloud metadata endpoints if (host === "169.254.169.254" || host === "metadata.google.internal") return true; // IPv4 private ranges const ipv4 = host.match(/^(\d+)\.(\d+)\.\d+\.\d+$/); if (ipv4) { const [, a, b] = ipv4.map(Number); if (a === 10) return true; // 10.0.0.0/8 if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12 if (a === 192 && b === 168) return true; // 192.168.0.0/16 if (a === 169 && b === 254) return true; // 169.254.0.0/16 link-local if (a === 0) return true; // 0.0.0.0/8 } return false; } function buildTrackers(urls: string[]): ITracker[] { return urls .map((url): ITracker | null => { try { const parsed = new URL(url); if (isPrivateHost(parsed.hostname)) { console.warn(`[tracker] Skipping private/internal URL: ${url}`); return null; } if (url.startsWith("http://") || url.startsWith("https://")) { return new HttpTracker(url); } if (url.startsWith("udp://")) { return new UdpTracker(url); } } catch { console.warn(`[tracker] Skipping malformed URL: ${url}`); } return null; }) .filter((t): t is ITracker => t !== null); }