342 lines
10 KiB
TypeScript
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);
|
|
}
|