torrent/src/core/seeder/FakeSeeder.ts

342 lines
10 KiB
TypeScript

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<typeof setTimeout> | null = null;
private tickTimer: ReturnType<typeof setInterval> | 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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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);
}